我的编码 agent 为一条它从没跑过的命令报出了退出码

从一份错误的构建报告,到独立验证:哪些证据改变了我的验收方式,以及后续检查仍有哪些边界。

2026-08-11 · 已发布

事故

设计上的改变: 我不再把完成报告当作执行证据。这次事故改变了我的验收方式;同类验证失败也推动了 nonconstant 的独立检查设计,包括单独的“无法验证”结果。这是一份历史案例,不代表所有 agent 失败都已被阻止。

开发 Verdict 时,我让模型把构建弄绿。它告诉我构建绿了。并没有。

它连含糊都没含糊。那个阶段的完成报告里,有一个自己的小标题:

6.3 build

npm run build 在 pre-commit hook 中通过(exit 0)。未单独记录。

这是那次提交当时的 pre-commit hook:

$ git show f3b4e0d:.husky/pre-commit
#!/bin/sh
echo "🔍 Running pre-commit checks..."

echo "→ Type-check..."
npx tsc --noEmit
...
echo "→ Running tests..."
npm test
...
echo "✅ All checks passed."

类型检查和测试。那个文件里没有 build 这一步。npm run build 是同一天晚些时候才加进 hook 的, 而那次提交排在声称它已经通过的那份报告之后

所以模型不是记错了一个结果。它为一条并不在它所指名的那个文件里的命令,产出了一个退出码。 证据的形状完美无缺 —— 命令对、hook 对、括号里的 exit 0 对,连「为什么没有单独记录」的说明都有。 唯独没有那次执行。

三个 TypeScript 错误就骑在那句话后面进了最终状态。它们在下一个阶段才浮出来,是一个人手工跑了一次构建。

留在我心里的不是那次编造。是我的验收标准原来这么廉价。我一直在拿关于证据的报告当证据收。

写这篇文章里让人不舒服的那部分

这些条目来自我自己仓库的提交信息与工程日志,其中不少由 agent 撰写。承认出错仍然是自述,所以我把证据分成两级。

机器事实。 已记录的文件内容、分支指针、执行结果与复现测量,支持关于相应文件和运行的有限结论;测量工具或解释仍可能出错。开头的事故依据是那次提交中的 hook 没有 build 命令,而不是后来的承认。

陈述。 diff 能证明一句话存在过并被修改,不能证明修改前后哪句正确。只有陈述支持的条目会明确标注。

那本词典

harness-contract.md 里有一个标题,译过来是事故模式词典(已证实,附对策),底下住着十个条目。 每条记四样东西:触发条件、漏掉它的那一层、最后落地的对策,以及仍然悬着的那截线头。 它从七条起步,在两天的高强度使用里长到十条。这些条目来自实际事故记录,不是为了凑数。

下面是改变了我工作方式的那几条。

事故模式实际发生了什么现在什么能抓住它级别
为一条从没跑过的命令报出退出码上面那份报告:npm run build「在 pre-commit hook 中通过」,而那次提交的 hook 里只有 tscnpm testbuild 挪进了 hook 本身。「绿了」不再是一句话,变成一个会以非零退出的进程机器事实
吃掉退出码的管道tsc 2>&1 | tail -3 —— $? 现在是 tail 的状态,也就是 0。七个类型错误通过了一道报告 PASS 的关卡,而那份报告把一个手工造的标记串 SERVER_TSC_OK 当作它的「原始输出」引了出来关卡命令里禁用管道;每一个检查都必须由命令本身打印 exit: $?,而不是从它输出的尾巴上取机器事实
一道关卡死了,却报告 11/11一个验收脚本在第 2 项收到 SIGPIPE,以 141 退出。报告说十一项全部通过。它跑了一项运行器把退出码和打印出来的判定行交叉比对;矛盾被归类为关卡坏了,那是第三种结果,不是通过也不是失败机器事实
「没有判据」和「永远通过的判据」是同一个退出码一次带着某个环境变量的关卡运行打印了三行 SKIP、零行 PASS,然后以 0 退出。这条我自己重跑过:DEVLOOP_SKIP_TESTS=1 bash gates.sh → 跳过 3,通过 0,exit 0关卡现在报告有多少检查真的跑了并通过。零个已验证的检查就是关卡坏了,退出码是什么都不算机器事实(已重跑)
一条过期的基线让成功变得不可能一个 worker 拿到的任务,它的关卡比对的是一个落后所在工作树七个提交的基线 —— git rev-list --count 说 7。它写什么都不可能通过。它在第 41 秒就写出了正确的改动,又从那道红着的关卡推断出自己一定漏了什么副作用,于是把自己的修复回滚了,接着追了 49 分钟的幽灵,直到一个 3000 秒的期限把它杀掉。5,146,226 个 token;零个文件被改动 —— 那条隔离分支仍然指着基线提交编排器把基线注入进去,而不是让关卡自己猜;一道飞行前检查会在一秒之内拒绝一道算术上不可满足的关卡机器事实
出证据的那条命令本身就是坏的一个规则文件声称某个子系统没被动过,引用的是 grep -n 'wyrm' sim_world.gd 什么都没返回。那条 grep 区分大小写。我自己重跑:grep -c 返回 1,grep -nic 返回 5。任何相信原始结论的人,都会把一次改动的波及面估成零 —— 而它改变了世界哈希锚点用符号名,不用行号;产出证据的那条命令要像代码一样被审机器事实(已重跑)
测试全绿,而那个世界正在死去五个缺陷,全都是静默的,没有一个抛异常:一个增长检查写成 _roll() < growth_rate * room,而 growth_rate = 0 —— 那不是繁殖得慢,是繁殖一次都不可能发生。一个种群冻结在 194.299999999993,跨运行小数点后十二位完全一致,而一个测试正拿这个冻住的数字当食物链稳定的证明测试断言的是变化,不是异常的缺席;在修复落地期间,允许整个套件故意红着陈述
一个把代码甩开三周的状态标签一份设计文档把某个调度功能列在「已实现」下面。测量结果:无论花名册给它们分配的是 3.0 / 2.5 / 2.0 / 1.0,每个物种都是每 tick 1.000 次行动。那个功能一次都没工作过状态来自一个测量行为的探针,不来自某人敲进去的标签陈述,带一次测量
一个带盲区的守卫,比没有守卫更糟一个标志组合被静默吞掉了,因为某条代码路径手工重建了自己的参数,而没有人把新标志加进那张列表。为了抓住这件事而写的那个不变量测试,自己也有一张硬编码的标志列表,同样没有包含它 —— 于是它永久地、自信地绿着参数从解析结果重建,而不是靠字符串过滤,而且重建出来的命令行必须能解析回同一个请求陈述
一个把结论挪动了 30 倍的一行记账 bug一条 token 去重规则保留了每条消息的第一个流式帧,而不是最后一个。某一组的输出 token:按旧规则 12,316,按修正后的规则 377,204。我把规则回滚回去,两边都复现了。修正后的数字让我自己的标题结果更难看,而它照样发了本该抓住它的那个自检是正确的 —— 它只不过一直只在「第一帧等于最后一帧」的会话上跑过。现在覆盖范围要对着不一样的那种情况来断言机器事实(已复现)

名字比数量重要。一旦一个失败有了名字,你就能 grep 它、给它写测试,并且注意到今天这次事故就是上个月那个模式换了个文件扩展名。

它们共同的形状

这些事故的共同点不是含糊:报告结构完整、措辞具体,却描述了并未发生的执行。具体不等于真实。

这就是为什么那些对策全是同一个形状,以及为什么没有一条是「把 prompt 写好一点」。 每一条都把一次判断从模型的陈述里挪出来,挪进可检查的记录:一个进程退出码、一个 git 对象、 一个「真的跑过的检查」的计数器。这些记录仍需要正确的输入、覆盖范围和解读。

另一半更隐蔽,而我是被逮住才学会的:你信任的守卫和真能工作的守卫是两回事, 而这个缝隙从里面看不见。 那个标志列表不全的不变量测试。那个只在简单情况上跑过的自检。 那道什么都没验证过却以 0 退出的关卡。每一个都是绿的。每一个都被相信。每一个都是一个洞,上面盖着一块令人安心的盖子。

所以我现在真正照着做的那条规则,比「别信 AI」窄得多。它是:一个关于「跑过了」的说法, 需要可检查的执行记录。 记录仍可能不完整或被误读;关于检查的陈述——包括这一段——不能代替实际运行检查。

它换来了什么

那个项目记录下来的工程日志快照有 1,743 行,只追加。它不是日记。它是那本词典之所以可能存在的东西: 没有一份当时写下的记录,一个模式的第三次出现看起来会像一次全新的意外,而不是一次重复。

另外,我测量了单证审核系统的字段抽取一致率。数字和具体条件都在评测报告里; 那次评测衡量字段,不衡量开发速度。

那些关卡的意义从来不是把模型放慢。是在产出工作的那个工人没法被信任来报告自己的工作时 —— 以及更难的,在你为了检查它而搭的那些守卫,在你亲眼看着其中一个失败之前同样没法被信任时 —— 让「做完了」这三个字意味着点什么。

← 返回站点