taskflow
核心概念工作区隔离

工作区隔离

taskflow 如何为每个阶段分配独立的工作目录——并在完成后清理。

大多数阶段在你启动 flow 的目录中运行。当子代理只是读取代码时这没问题,但一旦某个阶段需要写文件、跑构建或执行 git commit,这就成了隐患。两个并行分支同时编辑同一个 src/index.ts 会互相覆盖。一次失败的实验在你的目录树中留下残余文件,干扰下一个阶段。一个 script 阶段执行 npm install 会把只有某个子代理需要的包污染到你的 node_modules 里。

工作区隔离通过阶段定义上的一个字段 cwd 解决所有这些问题。

三个保留关键字

将一个阶段的 cwd 设为三个保留关键字之一,运行时会在执行前为它分配一个隔离的工作目录,然后在完成后清理。把 cwd 留作字面路径(或完全省略),阶段就像以前一样在默认目录中运行——隔离是 opt-in 的。

三种工作区模式
cwd: "temp"        →  /tmp/pi-tf-ws-<phase>-XXXXXX   (阶段完成后删除)
cwd: "dedicated"   →  <runs>/ws/<runId>/<phaseId>    (保留以供检查)
cwd: "worktree"    →  基于一次性分支的 git worktree  (阶段完成后删除)

每种模式在隔离性和持久性之间权衡。正确的选择取决于阶段的副作用是否需要在运行结束后保留。

temp — 临时草稿空间

temp 工作区是通过 fs.mkdtempSync 在系统的 os.tmpdir() 下创建的操作系统级临时目录。前缀 pi-tf-ws-<phaseId>- 使目录在 /tmp 列表中可识别;随机后缀保证并发阶段之间的唯一性。

一个不触碰你目录树的代码起草阶段
{
  "id": "draft-patch",
  "type": "agent",
  "cwd": "temp",
  "task": "根据下面的描述编写补丁。\n{steps.analyze.output}"
}

清理: 阶段完成时——无论成功还是失败——目录会被递归删除。删除操作是 best-effort 且有容器限制的:运行时拒绝删除 OS tmpdir 之外的任何东西,所以未来路径构造中的 bug 永远不会删除你的项目文件。

何时使用: 起草实验、代码生成、中间文件转换——任何产生的文件在阶段完成后你不再需要的场景。如果阶段的价值在于它的文本输出(由运行时捕获),而不是它的文件系统产物,temp 是正确的选择。

dedicated — 持久化的 per-phase 目录

dedicated 工作区是位于运行状态树 <runsRoot>/ws/<runId>/<phaseId> 下的目录。它通过 mkdirSync({ recursive: true }) 创建,并且——关键点——运行时从不删除它。

一个你希望在运行后检查其产物的阶段
{
  "id": "generate-report",
  "type": "agent",
  "cwd": "dedicated",
  "task": "根据审计数据生成完整的 HTML 报告。\n{steps.audit.json}"
}

清理: 无。目录与运行状态一起持久存在。只有当运行本身被保留策略(maxKeptRuns / maxRunAgeDays)修剪时才会被清理。

续跑行为: 路径基于 (runId, phaseId) 是确定性的。如果你续跑一次失败的运行,同一个目录会被复用——先前尝试的产物仍然存在。这是刻意设计的:dedicated 工作区是运行时假设你想跨尝试检查或积累产物的唯一模式。

何时使用: 产生你想在运行后审查的文件的阶段(报告、生成的代码、测试 fixture)。也适用于下游兄弟需要从磁盘(而不是通过插值系统)读取产物的阶段。

worktree — 基于一次性分支的真实 git worktree

worktree 工作区是一次真正的 git worktree add -b <branch> <dir> HEAD 调用。分支名为 tf/<runId>/<phaseId>-<timestamp>(时间戳用 base-36 编码以保持紧凑),工作目录分配在 OS tmpdir 下,然后由 git 用 HEAD 的完整检出填充。

一个执行隔离 git commit 的阶段
{
  "id": "apply-migration",
  "type": "agent",
  "cwd": "worktree",
  "task": "应用上一步的数据库迁移方案。每个迁移文件单独 commit。"
}

清理: 阶段完成后,运行时执行 git worktree remove --force <dir>(30 秒超时),然后 rmrf 目录作为纵深防御,最后 git branch -D <branch>(10 秒超时)。每一步都是 best-effort:残留的 worktree 或分支无害,不会导致阶段失败。

fail-open 降级: worktree 要求阶段的基目录位于一个 git work tree 内。如果不是——基目录没有 .git,或者 git rev-parse --is-inside-work-tree 失败——运行时会优雅地降级为 temp 工作区。阶段仍然在隔离目录中运行;只是失去了 git 语义。一个警告会被附加到阶段的 warnings 数组:

降级 worktree 的警告
workspace:temp — worktree requested but base cwd is not a git work tree; used a temp dir instead

同样的降级也适用于 git worktree add 本身失败的情况(例如分支名冲突、detached HEAD 或 60 秒超时)。运行时会清理失败的分配并用 temp 重试。

何时使用: 需要执行 git commit、运行 diff、应用补丁,或操作一份可以丢弃而不影响主工作树的仓库副本的阶段。一个 N 个变体各自独立 commit 的 tournament 是典型用例。

运行时如何集成

生命周期很直观:

运行时中的工作区生命周期
1. phase.cwd 是关键字?
   否  → 在 deps.cwd(默认路径)中运行阶段。完毕。
   是  ↓
2. allocateWorkspace(keyword, {baseCwd, runId, phaseId, runsRoot})
   → Workspace { dir, kind, teardown, branch?, note? }
3. 将 deps.cwd 覆盖为 ws.dir 用于阶段的子代理
4. 执行阶段
5. 将工作区诊断信息附加到 PhaseState.warnings(如有降级)
6. 在 finally 块中调用 ws.teardown()(best-effort)

teardown 运行在 finally 块中,所以无论阶段成功、失败还是被中止,它都会触发。teardown 本身被 try/catch 包裹——一个清理错误永远不会替代阶段的真实结果(项目错误处理惯例中的"safe emit"不变量)。

始终 fail-open

每种工作区模式都是降级而非失败。完整的降级阶梯:

失败降级
tempmkdtempSync 抛出异常回退到 baseCwd(运行的默认目录),附带警告
dedicatedmkdirSync 抛出异常回退到 baseCwd,附带警告
worktree:基目录不在 git 仓库中降级为 temp(仍然隔离,但无 git)
worktree:temp 目录分配失败先重试 temp,再回退到 baseCwd
worktreegit worktree add 失败降级为 temp(警告中包含 git 的 stderr)
任何 teardown 错误静默吞掉;best-effort 清理

在每种情况下,阶段仍然运行。运行时将一个 note 附加到 workspace 句柄上,作为 PhaseState.warnings 条目展示,所以降级在 /tf peek 中可见,但从不阻塞运行。

降级工作区的警告示例
workspace — dedicated workspace alloc failed: EACCES: permission denied

fail-open 设计是刻意的:工作区是隔离增强,不是正确性要求。一个在基目录而非隔离目录中运行的阶段可能会留下残余文件,但它仍然产出正确的结果。替代方案——因为 /tmp 满了就失败阶段——严格来说会更糟。

续跑安全

续跑(在续跑中记录的跨会话重放系统)与工作区的交互有特定方式:

  • tempworktree 在每次运行时从零开始重新分配。如果一个阶段被续跑(输入变了,或你强制了重新运行),会创建一个全新的目录。旧的 temp/worktree 目录在先前的尝试完成时已经被清理了。
  • dedicated 是确定性的:路径 <runs>/ws/<runId>/<phaseId> 在续跑尝试之间相同。如果先前的尝试留下了文件,它们在重新运行开始时仍然存在。这是有意为之——dedicated 工作区是积累模式。
  • 被缓存的阶段(输入哈希未变,结果从缓存恢复)根本不会触发工作区分配。缓存结果被直接复用,不重新运行子代理,因此不创建或触碰任何目录。

dedicated 工作区在续跑尝试之间积累。如果一个阶段在尝试 1 写入了 output.json,然后失败并在尝试 2 重新运行,尝试 2 开始时尝试 1 的文件仍然在目录中。设计 dedicated 阶段时确保它是幂等的,或检查已有产物。

路径容器化

运行时在两个层面强制执行容器化:

  1. dedicated 目录根植于运行状态下。 路径 <runsRoot>/ws/<runId>/<phaseId> 由经过 sanitize 的段构成——phase id 经过 safeSegment() 处理,剥离 [A-Za-z0-9._-] 之外的所有字符,折叠前导点,并限制在 100 字符以内。一个像 ../../etc 的 phase id 会变成 ______etc

  2. tempworktree 的删除受根目录限制。 rmrf 辅助函数拒绝删除 OS tmpdir 和运行自己的 ws/ 树之外的任何东西。即使未来的 bug 产生了一个错误路径,容器化检查也能防止项目文件被删除。

  3. 动态子流完全不能使用工作区关键字。 一个生成的子流(由 LLM 通过 flow { def }ctx_spawn 编写的)如果任何阶段将 cwd 设为 tempdedicatedworktree,会被验证拒绝。LLM 编写的计划不得分配隔离目录或创建会变更仓库的 git worktree。只有作者编写的 flow 才能使用工作区隔离。

选择正确的模式

决策树
阶段是否写入你在它完成后需要的文件?
  是 → dedicated
  否 ↓
阶段是否需要 git(commit、diff、分支)?
  是 → worktree
  否 → temp
模式隔离持久Git清理续跑
temp完成后删除重新分配
dedicated保留直到运行被修剪复用同一目录
worktreeworktree + 分支删除重新分配

示例

无冲突的并行实验

两个阶段各自生成完整实现,并行运行。没有隔离的话它们会写同一个文件:

并行隔离草稿
{
  "name": "compare-approaches",
  "phases": [
    {
      "id": "approach-a",
      "type": "agent",
      "cwd": "temp",
      "task": "使用方案 A 实现该功能。"
    },
    {
      "id": "approach-b",
      "type": "agent",
      "cwd": "temp",
      "task": "使用方案 B 实现该功能。"
    },
    {
      "id": "judge",
      "type": "agent",
      "task": "比较:\nA: {steps.approach-a.output}\nB: {steps.approach-b.output}",
      "dependsOn": ["approach-a", "approach-b"],
      "final": true
    }
  ]
}

每个草稿在自己的 temp 目录中运行。谁也看不到或覆盖不了对方的文件。

带 git commit 的 tournament

每个变体在自己的一次性分支上做独立 commit,然后评委选出最佳:

带 worktree 隔离的 tournament
{
  "name": "refactor-tournament",
  "phases": [
    {
      "id": "variant-1",
      "type": "agent",
      "cwd": "worktree",
      "task": "使用策略模式重构 auth 模块。每个文件变更单独 commit。"
    },
    {
      "id": "variant-2",
      "type": "agent",
      "cwd": "worktree",
      "task": "使用中间件链重构 auth 模块。每个文件变更单独 commit。"
    },
    {
      "id": "judge",
      "type": "gate",
      "task": "审查两种重构方案。对更好的那个输出 VERDICT: PASS 并说明理由。",
      "dependsOn": ["variant-1", "variant-2"],
      "final": true
    }
  ]
}

每个变体获得自己的一次性分支上的 worktree(tf/<runId>/variant-1-<ts>tf/<runId>/variant-2-<ts>)。commit 是真正的 git commit,与主目录树完全隔离。两个 worktree 和分支在各自阶段完成时被清理。

留下产物以供审查的审计

dedicated 工作区用于可检查的输出
{
  "name": "security-audit",
  "phases": [
    {
      "id": "scan",
      "type": "agent",
      "cwd": "dedicated",
      "task": "扫描代码库中的漏洞。将发现的所有问题写入 findings.json 文件。"
    },
    {
      "id": "report",
      "type": "agent",
      "task": "读取扫描结果:\n{steps.scan.output}\n写一份人类可读的摘要。",
      "dependsOn": ["scan"],
      "final": true
    }
  ]
}

scan 阶段的 dedicated 工作区在运行完成后将 findings.json 保留在磁盘上。你可以通过 /tf peek 检查它,或者直接在运行的 ws/ 目录下找到它。

警告与诊断

当工作区降级或遇到问题时,运行时将警告附加到阶段状态。这些在 /tf peek <runId> 和持久化的运行记录中可见:

工作区警告示例
workspace:temp at /tmp/pi-tf-ws-draft-patch-a1b2c3/
workspace:temp — worktree requested but base cwd is not a git work tree; used a temp dir instead
workspace — dedicated workspace alloc failed: EACCES: permission denied
workspace:worktree at /tmp/pi-tf-wt-apply-migration-x4y5z6/ (branch: tf/abc123/apply-migration-m3k2j1)

显示 workspace:<kind> at <path> 的警告是信息性的——工作区分配成功。kind 后面带 的警告表示降级。

下一步


---

Last updated on

这页内容对你有帮助吗?

帮助我们改进文档,或在社区中提问。

On this page