Skip to content

第 15 章 Worktree 隔离 —— 并行不干扰

多个 Agent 共享一个工作目录,本质上是 shared mutable state 问题——文件系统是全局可变状态,任何一个 Agent 的 git checkout、文件写入、分支切换都会影响所有其他 Agent。Git Worktree 的解决方案是:给每个任务分配独立的目录检出(directory checkout),用 Task ID 建立控制平面与执行平面的双向绑定,从而让并行任务在文件系统层面彻底隔离。


15.1 问题:共享工作目录的冲突

文件系统是 Shared Mutable State

回顾前几章的系统演进:第 10 章的 Task System 解决了"做什么"(任务 DAG),第 11 章的后台执行解决了"不等待"(并行 I/O),第 12 章的 Subagent 解决了"上下文隔离"(独立 messages[]),第 13-14 章的 Team 和自治 Agent 解决了"谁来做"(协作与认领)。但这些机制都遗留了一个未解决的问题:所有 Agent 共享同一个工作目录

这个问题在单 Agent 场景下不会暴露——只有一个执行者,文件系统的独占访问是天然保证的。但当多个 Agent(或同一 Agent 的多个后台任务)需要并行修改代码时,三类冲突立即浮现:

冲突 1:文件级竞争写入

Agent A 正在重构 config.py 的认证模块,Agent B 同时在 config.py 中添加新的配置项。两者的 write_file / edit_file 操作直接覆盖对方的修改。没有锁机制的文件系统不提供任何冲突检测——后写入的 Agent 默默吞掉前一个 Agent 的所有变更。

冲突 2:分支切换的全局副作用

Agent A 需要切到 feature/auth 分支查看历史实现,执行 git checkout feature/auth。这个操作影响的不仅是 Agent A 自己——整个工作目录的文件树都变了。Agent B 正在基于 main 分支编辑的文件瞬间变成了 feature/auth 版本,B 的下一次 read_file 读到的是完全不同的内容。

冲突 3:Git 状态污染

Agent A git add 了它修改的文件准备提交,Agent B 同时也 git add 了不相关的文件。git statusgit diff --staged 的输出混入了两个不相关任务的变更。最终的 commit 要么包含不该有的文件,要么因为冲突的暂存状态导致提交失败。

为什么上下文隔离不够

第 12 章的 Subagent 已经实现了上下文隔离——每个子 Agent 有独立的 messages[],互不干扰。但这只是逻辑层面的隔离。Subagent 的上下文是独立的,但它们共享同一个文件系统。类比操作系统:Subagent 隔离类似于进程有独立的虚拟地址空间(messages[]),但共享同一个文件系统挂载点。

真正的并行安全需要两层隔离:

隔离层机制隔离的对象
上下文隔离Subagent(独立 messages[])对话历史、推理状态
目录隔离Worktree(独立检出目录)文件系统、Git 状态

单独的上下文隔离只能防止"Agent A 的中间输出污染 Agent B 的推理",无法防止"Agent A 的文件写入破坏 Agent B 的工作区"。两者必须同时存在,才能支撑真正的并行多任务执行。


15.2 Git Worktree 解决方案

Git Worktree 原理

Git Worktree 是 Git 2.5(2015)引入的功能,允许在同一个仓库下创建多个工作目录(working tree),每个工作目录关联不同的分支。核心命令:

bash
git worktree add -b <new-branch> <path> <base-ref>

例如:

bash
git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD

这条命令做了三件事:

  1. .worktrees/auth-refactor 目录下创建完整的工作树检出
  2. 创建新分支 wt/auth-refactor,基于 HEAD
  3. 将该工作树与新分支关联

关键特性:所有 worktree 共享同一个 .git 对象数据库(objects、refs、packfiles)。这意味着 worktree 之间的文件检出是独立的,但 commit 历史是共享的——在一个 worktree 中创建的 commit,在其他 worktree 中通过 branch name 或 commit hash 都能访问到。

bash
repo/
├── .git/ 共享的 Git 对象数据库
├── .worktrees/
   ├── auth-refactor/ 独立工作树 1(分支 wt/auth-refactor)
   ├── .git 文件,指向 ../../.git/worktrees/auth-refactor
   ├── config.py
   └── ...
   ├── ui-login/ 独立工作树 2(分支 wt/ui-login)
   ├── .git
   ├── config.py 与上面是不同的文件副本
   └── ...
   ├── index.json Worktree 注册表
   └── events.jsonl 生命周期事件日志
├── .tasks/
   ├── task_1.json
   └── task_2.json
├── config.py 主工作树(main 分支)
└── ...

Task ID 到 Worktree 路径的映射

命名约定决定了映射关系。每个 worktree 的名称直接对应任务的语义标识,而非数字 ID。例如任务 "Implement auth refactor"(ID=1)对应 worktree 名称 auth-refactor,路径为 .worktrees/auth-refactor,分支为 wt/auth-refactor

名称校验使用严格的正则约束:

python
def _validate_name(self, name: str):
    if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", name or ""):
        raise ValueError(
            "Invalid worktree name. Use 1-40 chars: letters, numbers, ., _, -"
        )

1-40 个字符,仅允许字母、数字、点、下划线、连字符。这个约束不是随意的——它必须同时满足文件系统路径合法性(无空格、无特殊字符)和 Git 分支名合法性(无 ..、无 ~、无 ^)。

生命周期:创建 → 使用 → 清理

Worktree 的生命周期分为三个阶段:

创建——git worktree add 检出文件树,注册到 index.json,绑定到 Task:

python
def create(self, name, task_id=None, base_ref="HEAD"):
    path = self.dir / name
    branch = f"wt/{name}"
    self._run_git(["worktree", "add", "-b", branch, str(path), base_ref])

    entry = {
        "name": name,
        "path": str(path),
        "branch": branch,
        "task_id": task_id,
        "status": "active",
        "created_at": time.time(),
    }
    # 注册到 index.json
    idx = self._load_index()
    idx["worktrees"].append(entry)
    self._save_index(idx)

    # 双向绑定:Task 侧也记录 worktree 名称
    if task_id is not None:
        self.tasks.bind_worktree(task_id, name)

使用——在 worktree 目录中执行命令,cwd 指向隔离目录:

python
def run(self, name, command):
    wt = self._find(name)
    path = Path(wt["path"])
    r = subprocess.run(
        command, shell=True, cwd=path,
        capture_output=True, text=True, timeout=300
    )
    return (r.stdout + r.stderr).strip()[:50000]

cwd=path 是隔离的关键——所有文件操作(读写、编译、测试)都在独立目录中执行,不影响主工作树。

清理——两种关闭策略:

python
# 策略 1:保留(标记为 kept,不删除目录)
worktree_keep("auth-refactor")

# 策略 2:移除(删除目录 + 可选完成任务)
worktree_remove("auth-refactor", complete_task=True)

complete_task=True 是一键收尾——一次调用完成三件事:git worktree remove 删除目录、task.update(status="completed") 标记任务完成、task.unbind_worktree() 解除绑定。

完成后如何合并回主分支?标准 Git 工作流:

bash
git checkout main
git merge wt/auth-refactor
git branch -d wt/auth-refactor

Worktree 在 git worktree remove 时只删除工作目录,分支和 commit 历史仍然保留在共享的 .git 数据库中。合并操作在主工作树中完成,与 worktree 的创建/移除是解耦的。


15.3 Task + Worktree 双向绑定

双平面架构

系统的核心设计是将控制平面(Tasks)和执行平面(Worktrees)分离,通过 ID 建立双向引用:

text
控制平面 (.tasks/)                 执行平面 (.worktrees/)
+------------------+                +------------------------+
| task_1.json      |                | auth-refactor/         |
|   status: in_progress  <------>   |   branch: wt/auth-refactor
|   worktree: "auth-refactor"  |   |   task_id: 1           |
+------------------+                +------------------------+
| task_2.json      |                | ui-login/              |
|   status: pending    <------>     |   branch: wt/ui-login  |
|   worktree: "ui-login"      |    |   task_id: 2           |
+------------------+                +------------------------+
                                    |
                          index.json (worktree 注册表)
                          events.jsonl (生命周期日志)

Task 侧——task_N.json 中新增 worktree 字段,记录绑定的 worktree 名称:

json
{
  "id": 1,
  "subject": "Implement auth refactor",
  "status": "in_progress",
  "worktree": "auth-refactor",
  "owner": "",
  "blockedBy": []
}

Worktree 侧——index.json 中每个条目包含 task_id 字段,反向指向 Task:

json
{
  "worktrees": [
    {
      "name": "auth-refactor",
      "path": ".worktrees/auth-refactor",
      "branch": "wt/auth-refactor",
      "task_id": 1,
      "status": "active",
      "created_at": 1730000000
    }
  ]
}

双向引用的意义:从 Task 出发可以快速找到对应的执行目录(task.worktree → worktree 路径),从 Worktree 出发可以快速找到对应的任务目标(worktree.task_id → task 详情)。与第 10 章 Task System 的 blockedBy/blocks 双向冗余设计思路一致——牺牲存储一致性换取查询效率。

状态机同步

Task 和 Worktree 各有独立的状态机,但生命周期事件需要同步:

mermaid
stateDiagram-v2
    state "Task 状态机" as TaskFSM {
        [*] --> pending : task_create
        pending --> in_progress : bind_worktree
        in_progress --> completed : worktree_remove(complete_task=true)
    }

    state "Worktree 状态机" as WorktreeFSM {
        [*] --> active : worktree_create
        active --> kept : worktree_keep
        active --> removed : worktree_remove
    }

关键同步点:

  1. worktree_create(task_id=N)——创建 worktree 时,如果绑定了 Task,自动将 Task 从 pending 推进到 in_progress
  2. worktree_remove(complete_task=true)——移除 worktree 时,同步将绑定的 Task 标记为 completed 并解除绑定

bind_worktree 方法中的自动推进逻辑:

python
def bind_worktree(self, task_id, worktree, owner=""):
    task = self._load(task_id)
    task["worktree"] = worktree
    if task["status"] == "pending":
        task["status"] = "in_progress"  # 绑定即启动
    self._save(task)

这个设计的隐含假设是:如果一个任务分配到了执行环境(worktree),那么它就应该处于执行中。这消除了"任务已分配 worktree 但状态仍为 pending"的不一致状态。

EventBus:append-only 事件日志

EventBus 是整个系统的可观测性基座。所有生命周期操作都会向 .worktrees/events.jsonl 写入事件记录:

python
class EventBus:
    def __init__(self, event_log_path):
        self.path = event_log_path
        self.path.parent.mkdir(parents=True, exist_ok=True)
        if not self.path.exists():
            self.path.write_text("")

    def emit(self, event, task=None, worktree=None, error=None):
        payload = {
            "event": event,
            "ts": time.time(),
            "task": task or {},
            "worktree": worktree or {},
        }
        if error:
            payload["error"] = error
        with self.path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(payload) + "\n")

设计要点:

Append-only——只追加,不修改、不删除。这保证了事件日志的完整性——即使程序崩溃,已写入的事件不会丢失。JSONL 格式(每行一个 JSON 对象)保证了原子性——每次 write 要么写入完整的一行,要么不写入(对于小于 pipe buffer 的写入,write 在 POSIX 系统上是原子的)。

事件类型覆盖完整的生命周期

事件触发时机
worktree.create.before执行 git worktree add 之前
worktree.create.after创建成功后
worktree.create.failed创建失败时
worktree.remove.before执行 git worktree remove 之前
worktree.remove.after移除成功后
worktree.remove.failed移除失败时
worktree.keep标记为保留时
task.completed任务因 worktree 移除而标记完成时

before/after/failed 三段式事件是标准的 observability 模式。通过 before + after 的时间差可以计算操作耗时,通过 failed 事件可以追踪错误。

查询接口——list_recent 返回最近 N 条事件:

python
def list_recent(self, limit=20):
    lines = self.path.read_text(encoding="utf-8").splitlines()
    recent = lines[-limit:]
    return json.dumps([json.loads(line) for line in recent], indent=2)

事件日志的典型输出:

json
{
  "event": "worktree.remove.after",
  "ts": 1730000000,
  "task": {"id": 1, "subject": "Implement auth refactor", "status": "completed"},
  "worktree": {"name": "auth-refactor", "path": ".worktrees/auth-refactor", "status": "removed"}
}

崩溃恢复

系统的持久化设计保证了崩溃恢复能力。对话历史(messages[])是易失的——Agent 重启后对话上下文全部丢失。但文件状态是持久的:

  • .tasks/task_N.json——每个任务的完整状态
  • .worktrees/index.json——所有 worktree 的注册信息
  • .worktrees/events.jsonl——完整的操作历史

崩溃后重启时,系统通过读取这些文件即可重建完整的 Task-Worktree 映射。如果 index.json 中记录了某个 worktree 状态为 active 但对应目录已不存在(例如崩溃发生在 git worktree remove 之后但 index.json 更新之前),可以通过 git worktree list 命令与 index.json 做交叉校验来修复不一致。

16 个工具的完整系统

到这一步,Agent 的工具集从最初的 1 个(bash)增长到了 16 个。按功能分为四组:

基础工具(4 个)——文件系统操作:

工具功能
bash在主工作目录执行 shell 命令
read_file读取文件内容
write_file写入文件
edit_file精确替换文件中的文本片段

任务工具(5 个)——控制平面管理:

工具功能
task_create创建新任务
task_list列出所有任务(含状态、owner、worktree 绑定)
task_get获取单个任务详情
task_update更新任务状态或 owner
task_bind_worktree手动将任务绑定到已有 worktree

Worktree 工具(6 个)——执行平面管理:

工具功能
worktree_create创建 worktree 并可选绑定 Task
worktree_list列出所有已注册的 worktree
worktree_status查看某个 worktree 的 git status
worktree_run在指定 worktree 目录中执行命令
worktree_keep标记 worktree 为保留状态
worktree_remove移除 worktree(可选完成任务)

可观测性工具(1 个)

工具功能
worktree_events查看最近的生命周期事件

从最小循环(单工具执行)→ 工具系统(文件读写 + 搜索)→ 任务管理(DAG 调度 + 状态持久化)→ worktree 隔离(并发安全 + 生命周期管理),工具数量沿 1 → 4 → 8 → 12 → 16 的路径递增。每一步新增的工具都对应一个新的 harness 机制——工具集的增长反映了 Agent 能力边界的扩展。


15.4 Codex 的 Worktree 支持

Ghost Commit:基于快照的隔离

Codex 对 worktree 的使用方式与上文的"每任务一个 worktree"模型不同。Codex 的核心机制是 Ghost Commit——在每个 Agent turn 开始前,将工作树的当前状态快照为一个不挂载到任何分支的"幽灵提交":

rust
// 捕获当前工作树状态为一个无引用的 commit
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;

// 后续如果需要撤销,恢复到快照状态
restore_ghost_commit(repo, &ghost)?;

Ghost Commit 的底层原理:

  1. git add -A 暂存所有变更(包括未跟踪文件)
  2. git commit --no-verify 创建 commit 对象(不触发 hooks)
  3. 不将 commit 挂到任何分支(不更新 HEAD)——commit 存在于对象数据库中但没有 ref 指向它
  4. 返回 commit hash,用于后续 git restore --source <hash> --worktree 恢复

这种方式的优势是无需创建额外的工作目录——仍然在同一个工作树中操作,但通过 commit 快照提供了时间维度的隔离(可以回退到任务开始前的状态)。代价是无法支持真正的空间并行——同一时刻只有一个 Agent 可以使用工作树。

Worktree 感知的信任模型

Codex 的权限系统需要处理 worktree 场景下的信任继承问题。当 Agent 在 worktree 目录中工作时,该目录的 .git 是一个文件(gitfile,指向主仓库的 .git/worktrees/<name>),而非常规的 .git 目录。

Codex 的信任解析逻辑会沿目录树向上查找 .git 标记,如果发现是 gitfile(worktree),则继续追踪到主仓库根目录,让 worktree 继承主仓库的信任配置:

rust
// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd
pub active_project: ProjectConfig,

这意味着:如果用户已经信任了主仓库,在该仓库的任何 worktree 中工作时不需要重新授权。

沙箱中的 Worktree 保护

Codex 的沙箱权限系统对 .git 目录/文件做了特殊保护。在确定可写根目录的只读子路径时,如果检测到 .git 是文件(worktree/submodule 的标志),会额外保护该文件指向的 gitdir:

rust
// 如果 .git 是文件(worktree/submodule),还要保护其指向的 gitdir
if top_level_git_is_file {
    if let Some(gitdir) = resolve_gitdir_from_file(&top_level_git) {
        subpaths.push(gitdir);
    }
}

这防止了 Agent 意外修改 Git 内部数据结构——无论是常规仓库还是 worktree,.git 相关路径都是只读的。

Dirty Worktree 下的 Agent 行为约束

Codex 的 system prompt 中明确告知模型"你可能在一个 dirty git worktree 中工作",并给出严格的行为约束:

  • 绝不回退未知变更——工作树中可能有用户之前的未提交修改,Agent 不能因为自己的操作需要而 git checkout .git restore .
  • 区分自己的变更和他人的变更——如果在 Agent 编辑过的文件中发现意外变更,需要仔细分析而非直接覆盖
  • 不相关文件的变更一律忽略——不回退、不提交、不报告

这些约束的本质是:在共享或 dirty 的工作树中,Agent 必须是一个"好邻居"——只管理自己的变更,不影响环境中已有的状态。


15.5 Worktree 的边界问题

合并冲突

最常见的边界问题:多个 worktree 独立修改了同一个文件的不同(或重叠)区域,合并回主分支时产生冲突。

场景重现:

bash
# Worktree A 修改了 config.py 的第 10-20 行
# Worktree B 修改了 config.py 的第 15-25 行

git checkout main
git merge wt/auth-refactor    # 成功
git merge wt/ui-login          # 冲突!

这不是 worktree 特有的问题——它就是标准的 Git 合并冲突。但 worktree 模式让这个问题更容易发生:传统开发中,开发者会在合并前 rebase 以减少冲突;而 worktree 中的 Agent 通常不会主动 rebase(没有被指示这样做)。

缓解策略:

  1. 任务粒度控制——确保分配到不同 worktree 的任务在文件层面不重叠。这需要 Task 的 description 中明确列出涉及的文件范围
  2. 频繁同步——定期将主分支的变更合并到 worktree 分支中(git merge main),减少分支分叉时间
  3. 细粒度 commit——每完成一个原子修改就提交,而非累积大量变更后一次性提交。小 commit 的合并冲突更容易手动解决

磁盘占用

每个 worktree 是主分支的完整文件树检出。虽然 Git 的对象数据库是共享的(.git/objects 不会重复存储),但工作树文件是独立的——每个 worktree 都有自己的一份 src/node_modules/(如果被跟踪的话)等目录。

对于中等规模的项目(代码本身 100MB),每个 worktree 额外占用约 100MB 磁盘空间。如果项目包含大量二进制文件或生成产物,占用更大。

优化措施:

  • .gitignore 管理——确保 node_modules/build/__pycache__/ 等不被跟踪,worktree 创建时不会检出这些目录
  • 及时清理——任务完成后立即 worktree_remove,不要让废弃的 worktree 长期占用磁盘
  • 浅检出——对于大型 monorepo,可以使用 git sparse-checkout 配合 worktree,只检出任务需要的子目录

Submodule 与 LFS

Submodule——git worktree add 不会自动初始化子模块。如果项目使用 submodule,在新建的 worktree 中需要额外执行:

bash
cd .worktrees/auth-refactor
git submodule update --init --recursive

这增加了 worktree 创建的耗时和复杂度。如果 Agent 不知道需要初始化 submodule(system prompt 中没有提示),在 worktree 中执行依赖 submodule 代码的操作会失败。

Git LFS——Large File Storage 通过替身文件(pointer file)+ 远程存储实现大文件管理。worktree 创建时,LFS 文件默认只检出 pointer,不会自动下载实际文件。需要在 worktree 中执行 git lfs pull 才能获取真实文件内容。

对于 LFS 文件较多的项目,每个 worktree 的 lfs pull 可能消耗大量带宽和时间。缓解方案是配置 LFS 的本地缓存(lfs.storage),让多个 worktree 共享已下载的 LFS 对象。

并发 worktree 的上限

虽然 Git 对 worktree 数量没有硬性限制,但实际使用中存在软上限:

  • inode/文件句柄——每个 worktree 包含数千到数万个文件,大量 worktree 可能触达系统的 inode 或 ulimit -n 上限
  • Git 锁竞争——多个 worktree 同时执行 git add/git commit 时,会竞争 .git/index.lock。虽然每个 worktree 有自己的 index,但对共享 objects 的写入仍可能产生竞争
  • I/O 吞吐——机械硬盘上,10 个 worktree 同时编译/测试的 I/O 竞争会严重拖慢所有任务

经验值:对于 SSD 上的中等规模项目,3-5 个并发 worktree 是性能和隔离的平衡点。超过 10 个时,磁盘空间和 I/O 竞争通常会成为瓶颈。

Worktree 与其他隔离机制的对比

隔离机制隔离粒度开销共享范围适用场景
Git Worktree目录级低(共享 .git)Git 历史、对象同仓库多分支并行
Docker 容器进程级中(镜像层叠)异构环境隔离
VM系统级高(完整 OS)完全隔离
Git Clone仓库级中(完整 .git 副本)无(需 fetch 同步)跨机器分布式

Worktree 是"恰好够用"的隔离——在单机、同仓库的场景下,提供文件级隔离,开销最低。不需要 Docker 或 VM 的重量级隔离时,worktree 是最简方案。