第 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 status 和 git 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),每个工作目录关联不同的分支。核心命令:
git worktree add -b <new-branch> <path> <base-ref>例如:
git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD这条命令做了三件事:
- 在
.worktrees/auth-refactor目录下创建完整的工作树检出 - 创建新分支
wt/auth-refactor,基于HEAD - 将该工作树与新分支关联
关键特性:所有 worktree 共享同一个 .git 对象数据库(objects、refs、packfiles)。这意味着 worktree 之间的文件检出是独立的,但 commit 历史是共享的——在一个 worktree 中创建的 commit,在其他 worktree 中通过 branch name 或 commit hash 都能访问到。
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。
名称校验使用严格的正则约束:
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:
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 指向隔离目录:
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 是隔离的关键——所有文件操作(读写、编译、测试)都在独立目录中执行,不影响主工作树。
清理——两种关闭策略:
# 策略 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 工作流:
git checkout main
git merge wt/auth-refactor
git branch -d wt/auth-refactorWorktree 在 git worktree remove 时只删除工作目录,分支和 commit 历史仍然保留在共享的 .git 数据库中。合并操作在主工作树中完成,与 worktree 的创建/移除是解耦的。
15.3 Task + Worktree 双向绑定
双平面架构
系统的核心设计是将控制平面(Tasks)和执行平面(Worktrees)分离,通过 ID 建立双向引用:
控制平面 (.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 名称:
{
"id": 1,
"subject": "Implement auth refactor",
"status": "in_progress",
"worktree": "auth-refactor",
"owner": "",
"blockedBy": []
}Worktree 侧——index.json 中每个条目包含 task_id 字段,反向指向 Task:
{
"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 各有独立的状态机,但生命周期事件需要同步:
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
}关键同步点:
worktree_create(task_id=N)——创建 worktree 时,如果绑定了 Task,自动将 Task 从pending推进到in_progressworktree_remove(complete_task=true)——移除 worktree 时,同步将绑定的 Task 标记为completed并解除绑定
bind_worktree 方法中的自动推进逻辑:
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 写入事件记录:
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 条事件:
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)事件日志的典型输出:
{
"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 开始前,将工作树的当前状态快照为一个不挂载到任何分支的"幽灵提交":
// 捕获当前工作树状态为一个无引用的 commit
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
// 后续如果需要撤销,恢复到快照状态
restore_ghost_commit(repo, &ghost)?;Ghost Commit 的底层原理:
git add -A暂存所有变更(包括未跟踪文件)git commit --no-verify创建 commit 对象(不触发 hooks)- 不将 commit 挂到任何分支(不更新 HEAD)——commit 存在于对象数据库中但没有 ref 指向它
- 返回 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 继承主仓库的信任配置:
// 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:
// 如果 .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 独立修改了同一个文件的不同(或重叠)区域,合并回主分支时产生冲突。
场景重现:
# 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(没有被指示这样做)。
缓解策略:
- 任务粒度控制——确保分配到不同 worktree 的任务在文件层面不重叠。这需要 Task 的
description中明确列出涉及的文件范围 - 频繁同步——定期将主分支的变更合并到 worktree 分支中(
git merge main),减少分支分叉时间 - 细粒度 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 中需要额外执行:
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 是最简方案。