「没判」不等于「没过」:给每次验证补一行“是谁的锅”

一次测试基础设施修正:验证结果先说清楚“是谁的失败”,再说退出码;排队时给一个能用来做计划的上限,而不是一句“未定”。
1. 一个退出码装不下三种失败⌗
设想一条自动修复流水线:agent 改完代码,跑 bun test 和 tsc 验证,验证在一台多方共享的测试机上执行,槽位有限,要排队。
这次改动之前,验证入口的收尾大致是这样的:
say(`${result.infra ? `无结论(${result.infra})` : result.code === 0 ? '通过' : ...} — ${result.note}`)
return result.code ?? 1
注意最后一行。测试机不可达、繁忙、连接中途断开、runner 自己出错——这些情况下 result.code 是 null,于是退出码落成 1。而一个真正挂掉的测试,退出码也是 1。
对一个读 shell 结果的 agent 来说,这两件事看起来完全一样:命令失败了。它面对的下一个动作是“改代码”。可环境故障时代码根本没被判过,改了也是白改;更糟的是,往上一层的自动修复调度看到“修复任务没交付”,会按“候选模型自己的失败”处理——换下一个模型重派。
于是出现一种很浪费的循环:
验证 → 测试机繁忙/断开 → exit 1
→ agent 以为代码错了,乱改 / 或任务超时结束
→ 调度判为“候选失败” → 换模型重派
→ 新模型再验证 → 又排队 → 又遇上测试机繁忙 ...
一次几秒钟就能跑完的验证,被包在几十分钟的排队和反复重派里,始终拿不到一个结论。问题不在测试慢,而在结果的语义太穷:它只会说“过了 / 没过”,不会说“这次没人判”。
2. 五种结论,一行写完⌗
改动新增了 src/testing/verdict.ts(50 行),只做一件事:把一次运行的结果折叠成一种结论,再给出一行文字和一个退出码。
export function verdictKind(r: VerdictInput): VerdictKind {
if (r.infra) return r.stalled ? 'stalled' : 'environment'
if (r.code === 0) return 'pass'
return r.timedOut ? 'timeout' : 'code'
}
| 结论 | 触发条件 | 退出码 | 行里告诉读者什么 |
|---|---|---|---|
结论:通过 | 跑完,exit 0 | 0 | — |
结论:代码原因 | 跑完,非 0 | 原样 | 未通过(exit N) |
结论:超时 | 预算内没跑完 | 原样 / 1 | 排队不计入预算 |
结论:环境原因,无结论 | 测试机没给出判定 | 75 | 这次没有判定改动本身;不要据此改代码,环境恢复后重跑同一命令 |
结论:无结论,运行停滞 | 无输出被看门狗结束 | 1 | 可能是那个测试挂住,也可能是测试机;先看该文件 |
Mac 侧的 bun test / bun run test(offload.ts)和 bun scripts/typecheck.ts 两个入口都改成了同一套收尾:
const verdict = { ...result, stalled: (result.stalls?.length ?? 0) > 0 }
say(verdictLine(verdict))
return verdictExit(verdict)
两点设计值得单独说。
第一,环境原因那一行带着操作指令。 “不要据此改代码,环境恢复后重跑同一命令”不是给人看的客套话,读这行的是 agent。直接把正确的下一步写进结果,比指望它从“无结论”三个字里自己推出来可靠得多。
第二,停滞被刻意排除在“环境原因”之外。 一个运行一直没输出、最后被看门狗杀掉,看上去像测试机卡了,但同样可能是某个测试本身死锁。如果把它归给环境,一个真会挂住的测试就能永远躲在“环境问题”后面,被同模型无限续跑。所以测试里专门有一条:
test('a stalled run is neither the environment\'s nor the code\'s, and keeps exit 1', () => {
...
expect(verdictLine(r)).toStartWith('结论:无结论,运行停滞')
expect(verdictLine(r)).not.toContain('环境原因')
expect(verdictExit(r)).toBe(1)
})
分类的目标不是“尽量替代码开脱”,而是只在确有把握时才说“不是你的错”。
3. 为什么是 75⌗
NO_VERDICT_EXIT = 75,来自 sysexits 的 EX_TEMPFAIL:临时故障,稍后重试。选它有两个理由:
- 语义本来就对:不是错误,是“现在判不了”;
- 系统里已经有人在用它——本机内存保护把一个测试运行终止时,也是 exit 75。现在“测试机没判”和“本机因内存压力没判”是同一个码,调用方只要认一个数字。
这样即使上游只看退出码、不解析文字,也能区分三类:0 通过,75 没判,其他非零是需要看的失败。
4. 排队时给一个上限,而不是“未定”⌗
第二个问题是排队不可见。以前 runner 每 30 秒打印的排队行最后写着:
预计窗口未定(还取决于准备耗时及前序运行结束)
这句话完全正确,也完全没用。调用方不知道该等,还是该先去干别的,还是该放弃。
这次 runner 里加了 wait_bound。思路是:精确预测等多久做不到,但最迟等多久是可以算的,因为每个运行都有预算。
每个可用槽位:
空闲 → 0 秒后可用
被占用 → 持有者开始时间 + 预算 + 90s 准备 − 现在
持有者没记录预算 → 整体输出“未定(原因)”
排在自己前面的每张存活票据(按先来后到):
取当前最早空出的那个槽位,加上这张票据的预算 + 90s
票据没记录预算 → 整体输出“未定(原因)”
结果 = 所有槽位里最早空出的时间
为了让“前面的运行有多少预算”可读,排队票据的文件名多了一个后缀:<纳秒时间>.<类别>.<id>.b<预算秒数>。不用另开文件、不用加锁,ls 一下队列目录就有全部信息。
举个虚构的例子:两个共享槽位,A 已经跑了 20 秒、预算 60 秒;B 已经跑了 50 秒、预算 60 秒;队里没人排在前面。
| 槽位 | 计算 | 最迟空出 |
|---|---|---|
| A | 60 + 90 − 20 | 130s |
| B | 60 + 90 − 50 | 100s |
输出:预计等待:最迟约 100s(按持有者与前面运行的预算上限推算,实际通常更早)。测试里用的正是这组夹具,断言上限落在 (80, 100] 之间(下限给测试自身耗时留了余量)。
几个刻意的选择:
- 给上限,不给估计。 预算是硬上限,所以推出来的数是真上限;用历史均值做的“预计 X 分钟”会时准时不准,agent 一旦被骗过一次就不会再信它。
- 算不出就说算不出,并写明原因。 某个持有者或前面的票据没记录预算时,输出
未定(…原因…),而不是拿个默认值凑数。 - 不设排队上限。 有了可信的上限,什么时候放弃就成了调用方自己的决定:一个后台修复任务可以等 10 分钟,一个等着回复用户的线程可能不能。这个判断不该由测试机替所有人做。
- 排队不计入命令预算。 预算计时从拿到槽位后才开始,超时结论里也写明了这一点,排得久不会被误判成跑得慢。
5. 让调度真的读懂这一行⌗
结论行如果只写给人看,改动就只做了一半。自动修复调度(src/improve/autofix.ts)在修复任务结束时,会扫描该任务线程里的工具结果,看它的验证是不是卡在了测试机上:
const TEST_MACHINE_TROUBLE = String.raw`\[(test-exec|typecheck)\] 结论:环境原因|\[test-exec\] 无结论(测试机不可用|...`
这次在原有的“繁忙 / 等槽位 / 连接中断 / 内存保护”几种行之外,加上了新的环境结论行和 runner 不可用的无结论行。命中后,失败原因记为 test-slots,属于环境类原因:
修复任务结束(未交付)
│
├─ 模型以安全原因拒绝? → 记为拒绝,按原策略处理
├─ 线程里出现测试机故障行? → cause = test-slots(环境)
│ → 同一个任务、同一个模型、同一个工作区续跑
│ → 续跑前先检查环境是否就绪,没就绪就推迟 60s 再看
└─ 都不是 → 候选自己的失败,才轮到换下一个模型
测试把三种典型行(测试机不可达、runner 97、typecheck 进程被杀)各跑一遍,断言调度最后记下的是 { cause: 'test-slots', sameModel: true }。
这里有一点要说实话:环境原因的续跑仍然计入原任务的续跑次数(上限 3 次),并没有做到“完全不计数”。它省下的是更贵的东西——不会换模型、不会丢掉已有的实现和工作区,也不会在环境明显没恢复时硬去重跑。真正的计数豁免需要先有可靠的“环境已恢复”信号,否则一个永远起不来的测试机会让修复任务无限续跑下去;那是另一个取舍。
6. 这个设计比常见做法好在哪⌗
常见的处理方式大概有三种:
| 做法 | 问题 |
|---|---|
| 所有失败一律重试 N 次 | 代码错误被重试浪费,环境错误重试 N 次也可能不够;两类失败共享一个预算 |
| 在调度层猜原因(看日志关键词、看耗时) | 猜的人离事实最远,测试机侧明明知道是“没连上”,到上层只剩一个 1 |
| 给排队一个 ETA | 估计不可信时,调用方要么全信要么全不信,都做不了计划 |
这次的做法是把分类放在最了解事实的地方:运行入口知道自己是连不上测试机还是测试挂了,就由它在结果里直接写明;退出码和文字同时表达,脚本读码,agent 读字;调度只认这一行,不再从过程里拼凑。排队那边同理,只承诺能证明的上限。
它的代价也清楚:调度用正则匹配一行中文前缀,verdict.ts 导出了 ENVIRONMENT_VERDICT 常量,而 autofix 里的正则是手写的同一段文字——两处有耦合,改措辞时得一起改。目前靠 autofix 的测试兜住:测试直接用结论行的完整文本喂给调度,措辞一漂移就会变红。
整体改动不大:9 个文件,+152 / −13,其中一个新模块加一个测试文件,其余是在 runner 和两个入口上换掉收尾的那几行。没有改测试本身怎么跑,只改了“跑完之后怎么说”。很多时候一个闭环转不起来,不是因为哪一步做不到,而是某一步的输出说得不够清楚,下一步只能猜。