Worktree 工作区隔离与并行开发:从零到一
这是一份快照
本文的数字、常量、行数取自 2026-09-02 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档写给谁:想让多个 AI agent 同时改同一个代码仓、但还没搞清楚 「它们为什么会互相踩」「隔离该隔到哪一层」的人。 用途是知识梳理与 agent 开发面试准备。
它和同目录/相邻目录那几份执行文档的关系:那些是执行文档——写给已经懂的人, 满篇是「这条判据第二版写错了」「实测推翻」「61 项缺口」。信息密度极高, 但它们默认你已经知道 worktree、ALS、canonical root、fail-closed 是什么, 所以第一次读会卡在第三行。
本文补的正是那一层:先把机制讲通,再把那些文档里真正值钱的结论放回它该在的位置上。 每一章末尾都有「回到原始文档」的指路。
它不是摘要。 摘要会把结论抽成一句正确但没用的话(「用 worktree 做隔离」—— 然后呢?)。本文的写法相反:每个结论都从「为什么会有人搞错」讲起, 因为面试里能拉开差距的从来不是结论本身,是你能不能说清它的反面为什么诱人。
一个重要的立场声明:这个领域最容易犯的错,是把「文件隔离做好了」当成 「并行做好了」。文件隔离是入场券,不是终点——它之后还有三层 (运行时隔离、合并收口、语义冲突),而后两层没有任何 git 命令能帮你。 全文的骨架就是这四层。
怎么读这份文档
按顺序读。这是一条链,不是清单——后面每一章都在用前面章节建立的概念。
| 章 | 讲什么 | 读完你能回答 |
|---|---|---|
| §0 | 名词地图 | 别人说 worktree / ALS / canonical root / fail-closed 时,你知道指什么 |
| §1 | 为什么需要隔离:三种错误的做法 | 为什么「多开几个终端」「各自 clone」都不够 |
| §2 | git worktree 到底是什么(机制层) | 能画出 .git 指针文件的结构,说清它和 clone/branch 的区别 |
| §3 | 隔离的四个层次 | 拿到一个「并行卡住了」的现场,你知道该往哪一层找 |
| §5 | 生命周期与清理:最容易出数据丢失事故的一章 | 能说清 fail-closed 在这里为什么是唯一正确的方向 |
| §6 | 运行时隔离:端口 / 依赖 / 数据库 | 知道为什么「文件隔离完了还是跑不起来」 |
| §7 | 冲突判据:把「感觉会冲突」换成实测阈值 | 能说出 git 真正的冲突阈值,以及它管不到什么 |
| §9 | 会「绿着坏掉」的失效模式(本文最重要的一章) | 这一章是本领域的核心资产 |
| §10 | 一个真实的数据永久丢失事故的完整解剖 | 能讲清「归因错误 + 不可逆操作」这条链 |
| §11 | 横向对比:几家怎么做的,以及什么时候「对方有」不构成理由 | 调研时不会得出反的结论 |
| §13 | 动手:从零搭一套 | 五个阶段,每阶段有验收判据 |
如果只有 20 分钟:读 §3、§7、§9。这三章是这个领域的骨架,其余都是它们的展开。
如果只有 5 分钟,记住这三句:
- worktree 解决的是「文件层」的隔离,它是四层里最容易的一层。
- 并行的上限不在 agent 数量,在合并串行化——15 个 agent 并行产出, 如果合并前要跑 200 秒全量测试且没有队列,返工吃掉的比并行省下的更多。
- 这个领域最贵的错误不是冲突,是「误判 + 不可逆操作」—— 有一次真实事故是几小时的工作永久消失,恢复途径全空(§10)。
§0 名词地图:先把词认全
这一节是查询表,不用背。往后每章第一次用到某个词时都会重新解释, 这里放一份集中的,是为了你读那些执行文档时能随时回来查。
按「一次并行开发从头到尾」的顺序排列,不按字母序——因为这些词之间是有位置关系的。
0.1 Git 侧(本文的物理基础)
| 词 | 中文 | 是什么 |
|---|---|---|
| repository / repo | 仓库 | 一个项目的完整版本历史。物理上就是那个 .git 目录 |
| working tree / 工作树 | 工作区 | 你能用编辑器打开的那些文件。它是 .git 里某个版本的一次「展开」 |
| branch | 分支 | 一个指向某次提交的可移动指针。它不占额外工作区 |
| checkout / switch | 切分支 | 把工作区的内容换成另一个分支的内容。同一个工作区,内容被覆盖掉了 |
| clone | 克隆 | 复制一整个仓库(含全部历史对象)到新目录。磁盘代价最大 |
| git worktree | 二次工作树 | 同一个 .git,多个工作区。本文主角(§2 详解) |
.git pointer file | 指针文件 | worktree 目录里的 .git 不是目录而是一个文本文件,内容是 gitdir: /主仓/.git/worktrees/<名字>。这是 worktree 的识别标志 |
| canonical git root | 主仓根 | 真正含 .git 目录(不是指针文件)的那个目录。防嵌套的关键概念(§2.4) |
| detached HEAD | 游离头 | 工作区停在某次提交上而不在任何分支上。git rev-parse --abbrev-ref HEAD 此时返回字符串 "HEAD",容易让代码崩 |
| prune | 剪枝 | git worktree prune 清理「目录已被手工删掉、但 git 记录还在」的孤儿登记项 |
| rebase | 变基 | 把自己的提交挪到新的 base 上重放。并行时后合入的一方通常要做这一步 |
0.2 隔离侧(本文的核心)
| 词 | 中文 | 是什么 | 关键区别 |
|---|---|---|---|
| 文件隔离 | — | 两路 agent 改的是不同磁盘路径的文件 | worktree 提供的就是这一层,也只有这一层 |
| 运行时隔离 | — | 两路各自跑的 dev server、数据库、端口互不冲突 | worktree 完全不管(§6) |
| cwd | 当前工作目录 | 进程「站在哪个目录」。决定相对路径解析到哪里 | 它是全进程共享的一个变量,这正是麻烦的来源 |
process.chdir() | 改进程目录 | Node 里改 cwd 的 API | 进程级全局。两个并发任务都调它 = 互相踩(§4.1) |
| AsyncLocalStorage(ALS) | 异步本地存储 | 给「当前这条异步执行链」挂一份私有数据的机制 | 这是进程内并发的正解(§4.2) |
| isolation: "worktree" | 隔离参数 | 子代理的一个开关:给我一个独立 worktree 干活 | 用户手动 enter 和子代理自动创建是两条不同的路径 |
0.3 生命周期侧(最容易出事故的一组)
| 词 | 中文 | 是什么 |
|---|---|---|
| ephemeral worktree | 临时工作树 | 程序自己建的(子代理 / workflow / swarm),可以自动清理 |
| named / user worktree | 具名工作树 | 用户自己起名建的(brave-eagle-42),永不自动删除 |
| slug | 名字串 | worktree 的名字。会被拼进文件系统路径和 git 分支名,所以必须校验(§5.4) |
| flatten | 扁平化 | user/feature → user+feature。防止 / 在文件系统里造出嵌套目录和 D/F 冲突 |
| grace period | 宽限期 | 「刚建出来正在用」的保护窗口。本仓临时 worktree 是 6 小时 |
| stale cleanup / GC | 过期清理 | 定期扫描并删掉过期且无改动的临时 worktree |
| fail-closed | 失败即保守 | 检测失败时拒绝执行危险动作(这里是「拒绝删除」)。宁可留垃圾也不丢代码 |
| fail-open | 失败即放行 | 反过来。检测失败时照做。在删除路径上用它 = 数据丢失(§9.2) |
| orphan worktree | 孤儿 | 该删没删下来、一直占盘的目录。本仓实测出现过 5 个 / 361MB |
0.4 并行编排侧
| 词 | 中文 | 是什么 |
|---|---|---|
| 分层 / layering | — | 把 N 个改动排成「哪几个能同时做、哪几个必须等」 |
| fan-out subagents | 扇出子代理 | 一次并行起 N 个只读子代理去查东西,用来做分层分析 |
| conflict gap | 冲突间距 | 两处改动的行号差。实测 ≥2 就不冲突(§7.1),这是全文最反直觉的数字之一 |
| 语义冲突 | — | 两边 git 合得上、各自 CI 也绿,但合起来是错的。git 永远拦不住(§7.3) |
| merge queue | 合并队列 | 平台功能:把待合并的 PR 串行验证「合并后的状态」。语义冲突的唯一真解 |
| 汇聚门 / aggregate gate | — | 一个不干活的 CI job,只把其他 job 的结论收成一条,供分支保护绑定(§8.3) |
| stacked PR | 叠放 PR | 把 PR B 的 base 指向 PR A 的分支。本文明确反对(§8.4 有一个「不报红只转圈」的坑) |
0.5 治理与判据侧
| 词 | 中文 | 是什么 |
|---|---|---|
| 变异自证 / mutation test | — | 故意把被测的东西改坏,看门禁是否真的报红。新增门禁必做,否则你不知道它是「通过」还是「根本没在测」 |
| 机制 vs 约定 | — | 机制 = 做错了物理上做不到;约定 = 写在 prompt 里靠模型遵守。机制 > 约定,这条在本文出现四次 |
| 不变量 / invariant | — | 整个系统任何时刻都必须成立的性质。改代码时第一优先级是别破它 |
| 分母 | — | 算比例时的底数。分母口径一变,曲线整体平移——「7.5% 的 PR 会碰生成物」这个数只有说清分母才有意义 |
💡 一个能立刻用上的记忆法:把仓库想成一间图书馆的藏书库(
.git,全部历史都在里面), 把工作区想成一张阅览桌。
- 切分支(checkout)= 同一张桌子,把书换成另一本。上一本摊开的笔记就没地方放了。
- clone = 另建一座图书馆,把全部藏书复印一遍。贵。
- worktree = 同一个藏书库,多摆几张阅览桌。每张桌子摊开不同的书, 互不干扰,但共享同一批藏书。
这个类比后面还会用到好几次,尤其是 §2.4——「防嵌套」就是不许在阅览桌上再摆一张阅览桌, 以及 §5——「清理」就是收桌子,而收桌子最怕的是把别人还在写的稿子一起扫进垃圾桶。
§1 为什么需要隔离:三种直觉做法,为什么全都不够
先说清问题。你有一个代码仓,想同时让 3 个 agent 各改一件事。 就这么简单一句话, 但它藏着一个大多数人第一次不会想到的前提冲突。
1.1 冲突的根源:一个工作区只能表达一个状态
代码仓的工作区在任意时刻只能是一个版本。这不是 git 的设计缺陷,是文件系统的事实: src/app.ts 这条路径上只能放一份内容。
于是当 3 个 agent 都往同一份工作区写:
agent A:把 src/app.ts 第 10 行改成 X
agent B:把 src/app.ts 第 10 行改成 Y ← A 的改动被覆盖,A 不知道
agent C:跑 git checkout -- . 想回到干净状态 ← A 和 B 的改动全没了注意第三行。C 的动作在单人工作时完全合理(「先回到干净状态再开始」), 在并行时是灾难。这个形态不是假设——本仓真实发生过一次,几小时的工作永久消失, 完整解剖在 §10。
所以「隔离」要解决的第一件事是:让每一路 agent 有自己的一份工作区。
1.2 直觉做法一:多开几个终端
最自然的想法。开三个终端窗口,各跑一个 agent。
为什么不行:终端是视觉上的隔离,不是文件上的隔离。 三个终端 cd 到的是同一个目录,写的是同一批文件。 你只是让自己看不到冲突,冲突照样发生。
🔑 这一条值得记住,因为它是一个通用的错误形态: 把「界面上分开了」当成「状态上分开了」。 后面 §4.1 还有一个同源的版本——把
process.chdir()当成 per-agent 的目录设置, 而它其实是全进程一个变量。
1.3 直觉做法二:每个 agent 各自 git clone 一份
这个方向是对的——确实做到了文件隔离。问题在代价。
| 代价 | 量级(本仓实测) |
|---|---|
磁盘:每份含完整 .git 历史对象 | 一个仓的历史可能几百 MB 到几 GB |
| 时间:clone 要传输全部对象 | 大仓几十秒到几分钟 |
| 分支同步:N 份仓库各有各的 remote 状态 | 你要在 N 个地方 git fetch |
| 提交可见性:A 的提交 B 看不到 | 必须经过 remote 中转,本地无法直接引用 |
最后一条是质变,不只是慢:clone 出来的是两个独立仓库, A 提交的东西 B 要先 push 再 fetch 才能看到。而 worktree 共享同一个 .git, A 提交完 B 立刻就能 git log 看到——因为对象库是同一个。
1.4 直觉做法三:一个工作区来回切分支
「A 改完切到 B 的分支,B 改完切回来」——这不是并行,这是串行 + 额外的切换成本。
而且它有个更硬的障碍:切分支要求工作区是干净的。 A 改到一半(未提交)时切不过去,git 会拒绝。你要么先提交半成品,要么 stash。
⚠️
git stash在并行场景是禁用的。这条是本仓的一条铁律, 原因是 stash 卷走的是整个工作区的改动——包括别的任务正在写的文件。 「我 stash 一下做个基线对照」这个动作,会静默地把并行同事的在途代码收走。 单人开发时 stash 很好用,并行时它是一颗地雷。
1.5 于是 worktree 的位置就清楚了
把三种做法摆在一起看,worktree 恰好落在那个空档上:
| 方案 | 文件隔离 | 磁盘代价 | 提交互相可见 | 能同时存在 |
|---|---|---|---|---|
| 多终端 | ❌ 无 | 零 | — | ✅ |
| 各自 clone | ✅ 有 | 高(N 份历史) | ❌ 要过 remote | ✅ |
| 切分支 | ✅ 有 | 零 | ✅ | ❌ 一次只有一个 |
| git worktree | ✅ 有 | 中(N 份工作区,1 份历史) | ✅ | ✅ |
worktree = 切分支的隔离性 + clone 的并存性 − clone 的历史复制代价。
1.6 但这里有一个必须马上打破的错觉
上面那张表容易让人得出结论:「用 worktree,问题解决了」。
这个结论是本领域最常见的错误。 worktree 解决的是表里那一列——文件隔离。 而真实的并行开发会撞到四层问题,文件隔离只是第一层:
| 层 | 问题形态 | worktree 管吗 |
|---|---|---|
| ① 文件 | 两路改同一个文件互相覆盖 | ✅ 管,这是它的全部职责 |
| ② 运行时 | 两路各起 dev server → EADDRINUSE: port 3000 已被占用 | ❌ 完全不管(§6) |
| ③ 合并 | 两路各自 CI 绿,合起来 main 红 | ❌ 不管,且 git 也不管(§7.3 / §8) |
| ④ 进程内 | 一个进程里的 N 个子代理共享 process.cwd() | ❌ 不管,要靠 ALS(§4) |
一份行业调研(Penligent 的 Git Worktrees Need Runtime Isolation)把这件事 总结成一句很准的话:文件隔离已是解决的问题,运行时隔离是下一个痛点。
🔑 这就是本文的骨架。如果面试里被问「怎么做并行开发的隔离」, 只答 worktree 是不完整的答案——它只覆盖了四层里的一层, 而实际把人卡住的往往是 ② 和 ③。
§2 git worktree 到底是什么:把机制拆开看
这一章是机制层。读完你应该能在白板上把 worktree 的目录结构画出来—— 面试里这一步能立刻区分「用过」和「知道它怎么工作」。
2.1 一条命令,两个后果
git worktree add .claude/worktrees/feat-login -b worktree-feat-login这条命令做了两件事,分别在两个地方留下痕迹:
后果一:新目录里出现一份完整的工作区
.claude/worktrees/feat-login/
├── package.json ← 真实文件,不是软链
├── src/
├── ...
└── .git ← ⚠️ 注意:这是一个「文件」,不是目录后果二:主仓的 .git 里多了一个管理目录
主仓/.git/worktrees/feat-login/
├── HEAD ← 这个 worktree 停在哪
├── index ← 这个 worktree 的暂存区(本仓实测 338KB)
├── gitdir ← 反向指针,指回工作区里那个 .git 文件
├── commondir ← 内容是 "../.." ,指向共享的对象库
├── logs/ refs/ ORIG_HEAD FETCH_HEAD COMMIT_EDITMSG2.2 关键结构:那个 .git 文件(不是目录)
这是全章最该记住的一点。在 worktree 里:
$ cat .claude/worktrees/feat-harbor-h1/.git
gitdir: ~/sid-code/.git/worktrees/feat-harbor-h1而在主仓里:
$ stat -f "%HT" .git
Directory主仓的 .git 是目录,worktree 的 .git 是一行文本的文件。 这个差别是整套机制的支点,它直接派生出三件事:
| 派生结论 | 为什么 |
|---|---|
| 对象库是共享的 | worktree 里没有 objects/,所有提交对象都存在主仓那一份里 |
| 能判断「我在不在 worktree 里」 | stat 一下 .git:目录 → 主仓;文件 → worktree |
| 能从 worktree 反查主仓根 | 读那行文本,.git/worktrees/<名字> 向上两级就是主仓 .git,再上一级是仓库根 |
第三条就是 findCanonicalGitRoot() 的全部原理(§2.4)。
💡 回到图书馆类比:主仓的
.git目录是藏书库本体; worktree 里那个.git文件是一张写着「藏书库在隔壁 3 号房」的便签。 所以 worktree 很轻——它只有摊在桌上的书,没有复制整个藏书库。
2.3 共享什么,独立什么(一张必须记牢的表)
并行开发的所有「诡异现象」都能从这张表推出来:
| 东西 | 共享还是独立 | 后果 |
|---|---|---|
提交对象 / 历史(objects/) | 共享 | A 提交完,B 立刻 git log 能看到。这是 worktree 打败 clone 的地方 |
remote 配置、git config --local | 共享 | 在一个 worktree 里改 config --local = 改了所有 worktree |
.git/info/exclude | 共享 | ⚠️ 这一条是个坑,见下方 |
| HEAD / 分支 / index(暂存区) | 独立 | 各自可以停在不同分支、各自 git add 互不影响 |
| 工作区文件 | 独立 | 隔离的核心 |
gitignored 的本地文件(.env、settings.local.json) | 不存在 | ⚠️ 新 worktree 里根本没有这些文件(§5.6 / §6.4) |
node_modules | 取决于配置 | 本仓默认 symlink 到主仓,这是个有代价的选择(§6.2) |
⚠️ 那个 exclude 的坑值得单独说,因为它会让人白花半天: 你可能想「让 worktree 内某些文件不算改动,写进
.git/info/exclude就行」。 两条路都是死的(实测):
- 在 worktree 里跑
git rev-parse --git-path info/exclude解析到的是主仓那份 → 写它等于污染主仓和所有其他 worktree。- 写进
.git/worktrees/<name>/info/exclude→ git 完全忽略,git status毫无变化。原因是
info/不在 git 的 per-worktree 路径白名单里(只有HEAD/index/logs/HEAD等少数几个是)。🔑 正确的解法方向:不要动 gitignore/exclude 体系,在判定侧解决—— 自己解析
git status --porcelain输出,按事实(比如lstat出它是个 symlink)排除。 这一条在 §9.1 会变成一个完整的事故案例。
2.4 防嵌套:findCanonicalGitRoot() 解决什么问题
现在来看一个只有 agent 场景才会撞到的问题。
场景:一个 agent 已经在 worktree 里干活了,它又起了一个子代理, 子代理也要 isolation: "worktree"。新 worktree 该建在哪?
如果实现是「找最近的 .git 然后在它旁边建」,结果是:
主仓/
└── .claude/worktrees/agent-A/ ← agent A 在这
└── .claude/worktrees/agent-B/ ← ⛔ 嵌套了
└── .claude/worktrees/agent-C/ ← ⛔ 还能继续套为什么这是问题,而不只是难看:
- A 干完活被清理时,B 连带消失,而 B 可能还在跑
- A 的
git status里会看到 B 的整个目录(噪音,还可能让 A 判定「有改动」而拒绝被清理) - 层数不可控,清理逻辑要递归,孤儿目录会藏在深处
解法就是用 §2.2 的第三条派生结论:不找「最近的 .git」,找真正的主仓根。
从当前目录往上走,每层看 .git:
是目录 → 找到了,这就是主仓根,返回
是指针文件 → 读出 gitdir,向上两级 = 主仓 .git,再上一级 = 主仓根,返回
不存在 → 继续往上一层
走到文件系统根还没找到 → 返回 null(不是 git 仓库)本仓的实现在 packages/core/src/worktree/canonical.ts(93 行), 函数名就叫 findCanonicalGitRoot()。它还有一个防御性的循环上限(256 层), 防止异常符号链接造成死循环。
所以不变量是:所有临时 worktree 一律建在主仓的 .claude/worktrees/ 下, 永远是扁平的一层。 本机实测状态正是这样——10 个目录全在同一层:
$ git worktree list | wc -l
11 # 1 个主仓 + 10 个 worktree
$ ls .claude/worktrees/ | wc -l
10 # 全部在同一层,零嵌套2.5 为什么 worktree 必须放在仓库目录里面
有人会问:放在 /tmp/worktrees/ 岂不是更干净、还不污染项目目录?
答案是不行,这是 git 的约束:二次工作树必须能被主仓的 .git 管理到, git worktree add 的路径虽然可以在仓库外,但那样一来它就完全脱离项目上下文 (相对路径、项目配置、.gitignore 全都对不上)。
主流实现的选择是一致的:放在 <repo>/.claude/worktrees/(Claude Code)或 <repo>/.sid-code/worktrees/。
🔑 一条重要的判断经验(这条来自一次实际的方向纠偏): 当 worktree 出问题时,人的第一反应常常是「是不是位置放错了、该放到项目外面去」。 但位置从来不是问题,判定逻辑和清理时机才是。 把位置挪出去不会修掉任何一个真实缺陷,只会让实现多一堆路径特例。
⚠️ 位置放在
.claude/下确实有一个真实代价,但它不是「污染」, 是一个很具体的测试假失败:worktree 的 cwd 路径里含.claude/, 会命中权限系统的「Claude 配置目录」敏感路径守卫, 导致某个用相对路径的权限测试可预期地红。这条在 §9.5 详解—— 它是「判定一个失败与我无关时必须举证」的教科书案例。
2.6 本章自检
- worktree 目录里的
.git是文件还是目录?内容是什么?它有什么用? - A 在 worktree-1 提交了代码,B 在 worktree-2 能不能直接
git log看到?为什么? - 为什么不能靠
.git/info/exclude让 worktree 内的某些文件不算改动? findCanonicalGitRoot()和「找最近的.git」有什么区别?不做这个区分会怎样?
§3 ★ 隔离的四个层次(本文的架构核心)
§1.6 已经点了一句,这一章把它展开成可用的心智模型。 这是本文最值得先记住的一张图——它的作用是:当有人说「我们并行卡住了」, 你能立刻问出「卡在哪一层」,而不是笼统地回答「用 worktree 啊」。
3.1 四层的定义与边界
┌─────────────────────────────────────────────────────────┐
│ ④ 合并层 两路各自绿,合起来红 │
│ 解药:合并队列 / 汇聚门 / 合并后跑门禁 │
├─────────────────────────────────────────────────────────┤
│ ③ 运行时层 端口 / 数据库 / 依赖版本 │
│ 解药:端口偏移 / DB 分支 / lockfile 一致性告警 │
├─────────────────────────────────────────────────────────┤
│ ② 进程层 一个进程里 N 个子代理共享 process.cwd() │
│ 解药:AsyncLocalStorage │
├─────────────────────────────────────────────────────────┤
│ ① 文件层 两路改同一份文件互相覆盖 │
│ 解药:git worktree ← 只有这一层是 git 的事 │
└─────────────────────────────────────────────────────────┘读法:从下往上,难度递增、可自动化程度递减。
| 层 | 解药的性质 | 能不能完全自动化 |
|---|---|---|
| ① 文件 | 一条 git 命令 | ✅ 完全能 |
| ② 进程 | 一个语言机制(ALS) | ✅ 完全能 |
| ③ 运行时 | 一堆项目特定的约定(端口号、DB 名) | ⚠️ 部分能,且必须 opt-in |
| ④ 合并 | 需要人 review | ❌ 不该完全自动化 |
最后一行是这张图最重要的信息。很多「全自动并行开发」方案的失败点就在这里: 它们把 ④ 也自动化了(让 agent 自己 merge),于是唯一的质量闸门消失了。
3.2 每一层的典型症状(拿来对症)
面试或排查时,症状 → 层级 的映射:
| 你看到的现象 | 在哪一层 | 常见错误归因 |
|---|---|---|
| 「我明明改了,怎么没了」 | ① 文件 | 以为是 agent 写错了,其实是被另一路覆盖 |
| 「A 的改动出现在 B 的 diff 里」 | ① 文件 | 同上 |
| 「子代理读到了错的文件路径」 | ② 进程 | 以为是路径拼接 bug,其实是 cwd 串台 |
| 「并发跑就乱,串行跑就对」 | ② 进程 | 这是 ② 层的指纹信号(§4.1) |
EADDRINUSE: port 3000 | ③ 运行时 | 以为要改代码,其实要改端口分配 |
table already exists / migration 打架 | ③ 运行时 | 以为是 migration 写错,其实是共享同一个 DB |
module not found,但 package.json 里明明有 | ③ 运行时 | 最难归因的一个,成因是 symlink 的 node_modules 版本不匹配(§6.2) |
| 两个 PR 各自 CI 绿,合完 main 红 | ④ 合并 | 以为是 CI 抖动,其实是语义冲突(§7.3) |
| PR 页面「一直转圈,不报红」 | ④ 合并 | 以为在排队,其实门禁一次都没触发(§8.4) |
🔑 「并发跑就乱、串行跑就对」这个信号值得单独记住。 它几乎唯一地指向 ② 层——某个进程级全局态被多条执行链共享了。 因为文件层的冲突串行也会发生(只是形态不同),运行时层的端口冲突串行时不发生 但报错很明确,只有 ② 层会给你「时序相关、报错飘忽、重跑可能就好了」这种感觉。
3.3 一个反直觉的结论:投入产出比是倒过来的
大多数人的直觉是「先把文件隔离做扎实,再考虑其他」。这个顺序在收益上是反的。
| 层 | 实现成本 | 不做的后果 | 业界现状 |
|---|---|---|---|
| ① 文件 | 低(git 自带) | 明显,立刻发现 | 几乎所有工具都做了 |
| ② 进程 | 中(要重构 cwd 读取路径) | 隐蔽,时序相关 | 做的不多 |
| ③ 运行时 | 中(但很碎) | 明显但归因难 | 几乎没人做(下一个痛点) |
| ④ 合并 | 高(要平台支持 + 流程变更) | 最贵:返工吃掉全部并行收益 | 大项目才做 |
所以差异化的机会在 ③ 和 ④,不在 ①。 ① 已经是行业标配了, 把它做得更精致(更好看的名字、更细的配置项)几乎不产生用户可感知的价值。
一份横向调研的结论正是这样:查了 Claude Code / Cursor / Nimbalyst / Shards 四家, 基础 worktree 隔离四家全有;端口自动隔离四家全无;DB 冲突提醒四家全无。
⚠️ 但这个「四家全无」不能直接读成「所以我们该做」。 这里有一个必须补上的反转,它是本文方法论里很重要的一条:
对标之后发现「对方没有」时,先问一句「他们是不做,还是刻意外包出去了」。 实测下来,Claude Code 在端口隔离这件事上是刻意外包给
WorktreeCreatehook 的—— 它给了扩展点,让用户自己写。这和「没想到」是完全不同的两件事。🔑 「对方有 / 对方没有」从来不构成理由,「服务哪个目标」才构成理由。 这条在 §11 会展开成一整套判据。
3.4 本章自检
- 四层分别是什么?哪一层是 git 的职责范围?
- 「并发跑就乱、串行跑就对」指向哪一层?为什么这个信号有排他性?
- 为什么 ④ 层不该完全自动化?
- 发现竞品四家都没做某个能力,你的下一个动作是什么?
§4 进程内并发:chdir 为什么不够,ALS 为什么是解
这一章讲 §3 的 ② 层。它是四层里技术含量最集中的一层,也是面试里最能出深度的一层—— 因为它不是 git 知识,是并发编程知识。
4.1 先看问题:process.chdir() 是全进程一个变量
假设你有一个 agent 进程,它要同时跑 3 个子代理,每个子代理在自己的 worktree 里干活。
最直接的想法:进去之前 chdir 过去。
// ❌ 这段代码在并发下是错的
async function runSubAgent(worktreePath, task) {
process.chdir(worktreePath); // 切到我的 worktree
await doWork(task); // 干活(内部会读文件、跑命令)
process.chdir(originalPath); // 切回来
}
// 三个并发跑
await Promise.all([
runSubAgent("/repo/.claude/worktrees/A", taskA),
runSubAgent("/repo/.claude/worktrees/B", taskB),
runSubAgent("/repo/.claude/worktrees/C", taskC),
]);为什么错:process.chdir() 改的是整个进程的当前目录,它是一个全局变量。 三条执行链交错时会这样:
时刻 1: A 调 chdir(/A) → 进程 cwd = /A
时刻 2: A 开始 await(让出控制权)
时刻 3: B 调 chdir(/B) → 进程 cwd = /B ← A 还在跑!
时刻 4: A 的 await 返回,继续读 "src/app.ts"
→ 它以为在 /A,实际解析到 /B/src/app.ts ⛔ 串台这个 bug 的性质值得说清楚:
- 它不是每次都发生,取决于 await 的时序
- 它不报错,只是读写了错误的文件
- 它的症状是「并发跑就乱,串行跑就对」——正是 §3.2 那个指纹信号
🔑 一个重要的推论:如果你的实现只有
chdir这一条路, 那么worktree 隔离在进程内是失效的——你在磁盘上分了三个目录, 但进程只能同时站在一个目录里。这就是为什么某些实现里「隔离的成员只能串行执行」—— 不是设计选择,是被
chdir的全局性逼出来的。本仓的swarm/team.ts早期正是这个状态。
4.2 解法:AsyncLocalStorage
Node 提供了一个专门解决这类问题的机制:AsyncLocalStorage(下面简称 ALS)。
一句话原理:它能给「当前这条异步执行链」挂一份私有数据, 跨 await 不丢、且不同执行链之间互不可见。
本仓的实现只有 29 行,全文贴出来(packages/core/src/bootstrap/cwd-context.ts):
import { AsyncLocalStorage } from "node:async_hooks";
const cwdStorage = new AsyncLocalStorage<string>();
/** 在绑定到 dir 的异步上下文里运行 fn。期间 getAgentCwd() 返回 dir。 */
export function withAgentCwd<T>(dir: string, fn: () => T): T {
return cwdStorage.run(dir, fn);
}
/** 取当前异步上下文绑定的 cwd;不在任何 withAgentCwd 内时返回 undefined。 */
export function getAgentCwd(): string | undefined {
return cwdStorage.getStore();
}改造后的调用方:
// ✅ 并发安全
await Promise.all([
withAgentCwd("/repo/.claude/worktrees/A", () => doWork(taskA)),
withAgentCwd("/repo/.claude/worktrees/B", () => doWork(taskB)),
withAgentCwd("/repo/.claude/worktrees/C", () => doWork(taskC)),
]);三条链各自看到自己的 cwd,进程的 process.cwd() 一次都没被动过。
4.3 关键的第二步:让工具「自动」读到它
上面只解决了「存」,还差「取」。如果每个文件工具都要显式接一个 cwd 参数, 改造量会大到做不下去(read / write / edit / ls / glob / bash 全都要改签名)。
本仓的做法是把它塞进已有的那个咽喉:
所有路径类工具 → getCwd() → ① 先看 ALS 有没有值 → 有就用它
② 没有 → 回退到全局 state.cwd(与改造前完全一致)于是一个工具都不用改签名,所有文件操作自动以当前子代理的 worktree 为基准。
🔑 这个设计模式很值得记住,它的通用形态是: 把「按上下文变化的值」收进一个已有的单一读取入口,而不是让它出现在每个调用者的参数表里。
判断这个模式能不能用的条件有两个: ① 存在这样一个单一入口(这里是
getCwd()); ② 未进入上下文时的回退行为与改造前完全一致(这里是回退到state.cwd)。 第 ② 条是能安全落地的关键——它保证改造是纯增量的,不影响任何既有路径。
4.4 两条路并存的规则(不是所有地方都用 ALS)
一个容易被忽略的细节:ALS 并不是要把 chdir 完全消灭。 本仓的规则是分工明确的两条路:
| 场景 | 用什么 | 为什么 |
|---|---|---|
用户手动 enter_worktree | chdir + 全局状态 | 用户在整个会话里就是要「站在」那个目录,这是单条链,不存在并发 |
| 所有代理执行路径(子代理 / workflow / swarm) | ALS(withAgentCwd) | 它们天然可能并发 |
而且用户路径那个 chdir 也不能裸调,本仓把它包成了原子操作 (packages/core/src/worktree/canonical.ts):
export function switchCwd(newPath: string): void {
process.chdir(newPath);
setCwd(newPath); // 同步全局 cwd 状态,使 getCwd() 解析到新目录
}为什么要包:process.chdir() 和内部的 setCwd() 是两个独立的状态, 分别调用就会有「改了一个忘了另一个」的窗口。症状是路径类工具解析到旧目录, 而 shell 命令跑在新目录——一次典型的状态漂移。
💡 这是一个通用的小型设计原则: 两个必须同时更新的状态,就该只有一个更新入口。 留两个入口,迟早有人只调一个。
4.5 一个真实的缺陷:路径不统一的代价
上面规则听起来很清楚,但落地时曾有一处漏掉:inline 子代理走的是 chdir 而不是 ALS。
后果不是「不优雅」,是并发不安全:如果主循环里有异步操作与它交叉,就会撞上 §4.1 那个串台。 修法是把这条路径也收进 withAgentCwd()。
🔑 这条的可迁移价值:一个「并发安全」的保证,强度等于最弱的那条路径。 三条路径里两条用了 ALS、一条还在
chdir, 那么这个系统整体上不是并发安全的——而它的代码看起来 2/3 是对的。排查这类问题的判据很机械:grep 全仓
process.chdir,逐个确认它是否在 「保证单条链」的位置上。 不在 → 就是缺陷。
4.6 本章自检
- 为什么
process.chdir()在并发场景是错的?画出交错时序。 - ALS 解决了什么、没解决什么?「让工具自动读到」是怎么做的?
- 为什么 ALS 落地时要求「未进入上下文时回退行为不变」?
- 什么场景下用
chdir反而是对的? - 「并发安全」这个保证的强度由什么决定?
§5 生命周期与清理:最容易出数据丢失事故的一章
前面四章讲的是「怎么建起来」。这一章讲「怎么收拾」——而这一章的事故等级最高, 因为清理是唯一一个不可逆的环节。
5.1 完整生命周期:一个 worktree 的一生
① 校验 slug ← 必须在任何 fs/git 操作之前(§5.4)
② 定位主仓根 ← findCanonicalGitRoot(),防嵌套(§2.4)
③ 决定 base ← 从 origin/默认分支(fresh)还是当前 HEAD(head)
④ git worktree add -B <branch> <path> <base>
⑤ 创建后配置 ← symlink / 复制本地配置 / sparse-checkout / 告警(§5.6)
⑥ 持久化 session ← 让进程重启后还能恢复(§5.5)
───────────────── 干活 ─────────────────
⑦ 退出:keep 还是 remove?
├─ keep → 保留目录和分支,只切回原 cwd
└─ remove → 先数改动 → 有改动就拒绝(fail-closed)→ 无改动才删
⑧ 后台 GC ← 定期扫描过期且无改动的临时 worktree(§5.7)注意 ④ 那个 -B 而不是 -b:小写 -b 在分支已存在时会报错, 大写 -B 会强制重置。选 -B 是为了让「上次残留了同名分支」这种情况不阻断创建—— 这是幂等性的一部分。
5.2 两类 worktree:这个区分决定了谁能被自动删
这是整章最重要的一个概念区分。
| 类型 | 谁建的 | 名字形态 | 能自动删吗 |
|---|---|---|---|
| 临时(ephemeral) | 程序 | agent-a1b2c3d4 / wf_xxx-0-abc / swarm-backend / bridge-xx / job-xx | ✅ 能 |
| 具名(named) | 用户 | brave-eagle-42 / feat-login | ⛔ 永不自动删 |
判定靠正则匹配目录名(packages/core/src/worktree/cleanup.ts):
const EPHEMERAL_PATTERNS = [
/^agent-[0-9a-f]{8}$/, // 子代理隔离
/^agent-[a-z0-9]{8}$/, // 子代理(task id 形态)
/^swarm-.+$/, // Swarm teammate
/^wf_.+-\d+-[0-9a-f]{3,4}$/, // Workflow
/^wf-\d+$/, // Workflow(legacy)
/^bridge-.+$/, // 未来远程控制模式
/^job-.+$/, // 守护进程 job 模式
];这个白名单的方向很关键:它是「只删认识的」,不是「删掉不认识的」。
🔑 为什么方向必须是这个:两种写法在正常情况下行为一样, 但在遇到没见过的目录名时结论相反:
- 「只删认识的」→ 漏删,代价是占盘(可恢复)
- 「删掉不认识的」→ 误删用户工作,代价是数据丢失(不可恢复)
在代价不对称的地方,方向必须偏向可恢复的那一侧。 这是 fail-closed 思想在命名判定上的体现。
⚠️ 这条也解释了为什么每次新增一种自动创建路径(比如新加一个 workflow 模式), 必须同步往这个白名单加一条。忘了加的后果是:那种 worktree 永远不被清理, 静默累积占盘——而不会有任何报错。
5.3 删除的核心判据:fail-closed
删除前要回答一个问题:这个 worktree 里有没有还没保存的工作?
有三种可能的实现方向,只有一种是对的:
| 方向 | 检测失败时怎么做 | 后果 |
|---|---|---|
| fail-open | 删 | ⛔ git 命令一抖就丢用户代码 |
| 不检测 | 删 | ⛔ 更糟 |
| fail-closed | 拒绝删 | ✅ 留下一个垃圾目录,代价是占盘 |
本仓的不变量第一条就是这个:git 命令失败时拒绝删除,宁可留下垃圾 worktree 也不丢用户代码。
「有工作」的判据是两项,缺一不可:
① 未提交的文件改动数 > 0 → 拒绝
② 未推送的 commit 数 > 0 → 拒绝
③ 上面两个数取不到(git 报错)→ 拒绝(fail-closed)只查 ① 是不够的:agent 干完活可能已经 git commit 了但还没 push, 此时工作区是干净的,git status 一片空白——只看 ① 会判定「没工作」然后删掉, 而那些 commit 因为分支被删就再也找不回来了。
而且拒绝时必须报出具体数字,不能只返回一个布尔值:
❌ 「worktree 有未保存的改动,拒绝删除」 ← 用户不知道是啥,只能猜
✅ 「检测到 5 个未提交文件、2 个未推送 commit,
确认要丢弃请传 discard_changes: true」 ← 用户能判断💡 这个区别不是 UX 细节,是决策质量问题:只给布尔值时, 用户(或 agent)唯一的下一步就是「加上强制参数再来一次」—— 于是 fail-closed 这道防线在实践中被稳定绕过。 给出数字才让「该不该强制」成为一个可以判断的问题。
5.4 slug 校验:为什么它必须在最前面
worktree 的名字(slug)会被拼进两个危险的地方:文件系统路径、git 分支名。
所以它是一个安全边界,规则(packages/core/src/worktree/slug.ts):
| 规则 | 防的是什么 |
|---|---|
| 总长 ≤ 64 | 文件系统路径长度限制 |
不能以 / 开头 | 绝对路径注入 |
按 / 分段,每段只许 [a-zA-Z0-9._-] | 各种奇怪字符 |
禁止 . 和 .. 作为独立段 | 路径穿越(../../etc/) |
| 禁反斜杠、禁空字节 | Windows 路径 / C 字符串截断攻击 |
禁 Windows 驱动器号(C:) | 绝对路径的另一种形态 |
「必须在最前面」这一点值得单独说:校验要在任何 fs/git 操作之前做完。 如果先建目录再校验,那个恶意路径已经落到磁盘上了——校验只是事后知情。
而且所有入口都要校验,不能只在其中一个入口做: 用户工具、子代理创建、workflow 创建是三条独立路径。
🔑 一条通用的安全工程原则: 校验必须在「最后一个能拒绝的时刻之前」,而不是「第一个方便的时刻」。 而且多入口的校验只有全覆盖才有意义——漏一个入口, 整个校验就等于没有(攻击者当然会走那个没校验的入口)。
顺带一个相关设计:user/feature 这种带斜杠的名字要支持,但不能真的建嵌套目录, 所以做扁平化:user/feature → user+feature。 它必须是纯函数(同一个 slug 永远映射到同一个目录名),否则「按名字找回自己的 worktree」就不成立了。
5.5 持久化:为什么进程重启后必须能恢复
如果 worktree session 只存在内存里,进程一崩:
- 用户视角:「我刚才不是在 worktree 里吗?怎么回到主仓了」,得手动重新 enter
- 更糟的:后台 GC 不知道有个 worktree 正在被使用,可能把它当孤儿删掉
所以要持久化,但只持久化恢复所需的最小字段——像「创建耗时」这种运行期数据不该写盘。
恢复时有一个必做的校验:worktree 目录是否还在磁盘上。 不在就清除持久化状态,而不是恢复出一个指向空气的 session。
⚠️ 这里有一个容易漏的连带效应:持久化做了之后,GC 才有能力「跳过当前活跃的 worktree」。 没有持久化时,GC 启动时读不到任何 session 信息,它无法区分「正在用」和「孤儿」。 所以这两个功能在依赖关系上是绑定的——先有持久化,GC 的活跃保护才成立。
5.6 创建后配置:三件容易踩的事
git worktree add 只给你「git 追踪的文件」。而实际能跑起来的项目还需要三样它不给的东西:
① node_modules —— 新 worktree 里是空的。两种处理:symlink 到主仓(快,有版本风险), 或者跑一次 install(慢,正确)。这是个真实的两难,§6.2 详解。
② gitignored 的本地配置 —— .env、settings.local.json 这类文件因为被 gitignore, 在新 worktree 里根本不存在。后果很具体:
- 缺
.env→ 项目跑不起来 - 缺权限配置 → agent 每个操作都要人点一次确认(§6.4)
处理方式有两种:自动复制本地配置文件(本仓默认开), 或者提供一个 .worktreeinclude 让用户按 gitignore 语法指定哪些 gitignored 文件要跟过来。
③ symlink 失败要告警,但不能阻断 —— Windows 建 symlink 需要管理员权限,会失败。 这时候正确的处理是 warn 然后继续(fail-open),因为 symlink 只是个优化, 不是 worktree 能用的前提。
🔑 注意 ③ 和 §5.3 的方向是相反的:删除路径用 fail-closed,创建期的优化用 fail-open。 这不矛盾,因为代价结构不同:
- 删除时失败放行 → 丢代码(不可恢复)→ 必须 fail-closed
- 创建时优化失败放行 → 用户少一个便利(可恢复)→ 该 fail-open
fail-closed 不是一个通用默认值,它的方向由「哪一侧的错误代价更大」决定。 一个系统里两个方向并存是正常的、也是正确的;如果一个系统里所有地方都是同一个方向, 那大概是没想过。
5.7 后台 GC:三重保护 + 一个宽限期
自动清理的完整判据(每一条都是一道保护):
候选 = 目录名匹配 ephemeral 白名单 ← 保护 1:具名 worktree 完全不进候选
∧ 年龄 > 宽限期 ← 保护 2:刚建出来正在用的不动
∧ 无未提交改动 ∧ 无未推送 commit ← 保护 3:有工作绝不删(§5.3)
∧ 没有 git 的 locked 标记 ← 保护 4:别的进程标记了「我在用」
∧ 不是当前活跃 session ← 保护 5:依赖 §5.5 的持久化宽限期的取值是个有意思的判断。本仓从 30 天缩到了 6 小时,理由是:
临时 worktree 的正常寿命是分钟级。6 小时远超任何单次子代理任务, 足以避开「另一个进程正在跑长任务」的误判;同时又远短于 30 天, 让崩溃遗留的孤儿在下次启动时就被回收,而不是占盘一个月。
换句话说:活到 6 小时的临时 worktree,基本可以断定是崩溃遗留的。 这个推理方式(从「正常寿命的量级」反推阈值,而不是随手取个整数)值得学。
清理完还要跑一次 git worktree prune,清掉「目录已删但 git 登记还在」的孤儿条目。
5.8 磁盘会失控,这是个真实数字
本机实测(2026-09-02):
$ du -sh .claude/worktrees
3.3G .claude/worktrees # 10 个目录
$ du -sh .claude/worktrees/feat-harbor-h1/node_modules
141M .../node_modules # 单个 worktree 的依赖3.3G / 10 个目录,其中大部分是历史残留。
🔑 一个重要的判读:这里真正的问题不是并发峰值,是不清理。 「同时开 4 路要 1.4G」听起来很多,但那是临时的; 而 3.3G 里绝大部分是几周前的目录从没被清理过。
这个区分决定了修法方向:
- 如果问题是并发峰值 → 该做的是限流(限制同时开几路)
- 如果问题是不清理 → 该做的是让清理真的会跑
归错了因就会去做限流,而限流对 3.3G 这个数字没有任何帮助。
5.9 本章自检
- 临时 worktree 和具名 worktree 怎么区分?为什么白名单方向是「只删认识的」?
- 删除前为什么必须同时查「未提交改动」和「未推送 commit」?只查前者会怎样?
- 为什么 slug 校验必须在任何 fs 操作之前,且必须覆盖所有入口?
- 为什么删除用 fail-closed、创建期优化用 fail-open?这矛盾吗?
- 宽限期为什么是 6 小时而不是 30 天?这个推理是怎么做的?
§6 运行时隔离:文件分开了,为什么还是跑不起来
这一章讲 §3 的 ③ 层。它是最容易被忽略、但用户最先撞上的一层—— 因为文件隔离做完之后,用户的下一个动作就是「跑起来看看」,然后就撞墙了。
6.1 端口冲突:并行开发的头号痛点
三个 worktree 各起一个 dev server:
Error: listen EADDRINUSE: address already in use :::3000这个报错本身很清楚,但它揭示了一件事:端口是全机器共享的资源, worktree 完全不管它。 你可以有 10 份代码,但 3000 端口只有一个。
业界的通用解法是一个公式(Upsun 提出的形态):
SERVICE_PORT = BASE_PORT + (WORKTREE_INDEX × 10) + SERVICE_OFFSET翻译成人话:给每个 worktree 分一个序号,序号 × 10 就是它的端口段起点。 worktree 0 用 3000-3009,worktree 1 用 3010-3019,以此类推。 SERVICE_OFFSET 用来区分同一个 worktree 里的多个服务(web / api / ws 各占一位)。
落地形态通常是:创建 worktree 时生成一个 .env.worktree 文件, 里面写好 PORT=3010、API_PORT=3011……项目启动时通过 dotenv 加载。
⚠️ 这个方案有两个真实的坑,都是设计层面的,不是实现层面的:
坑 1:所有服务同端口。 如果实现写成「所有 envVar 都赋同一个值」, 那
PORT和API_PORT会拿到同一个数字——worktree 之间不冲突了, 但同一个 worktree 内部的两个服务开始冲突。公式里那个SERVICE_OFFSET不是装饰,漏掉它整个方案就是错的。坑 2:
.env.worktree没人自动读。 生成一个文件很容易, 但框架不会主动读一个叫.env.worktree的文件(dotenv 默认读.env)。 于是这个文件生成了、看起来功能"有了",实际零效果—— 这是 §9 那类「绿着坏掉」的典型形态。🔑 所以这个方案的正确验收判据不是「文件生成了」, 而是**「起两个 dev server,两个都能起来」**。
另一条路是把它外包出去:提供一个 WorktreeCreate hook 扩展点, 让用户自己写端口分配脚本。Claude Code 选的是这条路,本仓也提供了同一个扩展点。
两条路的取舍:
| 内置端口隔离 | 外包给 hook | |
|---|---|---|
| 用户开箱可用 | ✅ | ❌ 要自己写 |
| 适配各种项目 | ❌ 端口变量名千差万别 | ✅ 用户最清楚自己项目 |
| 维护成本 | 高(要维护 envVar 列表) | 低 |
| 出错时归因 | 难(用户不知道是我们改的) | 易(是用户自己的脚本) |
6.2 依赖:node_modules 的两难(本章最值得细看的一节)
这一节值得慢读,因为它是一个教科书级的「省事的默认值埋了个静默坑」案例。
背景:新 worktree 里没有 node_modules。两条路:
| 策略 | 做法 | 代价 |
|---|---|---|
| symlink | 把主仓的 node_modules 软链过来 | 快(毫秒级)、省盘,但版本可能不对 |
| install | 在 worktree 里跑一次 install | 正确,但慢(pnpm/bun 有全局 store 时 2-3 秒,npm 更慢) |
symlink 的坑具体是什么:如果这个分支的 lockfile 和主仓不同 (比如分支加了一个新依赖),那么 worktree 里 import 解析到的是主仓的依赖版本, 而不是这个分支该有的版本。症状是:
module not found: 'some-new-package' ← 但 package.json 里明明有或者更阴的:版本不对但能跑,行为诡异,你会去怀疑自己的代码。
这是 Stack Overflow 上 worktree + node_modules 的最高票问题。
两家的选择不同,而这个对比很有信息量:
| Claude Code | 本仓 | |
|---|---|---|
| 默认 | 空数组——根本不碰 node_modules | ["node_modules"],默认 symlink |
| 依赖谁装 | 用户 / WorktreeCreate hook | symlink 白拿主仓的 |
| 后果 | 用户要自己管,但绕开了整个坑 | 开箱免装依赖,但默认踩进坑里 |
🔑 这个对比的价值不在「谁对」,在于它演示了一种对标的正确用法: 对标标杆时,最大的收获常常不是「标杆有我们没有的功能」, 而是发现「我们比标杆激进的地方埋了什么坑」。
CC 用「默认不碰」这个看起来消极的选择,绕开了整个版本错乱问题。 我们用「默认 symlink」买到了便利,代价是把一个静默 bug 装进了默认路径。 两个选择都合理,但只有看清代价才叫做了选择。
本仓最后的处置方式很值得学,它是三个选项里的第三条路:
| 选项 | 为什么不选 |
|---|---|
| 改成默认不 symlink(学 CC) | 破坏性变更——存量用户正靠 symlink 免装依赖 |
| 自动跑 install | install 有一堆边界(monorepo workspace 协议、私有 registry、超时);且 CC 也从不 install |
| ✅ 把静默 bug 变成显式告警 | 保留默认行为,但当 lockfile hash 不一致时告警一句 |
告警的实现在 packages/core/src/worktree/advisories.ts,它有一个很重要的性质: 只在条件真实成立时才输出——lockfile 一致时零输出,没 symlink 时零输出。
🔑 「零噪音」这个约束是告警类功能的生死线。 一个每次都打印的告警,三天之后就没人看了; 而一个只在真有问题时才出现的告警,出现时人会真的读它。
⚠️ 这里还有一个容易漏的工程细节:子代理和 workflow 的隔离 worktree 没有输出通道 (它们不是用户交互会话,输出不会显示给人看)。 所以告警在那两条路径上要落
log.warn,否则告警本身会静默丢失—— 一个「防静默失败」的机制自己静默失败了,这个形态在 §9 会反复出现。
6.3 数据库:并行开发的「静默杀手」
这个说法来自实战分享(Cole Medin),形容得很准。
症状:
Agent A 在 worktree-1 跑 prisma migrate dev → 建了表
Agent B 在 worktree-2 跑同一个 migration → "table already exists"更危险的形态:两个分支的 migration 内容不同,互相覆盖, 最后 schema 处于一个两个分支都没有描述过的状态。 这时候两边的代码在本地都跑不通,而你会去怀疑代码。
为什么它比端口冲突更麻烦:端口冲突报错明确、立刻发生、影响范围是本进程。 数据库冲突是共享的持久状态被污染——报错发生在别的地方、别的时间, 而且清理它要手工改 schema。
解法分两档:
| 档 | 做法 | 成本 |
|---|---|---|
| 纯提醒 | 检测项目里有没有 migration 标记文件(prisma/migrations、drizzle.config.ts、alembic.ini……),有就在创建输出里提醒一句 | 极低,零风险 |
| 自动分支 | 用 Neon / PlanetScale / Supabase 的 DB branching,创建 worktree 时开一个 DB 分支,删除时清理 | 中等,但依赖具体 DB 服务 |
💡 为什么「纯提醒」这一档不该被看不起:它的性价比极高。 这类冲突的归因难度远大于修复难度——用户看到
table already exists想不到是并行导致的,会去查 migration 历史、查数据库状态, 可能花半小时才想到「我是不是开了两个 worktree」。一句在正确时机出现的提醒,省掉的是归因时间,不是修复时间。 这是很多「提示类功能」被低估的原因——它的收益不在自己那一步, 在于避免了下游一整条错误的排查路径。
6.4 权限配置:一个不显眼但影响巨大的缺失
这一节讲的东西不在任何「worktree 教程」里,但它是实际使用中第一个让人放弃的点。
现象:worktree 建好了,起个 agent 会话开始干活,然后—— 每一个文件读、每一个编辑、每一条命令都要人点一次确认。
根因很朴素:权限配置文件(settings.local.json)是 gitignored 的, 而 worktree 是从 origin/main 切出来的——那份配置根本不在里面。
后果的形状值得注意:
串行时:一个会话在问你 → 烦,但能忍
并行 2 路:两个会话交替问你 → ⛔ 比串行更累并行让体验变差了,这和「并行是为了更快」的初衷直接相反。 真实用户反馈的原话是:「权限没开,每个步骤还需要人点击授权,这个根本就不可接受。」
处置方式:分级放行,而不是全开。
| 类别 | 处理 |
|---|---|
| 文件编辑、搜索、读取 | ✅ 免确认 |
| 测试 / 构建 / lint | ✅ 免确认(白名单) |
| git 只读(status / diff / log) | ✅ 免确认 |
git add / commit / fetch / rebase | ✅ 免确认 |
git push / gh pr create / gh pr merge | ⚠️ 仍然问 |
rm -rf / reset --hard / clean -f / push --force | ⛔ 硬拦 |
读含真实 API key 的配置、**/.env、~/.ssh/** | ⛔ 硬拦 |
效果:一个 PR 的点击次数从几十次降到 2 次(push、开 PR)。
🔑 为什么不直接全开(
--dangerously-skip-permissions),三条理由按严重度排:
gh pr merge会绕过 review——而 review 是整套并行流程里唯一的质量闸门(§3.1)。 prompt 里可以写「停下等我 review」,但那是约定,不是机制。git push有难撤回的外部副作用,且并行几路共享同一个远端。rm -rf/reset --hard在 worktree 场景下格外危险: 本机.claude/worktrees有 10 个目录 / 3.3G 在途工作,一条走偏的清理命令能一次抹掉几小时的工作。 这不是假设,§10 是真实事故。⚠️ 一个关键的细节区分:
git push应该放在「问」而不是「硬拦」。 硬拦了第二路就没法交付了。该问的东西就让它问—— 目标是「每个 PR 问 2 次」,不是「一次都不问」。还有一条容易踩的限制:已经开着的会话不会热加载权限配置。 改完配置必须退出会话重开,因为权限是会话启动时读的。
⚠️ 关于白名单的维护,有一条判断标准必须守住: 往白名单加条目时,照「这个命令有没有外部副作用」判,不要照「烦不烦」判。 后者会一路滑到全开——每次都只多放一条,每次都看起来无害。
6.5 本章自检
- 端口隔离公式是什么?漏掉
SERVICE_OFFSET会发生什么? .env.worktree这个方案的验收判据该是什么?为什么不能是「文件生成了」?- symlink
node_modules的坑是什么?CC 用什么方式绕开了它? - 为什么「把静默 bug 变成显式告警」是比「自动修复」更好的处置?
- 为什么并行反而会让权限确认体验比串行更差?
- 为什么
git push该「问」而不该「硬拦」?
§7 ★ 冲突判据:把「感觉会冲突」换成实测阈值
现在假设文件隔离、进程隔离、运行时隔离都做好了。下一个问题是编排: 手上有 8 件事要改,哪几件能同时做,哪几件必须排队?
这一章是本文方法论价值最高的一章,因为它演示了一个完整的 「凭直觉判断 → 实测 → 推翻自己 → 重新分级」的过程。
7.1 先测阈值,再写判据
最朴素的判据是「改同一个文件就得串行」。这个判据的问题是太粗—— 一个 800 行的文件里两处相隔很远的改动,git 完全能自动合并,判它串行是白白牺牲并行度。
第二版判据细化成了「行号挨得近就冲突」。但「挨得近」是个感觉,不是判据。
正确的做法是把 git 的真实阈值测出来:造一个 30 行文件, A 分支改第 10 行,B 分支改第 10+GAP 行,在 B 上 git merge A,看 GAP 多大才不冲突。
| A/B 行号差 | 中间隔了几行 | git merge 结果 |
|---|---|---|
| 1(紧邻) | 0 | ⛔ 冲突 |
| 2 | 1 | ✅ 自动合并 |
| 3 | 2 | ✅ 自动合并 |
| 4 – 8 | 3 – 7 | ✅ 自动合并 |
| 同一行两侧改 | — | ⛔ 冲突 |
阈值结论:行号差 ≥ 2 就不冲突。
原理:git 的合并算法需要至少一行未改动的上下文来分隔两个 hunk。 紧邻两行会被并进同一个 hunk,于是变成「同一处的两种改法」→ 冲突。 隔一行就有了分隔,两个 hunk 各自独立 → 自动合并。
再测一步(这一步很重要,因为它对应真实工作流): A 先合入 main,B 再 rebase 到含 A 的 main 上——差 ≥2 的情况下 rebase 也是干净的, 不需要人工干预。
🔑 这个实测直接推翻了一个流传很广的直觉。 第二版判据举过一个例子: 「工具列表里
enter_worktree在第 39 行、grep在第 43 行,三个工具挨在一起, 两边各改一个就会冲突」——实测结果是它们相差 4 行,git 自动合并,完全不冲突。为什么会错:那个结论是目测行号得出的,从来没实跑过
git merge。 这正是「计数必须写脚本,禁目测」这条教训的复现——只不过这次目测的对象 不是数字,而是「相邻」这个关系。💡 可迁移的一条:关系型的判断(相邻、包含、依赖)比数值型的判断更容易目测错, 因为数值错了容易被察觉(数量级不对),关系错了看起来完全合理。
7.2 修订后的判据:四级 + 一条与文件无关的前置
| 级别 | 判据 | 处理 |
|---|---|---|
| C1 | 同文件 + 同一行(含同一函数 / 常量 / class 成员的同一处) | ⛔ 必须串行 |
| C2 | 同文件 + 紧邻行(行号差 1) | ⛔ 必须串行 |
| C3 | 同文件 + 行号差 ≥ 2 | ✅ 可并行,后合入者 rebase(实测无需人工干预) |
| C4 | 零文件重叠 | ✅ 可并行 |
| G · 生成物 | 两个改动都会触发同一份「从源码生成的文档」变化 | ⚠️ 不升级为串行,按 C1–C3 判具体行距;但要告知「你可能需要重跑生成器」 |
| S · 语义前置 | B 的验收判据需要 A 的产出才成立(与文件完全无关) | ⛔ 必须串行 |
两个细节值得单独说:
① 为什么级别名从 L1/L2/L3 改成了 C/G/S——这是个刻意的改动。 L1/L2/L3 暗示了「严重度从高到低的单一维度」, 而实测表明「生成物」根本不在那个维度上:它是一个正交属性 (要不要多跑一次生成器),不是「更严重的冲突」。 用 L 编号会让人继续把它当最高级来处理。
🔑 命名会固化错误的心智模型。这条比它看起来重要: 一旦编号体系暗示了某个维度,后来的人就会顺着那个维度思考, 哪怕文字说明里已经写清了它是正交的。
② 行号必须在同一个 base 上取。 两路各自从 origin/main 切出来时行号一致; 但如果一路已经改了前面的行,后面的行号就漂了。所以提取「足迹」时 必须锚定 origin/main,不是各自的工作区。
7.3 判据管不到什么:语义冲突(这一节是本章的诚实边界)
上面那张表判的是「能不能同时开工不撞车」,不是「合起来一定对」。
一个具体例子:两个 PR 各改一个工具的参数定义,行号相隔很远,git 合得上, 两边 CI 各自也绿。但如果它们同时改动了注册表的注册顺序, 合并后生成物的行序会变,输出和预期都不一样。
更一般地:两路各自 CI 绿 + git 自动合并成功,仍可能因语义耦合而合后红。
这类问题 git 永远拦不住,因为 git 不理解代码含义,它只看文本行。
对策分两层,强度差一档:
| 层 | 手段 | 时机 | 强度 |
|---|---|---|---|
| 真解 | 合并队列 + 汇聚门 | 合并前拦住 | 强,但要先做 PR 化(§8) |
| 现在能做的 | 层内全部合入后,在 main 上跑一次完整门禁 | 合并后发现 | 弱一档,需要配回滚路径 |
🔑 「承认边界」本身是这份判据可信的原因。 一个声称「用了我的判据就不会有问题」的方案是不可信的; 一个说清「我管这一类、那一类我管不到、那一类的对策是什么」的方案才可用。
面试里这一点很值得主动说——能说清自己方法的失效边界, 比多背两个判据更能体现深度。
7.4 频率量化:一个动作该不该设成「必做」,先看分母
这一节演示一条通用纪律。
第二版判据里有一条:「每个 worktree 改完都要跑一次生成物检查,这是必做动作,不是可选优化。」
实测之后这条被推翻了,方式是查分母:
gh pr list --state merged --limit 100 --json number,files --jq '...'
# → {"refPRs": 4, "total": 53}近 60 天 53 个合入 PR,只有 4 个触及生成物 = 7.5%。
于是判断就变了:把一个「起一次进程、约 1 秒」的检查 + 相应的认知负担, 压在 92.5% 不需要它的改动上,会发生什么?
答案在项目自己的一条 hook 注释里写得很清楚: 「每次提交都跑会让无关提交也变慢,久了就会被
--no-verify绕过。」🔑 结局是最坏的那一种:不是「大家忍着慢」,而是这道检查被整体绕过, 于是剩下那 7.5% 真需要它的时候也不再被检查。
一个会被绕过的门禁,比没有门禁更糟——因为它给了「我们有检查」的错觉。
正确形态是条件触发:只在改动碰到了生成物的数据源路径时才跑。 而且锚点最好复用现成的(本仓直接复用了 pre-commit 里那条路径匹配), 不要另写一套判断——两套判断迟早漂移。
💡 这一节的通用形态: 判断一个动作该不该设成默认,必须先知道它命中的比例。 「这个检查很重要」和「这个检查该每次都跑」是两个不同的命题, 前者成立不代表后者成立。
7.5 一个必须知道的辅助事实:.gitattributes 不是解药
有人会想:把生成物标记成 linguist-generated,是不是就不冲突了?
不是。 linguist-generated 只让代码托管平台在 PR 页面折叠 diff, 完全不影响 merge 行为——一行冲突也少不了。
真要消掉生成物冲突需要配一个 merge=ours 的 merge driver + 合并后重跑生成器。 而按 §7.4 的频率(7.5%),不配是合理的。
🔑 这条的价值在于它是一个「看起来相关、实际不相关」的典型。 排查时最容易浪费时间的不是「找不到线索」,是找到一条看起来对的线索。 判据很机械:问这个机制作用在哪个阶段——
linguist-generated作用在「展示」阶段,冲突发生在「合并」阶段,两者不相交。
7.6 本章自检
- git 真正的冲突阈值是多少?为什么是这个数(原理)?
- 「三个工具挨在一起所以会冲突」这个判断错在哪?错误的性质是什么?
- 为什么级别名要从
L1/L2/L3改成C/G/S? - 语义冲突是什么?为什么 git 永远拦不住?两层对策的强度差在哪?
- 一个「很重要的检查」为什么不该设成每次必跑?
§8 合并收口:并行的真正上限在这里
这一章讲 §3 的 ④ 层。它是四层里最贵、最容易被跳过、也最决定成败的一层。
8.1 一句话结论
并行的上限不在 agent 数量,在「合并串行化」。
展开:15 个 agent 并行产出 15 份改动,如果每一份合并前必须跑 200 秒全量测试、 没有合并队列、没有覆盖率门禁——那么并行度越高,主干上的互相干扰越大, 返工吃掉的比并行省下的更多。
这句话的意思不是「别并行」,而是:扩并行度之前先做收口,顺序反了就是拿主干稳定性换吞吐。
8.2 三段浪费,性质完全不同
串行做 8 个改动,一次循环是这样的:
开会话 → 喂上下文 → 写代码 → 跑门禁 → 提 PR → 等 CI(≈10min) → review → merge → 关会话
↑ 人工重复 ↑ 空转 ↑ 人工必需| 浪费 | 量级 | 能不能消 |
|---|---|---|
| 每次重新喂上下文 + 人工算依赖顺序 | 8 次 × 5-10 分钟 | ✅ 能自动化(一次性算完全部分层) |
| 等 CI 空转 | 8 次 × 10 分钟 | ✅ 能重叠(一路等 CI 时另一路在写) |
| review + merge | 8 次 × ? | ❌ 不该消。这是唯一的质量闸门 |
所以方案的形状是:压缩前两段,保留第三段。
🔑 「一次编排跑完 8 个 PR 完全不用人管」这个目标本身是错的—— 它要求你放弃第三段。而第三段是唯一在检查「这些改动到底对不对」的环节。
8.3 收口需要的三样东西
① 汇聚门(aggregate gate)
一个不干活的 CI job,它的唯一职责是把其他 job 的结论收成一条:
lint ─┐
test ─┼→ all-checks-passed ← 分支保护只绑这一个
build ┘为什么要这一层:分支保护如果直接绑 lint/test/build 三个名字, 那么每次增删一个 job 都要改分支保护配置;更糟的是漏绑一个就等于那个 job 白跑 (红了也能合)。有了汇聚门,分支保护只认一个名字,增删 job 不用动配置。
验收判据必须是变异自证:故意让 lint 红 → 汇聚门必须 exit 1。 不做这一步你不知道它是「通过」还是「根本没连上」。
② 合并队列(merge queue)
它做的事:把待合并的 PR 排队,**串行验证「合并后的状态」**而不是「各自分支的状态」。
这是 §7.3 那个语义冲突的唯一真解——因为它测的正是「合起来对不对」。
③ 体积门禁
单个 PR 超过 N 个文件就硬拦。理由是真实数据:某仓近 200 个 commit 的体积分布是 p50=5 / p90=21 / p99=233 / max=1776 文件。
p99 这一档既审不了、也没有 CI 在合并前拦,而事故已实证发生过: 一个改 1613 个文件的重构 commit,紧接着就有一个「补回 212 个文件的漏提交, HEAD 构建不出来」的修复 commit。
🔑 第二条是第一条的直接后果:大批量改动 + 无 PR 门禁 = 主干上的 HEAD 构建不出来,而当时「9184 个测试通过」是对着工作区跑的—— 工作区里有那 212 个没提交的文件,所以它绿。
💡 这个形态值得单独记:「测试全绿」的作用域是工作区,不是仓库。 少提交了文件的情况下,本地全绿和 HEAD 能不能构建是两个独立的事实。 这就是为什么 CI 必须在干净 checkout 上跑。
8.4 一个具体的坑:stacked PR 会让门禁「一次都不跑」
stacked PR = 把 PR B 的 base 指向 PR A 的分支(而不是 main),形成一条链。
它听起来很合理(B 依赖 A,所以基于 A 开发)。但它在 CI 层面有一个静默失效:
PR B 的 base 指向 PR A 的分支
→ CI 的触发条件通常只匹配「base 是 main」的 PR
→ 于是 PR B 的 CI 一次都不跑
→ PR 页面显示「等待检查」,不报红
→ agent 在零门禁反馈下往上继续堆提交「不报红」是这个坑最危险的地方。 报红了人会去看;一直转圈,人会以为在排队。
⚠️ 而且这个坑有一个连带的细节:A 合并后自动把 B 的 base 改成 main 时, 平台发的是
edited事件——如果 CI 的触发事件列表里没有edited, 那么改完 base 之后 CI 依然不跑。 补上edited之后仍有一段窗口 (base 指向别的分支的那段时间)CI 是不跑的。🔑 所以结论是:一个 agent 一个 PR,禁止 stacked。 依赖关系用「分层 + 等上一层合入 main」表达,而不是用 stacked 表达。
这背后是一条更一般的原则(本文第三次出现):机制 > 约定。 「上一层没合入 main,下一层物理上拿不到它的代码」是机制; 「B 依赖 A,请注意顺序」是约定。
8.5 一个必须先修的前置:让验证本身变快
如果合并前的验证要 200 秒,那么并行度越高,总的验证时间线性膨胀, 而且每次冲突重跑都要再付一次。
所以顺序上有一条硬规则:
先修快测试,再谈少跑测试。
理由:某仓实测 202 秒的全量测试里,51% 的时间集中在两个目录,且全是真实的 sleep (测试代码里为了等退避而真的睡)——这不是算力问题,是测试自己在等。 逐条定位到 7 处、归纳出 4 类根因之后,202 秒能降到约 95 秒。
如果顺序反了会怎样:你会为一个本可以直接消除的问题, 建一整套「选择性测试」的规避设施——而那套设施本身有维护成本和漏测风险。
🔑 这条的通用形态: 规避一个成本之前,先确认这个成本是不是可以直接消掉的。 建规避设施永远比修根因显得有进展(有新代码、有新脚本), 但它把一个可消除的问题变成了一个永久的复杂度。
而选择性测试(只跑受影响的测试)一旦要做,必须和 PR 化成对上线:
⚠️ 只做选择性测试而不做 PR 化 = 把风险从本地挪到主干。 本地不再跑全量了,而主干上也没有门禁在补跑——净结果是全量测试谁都不跑了。 这两件事必须同批做,这是一条不能拆的成对约束。
8.6 一个容易被忽略的代价:review 队列被串行化
这一节是对 §8.2 那张表的重要修正。
那张表说「review 不该消」,暗示它保持原样。实际上它会变差:
串行时:review 请求一个一个到 → 看完一个 merge 一个
并行时:2-3 个 PR 同时到达 → 而人的 review 无法并行(只有一个上下文)所以并行实际上是把「等 CI 的空转」换成了「review 的排队 + 上下文切换」。
这个交换什么时候划算:
| 条件 | |
|---|---|
| ✅ 划算 | 几路的关注点差异大(读 A 时脑子里不用装着 B),且单个 diff 小 |
| ❌ 不划算 | 几路改的是同一模块的不同角落——你会在 review 第二个时反复回想第一个改了什么 |
🔑 所以「批次数从 5 批减到 3 批」这个收益,不等于「总耗时减少 2 批」。 这是一个必须用实测数据回答的问题,而不是推理能回答的问题: 分别记录批次数、实际总耗时、以及 review 阶段花的时间。
如果实测发现 review 排队吃掉了全部收益,正确的处置是 砍掉并行交付、只保留分层分析——而不是继续调优编排。
「只保留一半」这个选项值得单独点出来,因为它常被忽略: 分层分析的两项收益(不用人算、不漏真冲突)与并行度完全无关。 就算你打算纯串行做 8 个改动,知道正确的顺序仍然有价值(省掉中途发现依赖搞错的返工)。 而并行交付的全部代价(磁盘、review 排队、语义冲突、清理负担)只在并行时才产生。
8.7 成本性质:这不是省钱方案
必须说清楚账:
| 项 | 方向 |
|---|---|
| LLM 费用 | 净增。会话数不变(还是一个改动一个会话),额外多出分层阶段的子代理 + 冲突后的重跑 |
| 磁盘 | 净增,且实测已经在失控(3.3G,§5.8) |
| 人的时间 | 净省。分层不用人算 + 等 CI 重叠 |
🔑 按 token 记账它是净支出;它买的是人的时间,和「漏掉真冲突」的风险下降。如果你的瓶颈是预算而不是人的时间,这套方法不适用。
这种「明确说出方案不适用的场景」的表述,比「本方案全面提升效率」有用得多—— 后者让人无法判断自己该不该用。
8.8 什么时候该放弃并行,回到纯串行
一份清单,每一条都是判据不是感觉:
- 改动数 ≤ 3:分层的收益盖不住 worktree 的开销
- 所有改动两两都是 C1/C2(挤在同一处或紧邻行):分层算出来就是全串行,白跑
- 方案本身没被复核:并行会把方案的错误放大 N 倍——两路都基于同一份错方案, 就错两份,而且 review 要发现两次
- 你不打算逐个 review:那不是并行问题,是质量闸门问题
- 想开 3 路以上但合并队列还没做:路数越多,语义冲突的暴露面越大, 而唯一对策只有「合并后发现 + 回滚」。先做队列,再扩并行度。
8.9 本章自检
- 为什么说「并行的上限在合并串行化」?
- 汇聚门解决什么问题?它的验收判据为什么必须是变异自证?
- 「本地 9184 个测试全绿,但 HEAD 构建不出来」是怎么可能的?
- stacked PR 的静默失效是什么?为什么「不报红」比报红更危险?
- 为什么「修快测试」必须排在「少跑测试」之前?
- 并行把什么换成了什么?这个交换什么时候不划算?
§9 ★ 会「绿着坏掉」的失效模式(本文最重要的一章)
前面八章讲的是「怎么做对」。这一章讲做错了但看起来没错的那些形态。
为什么这一章最值钱:并行开发这个领域的失效有一个共同特征—— 它们大多不报错。报错的问题会被修掉;不报错的问题会一直存在, 并且在你以为一切正常的时候持续造成损失。
下面 8 条全部来自真实踩坑,编号 R1–R8,按严重度排。
9.1 R1 🔴 判定逻辑错误 → 孤儿累积(一个完整的因果链)
这一条值得完整看清结构,因为它的链条有五环,而每一环单看都很合理。
现场:仓库里累积了 5 个孤儿 worktree / 361MB,全都是「该删但删不掉」的。
完整因果链:
① 默认把主仓 node_modules 用 symlink 链进每个 worktree(为了免装依赖)
↓
② 主仓 .gitignore 里写的是 "node_modules/"(带尾斜杠)
↓
③ ⚠️ 带尾斜杠的 gitignore 规则只匹配目录,而 symlink 不是目录 → 规则不命中
↓
④ 于是 worktree 内 git status 永久报 "?? node_modules"(一个未追踪项)
↓
⑤ 改动计数 = 1 > 0 → fail-closed 判定「有工作,拒绝删除」
↓
⑥ 每个隔离子代理跑完都留下一个几十 MB 的孤儿目录,永久累积注意第 ⑤ 环:fail-closed 在这里完全按设计工作——它看到「有一个未追踪文件」 就拒绝删除,这正是我们要它做的事。问题不在防线,在喂给防线的那个数字是错的。
🔑 这是本章最重要的一个心智模型: 一道防线的正确性 = 防线逻辑的正确性 × 输入数据的正确性。 而排查时人的注意力会全部放在防线逻辑上(「fail-closed 是不是太保守了」), 因为那是"我们写的逻辑"。输入数据的口径错误几乎不会被怀疑。
修法的方向选择也很有教学价值。三个候选:
| 方向 | 判定 |
|---|---|
把 .gitignore 改成 node_modules(去掉尾斜杠) | ❌ 改主仓 gitignore 影响所有人,且这是用户的文件 |
| 靠 worktree 级别的 exclude 隐藏它 | ❌ 两条路都是死的(§2.3 那个坑) |
| ✅ 在判定侧按事实排除 | 解析 git status --porcelain -z,对 ?? 条目用 lstat 判断它是不是 symlink,是就跳过 |
选第三条的理由:它不改任何用户可见的状态,只改我们自己的判定。 而且 lstat 出来的是事实(这东西物理上是不是 symlink), 不依赖任何配置——所以对「上次运行留下的 worktree」同样有效。
⚠️ 一个重要的边界:只排 symlink,普通的未追踪文件照常算改动。 放宽到「所有未追踪都不算」就会滑到下一条(R2)。
9.2 R2 🔴 为性能放宽检测 → 用户未保存的工作变得不可见
这一条和 R1 是同一次修复里发现的第二个缺陷,而它比 R1 严重—— R1 的代价是占盘,这一条的代价是数据丢失。
后台 GC 为了快,检测改动时用了 git status -uno。
-uno 的含义是「完全跳过未追踪文件的扫描」。它确实快(不用遍历工作区), 但后果是:
用户新建了一个文件,还没 git add
→ -uno 模式下这个文件对 GC 完全不可见
→ GC 判定「无改动」
→ 连同用户的工作一起删掉修法是把两种模式都改成 -unormal(会扫未追踪)。
🔑 这个取舍的正确方向值得记住: 「用性能换安全」这笔交易在这里根本不成立—— 未
git add的新文件恰恰是最该保护的一类,因为它在 git 对象库里没有任何副本, 删掉就是永久丢失(§10 会证明这一点)。已经 commit 的文件删了还能从对象库找回;未 add 的文件删了什么都不剩。 所以「跳过未追踪扫描」这个优化,恰好跳过了唯一不可恢复的那一类。
💡 一个方法论细节:这个缺陷是预先存在的,被回归测试意外抓到的。 这说明了一件事:给一个模块补测试时, 要同时测两个相反的方向——「无工作能自动清理」和「有工作绝不被删」。 只测一个方向会漏掉另一类缺陷,而这次恰好是两类各中一个。
9.3 R3 🔴 构建静默成功,产物里函数是 undefined
这一条是「绿着坏掉」的最纯粹形态:所有信号都是绿的,交付物是坏的。
现场:worktree 默认没有自己的 node_modules,于是包解析会向上找到主仓的那份—— 而主仓那份里没有你本次新增的导出。
症状:
$ make build
... <你的新函数名> will always be undefined
$ echo $?
0 ← ⚠️ 构建成功三个信号全绿:
- 构建 exit 0 ✅
- 测试全绿 ✅(因为测试也解析到主仓源码,那里有旧版本)
- 没有任何报错 ✅
而交付物里那个新函数是 undefined。
🔑 为什么这一条特别危险:一个只看退出码的 agent 会交付这个版本。 它不是「没发现问题」,是所有它被教导要检查的信号都告诉它没问题。
这个形态的通用名字是:警告与退出码脱钩。 构建工具把它当 warning(不影响退出码),而它的实际后果是交付级的。
修法有两层,都要做:
① 进 worktree 后先跑一次依赖安装(bun install / pnpm install), 让 worktree 有自己的 node_modules,包解析不再向上落到主仓那份。
② 把这条教训升级成门禁——这是关键,因为 ① 是约定,会被忘:
build:
@bun build ... 2>&1 | tee /tmp/build.log
@if grep -q "will always be undefined" /tmp/build.log; then \
echo "❌ 检测到未解析导出(worktree 缺 node_modules?先跑 bun install)"; exit 1; fi三行 Makefile,把一个静默的交付级错误变成硬失败,零副作用。
🔑 这一条演示了「散文教训 → 门禁」这个升级动作的价值。 教训写在文档里的状态是:它只在有人恰好记得的时候起作用。 而恰好忘记的那一次,往往就是最需要它的那一次。
⚠️ 判据推论:新增模块 / 新增导出的改动,必须显式 grep 这条 warning, 不能只看 exit code。这是「只看退出码不足以判断成功」的一个具体实例。
9.4 R4 🟠 告警本身静默丢失(防静默的机制自己静默失败)
§6.2 已经点过一次,这里正式列为一个失效模式,因为它是递归形态。
现场:为了防止 symlink 的版本错乱,加了一个 lockfile 一致性告警。告警逻辑本身是对的。
但告警是通过「创建结果的输出」回传给用户的。而:
用户手动 enter_worktree → 有输出通道 → 告警显示出来 ✅
子代理 / workflow 隔离 → 没有输出通道 → 告警静默丢失 ⛔而后者恰恰是并行场景——也就是最需要这个告警的场景。
修法:那两条路径改成落 log.warn。
🔑 通用形态:一个「防止 X 静默发生」的机制,必须先确认它自己不会静默失败。
这类问题的排查判据很机械: 对每一条产生该信号的代码路径,问「这个信号最终会出现在人能看到的地方吗」。 有几条路径就问几遍——不要只验证你自己测试时走的那一条。
9.5 R5 🟠 环境造成的假失败被当成「既存失败」
这一条的代价不是数据,是判断——它会让人跳过本该排查的东西。
现场:三个并行执行的分身都把 worktree 里的测试失败报成「既存失败」 (意思是「主干本来就坏,与我无关」)。复核后发现这些测试在干净主干上是全绿的。
真实成因是环境,而且有两类已知的:
| 成因 | 机制 |
|---|---|
node_modules 向上解析 | 就是 R3 那条,导致导出解析不到 |
worktree 路径里含 .claude/ | 命中权限系统的「Claude 配置目录」敏感路径守卫,在 plan-mode 判定之前就返回 |
第二条值得展开,因为它很精巧:那个测试传的是相对路径, 解析时会拼到 cwd 上;而 worktree 的 cwd 含 .claude/, 于是敏感路径守卫先命中了。最终结论仍然是拒绝,只是拒绝的理由不同—— 所以断言字符串对不上,测试红。
🔑 「既存失败」这个词本身是有害的,因为它把「环境问题」和「主干坏了」 混成了一句话,而这两者的处置完全不同。
宣称一个失败与本次改动无关时,三条证据缺一不可:
- 该测试不 import 本次改动的任何模块(可以
grep -c证明);- 在父仓主干上单跑能通过(不是在你的 worktree 里跑);
- 能指出具体的环境成因(说不出机制就不算举证)。
第 2 条最容易被跳过——人会在自己的 worktree 里重跑一遍, 看到还是红,就更确信「不是我的问题」。而那正是环境相同的地方,重跑不提供任何新信息。
顺带两条同源的判读经验:
- 超时类失败先看数字对不对得上。一个测试失败于 5002ms—— 那是测试框架默认的 5 秒单测超时,不是被测对象自己的 60 秒上限。 写文件 + 起子进程的测试在慢机器上会踩线,是 flake,重跑即绿。 不要当回归去改代码。
- 防漂移哨兵红了,要补清单而不是删断言。一个「覆盖所有调用点」类的断言红了, 意味着它正在正常工作(它就是为了拦「新增了一处但忘了同步清单」而存在的)。
9.6 R6 🟠 「文件领地」只是约定,不是机制
现场:并行几路时,为了防止它们改同一个文件,在 prompt 里写了一段 「你的文件领地是这几个,请严格遵守」。
这段话的问题不在于它没用,在于它的性质被误认了。它是约定:
- 没有任何东西会拦住越界的写操作
- 模型可能漏读、可能在长上下文里遗忘
- 路数越多、prompt 越长,遵守率越低
更糟的是有一个看起来能救它的机制实际救不了:某些实现里有一个「冲突检测器」, 但它是按绝对路径匹配的——而两路在不同的 worktree 里, 绝对路径天然不同,于是跨 worktree 的检测天然零触发。
🔑 这是「防线存在但结构性零触发」的一个典型。 它比「没有防线」更危险,因为清单上那一栏是 ✅。
判据:一道防线的验收不是「代码写了 / 单测过了」, 而是**「在真实场景里被触发过」**。一个从未触发的防线, 和一个不存在的防线在效果上完全相同——区别只在于前者会让你放松警惕。
当前唯一真正机制性的隔离是 worktree 本身(不同 worktree 物理上是不同路径)。 所以正确的做法是依赖 worktree 这层机制,而不是依赖 prompt 里的领地约定—— 领地约定可以有,但不能把它算作一道防线。
9.7 R7 🟠 人工操作错一步就毁掉核心保证
这一条讲的是流程设计缺陷,不是代码缺陷。
原本的执行步骤要求人这样做:
cd .claude/worktrees/fix-openrouter-filter-non-chat && <起会话>
# 然后手工把某个 prompt 文件的内容喂进去并行 2 路 = 人要记住 4 条长路径、手工 cd 两次。
而 cd 错了的后果不是"不方便":在错误的 worktree 里开了会话 → 两路改同一份文件 → 整套隔离白做。
用户的原话反馈是:「这样太麻烦,容易出错,人类不友好。」
🔑 这条的核心判断: 把一个「错了就毁掉核心保证」的操作交给人手工完成,是设计缺陷,不是使用者的问题。
而且一个人不愿意用的流程等于不存在——它会被绕过, 然后所有基于它的保证一起失效。
修法方向要分清:正确的简化是让脚本替人 cd,不是不 cd。
用户最初的诉求是「直接在仓库根目录开会话就好」,这条必须拒绝,理由是机制性的: 会话的 cwd 决定它改哪份文件。 在仓库根目录开会话 = 改主仓的工作区 = 两路真的互相覆盖 = worktree 提供的唯一机制性隔离当场失效。
💡 这个区分很重要,它是一类常见的简化陷阱: 前者消灭的是人的操作成本,后者消灭的是隔离本身。 两者在用户体验的描述上都是「不用 cd 了」,但一个是改进,一个是把地基拆了。
顺带一个好设计:那个替人 cd 的脚本在模糊匹配 worktree 名字时, 宁可报错也不猜——匹配到 2 个就列出候选让人选,绝不挑一个。 理由就是上面那个后果:猜错的代价是隔离失效,而猜对省下的只是几个字符。
9.8 R8 🟡 隔离让上下文丢失,而这既是优点也是代价
最后一条是结构性的、无法"修复"的,只能承认并补偿。
worktree 隔离带来的必然结果:几路之间不共享上下文。 第 2 层的会话不知道第 1 层干了什么,除了它能从代码里读到的部分。
好的一面:这正是隔离的目的,也是「上一层没合入主干,下一层物理上拿不到它的代码」 这个机制性保证的来源。
代价有三条:
| 代价 | 形态 |
|---|---|
| 有语义前置时要人工补 | 得在 prompt 里写明「上一层已合入,新增了 X 字段」 |
| 上一层的错误会被下一层继承 | 隔离让坏代码进了下一层的 base。串行时 review 完一个才写下一个,检查点更密 |
| 并行会放大同源错误 | 几路都基于同一份方案,方案错了就错几份,而 review 要发现几次 |
🔑 第三条最容易被忽略,因为它反直觉: 人们期待并行能"分摊"风险,实际上并行会复制风险。 一份没被复核的方案,串行做时你会在第一个改动的 review 里发现问题然后停下; 并行做时三份错的东西同时到达 review。
所以「方案本身没被复核」是放弃并行的判据之一(§8.8)—— 并行放大的不只是产出,也是错误。
9.9 一个统一的心智模型:八条其实是同一件事
回头看 R1–R8,它们有一个共同的结构:
让「没有信号」和「信号是负的」在数据上可区分。
逐条对照:
| # | 「没有信号」被误读成了什么 |
|---|---|
| R1 | 「symlink 造成的假改动」被读成「有真实工作」 |
| R2 | 「未追踪文件没被扫描」被读成「没有未追踪文件」 |
| R3 | 「导出没解析到」被读成「构建成功」 |
| R4 | 「告警没有输出通道」被读成「没有告警」 |
| R5 | 「环境不同导致的失败」被读成「主干本来就坏」 |
| R6 | 「防线结构性零触发」被读成「防线在保护我们」 |
| R7 | 「人还没来得及出错」被读成「流程是安全的」 |
| R8 | 「几路各自看起来合理」被读成「方案是对的」 |
每一条都是同一个形态:一个「测不到」被当成了「测到了,结果是好的」。
🔑 这就是为什么本领域的排查纪律里,「先做零命中的反向自证」是第一条:
bash# 你的检测报告"一切正常"。先验证这个检测能抓到已知的东西: # 抓不到 → 你的"一切正常"毫无意义具体到 worktree:
- 新增一条清理白名单 → 先造一个该被删的目录,确认它真的被删
- 新增一道构建门禁 → 先故意制造一次该失败的构建,确认它真的 exit 1
- 新增一个告警 → 先造一次该告警的条件,确认它真的显示出来
这一步叫变异自证,它不是"额外的严谨",它是让结论有意义的前提。
9.10 本章自检
- R1 的因果链有五环,哪一环是真正的根因?为什么排查时人会盯错环节?
- 为什么「用性能换安全」在 R2 那个场景根本不成立?
- R3 里三个信号全绿,agent 该怎么发现问题?门禁该怎么写?
- R6 的「冲突检测器」为什么结构性零触发?这比没有检测器更好还是更糟?
- R7 里「让脚本替人 cd」和「不用 cd」的区别是什么?
- 八条失效模式的统一形态是什么?由它派生出的第一条排查纪律是什么?
§10 一个真实的数据永久丢失事故:完整解剖
这一章只讲一件事,但值得单独一章——因为它是这个领域代价最高的一次失败, 而且它的成因链条极其普通,任何人都会撞上。
10.1 案发现场
当时的状态:用户正在并行写官网文档(改 website/ 下若干页面), 同时 agent 在同一个仓库里跑测试。
agent 的动作:测试结束后查 git status, 看到 website/ 下多出若干未追踪文件与已修改文件, 误判为「测试跑出来的脏产物」,随即执行了三条命令:
rm -f website/extend/workflows.md
rm -f website/team/scheduled.md
git checkout -- website/10.2 后果
| 丢失内容 | 状态 |
|---|---|
2 个从未 git add 的完整新页面 | 永久丢失 |
| 3 个已追踪文件约 300 行新增内容 | 被 git checkout -- 静默丢弃 |
合计用户数小时的工作凭空消失。
10.3 恢复途径逐一查证:全部为空
这张表是这一章最有说服力的部分——它证明了「不可恢复」不是修辞:
| 途径 | 结果 | 原因 |
|---|---|---|
| git 对象库 | 空 | 两个新页面从未 git add,没有 blob 存在过 |
git fsck 悬空 blob | 空 | 同上;checkout -- 丢弃的是工作区改动,不产生任何对象 |
| 编辑器本地历史 | 空 | 文件未在编辑器中打开过足够久 |
| Time Machine | 空 | 未配置 |
| 文件系统快照(APFS) | 空 | 无快照 |
| 构建产物目录 | 空 | 未构建过含新页的版本 |
| 系统回收站 | 空 | rm 不进回收站 |
| 全盘同名文件搜索 | 空 | 无副本 |
结论:
rm+git checkout --这个组合造成的丢失, 在没有备份机制时是 100% 不可恢复的。
10.4 两个本该拦住它的信号(都被看到了,都没被用上)
这是整章最值得学的部分——不是「应该更小心」这种空话, 而是两个具体的、当时确实存在的信号:
信号 1:会话最开始的 git status 快照里,website/.vitepress/config.ts 就已经是 M。
这个时间点早于 agent 的任何操作,所以逻辑上不可能是测试产物。 这个信号 agent 看到了,但没有用它去推翻自己的判断。
信号 2:在 rm 和 git checkout 之前,从未读过这些文件的内容,只看文件名就下了结论。
人写的文档和生成产物一眼可辨(一个有段落和标题,一个是模板化的表格)。 省掉「读一眼」这一步等于在赌。
10.5 教训的精确形态
事故的一句话总结:
归因错误 + 立即执行不可逆操作 = 数据永久丢失。
注意这个公式有两个因子,而只有一个是可以要求"不出错"的。
归因错误是必然会发生的——任何人、任何 agent 都会偶尔判断错。 所以防线不能建在「不要判断错」上,只能建在**「判断错了也不至于不可逆」**上。
于是派生出的行为约束是关于动作的,不是关于判断的:
禁止的操作(除非用户明确要求删除这个具体目标):
rm/rm -f任何不是自己亲手创建的文件git checkout -- <dir>/git restore/git reset --hard/git clean—— 这类命令会静默且不可逆地丢弃未提交改动,没有回收站、没有 reflog 可救- 以「清理测试产物」「回到干净状态」为由批量还原目录
必须遵守的判断顺序:
- 动手前先读。 要删/还原任何文件,先看一眼内容。
- 会话开始时的
git status快照是证据。 那时已存在的改动一定不是你造成的。 - 区分「测试写脏」与「别人在写」。 测试产物的路径是确定且可枚举的; 出现在预期之外路径的新文件,按「别人的工作」处理。
- 不确定就问,或者干脆不动。
10.6 三个可迁移的原则
① 代价不对称时,方向必须偏向可恢复的那一侧
留着一个多余文件的代价 = 占几 KB 磁盘 (可恢复)
删错一个文件的代价 = 用户几小时的工作 (不可恢复)这两个代价差了好几个数量级,所以决策根本不需要"权衡"—— 在这种不对称下,保守是唯一合理的选择。
② 「工作区不干净」不是理由
这条要单独说,因为它是当时的心理动机:agent 想要一个干净的工作区。
但工作区脏不影响交付任务。 它只是看起来不整齐。 把「让工作区变干净」当成一个需要立刻达成的目标, 是把审美偏好升级成了操作理由。
③ 多任务并行是这个仓库的常态,所以「意外文件」的默认归属是别人
这条是并行开发直接派生的纪律:仓库里随时可能有多个任务在跑—— 人在写文档、另一个 agent 在改代码、测试在跑。
因此 git status 里的「意外文件」默认属于别人的在途工作,不是你的脏数据。
🔑 注意这条纪律和 §9 那些技术性防线的区别: 前面所有失效模式都能靠代码或门禁来防,这一条只能靠约束动作—— 因为「判断文件属于谁」这件事没有任何机械判据。
而这恰恰说明:当一个风险无法用机制拦住时, 正确的处置是缩小可执行的动作集合,而不是要求判断更准。
§11 横向对比:怎么调研别人,以及什么时候「对方有」不构成理由
这一章换一个视角:不是「怎么建」,而是**「怎么摸清现状」**——自家的和别人的。
为什么值得单独一章:面试里很可能被问「你了解业界都怎么做的吗」, 而更实际的场景是——你入职后第一件事就是摸现状。 这件事看起来只是「搜一下代码库」,实际上有一整套陷阱,每一个都会让你得出反的结论。
11.1 各家在做什么:一张对比表和它的正确读法
| 能力 | Claude Code | Cursor | 若干专用工具 | 本仓 |
|---|---|---|---|---|
| 基础 worktree 隔离 | ✅ | ✅ | ✅ | ✅ |
| 端口自动隔离 | ⚠️ 外包给 hook | ❌ | ❌ | ⚠️ 同(文档化 hook 示例) |
| 依赖处理 | 默认不碰 | N/A | 部分有 | 默认 symlink + 一致性告警 |
| DB 冲突提醒 | ❌ | ❌ | ❌ | ✅ 纯提醒版 |
| 健康面板 / 一键清理 | ❌ | N/A | ✅ | ⚠️ 基础 list |
| 进程内并发安全(ALS) | ✅(后加) | N/A | N/A | ✅(早期植入) |
| 防嵌套(canonical root) | ✅ | N/A | N/A | ✅ |
这张表的信息量在对比,不在单行。 三个读法:
① 基础 worktree 隔离这一行全是 ✅ ——所以它不是差异化点。 把它做得更精致(更好看的名字、更多配置项)几乎不产生用户可感知的价值。
② 注意「外包给 hook」这一档不能记成 ❌。 CC 在端口隔离上不是"没想到",是刻意提供扩展点让用户自己写。 这两者在功能表上长得一样,但在设计意图上完全相反, 而设计意图决定了「我们该不该做」的答案。
③ 「默认不碰依赖」这一格是本表最有信息量的一格。 它看起来是"少一个功能",实际上是用一个消极选择绕开了整个版本错乱问题(§6.2)。
11.2 三档图例,不是两档
上面那张表刻意用了三个符号,这是有理由的。
只用 ✅/❌ 两档会系统性地骗人,因为它把这些完全不同的状态压成了同一个符号:
| 真实状态 | 两档会记成 | 应该记成 |
|---|---|---|
| 有,默认开启 | ✅ | ✅ |
| 有,但默认关闭,要用户配 | ✅ | ⚠️ |
| 不做,但给了扩展点(刻意外包) | ❌ | ⚠️ |
| 有代码,但从未被调用(死代码) | ✅ | ⛔ |
| 真的没有 | ❌ | ❌ |
🔑 第四行是最贵的错误:有代码 ≠ 有能力。 一个存在但从未被调用的模块,在功能表里是 ✅,在实际效果上是 ❌, 而且它比"没有"更糟——因为它让你以为这块已经解决了。
判据:清单里的每个 ✅ 都要能回答「它最近一次真的跑起来是什么时候」。
11.3 检索陷阱:为什么你查不到别人有没有做
一组具体的陷阱,全部来自真实调研踩坑。
P-1 「worktree」这个词是可靠的,但「并行」不是
搜 worktree 通常准确(它是个专有名词,没什么歧义)。 但搜 parallel / 并行 会命中大量无关内容—— Promise.all、并行测试、并行构建都叫 parallel,跟工作区隔离无关。
处理:判「有没有做工作区隔离」用 worktree; 判「有没有做并行编排」必须看目录结构和脚本名,不能靠关键词。
P-2 能力可能不在你以为的名字下
同一个能力在不同项目里的命名差异极大: worktree / workspace / sandbox / isolation / session-dir。 只搜自己习惯的那个词会得出「他们没做」的假结论。
处理:词汇表要覆盖同义词族,而且从功能入口反查—— 找「创建隔离环境」这个动作在哪被调用,而不是猜它叫什么名字。
P-3 🔴 最坑的一条:自己那一栏也会因命名不同而被自己填错
这条是镜像形态,也是所有陷阱里最贵的:
调研时用「我们的词」搜自己,用「对手的词」搜对手—— 于是自家一个已经存在的能力,因为目录名和字段名不同,被写成了「我们没有的形态」。
后果:对比表里自己那一栏是错的 → 结论变成「竞品有这个我们没有」→ 据此派生的优化任务是去建一个已经存在的东西。
🔑 处理办法很具体,值得记住: 摸自己也要两轮检索——第一轮用我们的词,第二轮用对手的词回头搜自己。
这个手法通用性很强:任何「我们 vs 他们」的对比, 都要用对方的词汇表回来搜一遍自己,否则命名差异会被误读成能力差异。
P-4 README 与实现会漂移,而且 README 会自称「就绪」
一份文档写着「框架就绪,真实接入待后续阶段」——那是计划,不是现状。
处理:联网或读 README 得到的形态,必须落地到源码验证。
P-5 🔴 引用自家旧文档的「现状」= 引用一个已过期的快照
这一条造成的代价在所有陷阱里最大。一个典型形态:
旧文档里记着某个方案的一个数字偏低,引用者据此裁决「这条路修不动,不投入」。 而那个数字早已被修正——裁决的前提整个不存在。
但错误的机制比结论更值得看:那份旧文档已经更新了, 它用「旧值 → ✅ 已修复:新值」的写法记录了修正。 引用者取了被删除线划掉的那半句。
所以问题不是文档没更新,是读法出了问题。
🔑 由此得到的纪律不是"要查文档扩散点",而是一句更小、更机械的话: 读到句末。
新旧结论并存的文档很常见(删除线、「已废弃」标注、「更新:」前缀), 而人的眼睛会停在第一个匹配到的数字上。
11.4 什么时候「对方有」不构成理由
这一节是本章的核心判断,它防的是照着竞品功能表做需求。
判据一:先问「服务哪个目标」
一个能力值得做,是因为它服务某个你在追的方向(更快 / 更省 / 更少返工 / 更安全), 不是因为对方有。
一个具体的例子:某方案里的「worktree 健康面板 + 一键清理」, 对标之后发现 CC 也不做。但这不是砍掉它的理由—— 砍掉它的真正理由是:现有的清理逻辑(宽限期 / ephemeral 白名单 / fail-closed) 已经和 CC 平级,面板买到的只是「看得见」,而当时并没有人因为看不见而受阻。
判据二:按「量级 × 趋势能力 ÷ 改动量」排序,不按「对方有没有」
三个因子缺一不可:
- 量级:它影响的是 3.3G 还是 3MB
- 趋势能力:它能不能画出一条随版本变化的曲线(不能的只是一次性快照)
- 改动量:15 行还是 500 行
判据三:⚠️ 的严重程度取决于它的邻居是什么
同一个符号在不同上下文里含义不同: 一个 ⚠️ 如果周围全是 ✅,它就是短板; 如果周围全是 ❌(大家都没做),它可能是领先。
判据四:宏大结论必须逐条证伪
这一条有一个很好的真实案例:某方案的结论写着 「实施后将全面超越 Claude Code」,并配了一张竞品对比表。
逐条回源码核对之后:
| 方案里的那一项 | 核对结果 |
|---|---|
| 端口隔离「我们做 CC 不做」 | ❌ CC 是刻意外包给 hook,而且原方案的设计本身有 bug(所有服务同端口 + 生成的文件没人读) |
| 健康面板「我们做 CC 不做」 | ⚠️ 清理逻辑已与 CC 平级,面板的增量价值未论证 |
| 「fetch cooldown 优化」 | ❌ 前提是错的——方案说「每次创建都会 fetch」,而实际实现在本地已有远端 ref 时就跳过,两家逐行同构,fetch 几乎不跑 |
最后一项最值得注意:它不是"优化幅度估高了",是要优化的那个问题不存在。
🔑 两条可迁移的纪律:
- 文档里的宏大结论(「全面超越 X」)必须用代码逐条证伪,不能照抄。
- 对标标杆的最大价值,常常是发现「我们比标杆激进的地方埋了什么坑」, 而不是「标杆有我们没有的功能」。
第 2 条尤其反直觉——人做对标时的默认心态是"找差距、补齐", 而实际上收获最大的往往是反方向:看清自己在哪里比对方走得更远, 以及对方为什么没走那么远。
11.5 一条硬纪律:对自己的核验强度必须 ≥ 对对手的
这条是 P-3 的一般化,也是本章最该带走的一句。
人的自然倾向是:调研外部对象时很谨慎(查两遍、记 commit、找源码), 摸自己时凭印象——因为"自家的事我还不知道吗"。
结果是:对比表里最不可靠的一栏,是你自己那一栏。
🔑 面试里这一条特别值得说,因为它体现的是方法论上的诚实:
「做横向对比时我有一条硬纪律:基准列的核验强度不能低于对手列。 人的自然倾向是查别人查得很细、摸自己凭印象, 结果对比表里最不可靠的恰恰是自己那一栏。 我们踩过一次——自家已有的一种能力因为命名不同被写成了"我们没有", 据此派生的优化任务是去建一个已经存在的东西。」
11.6 本章自检
- 为什么「基础 worktree 隔离四家全有」意味着它不是差异化点?
- 「不做但给了扩展点」为什么不能记成 ❌?
- 三档图例分别对应什么真实状态?哪一档是最贵的错误?
- P-3 的镜像形态是什么?处理办法是什么?
- 「对方有」为什么不构成理由?该用什么判据替代?
- 对标标杆最大的价值常常是什么(反直觉的那个方向)?
§13 动手:从零搭一套(五个阶段,每阶段有验收判据)
前面十二章是知识和表达。这一章是做。
总原则:每个阶段都有可执行的验收命令,不接受「机理上讲得通」。 而且每一步都遵守 §9.9 那条纪律——新增的检查必须先变异自证。
阶段 0:先手动跑一遍(30 分钟,不写任何代码)
目的不是搭系统,是把 §2 的机制在自己机器上摸一遍。 跳过这步, 后面写代码时你会对着抽象概念编程。
# 1. 建两个 worktree
git worktree add /tmp/wt-a -b tmp-branch-a
git worktree add /tmp/wt-b -b tmp-branch-b
# 2. 看那个 .git 是文件不是目录(§2.2 的核心)
cat /tmp/wt-a/.git # → gitdir: /你的仓库/.git/worktrees/wt-a
stat -f "%HT" .git # → Directory(主仓是目录)
# 3. 看主仓 .git 里多了什么
ls .git/worktrees/wt-a/ # → HEAD index gitdir commondir logs refs ...
# 4. 验证对象库共享:在 A 提交,在 B 直接就能看到
cd /tmp/wt-a && echo hi > f.txt && git add f.txt && git commit -m tmp
cd /tmp/wt-b && git log tmp-branch-a --oneline -1 # ✅ 看得到,没过任何远端
# 5. 亲手验一次 §7.1 的冲突阈值(这一步最有价值,别跳)
# 在 A 改第 10 行、在 B 改第 12 行,然后 git merge —— 应该自动合并
# 改成第 11 行(差 1)再试 —— 应该冲突
# 6. 清理
git worktree remove /tmp/wt-a --force
git worktree remove /tmp/wt-b --force
git branch -D tmp-branch-a tmp-branch-b
git worktree prune验收判据:你能不查文档说出「worktree 里的 .git 是什么、内容是什么」, 并且亲手看到过「差 1 冲突、差 2 不冲突」。
🔑 第 5 步值得强调:§7.1 那个阈值是本文唯一一个你能在 5 分钟内独立验证的核心结论。 亲手跑过之后,你在面试里说这句话的底气完全不一样—— 而且你会理解为什么「目测行号」会错。
阶段 1:最小可用的创建 / 删除(半天)
只做四件事,顺序不能变:
① validateSlug(slug) ← 必须最先,在任何 fs/git 操作之前(§5.4)
② findCanonicalGitRoot() ← 防嵌套(§2.4)
③ git worktree add -B <branch> <path> <base>
④ remove():先数改动 → 有改动拒绝(fail-closed)→ 无改动才删这一阶段最容易犯的三个错:
| 错 | 后果 |
|---|---|
用 -b 而不是 -B | 上次残留同名分支时创建失败 |
| 校验放在建目录之后 | 恶意路径已经落盘了,校验只是事后知情 |
remove() 只数未提交改动 | 已 commit 未 push 的工作被连分支一起删(§5.3) |
验收判据(三条,每条都要变异自证):
# ① slug 校验真的在拦
create("../../etc/evil") → 必须拒绝,且磁盘上不能出现任何目录
create("a".repeat(65)) → 必须拒绝
# ② 防嵌套真的生效
# 在一个 worktree 内部调用 create() → 新目录必须落在主仓的 worktrees/ 下
# ③ fail-closed 真的在拦(两个方向都要测!)
# 方向 A:worktree 里改一个文件 → remove() 必须拒绝,且报出具体数字
# 方向 B:worktree 里 commit 但不 push → remove() 也必须拒绝
# 方向 C:干净的 worktree → remove() 必须成功⚠️ 方向 A 和 B 必须都测。 只测 A 是最常见的漏洞—— 那正是 §5.3 讲的「只数未提交改动」那个缺陷的形态。 而 C 也必须测,否则你可能做出一个「什么都删不掉」的实现(那就是 §9.1 的孤儿累积)。
阶段 2:并发安全(半天到一天)
这一阶段做 §4 那件事。它是本文唯一一处需要动既有代码的地方,所以要小心。
① 写 withAgentCwd / getAgentCwd(照 §4.2,29 行)
② 找到那个「单一 cwd 读取入口」(大多数项目里是某个 getCwd())
③ 改成:先看 ALS → 没有则回退到原来的全局状态
④ 把所有代理执行路径包进 withAgentCwd()
⑤ grep 全仓 process.chdir,逐个确认它在「保证单条链」的位置上第 ③ 步的回退行为是安全落地的关键——它保证改造是纯增量的, 未进入 ALS 上下文时行为与改造前完全一致,所以既有功能不可能被影响。
第 ⑤ 步不能省,因为「并发安全」的强度等于最弱那条路径(§4.5)。
验收判据:
# 起 3 个并发任务,各绑一个不同目录,各自写一个同名相对路径的文件
# → 三个文件必须分别出现在三个目录里,不能有任何一个跑错地方
# 这个测试必须真的并发(Promise.all),串行跑它永远是绿的🔑 注意最后那句:这个测试串行跑必然通过, 所以如果你的测试框架为了稳定性把它改成串行执行, 这个测试就变成了一个永远绿的空壳(§9 的形态)。 写完之后故意把实现改回
chdir,确认它真的报红。
阶段 3:清理与生命周期(一天)
① ephemeral 白名单(正则匹配目录名,§5.2)
② 宽限期(按「正常寿命的量级」反推,不要随手取整数)
③ 三重保护:改动检查 / locked 标记 / 活跃 session
④ 持久化 session(先做这个,③ 的活跃保护才成立)
⑤ 清理完跑 git worktree prune这一阶段最重要的一条实现细节(就是 §9.1 那个真实缺陷):
数改动时用 git status --porcelain -z -unormal
⚠️ 不要用 -uno —— 它跳过未追踪扫描,而未 add 的新文件恰恰不可恢复
⚠️ 要对 ?? 条目用 lstat 排除 symlink —— 否则 symlink 的 node_modules
会被永久报成一个假改动,导致所有 worktree 都删不掉验收判据(这里最容易做出「绿着坏掉」的实现,所以要四条):
# ① 该删的真被删:造一个过期 + 无改动的 ephemeral 目录 → GC 必须删掉它
# ② 不该删的真没被删:造一个具名目录(名字不匹配白名单)→ GC 必须跳过
# ③ 有工作的真没被删:造一个含「未 git add 的新文件」的目录 → GC 必须跳过
# ⚠️ 这一条是 §9.2 那个数据丢失缺陷的专用回归测试
# ④ symlink 不算改动:造一个含 symlink node_modules 的干净目录 → GC 必须能删
# ⚠️ 这一条是 §9.1 那个孤儿累积缺陷的专用回归测试🔑 ③ 和 ④ 是一对方向相反的测试,必须都有。 只测 ③ 会做出「什么都不敢删」的实现(孤儿累积); 只测 ④ 会做出「删得太狠」的实现(数据丢失)。 这正是那次真实修复里「两类缺陷各中一个」的原因—— 当时只有一个方向有测试。
阶段 4:运行时隔离(半天,全部 opt-in)
这一阶段的所有功能默认关闭,理由是它们都依赖项目特定的约定。
① 依赖一致性告警:比对主仓与 worktree 的 lockfile hash,不一致才告警
⚠️ 必须零噪音(一致时不输出),否则三天后没人看
⚠️ 子代理路径没有输出通道 → 要落 log.warn,否则告警自己静默丢失(§9.4)
② DB migration 提醒:检测 prisma/drizzle/alembic 等标记文件
③ 端口隔离(如果做):BASE + index × 10 + service_offset
⚠️ 别漏 service_offset,否则同一 worktree 内部的服务开始互撞
⚠️ 验收判据是「两个 dev server 都能起来」,不是「文件生成了」
④ 本地配置复制:把 .env / 权限配置这类 gitignored 文件带进 worktree④ 的实际收益最大,因为它直接决定「agent 要不要每步都问人」(§6.4)。
验收判据:
# ① 改一下 worktree 里的 lockfile → 必须告警;改回去 → 必须零输出
# ③ 起两个 worktree 各跑 dev server → 两个都能起来(这是唯一有效的判据)
# ④ 进 worktree 开一个会话 → 点击确认次数应该是个位数,不是几十次阶段 5:合并收口(这一阶段是流程变更,不是代码)
顺序有硬约束,不能跳步:
① 先修快测试本身 ← 「先修快,再谈少跑」(§8.5)
② 汇聚门(aggregate gate)
③ 禁直推 + 分支保护只绑汇聚门
④ 合并队列
⑤ 体积门禁(单个改动超过 N 个文件硬拦)为什么 ① 必须最先:如果验证要 200 秒,那么后面每一步的成本都乘以这个数。 而且如果先做「选择性测试」(少跑),你就是为一个本可以直接消除的问题 建一整套永久的规避设施。
为什么 ③ 和「选择性测试」必须成对:只做选择性测试而不禁直推 = 本地不跑全量了、主干上也没门禁在补跑 = 全量测试谁都不跑了。
验收判据(每条都是变异自证):
# ① 测试提速后:功能不能回归(pass/fail 数完全不变),且
# 故意改错一处逻辑 → 必须仍然报红(否则你是把测试改瞎了不是改快了)
# ② 故意让 lint 红 → 汇聚门必须 exit 1
# ③ git push origin main → 必须被拒
# ④ 两个改动同时入队 → 队列必须串行验证「合并后的状态」
# ⑤ 造一个超过阈值的改动 → CI 必须红⚠️ ① 那条「变异自证」是这一阶段最容易漏的。 把测试从 200 秒改到 95 秒有很多种方法,其中有些方法是把测试改瞎了 (比如把该等的条件直接跳过)。 判据不是「更快了且还是绿的」,是「更快了、还是绿的、 而且故意改坏被测逻辑时它会红」。
阶段总览:你会亲手撞到的坑
| 阶段 | 你大概会撞到 | 对应章节 |
|---|---|---|
| 0 | 「差 4 行居然不冲突」 | §7.1 |
| 1 | remove() 什么都删不掉(symlink 假改动) | §9.1 |
| 2 | 并发测试串行跑永远绿 | §4.1 / §9.9 |
| 3 | 两个相反方向只测了一个 | §9.2 |
| 4 | 生成了配置文件但没人读它 | §6.1 |
| 5 | 测试改快了但也改瞎了 | §8.5 |
每一个坑都在前面章节里有对应的完整案例——撞到时回去读那一节。
附录
A. 术语表(按字母序,供速查)
| 术语 | 一句话 |
|---|---|
| ALS(AsyncLocalStorage) | 给「当前异步执行链」挂私有数据的 Node 机制。进程内并发的正解(§4.2) |
| base ref | 新 worktree 从哪个提交切出来。fresh = 远端默认分支,head = 当前 HEAD |
| canonical git root | 真正含 .git 目录的仓库根。防嵌套的锚点(§2.4) |
| conflict gap | 两处改动的行号差。≥2 就不冲突(§7.1) |
| detached HEAD | 不在任何分支上。此时 rev-parse --abbrev-ref HEAD 返回字符串 "HEAD" |
| ephemeral worktree | 程序建的临时工作树,可自动清理(§5.2) |
| fail-closed | 检测失败时拒绝危险动作。删除路径必须用它(§5.3) |
| fail-open | 检测失败时照做。创建期的优化该用它(§5.6) |
| grace period | 「刚建出来正在用」的保护窗口。临时 worktree 取 6 小时(§5.7) |
| linguist-generated | 让平台折叠 PR diff 的标记。不影响 merge 行为(§7.5) |
| merge queue | 串行验证「合并后状态」的队列。语义冲突的唯一真解(§8.3) |
| orphan worktree | 该删没删下来、持续占盘的目录(§9.1) |
.git pointer file | worktree 里那个文本文件形态的 .git(§2.2) |
| prune | git worktree prune,清理「目录已删但登记还在」的条目 |
| slug | worktree 的名字。会拼进路径和分支名,所以是安全边界(§5.4) |
| stacked PR | 把 PR B 的 base 指向 PR A 的分支。本文反对(§8.4) |
| 变异自证 | 故意把被测对象改坏,确认门禁真的报红。新增检查必做(§9.9) |
| 汇聚门 | 把多个 CI job 的结论收成一条,供分支保护绑定(§8.3) |
| 机制 > 约定 | 「物理上做不到」优于「写在 prompt 里请遵守」(本文出现四次) |
| 语义冲突 | git 合得上、各自 CI 绿、合起来错。git 永远拦不住(§7.3) |
B. 三十秒自检清单
做隔离时:
- [ ] 我隔的是哪一层?另外三层怎么办?(§3)
- [ ] 代理执行路径全部走 ALS 了吗?
grep process.chdir过了吗?(§4.5) - [ ] 新 worktree 是建在主仓的 worktrees 下吗?(§2.4)
做清理时:
- [ ] 白名单方向是「只删认识的」吗?(§5.2)
- [ ] 数改动时两个方向都数了吗(未提交 + 未推送)?(§5.3)
- [ ] 用的是
-unormal而不是-uno吗?(§9.2) - [ ] symlink 用
lstat排掉了吗?(§9.1) - [ ] 拒绝时报的是具体数字还是布尔值?(§5.3)
- [ ] 两个相反方向都有回归测试吗(该删的能删 + 有工作的不删)?(§13 阶段 3)
做并行编排时:
- [ ] 冲突判据是实测的还是目测的?(§7.1)
- [ ] 语义前置(与文件无关的依赖)考虑了吗?(§7.2)
- [ ] 层内 ≥2 路时,合并后跑门禁了吗?(§7.3)
- [ ] 「先修快测试」做在「少跑测试」之前了吗?(§8.5)
- [ ] 选择性测试和 PR 化是成对上线的吗?(§8.5)
新增任何门禁 / 告警时:
- [ ] 变异自证做了吗——故意改坏,它真的报红了吗?(§9.9)
- [ ] 它在每一条代码路径上都能被人看到吗?(§9.4)
- [ ] 它命中的比例是多少?会不会被绕过?(§7.4)
做横向对比时:
- [ ] 「对方没有」是真没有,还是刻意外包了?(§11.1)
- [ ] 用了三档图例吗,还是压成了 ✅/❌?(§11.2)
- [ ] 用对手的词回头搜过自己吗?(§11.3 的 P-3)
- [ ] 引用旧文档时读到句末了吗?(§11.3 的 P-5)
做任何删除 / 还原操作前(这一组最重要):
- [ ] 我读过这些文件的内容吗?(§10.4)
- [ ] 会话开始时的
git status快照里,它就已经存在了吗?(§10.5) - [ ] 这是我亲手创建的文件吗?不是就别动。
- [ ] 「工作区不干净」不是理由。(§10.6)
C. 一句话记住每一章
| 章 | 一句话 |
|---|---|
| §1 | worktree = 切分支的隔离性 + clone 的并存性 − clone 的历史复制代价 |
| §2 | worktree 里的 .git 是文件不是目录,这个差别派生出整套机制 |
| §3 | 隔离有四层,worktree 只管最下面那一层 |
| §4 | process.chdir() 是进程级全局变量,并发下必错;ALS 是正解 |
| §5 | 删除路径 fail-closed,创建期优化 fail-open——方向由代价不对称决定 |
| §6 | 文件分开了还是跑不起来,因为端口 / DB / 依赖 / 权限都不在 git 管辖内 |
| §7 | 行号差 ≥ 2 就不冲突;但这条判据管不到语义冲突 |
| §8 | 并行的上限不在 agent 数量,在合并串行化 |
| §9 | 八条失效模式是同一件事:「测不到」被当成了「测到了,结果是好的」 |
| §10 | 归因错误必然发生,所以防线要建在动作层,不是判断层 |
| §11 | 对比表里最不可靠的一栏,是你自己那一栏 |
| §12 | 能说清方法的失效边界,比多背两个判据更能体现深度 |
| §13 | 每个新增的检查都要变异自证,否则你不知道它是通过还是没在测 |
最后:这份文档想让你记住的三件事
一、隔离是四层,不是一层。
worktree 只解决文件层,而它是四层里最容易的一层——行业标配,没有差异化空间。 真正卡住人的是运行时层(端口 / DB / 依赖,几乎没人做)和合并层(最贵,且不该完全自动化)。 所以被问「怎么做并行开发」时,先把层次切开,这一步就已经区分了懂和不懂。
二、这个领域的失效大多不报错。
八条失效模式(§9)有一个共同结构:一个「测不到」被当成了「测到了,结果是好的」。 构建 exit 0 但产物里函数是 undefined;防线在清单上是 ✅ 但结构性零触发; 告警逻辑完全正确但在最需要它的路径上没有输出通道。
所以派生出的第一条纪律是变异自证——新增任何门禁或告警, 先故意把被测的东西改坏,确认它真的报红。 这不是额外的严谨,它是让"通过"这个结论有意义的前提。
三、代价不对称的地方,不需要权衡。
这句话能同时解释本文好几个看起来无关的决定: 清理白名单为什么是「只删认识的」而不是「删掉不认识的」; 删除路径为什么必须 fail-closed 而创建期优化该 fail-open; 为什么 git push 该「问」而 rm -rf 该「硬拦」; 以及为什么「用性能换安全」在跳过未追踪扫描这件事上根本不成立。
它们都是同一个判断:当一侧的错误可恢复、另一侧不可恢复时, 方向就已经确定了,剩下的只是把它写对。
而最后那条真实事故(§10)说的是这个判断的极限形态: 归因错误必然会发生——任何人、任何 agent 都会偶尔判断错。 所以防线不能建在「不要判断错」上,只能建在**「判断错了也不至于不可逆」**上。