跳到正文跳到正文
深度解析

多个编程智能体,一个仓库:用认领代替锁

并行的编程智能体会在合并时发生冲突,因为谁都不知道别的智能体正在改什么。现在,Foxl Code 的云端智能体会先认领即将修改的文件,并得知你的其他运行中的智能体里,哪个已经认领了有重叠的路径。认领无论如何都会被记录,也不会锁住任何东西;发生冲突时,智能体会被告知把改动发给持有者、等待,或者问你。本文包含已发布命令的真实输出、glob 规则、数据库成本,以及智能体是否真的会遵守这条规则,而这一点我们还不知道。

Foxl Team更新于 阅读约 9 分钟

一个文件的一角系着两个智能体的标签,一个蓝色,一个橙色,旁边放着一把打开的挂锁:这个文件上的两个认领都被记录下来,而什么都没有被锁住。
本页内容
已于 2026年10月27日 对照已发布的实现和保留的测试复核。

在一个仓库里同时运行几个编程智能体,它们会在合并时发生冲突,因为谁都不知道别的智能体正在改什么。Foxl Code 在云端运行 Claude Code 和 Codex 等编程智能体,从 v0.9.3-beta.5 起,每个智能体在动手编辑之前,都会被要求运行 foxl-agents claim <paths>,认领它即将修改的路径。如果同一用户的另一个仍在运行的智能体(运行中的智能体)已经认领了有重叠的路径,返回结果会指出是哪个智能体,以及它留下的备注。什么都不会被锁住。发生冲突时,智能体会被告知:把它需要的改动发给持有者,等持有者释放认领,或者去问用户;它自己的认领会在它的运行结束时自动消失。

为什么并行的编程智能体会冲突

每个智能体都在自己的检出目录、自己的分支上工作,所以没有什么能阻止两个智能体修改同一个文件。Git 会在合并第二个拉取请求时报告文本冲突,那时两个智能体都已经完成工作,钱也已经花了。语义冲突,也就是两个拉取请求各自都能通过检查,但两个都合并后构建就会失败,在有人把两个都合并之前,没有任何东西会报告它。只有当两个智能体碰到同样的路径时,认领才能发现这类冲突。

我们自己的工作方式让我们看到了什么是有用的。我们开发 Foxl 时,会在它的仓库上并行运行 15 到 20 个 Claude Code 会话,14 天里它们互相发送了 4,773 条消息。让它们不至于互相覆盖的,是一份共享的归属清单。一个协调者会话记录哪个会话负责哪些文件(对于很多会话都会碰的文件,还记录负责其中的哪些部分);一个会话如果需要修改不归自己负责的文件,就把改动发给负责人,而不是自己动手。在有这份清单之前,多个会话会同时编辑同样的文件,直到合并时才发现。这份清单也有它自己的代价:在那 14 天里,会话被阻塞的 123 次中,有 33 次是在等协调者回复。

Foxl Code 已经有了消息这一部分:foxl-agents 命令,每个云端智能体都可以用它列出该用户其他运行中的智能体,并给它们发消息。认领则在没有协调者的情况下补上了归属清单:每个智能体说明自己要碰哪些内容,数据库在一次请求内给出答复。

为什么用认领,而不是锁?

锁只有在每个智能体都遵守它、每个智能体都会释放它时才管用。崩溃、预算耗尽或被取消的编程智能体什么都不会释放,所以锁需要超时;而一个长到足以覆盖真实运行的超时(我们的运行可能超过七个小时),会让所有人都被阻塞那么久。两个各自需要改对方文件中一行的智能体,会永远互相等待。而锁被拒绝的智能体,除了停下来就无事可做。

认领绕开了这些问题。崩溃的持有者一旦不再处于运行状态,就不再计算在内,所以不用干等超时。与别人重叠的认领不会被拒绝,而是会被记录下来,重叠情况连同持有者的任务 ID、标题、认领的路径和备注一起返回给发起请求的智能体。遇到冲突的智能体首先会被告知把它需要的改动发给持有者,所以两个互相需要对方文件的智能体,除了等待之外还有事可做,尽管也没有什么能阻止它们俩都选择等待。下面是每个智能体收到的指令,一字不差:

- Before you start editing, claim the files and directories you expect to
  change, in one command: `foxl-agents claim <path>... --note "<why>"`. A
  directory claims everything under it; `*`, `?` and `**` globs work. Claim
  what you will change, not the whole repository, and claim again if your
  plan grows.
- The answer names any other live agent that already claimed an overlapping
  path. On a CONFLICT, do not edit those files: send the holder the exact
  change you need with `foxl-agents send <task-id> "<the change>"`, wait until
  it releases them (check `foxl-agents claims` at a natural checkpoint, never
  in a loop or behind a `sleep`), or tell the user. A claim is information,
  not a lock.
- `foxl-agents release <path>...` drops a claim you no longer need. Every
  claim you hold is dropped when your run ends.

这几行会追加到每个 CLI 读取的用户级指令文件中:Claude Code 是 CLAUDE.md,Codex、Kiro、Cursor、Hermes 和 OpenCode 是 AGENTS.md。同一套机制要覆盖六个不同的 CLI,这就是 foxl-agents 是一个 shell 命令而不是 MCP 服务器的原因。每个编程 CLI 都能运行 shell 命令,所以一个脚本就能在六个 CLI 里通用,不用逐个配置它们加载服务器。

智能体运行什么,又看到什么

下面是已发布命令的真实输出,由脚本而不是智能体运行,针对一个演示仓库里的两个演示任务。任务 A 是一个添加限流的 Codex 任务,任务 B 是一个给 users 端点添加分页的 Claude Code 任务。服务器、它的数据库和这个命令,都与云端智能体使用的完全相同。

终端输出。任务 A 认领 src/api/middleware.ts 和 src/api/routes.ts,备注为“adding a token bucket per API key”,并被告知没有其他运行中的智能体认领过重叠的路径。随后任务 B 认领目录 src/api/,收到一条 CONFLICT,其中列出任务 A、它的标题、那两个文件和它的备注,后面跟着指令:把改动发给持有者、等待释放,或者告诉用户。运行中认领的列表显示了两个任务的认领。
图 1. 已发布的命令对已发布的服务器运行时捕获的输出,服务器在 workerd(Cloudflare 的 Workers 运行时)中本地运行,2026-10-23。任务 B 的认领同样被记录了下来。

任务 B 认领 src/api/ 时带了结尾的斜杠,表示这是一个目录,服务器把它存为 src/api/**。即使没有斜杠,命令也会自己加上 /**:它在检出目录内运行,而那里是唯一能分辨文件和目录的地方。无论当前工作目录在哪里,命令都会把每个路径改写为相对于仓库根目录的路径,并在发送任何内容之前拒绝检出目录之外的路径。

冲突信息列出了任务 A 的两个文件和它的备注,任务 B 根据备注做决定。在这个例子里,它可以不碰中间件,把它在那里需要的那一处改动发给任务 A;也可以先去写端点本身的处理函数,等任务 A 释放后再回来看看。

如何判断两个 glob 是否重叠?

只要至少有一个路径同时匹配两个认领,它们就算重叠。语法刻意保持得很小:

  • 普通路径就是不含通配符的 glob;
  • * 在一个路径段内匹配;
  • ? 恰好匹配一个字符;
  • 恰好为 ** 的路径段匹配任意数量的完整路径段;
  • 其他所有字符都按字面处理,所以 [id].tsx 是一个文件名,而不是字符类。

下面是已发布的匹配器对几组认领给出的结果:

认领 A认领 B是否重叠
src/api/**src/api/users.ts是
src/*.tssrc/api/users.ts否,* 只在一个路径段内
src/**/*.test.tssrc/api/*.ts是,两者都匹配 src/api/x.test.ts
docs/**src/**否
src/app/[id]/page.tsxsrc/app/a/page.tsx否,[id] 按字面处理
**/*.mddocs/guide.md是

重叠永远不会导致认领被拒绝,但有几种形式会被拒绝:

  • 只由通配符组成且包含 ** 的 glob,例如 ** 或 **/*,因为它会认领整个仓库;
  • 含有 .. 路径段、反斜杠或控制字符的路径;
  • 以 ! 开头的路径,因为它在这里不表示否定,也不应该看起来像否定。

一个任务最多持有 50 个认领,每个最长 200 个字符,备注最长 280 个字符。

比较过程设有上限,这样一组不寻常的认领也不会耗费服务器太多 CPU。开头字面路径段不同的两个认领(docs/** 和 src/**)会立即被排除。否则,服务器会带记忆化地遍历成对的路径段,在一个路径段内则遍历成对的字符。一次请求中的所有比较共享 50,000 步的预算,预算没能覆盖到的那一对不会被丢弃,而是报告为可能重叠,所以即使请求用完了预算,智能体仍然会得到提示。

认领何时结束

一个认领属于一次运行。运行结束时,任务会在把这次运行记录为已完成的同一步里,撤销它做出的所有认领。对于没能走到这一步的运行,读取时有两项检查兜底:持有者已不再运行的认领会被忽略,早于一次运行可能持续的最长时间(7 小时 20 分钟)的认领也会被忽略。认领的可见范围与消息相同:只有同一个用户的运行中的智能体,并且是在同一个仓库上通过同一个 GitHub App 安装工作的,才能看到彼此的认领。

两个智能体和一张认领表。智能体 A 认领两个文件,被告知没有重叠。智能体 B 认领包含它们的目录,它的认领被写入,并收到一条指出智能体 A 的冲突。接着智能体 B 有三个选择:把改动发给智能体 A、等待智能体 A 释放,或者询问用户。一次运行结束时,它的认领会被移除。
图 2. 冲突会返回给发起请求的智能体。没有任何认领因为重叠而被拒绝,也没有任何东西在等一把锁。

在免费数据库套餐上的成本

认领存放在一张表里,只有一个基于(用户、仓库、创建时间)的索引,也没有任何东西轮询它。每次读取只查询过去 7 小时 20 分钟内创建的认领,而创建时间是索引的最后一列,所以更早的行落在数据库扫描的范围之外。没人释放的过期认领,无论有 1,000 个还是 2,000 个,都只会给一次读取多加一行;这两种情况我们都测过。

我们测量这些,是因为 Foxl 运行在 Cloudflare 的免费套餐上:一个账户下的所有 D1 数据库共享每天 500 万行读取和 100,000 行写入,账户每天有 100,000 次 Worker 请求。下面的数据来自在 workerd(Cloudflare 的运行时)中本地运行的 D1,还没有与生产环境 D1 自己的指标核对过:

调用读取行数写入行数
认领 N 个路径仓库上每个运行中的认领约 2 行,另加每个路径约 1 行和少量固定读取每个路径 2 行
列出认领1 + 每个运行中的认领 2 行0
释放每个路径约 1 行每个被释放的行 1 行
一次从未认领的运行结束00

按所有用户每天约 50 个云端任务、每个任务两次认领调用计算,每天是 100 次调用。每次调用是两个 Worker 请求,一个在 code.foxl.ai,一个经内部跳转到服务器,所以约 200 个请求,占免费套餐 100,000 次的 0.2%。即使每次调用的成本都相当于一个繁忙用户的最坏情况(十个智能体在同一个仓库上各持有五个认领),一天也只会读取约 11,000 行(500 万的 0.2%),写入约 830 行(100,000 的 0.8%)。

智能体真的会遵守这条规则吗?

我们还不知道,这个阶段就是为了弄清楚这一点。我们自己的本地会话也需要一个协调者来执行同样的规则,所以我们不会仅仅因为指令里这么写了,就假设六个不同的 CLI 会在编辑前认领、在冲突时退让。每次冲突都会写一条审计记录(每个服务器实例会跳过十分钟内同一任务和同一组持有者的重复记录),一个智能体发给另一个智能体的每条消息也一样。智能体有没有给持有者发消息,看审计日志;有没有释放认领,看认领表;有没有照样修改了文件,看拉取请求的差异。

下一阶段取决于这些结果。如果智能体确实遵守认领,计划是让 Foxl Code 中把一个请求拆分给多个智能体的那部分,为每个子任务提供一份文件清单,并让有重叠的子任务依次运行,而不是同时运行。截至 2026-10-23,生产环境中的这张表是空的,因为功能部署后还没有启动过任何云端任务。

如果你也在做类似的东西,我们会保留的三个特性是:认领总会被记录;重叠会连同持有者和备注一起返回;认领只在它所属的运行期间有效。我们在 Foxl Code 发布时写的文章介绍了智能体循环本身,它会朝着目标持续校准,而不是信任每一步。

参考资料与延伸阅读

  1. Cloudflare D1 定价与免费套餐限制文档
  2. Foxl v0.9.3-beta.5 发布发布