一次测试基础设施修正:验证结果先说清楚“是谁的失败”,再说退出码;排队时给一个能用来做计划的上限,而不是一句“未定”。

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 00—
结论:代码原因跑完,非 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 秒;队里没人排在前面。

槽位计算最迟空出
A60 + 90 − 20130s
B60 + 90 − 50100s

输出:预计等待:最迟约 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 和两个入口上换掉收尾的那几行。没有改测试本身怎么跑,只改了“跑完之后怎么说”。很多时候一个闭环转不起来,不是因为哪一步做不到,而是某一步的输出说得不够清楚,下一步只能猜。