第 18 章 / 共 21 章
Worktree 隔离:并行不打架
上一章留下的问题:alice 和 bob 现在能自己认领任务并行干活了。然后事故发生了:alice 在重构认证模块时改了 config.py,bob 在改路由时也改了 config.py。两个人都是全文件写入,后写的覆盖了先写的。
第12章的任务系统解决了”谁做什么”,但它完全没有回答”在哪里做”。所有 Agent 站在同一个目录里挥舞锤子。
18.1 一任务一工作区
Git 早就有这个能力:git worktree 可以从同一个仓库检出多个工作目录,每个挂在不同分支上,互不干扰。
于是规则变成:一个任务绑定一个 worktree,认领任务时自动切到那个目录。
import subprocess, re
from pathlib import Path
WORKTREES_DIR = Path(".worktrees")
def run_git(args: list[str]) -> tuple[bool, str]:
r = subprocess.run(["git", *args], capture_output=True, text=True)
return r.returncode == 0, (r.stdout + r.stderr).strip()
def validate_worktree_name(name: str) -> None:
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
raise ValueError(f"非法的 worktree 名称:{name}")
def create_worktree(name: str, task_id: str = "") -> str:
validate_worktree_name(name) # 堵住 ../ 之类的路径穿越
path = WORKTREES_DIR / name
ok, out = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"])
if not ok:
return f"创建失败:{out}"
if task_id:
bind_task_to_worktree(task_id, name)
log_event("create", name, task_id)
return f"已创建 worktree '{name}',路径 {path},分支 wt/{name}"
那个名称校验只允许字母、数字、点、下划线和短横。看起来吹毛求疵,但少了它,一个 ../../etc 就能让 git 在你完全没预期的地方建目录。任何来自模型的字符串,只要会被拼进文件路径,就必须校验。

18.2 认领即切目录
def teammate_claim(task_id: str, name: str, wt_ctx: dict) -> str:
result = claim_task(task_id, owner=name)
if "Claimed" in result:
task = load_task(task_id)
if task.get("worktree"):
wt_ctx["path"] = str(WORKTREES_DIR / task["worktree"])
return result
# 队友的工具被包一层,全部落在自己的 wt_ctx 里
def make_teammate_handlers(wt_ctx: dict) -> dict:
return {
"bash": lambda command: run_bash(command, cwd=wt_ctx["path"]),
"read_file": lambda path, **kw: run_read(f"{wt_ctx['path']}/{path}", **kw),
"write_file": lambda path, content: run_write(f"{wt_ctx['path']}/{path}", content),
# …
}
队友完全不需要知道自己在哪个目录——它照常写 write_file("config.py", ...),wt_ctx 负责把它落到正确的工作区。隔离对被隔离者是透明的,这是好隔离设计的标志。
18.3 安全的清理
工作完成后有两条路:
def keep_worktree(name: str) -> str:
log_event("keep", name)
return f"已保留 worktree '{name}'(分支 wt/{name}),可供人工评审与合并。"
def remove_worktree(name: str, discard_changes: bool = False) -> str:
validate_worktree_name(name)
path = WORKTREES_DIR / name
if not discard_changes:
files, commits = _count_worktree_changes(path)
if files > 0 or commits > 0:
return (f"拒绝删除:'{name}' 有 {files} 个未提交文件、{commits} 个未合并提交。"
f"请先处理,或显式传入 discard_changes=True。")
ok, out = run_git(["worktree", "remove", str(path), "--force"])
log_event("remove", name)
return f"已删除 worktree '{name}'" if ok else f"删除失败:{out}"
默认拒绝删除有改动的工作区,是一个刻意设置的摩擦。Agent 在”清理一下”这类模糊指令下很容易顺手删掉还有价值的东西;让删除在有未提交改动时失败,把决定权还给人。
18.4 走一遍:同一场事故的两种结局
没有隔离时:
| 时刻 | alice | bob | config.py |
|---|---|---|---|
| T0 | 读到内容 X | 读到内容 X | X |
| T1 | 写入 X+认证改动 | — | X+认证 |
| T2 | — | 写入 X+路由改动(基于 T0 读到的 X) | X+路由 |
| 结果 | 认证改动消失了,没有任何报错 |
有隔离时:
| 时刻 | alice(.worktrees/auth) | bob(.worktrees/routes) |
|---|---|---|
| T0 | 分支 wt/auth,读 config.py | 分支 wt/routes,读 config.py |
| T1 | 改并提交到 wt/auth | 改并提交到 wt/routes |
| T2 | 汇报完成 | 汇报完成 |
| 合并 | 两个分支合并时,git 会显式报冲突,由人来裁决 |
差别不在于”有没有冲突”——冲突照样存在。差别在于冲突从”静默覆盖”变成了”显式报错”。前者你可能几周后才发现,后者当场就要处理。
18.5 工程现实
- 绑定不改变任务状态。
create_worktree(name, task_id)只是建立关联,任务仍然是pending,等着被认领。这一点容易误解——很多人以为绑定就等于开工了。 - **清理容易被遗忘。**跑了几十个任务之后,
.worktrees/下堆着几十个目录和分支。需要一个定期清理或自动过期机制。 - 事件日志要如实。
run_git返回(成功与否, 输出),日志必须记真实结果——一份”看起来都成功了”的日志比没有日志更危险。 - **worktree 不是万能隔离。**它隔离的是仓库内的文件。数据库、网络端口、全局缓存、环境变量这些仍然是共享的。真正需要完全隔离时,要上容器。