工作区隔离
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 的完整检出填充。
{
"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 数组:
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
每种工作区模式都是降级而非失败。完整的降级阶梯:
| 失败 | 降级 |
|---|---|
temp:mkdtempSync 抛出异常 | 回退到 baseCwd(运行的默认目录),附带警告 |
dedicated:mkdirSync 抛出异常 | 回退到 baseCwd,附带警告 |
worktree:基目录不在 git 仓库中 | 降级为 temp(仍然隔离,但无 git) |
worktree:temp 目录分配失败 | 先重试 temp,再回退到 baseCwd |
worktree:git worktree add 失败 | 降级为 temp(警告中包含 git 的 stderr) |
| 任何 teardown 错误 | 静默吞掉;best-effort 清理 |
在每种情况下,阶段仍然运行。运行时将一个 note 附加到 workspace 句柄上,作为 PhaseState.warnings 条目展示,所以降级在 /tf peek 中可见,但从不阻塞运行。
workspace — dedicated workspace alloc failed: EACCES: permission deniedfail-open 设计是刻意的:工作区是隔离增强,不是正确性要求。一个在基目录而非隔离目录中运行的阶段可能会留下残余文件,但它仍然产出正确的结果。替代方案——因为 /tmp 满了就失败阶段——严格来说会更糟。
续跑安全
续跑(在续跑中记录的跨会话重放系统)与工作区的交互有特定方式:
temp和worktree在每次运行时从零开始重新分配。如果一个阶段被续跑(输入变了,或你强制了重新运行),会创建一个全新的目录。旧的 temp/worktree 目录在先前的尝试完成时已经被清理了。dedicated是确定性的:路径<runs>/ws/<runId>/<phaseId>在续跑尝试之间相同。如果先前的尝试留下了文件,它们在重新运行开始时仍然存在。这是有意为之——dedicated工作区是积累模式。- 被缓存的阶段(输入哈希未变,结果从缓存恢复)根本不会触发工作区分配。缓存结果被直接复用,不重新运行子代理,因此不创建或触碰任何目录。
dedicated 工作区在续跑尝试之间积累。如果一个阶段在尝试 1 写入了 output.json,然后失败并在尝试 2 重新运行,尝试 2 开始时尝试 1 的文件仍然在目录中。设计 dedicated 阶段时确保它是幂等的,或检查已有产物。
路径容器化
运行时在两个层面强制执行容器化:
-
dedicated目录根植于运行状态下。 路径<runsRoot>/ws/<runId>/<phaseId>由经过 sanitize 的段构成——phase id 经过safeSegment()处理,剥离[A-Za-z0-9._-]之外的所有字符,折叠前导点,并限制在 100 字符以内。一个像../../etc的 phase id 会变成______etc。 -
temp和worktree的删除受根目录限制。rmrf辅助函数拒绝删除 OS tmpdir 和运行自己的ws/树之外的任何东西。即使未来的 bug 产生了一个错误路径,容器化检查也能防止项目文件被删除。 -
动态子流完全不能使用工作区关键字。 一个生成的子流(由 LLM 通过
flow { def }或ctx_spawn编写的)如果任何阶段将cwd设为temp、dedicated或worktree,会被验证拒绝。LLM 编写的计划不得分配隔离目录或创建会变更仓库的 git worktree。只有作者编写的 flow 才能使用工作区隔离。
选择正确的模式
阶段是否写入你在它完成后需要的文件?
是 → dedicated
否 ↓
阶段是否需要 git(commit、diff、分支)?
是 → worktree
否 → temp| 模式 | 隔离 | 持久 | Git | 清理 | 续跑 |
|---|---|---|---|---|---|
temp | ✓ | ✗ | ✗ | 完成后删除 | 重新分配 |
dedicated | ✓ | ✓ | ✗ | 保留直到运行被修剪 | 复用同一目录 |
worktree | ✓ | ✗ | ✓ | worktree + 分支删除 | 重新分配 |
示例
无冲突的并行实验
两个阶段各自生成完整实现,并行运行。没有隔离的话它们会写同一个文件:
{
"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,然后评委选出最佳:
{
"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 和分支在各自阶段完成时被清理。
留下产物以供审查的审计
{
"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