智能体全天推送,CI 就是等待
AnyRouter 的大部分改动由编程智能体编写和合并。智能体推送代码,等 CI,修复失败的部分,再推送一次。写代码变快了而 CI 没有变快时,大家等的就是 CI。
我们的 CI 不在 GitHub 上运行。为了省钱,我们关掉了 GitHub Actions 的 CI,所有检查都通过 pnpm local-ci 在一台 16 核、15 GB 内存的机器上运行。很多智能体共用这台机器。本系列第一篇介绍了这个循环。
本文的起点是 Owen Gretzinger 的文章 How I made our CI 20x faster。一句话概括,Owen 审计了 CI 时间花在哪里,然后逐个修复原因,例如运行器慢、lint 工具慢、缓存未命中和测试隔离。文中的一些想法适合我们,一些不适合。下文会逐一说明。
先审计:排队、准备和运行
在做任何改动之前,我们读了最近 50 份本地 CI 日志,时间是 10 月 23 日到 26 日。我们把每次运行拆成三段。
- 排队。等待机器或锁的时间。平均值是 0 秒。
- 准备。准备一份干净的仓库副本和依赖的时间。平均值是 52 秒。
- 运行。检查本身的时间。平均值是 326 秒,中位数是 20 秒,最长的一次约 2,900 秒。
平均值和中位数之间的差距是最有用的数字。一半的运行在 20 秒内完成了检查。少数几次运行一个接一个地对约 20 个应用做类型检查,把平均值拉高了。所以问题不在每一次运行,而在长尾,而长尾就是类型检查。
我们以为慢的地方其实并不慢。Biome lint 约 1 秒。50 次运行中只有 1 次跑了完整的测试套件,因为本地 CI 只运行与改动文件相关的测试。等待机器不花时间。限制在内存,而不在核数。
日志还显示了一些较小的开销。
- 图片构建步骤在 21 次运行中有 19 次没有命中缓存。每次运行都会删除未被 git 跟踪的文件以便从干净状态开始,这也删掉了约 1,200 张生成的社交卡片图片。所以每次都要重新绘制。
- 锁文件一有改动,就会为 CI 创建一份新的工作副本,里面没有 TypeScript 构建状态。日志中 6 天内出现了 11 个锁文件键,所有类型检查程序冷启动一次约 5 分钟。
- 测试的 setup 文件把 Testing Library 和 React DOM 加载进每个测试文件,包括不使用 DOM 的文件。这占了一小批 node 测试 38% 到 42% 的时间。
同一个检查跑了两次
接着我们找到了最大的问题,而它来自一次修复。11 月 1 日,我们修改了根目录的 pnpm typecheck,让它检查每个包和应用。在此之前,一些测试文件里的类型错误可能进入 main。这个改动是对的。但本地 CI 在根步骤之后,仍然分别对 API、server core、UI 和每个应用再做一次类型检查。结果每次运行有 29 个 TypeScript 程序,其中约 26 个被检查了两次,并且全部串行。一次热启动运行要 162 秒。
没有任何检查失败,所以没有任何东西提醒我们。重复的检查是绿色的,只是慢。现在,本地 CI 规划根类型检查时,规划的正是 pnpm typecheck 覆盖的那一组,并且每个程序只运行一次。它从每个包的 package.json 读取这一组,所以新应用会被自动纳入,不需要手写列表。
并行运行,但要给内存做预算
本系列第一篇说过,本地 CI 一次只跑一个检查,因为机器只有 15 GB 内存,承受不了并行构建。对固定数量的任务来说,这是对的。在这台机器上,同时启动三个冷启动的 TypeScript 程序,会在剩余约 1.5 GB 时被内存不足处理程序杀掉。
所以我们没有选一个固定的任务数。一个新脚本先启动最大的程序。只有当下一个程序的内存峰值放得进剩余内存时,它才会启动下一个程序。计算剩余内存时,先减去正在运行的程序还会增长的部分,再保留 2 GB。它从过去的运行中学习每个程序的峰值,没见过的程序按 2.8 GB 估算。
一个程序如果被杀掉而且没有类型错误,就会单独重跑一次。如果再次被杀,这次运行会报告为内存不足,而不是检查失败。看到类型检查失败的智能体会去找类型错误。看到内存不足的智能体知道应该等一等再重试。
热启动运行时,剩余内存始终不低于 2.5 GB。在冷启动并行运行中,没有程序被杀掉。
我们还改了什么
- 15 个轻量守卫检查,例如 schema 漂移和应用间导入边界,现在每次 4 个并行。它们只读文件,所以并行运行是安全的。重量级检查和测试运行仍然各自持有锁。
- 有一个守卫会解析每个 TypeScript 文件来查找 issue 引用。现在它会跳过不包含
#加数字的文件。 - 新的 CI 工作副本会带上最近使用过的副本的 TypeScript 构建状态和缓存。TypeScript 会重新检查任何改动过的文件,我们也确认了,注入的类型错误在复制来的状态上仍然会让检查失败。
- 渲染好的社交卡片存放在清理步骤不会删除的缓存里,按路径和源内容做键。14 天未使用的卡片会被删除。
- Testing Library 只在有 DOM 的测试中加载。
- pre-push 钩子会对改动的文件运行 Biome,只报告错误。智能体在推送之前就能知道 lint 错误,而不是等 CI 跑完。
下面是数据,每一项都在同一个提交上测量改动前后。每行都注明了运行次数。
| 检查 | 之前 | 之后 | 变化 |
|---|---|---|---|
| 类型检查,29 个程序,热启动(3 次的中位数) | 162 秒 | 79 秒 | -51% |
| 类型检查,29 个程序,冷启动(各 1 次) | 402 秒 | 256 秒 | -36% |
| 15 个守卫(3 次的中位数,机器繁忙) | 35 秒 | 14 秒 | -60% |
| 仅 issue 引用守卫(3 次的中位数) | 13.9 秒 | 5.0 秒 | -64% |
| 清理后的图片构建(各 2 次) | 100 秒 | 60 秒 | -40% |
| 20 个 node 测试文件(3 次的中位数) | 4.86 秒 | 3.58 秒 | -26% |
| 12 个 DOM 测试文件(各 2 次) | 16.5 到 17.6 秒 | 16.7 秒 | 无变化 |
| pre-push Biome,13 个改动文件 | 无 | 0.1 秒 | 新增检查 |
每个程序只运行一次但仍然串行时,热启动类型检查从 162 秒降到 111 秒。并行运行后降到 79 秒。所以大约 60% 的提升来自去掉重复,其余来自并行。
这些是单项检查的数据。我们还没有测量改动后整次本地 CI 运行的平均时间。接下来的 50 份日志会告诉我们。
空列表可能意味着全部
有一个陷阱值得单独说。本地 CI 只运行与改动文件相关的测试。脚本通常从 git diff 生成这个列表,例如 T=$(git diff --name-only main | grep test),然后运行 vitest run $T。
如果 diff 返回空,例如 git pull 失败、HEAD 没有移动,命令就变成了不带文件的 vitest run。对 Vitest 来说,这意味着运行整个测试套件。在我们的机器上,整个套件有触发内存不足处理程序的风险。我们曾在 600 秒后停掉过这样一次运行。
守卫只需要一行。运行之前先检查列表,写成 [ -n "$T" ] && vitest run $T。Owen 关于 Nx 也说过类似的话。跳过工作的规则需要自己的测试,因为一个失效的跳过要么看起来是绿色,要么会运行全部。
哪些想法适合我们
Owen 的文章列出了九项改动。下面是每一项在我们这里的情况。
| 想法 | 对我们来说 | 状态 |
|---|---|---|
| 更快的运行器 | 本地 CI 排队 0 秒。但我们在 GitHub 上的发布工作流一直在等一个从未出现的自托管运行器,146 次运行全部被取消。我们把它们改到了 GitHub 的标准运行器。 | 已完成 |
| 更快的 lint 工具 | 我们已经在用 Biome,约 1 秒。 | 没有可提升的空间 |
| 更快的类型检查器 | 用 Go 编写的 TypeScript 编译器 tsgo。 | 下一步 |
| 把测试分片到更大的机器 | 我们只有一台机器,CI 不花钱。增加机器要花钱。 | 不采用 |
| 不要为每个测试文件重新加载应用 | Vitest 的 isolate: false。我们的 DOM 测试没有变快,所以测试启动是要看的地方。 | 下一步 |
| 修复准备阶段 | 预先填充的工作副本,以及在清理后仍然保留的卡片缓存。 | 已完成 |
| 并行运行检查 | 按内存预算做类型检查,守卫每次 4 个。 | 已完成 |
| 只运行改动涉及的部分 | 之前已经这样做了。新增了空列表守卫。 | 已完成 |
| 每次推送前先检查 | pre-push 钩子中只报错误的 Biome。 | 已完成 |
Owen 的团队花钱买速度。Owen 在文中说 CI 账单从每月 $230 涨到了 $4,300,并认为值得。我们的约束不同。CI 运行在我们已有的机器上,所以我们先找被浪费的时间,再考虑能买到的时间。
给你的编程智能体的提示词
你可以把这段提示词交给自己的编程智能体。它要求做同样的审计,并遵守我们遵守的规则。
审计这个仓库的 CI 并让它更快。绝不删除或放宽任何检查。
1. 拉取最近 50 次 CI 运行。把每个任务的时间拆成排队、准备和运行。
2. 报告每一段的平均值、中位数和最大值。如果平均值远高于中位数,找出造成这一点的慢运行。
3. 列出最慢的 5 个任务,以及每个任务的时间花在哪里。
4. 检查常见原因:运行器排队慢、lint 工具慢、类型检查器慢、测试可用的核数太少、每个测试文件都重新加载应用、缓存未命中或被清理步骤删除、可以并行却在串行的工作、对改动无法触及的代码做检查,以及推送前从不运行的检查。
5. 查找跑了两次的同一个检查,以及被当成"全部运行"的空文件列表。
6. 在内存预算内并行运行。因内存被杀的任务报告为内存不足,而不是检查失败。
7. 每项改动都在同一个提交上测量前后,取 3 次的中位数。不要为了数字好看而跳过测试或降低阈值。
8. 不要包含要花钱的改动(付费运行器、更大的机器、更多分片)。把它们列给人来决定,写明节省的时间和成本。
9. 开一个 PR,附上前后对比表和复现结果的命令。相关文章
How agents ship AnyRouter: the merge and deploy loop
Coding agents write, review, merge and deploy most AnyRouter changes. In the week of 19 to 25 October 2026 that loop merged 346 pull requests. This first post in the series shows the loop: a signed local CI gate, one reviewer per commit, one merge script, and gated deploys about once an hour.
How agents deploy AnyRouter: one Worker at a time
AnyRouter runs as a fleet of Cloudflare Workers, and agents deploy it about once an hour from more than one machine. Each Worker now records the commit it runs, a deploy ships only the Workers whose code changed since that commit, and a checkout that is older than production cannot replace it. This post shows how that works and what it still costs.