一个仓库里的多个 Claude:锁、交接与 1982 年的电子邮件
作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-04-23 · 更新于 2026-09-23
译自英文版: English
我在三个 Pro 订阅上并行运行 Claude Code,而我的同事们也用他们自己的订阅工作,常常在同一天修改相同的文件。这篇文章讲的是由此产生的三个协调问题,按它们出现的先后顺序排列:如何把上下文从一个会话传到下一个会话,如何让一个会话独占一项资源,以及如何让两个同时在线的会话相互通信。结果每一个都是一个大约四十年前的分布式系统模式:只追加的日志、带心跳的锁文件,以及电子邮件。文末是这三者如何打包为 mclaude、真实使用的情况,以及它在哪里会失效。
交接文件是最先出现的,那时我已经厌倦了向每个新对话重新讲述当前的状况。滚动归档是在长期项目各积累了八十份交接之后才出现的。邮件则是在我厌倦了通过即时通讯软件转述同事的 Claude 状态之后才出现的。
第一层:交接,"我做完了,你接着做"
第一次尝试是:会话结束时把所有内容都写进一个 HANDOFF.md。这在有一个以上的对话时就会失效。最后写入者获胜,其他人都被覆盖。
所以我改为只追加。每个会话写入自己独有命名的文件:
.claude/handoffs/
├── 2026-04-09_14-32_373d1618_drift-validator.md
├── 2026-04-09_16-47_b858f500_dashboard-refactor.md
├── 2026-04-10_11-20_a1c2d3e4_auth-fix.md
└── INDEX.md (append-only log)
文件名编码了日期、时间、会话 id 的前八个字符以及一个任务短名。两个对话不可能产生相同的文件名:即使在同一秒内,它们的会话 id 也不同。竞争条件在构造上就不可能发生,无需锁,也无需消息代理(broker)。
INDEX.md 同样只追加:在底部添加新行,绝不编辑上方的内容。要把状态从 ACTIVE 改为 CLOSED,你要追加一条新记录,而不是修改旧记录。数据库中的预写日志正是这样工作的,原因也相同:如果进程在写入中途死亡,日志仍然保持一致。
交接不是日记,而是与下一个会话之间的一份契约。五个部分,每个只回答一个问题:目标、做了什么(附路径,使得没有任何东西需要被重建)、什么没有奏效以及原因、当前状态(正常、损坏、受阻),以及一个下一步行动。下面是同一个色彩检查项目的真实会话中每个部分的一行:
- 目标:"Colour checker: CNN sweep + diffusion, first results"
- 已完成:"CNN baseline median 1.99 deg, 11M params, 21 MB"
- 没有奏效:"lr=3e-4, NaN after epoch 10-13, no gradient clipping"
- 已损坏:"Diffusion training OOM at bs=16"
- 下一步:"Inference script for diffusion + visual sheets"
同一项目的完整交接文本,见 我的 CLAUDE.md 有 582 行。
真正值回票价的是"没有奏效"这一部分。四天之内积累了 27 份交接,没有任何一个死胡同被走过两次,因为每个新会话都会看到这些警告。一份交接大约 1,500 个 token,而原始会话上下文大约是 100K:约小 67 倍,而且对继续工作而言,没有丢失任何重要的东西。
写入是自动化的。一个 Stop hook 会检查会话运行了多久,如果它足够长而且什么都没有记录,就询问是否保存交接。除此之外,在长会话中,模型自己也会在继续之前主动提出写一份交接,通常是在一项大任务之后,或者上下文占用达到 70% 到 80% 时。我的 hook 没有长度检查来触发这件事;这是模型自己的判断。读取则只需要下一个会话开头的一句话,"接着做色彩检查的交接",或者一个指向该文件的链接。
滚动归档,当交接堆积起来
在一个项目上工作一两个月,就会积累几十份交接,里面充满了已经走过的死胡同和已经被推翻的假设。新会话读取最近的几份,不再深入,然后心平气和地提出一个我两周前就推翻了的方法。
解决办法是把旧的交接折叠起来。一份滚动归档总结二十份较早的交接,标注边界日期,并给它所覆盖的每份交接加上一行 rolled_up_into:,指明该滚动归档文件。新会话只读取一份滚动归档,加上日期晚于其边界的交接。其余内容保留下来供追溯,但不进入上下文。
滚动归档的文件名带有 rollup_ 段,使它在 INDEX.md 中显得突出,其 frontmatter 列出它所覆盖的内容(取自 mclaude README 的格式,已缩略):
---
type: rollup
covers:
- 2026-03-01_12-00_session-01_kickoff
- 2026-03-02_14-30_session-02_auth-refactor
- 2026-03-15_18-00_session-12_ci-pipeline
through: 2026-03-15 18:00
---
正文就是一份普通的交接,只不过它描述的是二十个会话,而不是一个。这个模式不是我发明的。PavelMuntyan/MF0-1984 在一个 SQLite 模式中解决了同样的问题,使用的是 covered_until_message_id 列,而我把表换成了 Markdown 的 frontmatter。没有什么巧妙之处:就是熟悉的"前几章的摘要"。
第二层:锁,"这项资源现在归我"
下一个问题出现在远程服务器上的训练运行中。交接只会追加,所以没有人覆盖别人。真正的资源不同。两个会话不能同时重构同一个模块(会产生合并冲突),不能同时部署到预发布环境(第二个会覆盖第一个),也不能同时在一块 GPU 上训练(第二个会遇到显存不足)。其中一个必须独占这项资源。
每项资源一个文件,以它的名字命名:auth-middleware.lock、staging-deploy.lock、gpu-host-a_gpu2.lock。文件内容:谁占用了它、何时占用、为了什么。新会话列出该文件夹,一眼就能看到哪些资源已被占用,并且不用问我就能挑一个空闲的。
诀窍在于文件是如何创建的。os.open(path, O_CREAT | O_EXCL) 是原子的:如果文件已经存在,调用就会失败。两个进程不可能同时创建同一个文件,因为内核保证了这一点。这正是自 1980 年代以来 /var/lock 和 pid 文件背后的原语。
没有心跳的锁,只能撑到第一次崩溃为止:会话死掉,锁留了下来,所有人永远等待。所以在这个文件约定中,活跃的会话每隔 30 到 60 分钟刷新一次时间戳,而心跳超过约四小时的锁可能已经失效。注意是"可能",不是"确定"。在接管之前,我会检查资源本身:通过 SSH 运行 ps,在 GPU 上运行 nvidia-smi,查看部署队列,对模块执行 git status,任何能告诉我它是否真的在忙、还是只是从未被释放的东西。只有这样才会接管。Chubby 和 ZooKeeper 在接管之前也会从外部进行验证,理由相同。
每个锁旁边都有一份 JSON 元数据:任务短名、会话 id、占用时间、描述以及文件列表。新会话读取 .claude/locks/,一秒钟内就能看到 auth-middleware 被 ani 占用,用于重构,已经第二个小时;GPU 2 正在训练 embedding。"这里到底在发生什么?"这个问题不再需要问。
我最初的直觉是一个 PreToolUse hook:解析命令,并在命令触及资源 X 时自动将 X 标记为占用。这是一条死路。命令种类太多,正则解析器会变成制造误报的机器。正确的顺序恰恰相反:先约定和文件,自动化之后再说,而且只针对确实重复出现的模式。
第三层:邮件,"Artem,看看我的代码"
交接和滚动归档携带的是跨越时间的上下文。锁则为资源仲裁。第三类工作两者都不适合:让两个同时在线的会话保持同步。
一个典型的日子:我让同事的 Claude 去重构 auth-middleware,然后自己在我的对话中去写一个插件。一个小时后,我想知道它进展到哪里,是否卡住了。旧的做法是:我打开即时通讯软件,给同事写消息,同事看自己的对话,再把文字复制回来。每天晚上都要经过一个人的三次转述,而且是好几个项目。
我需要的是会话之间的定向消息,这些会话可能从不同时在线,并且保证送达:如果消息现在没有被读取,那么它会在下一个会话中被读取。我想了想,发现自己描述的正是电子邮件。
.claude/mailbox/
├── ani/
│ ├── inbox/
│ ├── sent/
│ └── archive/
├── artem/
│ └── ...
└── all/ (broadcast)
每条消息是一个单独的 Markdown 文件,带有 YAML frontmatter(from、to、type、subject、message_id、in_reply_to、status),信件正文在其下。一个简单的例子:ani 的会话给 Artem 发送一个问题,主题是 "Re: cmake libsodium",正文是 "Did you change the libsodium linking in CMakeLists? My build fails on target_link_libraries"。一个小时后,Artem 在他的机器上打开 Claude Code,UserPromptSubmit hook 在他的第一条提示之前检查他的收件箱,看到未读消息并把它放进上下文。他当场回复,回复会进入我的收件箱。
线索通过 in_reply_to 串联,与 RFC 822 一样。送达回执是单独的文件。发往 to: * 的消息进入 all/,所有人都会检查。为了去重,hook 在 .watcher_state.json 中记住它已经展示过哪些消息,因此没有任何东西会两次进入上下文。没有轮询,没有代理,没有后台守护进程:一切都依赖于一个在正确时刻触发的 hook。每一项功能都一一对应于经典的电子邮件:
| 电子邮件(RFC 822,1982) | 代理信箱 |
|---|---|
| To / Cc | 定向到特定会话 |
| From / Reply-To | 回复的返回地址 |
| Subject | 不读正文就能分诊 |
| In-Reply-To | 消息线索 |
| Date | 请求有多新鲜 |
| Read / Unread | 是否已经看过 |
| 每位用户一个收件箱 | 每个收件人的队列 |
| 已发送文件夹 | 发送者的审计记录 |
| 送达回执 | 无需问"你看到了吗?"即可确认 |
mclaude:把三层打包成一个工具
我把这些层打包成了一个开源工具 mclaude:Python,核心无依赖,193 个测试,版本 0.6.0。按我当时的描述,它有六层,前三层是基础,后三层是后来加入的:
| 层 | 作用 | 代码行数 |
|---|---|---|
| Locks | 原子资源占用 + 心跳 | 469 |
| Handoffs | 按会话的摘要,只追加的 INDEX | 371 |
| Memory Graph | 决策与坑点的分层存储 | ~400 |
| Identity Registry | 会话的名字(ani、artem、nastya) | 246 |
| Messages + Active Mail | 会话之间的消息,带线索 | 400 + 236 |
| Code Indexer | AST 扫描器 → code-map.md + llms.txt | v0.6.0 新增 |
索引器遍历项目,为代理写出两个文件:code-map.md,即模块、类和函数的地图;以及 llms.txt,一个用于快速加载的紧凑索引。新会话不会把最初几分钟花在重读代码上,因为地图已经在仓库里,与交接放在一起。
在此之上是五个 hook:四个对应 Claude Code 事件(SessionStart、PreToolUse、UserPromptSubmit、Stop),以及一个 git pre-commit 守卫,它会拒绝触及他人已锁定文件的提交。一个带有 20 多个工具的 MCP 服务器,直接把结构化的 JSON 返回给 Claude Code,无需解析命令行输出。
以下是从命令行看锁的样子,摘自 mclaude README:
$ mclaude lock claim --slug fix-auth-middleware-race \
--description "Race condition when two requests write the same session key" \
--files src/auth/middleware.py src/auth/session.py
[lock] claimed fix-auth-middleware-race
# a second session tries the same slug
$ mclaude lock claim --slug fix-auth-middleware-race --description "same bug I guess"
[lock] fix-auth-middleware-race already held
by session: 665a44027f424db7
doing: Race condition when two requests write the same session key
设计规则是:hub 是加速器,而不是依赖。一切都在文件上工作。hub 运行时,消息通过 WebSocket 实时传输。hub 停止时,桥接会把同样格式的消息写入同一个 .claude/messages/inbox/,接收方在 git pull 之后看到的完全一样。即使移除 mclaude 包,.claude/ 中的文件依然可读:叙述用 Markdown,元数据用 JSON。
mclaude demo --no-pause 大约需要 30 秒。它运行两个模拟会话 ani 和 vasya,让它们经过各个层,然后打印一张 Mermaid 图,描述发生了什么。这张图可以原样渲染在 pull request 或文章中。
生态中的空白
然后我去看看还有谁在解决这个问题。隔离方面已经有很多答案:git worktrees(parallel-cc)、沙箱(Kmux)、为每个代理分配独立端口并转发环境变量,以及 Anthropic 的 Agent Teams,带有任务队列和认领机制。它们都通过确保根本没有共享状态,来解决"会话互不干扰"的问题。
共享状态的协调几乎是空白的。只有 Dicklesworthstone 的 claude_code_agent_farm 使用基于文件的锁,但那是在 tmux 中为 20 多个代理做的编排,而不是为同一项目上的多个对话。Claude Code 自己的问题跟踪器从另一端展示了这一空白:#19364,一个关于会话锁文件的请求,从未被实现;#29217 则记录了 .claude.json 因多个会话的并发写入而损坏。
生态系统在避免共享状态方面已经成熟,而在协调共享状态方面几乎空白。
这些都不是新东西。心跳超时、以资源命名的锁文件、只追加的日志、接管陈旧锁之前的外部检查、每项资源一个文件而不是一张共享表:Google 的 Chubby、ZooKeeper,以及过去四十年的每一个协调系统,都建立在这些之上。AI 代理只是我们已经理解的分布式系统中的又一种进程。
RFC 822 之所以适用,原因相同。电子邮件正好解决了这个抽象问题:在可能不同时活跃的进程之间进行异步投递,带有线索和送达确认。每一代分布式系统都会重新发现,电子邮件是对的。2010 年代是消息队列、RabbitMQ 和 Kafka。2026 年,是 AI 代理。
真实使用
三个人,一个 C++ 插件项目,六个并行的 Claude Code 会话,每人负责自己的部分:架构、推理流水线、界面。
一个代理决定修改 scramble 模块中的密钥派生,并通过 mailbox/all/ 发出了广播。另一个代理在其下一个会话开始时看到了它,在写任何代码之前就把这项改动考虑进去。如果没有信箱,它就会针对不兼容的格式写代码:最好的结果是合并冲突,最坏的结果是数据损坏。
在订阅之间交接,在一个订阅中结束、在另一个中继续,大约节省了四个小时,否则这四个小时会花在重建上下文上。
失效之处,以及我不知道的事情
- 规模。 在 3 到 6 个并行会话、2 到 3 个人、一个 C++ 项目上测试,外加网站上的大量会话。我没有测量过 10 个代理的 20 多个会话。我怀疑收件箱会最先失效:通过
.watcher_state.json去重的复杂度与消息数量成线性关系,在数百条消息时会变慢。 - 没有 hub 的多台机器。 文件核心通过共享仓库同步,因此另一端要在
git pull之后才能看到消息:是分钟级,而不是秒级。hub 通过 WebSocket 提供秒级的响应,但它多了一个要运行的进程,而我还没有在生产环境中试过它。 - 网络文件系统上的锁竞争。
O_CREAT | O_EXCL在本地文件系统上是原子的。在 NFS 或 SMB 上,这一保证取决于具体实现,因此把.claude/locks/放在那里的人,可能会遇到竞争。 - 陈旧锁的接管是半自动的。 通过
nvidia-smi、ps和git status的检查是为人编写的。要将其自动化,需要一个知道如何检查每种资源的处理程序。 - 邮件不会唤醒会话。 仅靠 hook,消息只会在活动会话输入内容时、在 UserPromptSubmit 时出现。真正的推送投递并不存在(见下面的更新)。
- 仍然需要人在回路中。 如果六个代理同时认定 auth-middleware 需要重写,它们会通过邮件协商,但谁来接手仍然是人的决定。mclaude 提供的是协商的工具,而不是自主编排。
四月之后的变化
2026 年 9 月更新。这里有三处更正和两处补充,都已于 2026 年 9 月 23 日核实。
- 安装。 我在四月写的
pip install mclaude行不通:这个包没有发布到 PyPI,PyPI 对它返回 404。请改为从 仓库 的克隆中安装,如下所示。 - Issue #19364。 我当时把它描述为一个仍然开着的请求。实际上,一个 stale 机器人(自动关闭长期无动静 issue 的机器人)已经在 2026 年 2 月 28 日把它以 not planned 关闭了,早于本文发布。它从未被实现,这正是本节要说明的要点,但它当时并不是开着的。
- Issue #29217。 我引用了"七天内 315 个损坏的备份"。该数字在当前讨论串中并不存在。最初的报告统计的是大约 40 分钟内损坏的 165 个备份,而一位评论者统计出七天内至少有 423 次损坏。该 issue 于 2026 年 5 月 25 日因长期不活跃而关闭。
- 第七层。 mclaude README 还描述了基于文件的会话心跳,本文没有涉及。另外,该包比上文的手动约定把锁管理得更严格:每 30 秒一次心跳,超过 3 分钟没有动静的锁即视为陈旧。
- 对话中途的邮件。 俄文原文结尾处的第一个计划,在同一天就落地了。4 月 23 日 UTC 17:22 合并的 pull request #5,在本文发布后几个小时,增加了一个用于 Claude Code Monitor 工具的脚本:它每 30 秒轮询一次收件箱,并把每条新消息交给正在运行的会话,无需任何人输入提示。因此,上文"邮件不会唤醒会话"这一限制,只适用于仅使用 hook 的配置。
主分支自该次合并以来再无提交。那个让滚动归档得以产生的 MF0-1984 仓库,现在返回 404。
试一试
git clone https://github.com/AnastasiyaW/mclaude.git
cd mclaude
pip install -e .
mclaude hooks install --apply
mclaude demo --no-pause
采用 MIT 许可证,要求 Python 3.9+。其背后的原则单独写成了 原则 18:多会话协调,位于 codex-claude-code-config 页面所描述的配置仓库中。该工具的项目页面是 mclaude。至于我为什么依赖自己仓库中的交接,而不是桌面应用自带的会话历史,见 你从来不看的 45 MB Claude Code 会话。
俄文原文发表于 Habr。