10个代理同时跑了同一个类型检查。解决办法是一个目录。
同一个检出目录里17个编码代理,同时跑着10个tsc,load average 37,空闲内存只剩87 MB。一次九十秒的类型检查花了7分36。这里是我们的测量数据,机器为什么根本没在计算,以及那个解决了问题的小小共享锁。可以直接抄进任何仓库。
9月7日,我们的开发机器不再正常响应。不是崩溃,也不是卡死。只是所有事情都慢了十倍,连跟代码毫无关系的事情也一样。
那些显而易见的嫌疑对象全都错了。笔记本没有过热:没有记录到任何降频,电池30.6 C。没有失控的进程在吃CPU。什么都没有部署。唯一不寻常的是同一个仓库里活着17个代理CLI,而这在我们这儿是普通的一个工作日。
下面是真正发生的事情,测出来的,不是猜的。
测量
16 GB、8核的机器,已经开了五个半小时,17个代理在干活:
| 我们测了什么 | 数值 |
|---|---|
| 活着的代理CLI | 17 |
并发的 tsc --noEmit 进程,两分钟内观察到 | 3,然后10 |
| Load average | 37到41 |
| 空闲内存 / 内存压缩器 | 87 MB / 7.2 GB |
| 桌面应用的一次类型检查,饱和的机器 | 26 秒CPU对应7分36的墙上时间 |
| 同一个类型检查,空闲的机器 | 33 秒 |
关键的是倒数第二行。26秒的CPU摊在七分半里,也就是百分之十的利用率。类型检查没有在计算。它在等内存。
而且其中一个进程被操作系统在半路杀掉了。被杀掉的 tsc 会以非零码和空输出退出,这跟一个真正的类型错误没法区分。所以这台机器不只是慢,它还在产出没人敢信的判决。
没有人做错任何事
这一段值得停下来想想,因为正是它让这种故障如此难以预见。
那些代理每一个都在遵守规则。每一个都改过TypeScript。每一个都被要求在交还之前验证类型。每一个都跑了 tsc --noEmit 。没有一个能看见别的代理。 不存在一块公共黑板,让某个代理在上面写下「我正在做那件很贵的事,等一下」。
接着它开始自我喂养。机器饱和了,所以类型检查变慢。盯着它的那个代理判断它卡住了。于是杀掉,再起一个。这个反射单独看是对的,放进一群里就是灾难,而这跟我们一个月前记录过的故障是同一族:当时代理们留下了卡住的搜索进程。Process Guard 是找出已经启动的东西的安全网,而这里做的是阻止它启动。
我们没有采纳的三个答案
少开几个代理。 这把症状减半,把缺陷留下。在一台负载很重的机器上,两个同时跑的类型检查依然比一个慢,而缩减队伍等于用让工作变快的那样东西去偿付问题。
最后只做一次类型检查。 很诱人,但它错在一个跟性能毫无关系的地方。10个工单之后才发现的类型错误是个孤儿:写下它的代理已经关闭,上下文已经丢失,得由一个人把整个话题重新打开去改一行。我们不想推迟这次验证。
增量编译。 试过,放弃了。在 --noEmit 模式下收益存疑,而并发的进程会破坏共享的 .tsbuildinfo 。它解决了一半问题,代价是让另一半更糟。
我们改成怎么做,一次检查,大家共享
规则不是「少检查几次」。而是每个项目、面向所有人、同一时刻只有一个类型检查。一个包装脚本把N次检查换成一次,并回答三种情况:
- 距离上一次运行什么都没变,那就把它的结果交回去。
- 已经有一次运行在进行中,那就等它,拿它的结果。
- 否则,拿下锁,成为机器上唯一的
tsc。
从代理的角度看什么都没变:它敲 yarn typecheck ,它拿到自己的类型错误。它也不会比以前等得更久,因为它等的那次运行是比它自己更早启动的运行。机器付一次的钱,而不是十次。
整个想法就是这样。有意思的是,它需要的两套机制都比你以为的小得多。
锁是一个目录
不是文件,不是数据库,不是守护进程。是一个目录。
try {
mkdirSync(lockDir); // 成功: 锁归我们
} catch (err) {
if (err.code === 'EEXIST') { /* 别人拿着,我们等 */ }
}
mkdir 要么创建目录,要么以 EEXIST 失败,而且它在macOS、Windows和Linux上都以原子方式完成,不需要依赖,也不需要原生调用。先写一个文件再检查它是否存在,那就是两步操作,而两步操作恰恰是第二个代理挤进中间的地方。
在这个目录里,我们放一个带着pid、主机名和启动时间的 owner.json 。这个文件用于诊断,也用于检测一把死锁。做互斥的从来不是它。
死锁会在两种情况下被自动收回:属主进程已经消失,这一点只在主机名相符时才检查,因为pid跨机器毫无意义;或者这把锁已经超过十五分钟。
一个让我们付出了一个缺陷代价的陷阱。 在 mkdir 和写入 owner.json 之间,存在一个属主读不到的窗口。在这个窗口里判定锁已死,就是从刚刚拿到它的那个进程手里把它偷走,而这正是这个文件存在要防止的竞争。所以当读不到任何属主时,我们按目录的年龄来判断,而不是按缺失的文件。
指纹是一个日期加一个计数
第一种情况需要知道距离上一次运行有没有东西变过。显而易见的答案是对源文件做哈希。我们不这么做。
指纹是 <最新的mtime>:<文件数量> ,覆盖从tsconfig的 include 推导出的那些根目录,再加上tsconfig本身。
在2300个文件上,读完每一个字节比它省下的那次检查还贵。只看日期会漏掉一次删除。只看计数会漏掉一次编辑。两个合在一起就把两边都盖住了。我们认下的假阴性,是同一毫秒内的两次修改让计数保持不变,而那种情况下最坏的结果也不过是一个过期几秒的缓存结果,绝不会是一个悄无声息的类型错误,因为真正拦人的验证仍然在构建那一步。
一条写下来的规则不够,我们加了一个钩子
这条指示从第一天起就写在 AGENTS.md 里:绝不直接跑 tsc ,永远走共享脚本。它不够用,而说清楚原因是值得的。
改完代码就去验证类型,这是一个刻得很深的反射。在压力之下,代理会不重读指示就敲下 npx tsc --noEmit 。而只要有一个代理绕开规则,就足以重现这个锁存在要阻止的一窝蜂。指示是可以商量的。钩子不行。
于是我们在 Bash 工具上挂了一个 PreToolUse 钩子,它拒绝直接的 tsc ,并在拒绝信息里点名正确的命令:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/scripts/hooks/block-direct-tsc.mjs\"" }
]
}
]
}
}
钩子从stdin以JSON读取工具调用,拒绝时以2退出并把理由写到stderr,放行时以0退出。有两个细节决定了一个钩子是有用还是烦人。
它匹配处在命令位置的 tsc ,而不是字符串里的任何位置。 到处去找这三个字母,连 grep -rn tsc AGENTS.md 都会被拒绝。所以这个模式要求 tsc 出现在行首,或者出现在 ; 、 && 、 || 、 | 、 ( 之后,前面可以带一个包管理器执行器和一段路径。它也放行 tsc --version :没有理由拒绝一个只输出信息的选项。
它还拒绝第二样我们没料到的东西。 我们看着一个代理在锁后面等,过了一会儿认定这把锁一定死了,就把锁目录删掉好让自己继续。那会在活着的进程旁边再启动一个重进程,是对这把锁所保护的一切最完美的绕过。所以删除锁目录或缓存目录同样被拒绝,并附上解释:一把死锁会自己被收回。
第二条拒绝,我们事先绝对写不出来。它来自观察代理被卡住时真正会做什么,而这比想象它们可能会做什么更适合当护栏的来源。
它到哪里为止
这个钩子只属于Claude Code。队伍里其他的代理CLI只看得到写下来的规则。这是一个已知的窟窿,我们认下了:在等一个所有CLI都会读的钩子标准出现之前,一道覆盖大多数队伍的护栏胜过没有护栏。
共享脚本本身对供应商是中立的,因为它就是一条普通命令。任何能跑 yarn typecheck 的CLI都能从这把锁里受益,不管有没有东西强制它这么做。
从这里该带走什么
类型检查是我们动静最大的那个例子,而不是一个特例。这个模式适用于任何昂贵的、在一小段时间窗口内幂等的、并且每个代理都出于同一个正当理由去启动的命令:安装依赖、跑完整的测试套件、构建生产版本、在固定端口上启动开发服务器。
按这个顺序问三个问题,整套设计就齐了:
- 我能不能复用一个最近的结果。
- 我能不能汇入已经在进行的那次运行。
- 否则,我是不是那个独自启动它的人。
如果有好几个代理共用你的机器,值得测量的不是有多少个在跑。而是其中有多少个在同一分钟里启动了同一条命令。你的机器真正感受到的就是这个数字,而在你去看它之前,你只会去怪机器发热。
如果你想看我们怎么在同一个仓库上跑多个代理而不让它们互相踩脚的完整做法,那在 如何并行运行3到8个编码代理而不失去跟踪 里;至于那些确实已经启动的进程,安全网是 Process Guard 。共享脚本和钩子都住在AgentsRoom的仓库里,也就是那天下午那17个代理干活的地方。
下载 AgentsRoom
在一个窗口中运行你所有项目的所有 AI 代理。
配套应用:随时随地监控你的 Agent
使用 Claude、Codex、Antigravity CLI 或其他 AI 提供商。
把 Bug 和需求直接发送到您的公开待办清单。
AgentsRoom 实际运行一瞥。
继续阅读
一次 Claude Code 会话会触发 30 个 hook 事件。只有 3 个能回话。
Claude Code hook 事件的完整清单:每个事件何时触发、其中哪 15 个能阻断,以及那条悄悄吞掉大部分 hook 输出的 stdout 规则。一份在数千次代理会话的生产环境中跑出来的实战参考。
阅读全文我的 AI 跑步教练是一个 Git 仓库加一个 Claude 代理
跑完一趟,手表自动同步,三分钟后分析已经写进我的仓库,本周计划被重新调整过,教练还在 Strava 活动下留了一条评论。没有做 App,没有写服务器,没有按 token 计费的账单:一个 Claude 订阅、AgentsRoom,加上一堆 Markdown 文件。下面是完整的搭建过程,可以照着复现。
阅读全文Antigravity CLI每台机器只保留一个Google登录,以下才是真正管用的做法
为什么您无法在Antigravity CLI上交替使用两份Google AI Pro订阅、它究竟把您的登录信息存在哪里、那些账户切换工具对您的系统钥匙串到底做了什么、为什么家庭方案并不会让额度翻倍,以及唯一能真正并行运行多个账户的做法。
阅读全文