RSS

第 18 章 / 共 21 章

Worktree 隔离:并行不打架

约 6 分钟 更新于

上一章留下的问题: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章:共享目录 vs 独立工作区
图 18.1:同一目录并行改动与一任务一 worktree 的对比。要看的是左侧那个双向覆盖的箭头——它不会报错,也不会有任何提示,只会在某次 code review 时以”我明明改过这里啊”的形式出现。

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 走一遍:同一场事故的两种结局

没有隔离时:

时刻alicebobconfig.py
T0读到内容 X读到内容 XX
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 不是万能隔离。**它隔离的是仓库内的文件。数据库、网络端口、全局缓存、环境变量这些仍然是共享的。真正需要完全隔离时,要上容器。

18.6 常见坑

18.7 亲手制造一次覆盖再用分支避开

18.8 解释显式冲突为何优于静默覆盖