题记:少跑一轮测试,省下的是执行成本,接过来的是判断责任。

1. 一批改动失败,为什么每个人都要重新排队?⌗

多个 Agent 各自在隔离分支上工作,最后交给同一条落地通道:合并、验证、推进主线,再交给部署流程。

批量处理很自然。把几个候选依次合到临时工作树,统一验证一次,成功后保留各自的合并提交。比每个候选重复启动测试便宜,也不必把提交历史揉成一团。

但只要一个测试红了,批量的好处就可能被失败处理吃掉。

旧实现采用二分:拆开批次,分别验证,继续缩小失败范围。新版改成另一条路:不再靠不断执行子批次寻找嫌疑候选,而是用受影响测试图,把已经观测到的失败归属给候选。

这不是“同样安全但更快”的免费午餐。它改变了系统相信什么。

本文依据提交 f17d3b1 的源码与测试。文中的测试次数来自测试替身的调用断言,不是生产环境耗时统计,也不是本文重新执行测试得到的成绩。

2. 影响图不是案发现场,但可以当派工表⌗

核心机制在 src/landing/verification-failures.ts 的 attributeFailures。

它收到合并基线、批次候选,以及仍然失败的测试文件。在已经合并的工作树上,逐个计算候选相对基线的改动,再调用现成的受影响测试选择器。

候选 A 的改动 ──► 受影响测试集合 A ──┐
候选 B 的改动 ──► 受影响测试集合 B ──┼── 与失败文件求交
候选 C 的改动 ──► 受影响测试集合 C ──┘
                                      │
                         ┌────────────┴────────────┐
                         ▼                         ▼
                  有关联的候选退回              无归属的失败
                  附失败文件与日志              记为 known red

“受影响”并不只是文件名相似。src/testing/affected-tests.ts 使用静态导入图的反向可达关系,还包含直接改动的测试、部分按名称引用的资源、仓库扫描类测试等规则。依赖配置、测试基础设施和迁移等基础层改动会触发全量选择。

而且,全量选择也不是“不分青红皂白地把所有测试算进来”。实际执行和失败归属都会经过 selectGateTests:浏览器测试等排除项,以及按所辖路径启用的区域测试规则,仍然生效。

复用同一个选择器的价值在这里:决定该跑什么与决定失败该交给谁,不再各自维护一套近似规则。

不过,“受影响”只说明有联系,不说明谁造成了错误。

假设一个共享测试同时覆盖 A、B 的改动,它失败时,代码会把 A、B 都退回,而不是随便挑第一个。lane.test.ts 对这个行为有明确断言。这是一张保守的派工表,不是一份精确的因果鉴定书。

3. 真正激进的不是归属,而是删掉候选后不再验证⌗

归属之后,批次里可能还有未被退回的候选。

新版 src/landing/lane.ts 会把这些剩余候选重新合到原基线上,但不再重复调用验证函数。它把先前批次的判定带入新的 verifyGroup,只要基线没变,就复用结果。

一个测试场景很直观:四个候选 A、B、C、D,失败测试只关联 C。

实现测试替身记录的验证批次
旧版二分ABCD → AB → ABCD → ABC → ABD
新版归属ABCD

这些序列来自该场景修改前后的 lane.test.ts 断言。新版还断言:C 失败,A、B、D 落地;最终源码树没有 C 的文件。它证明的是这个场景下的流程规格,不代表所有批次都能固定节省四次执行。

有一个容易被“复用结果”四个字藏起来的事实:

实际验证的树:基线 + A + B + C + D
最终留下的树:基线 + A + B     + D

两棵树并不相同。

因此,系统不是复用了“最终这棵树已经全绿”的证据,而是根据影响范围,推断移除 C 不会推翻其余候选的可接受性。

这比普通缓存更强,也更冒险。普通缓存通常要求输入相同;这里连代码树都变了。

4. 省掉重新执行,到底押了什么?⌗

我的判断是,这个方案押的是一种局部性假设:失败候选退出后,未关联候选的验证结论仍然成立。

它有现实收益。多 Agent 并行开发会持续产生小候选,如果失败处理不停拆批重跑,验证资源很容易都花在重新确认已经看过的代码上。影响图让一次执行的输出直接变成维修输入,而不是另一个执行循环的起点。

但依赖图不能证明删除改动后的行为具有单调性。

举个纯假设的例子:A 改了调用方,B 恰好补齐了 A 所需的接口;B 又因为自己的另一个测试失败被退回。带着 A、B 的树可以通过类型检查,移除 B 之后,A 却未必还能通过。

新版的结果复用路径不再运行测试,也不再运行类型检查。因此,这种跨候选的补偿关系,不能靠“原批次类型检查通过了”来排除。

还有运行时注册、环境约定、跨测试状态等关系,未必能完整落在静态导入图上。这是方法的边界,不是某个测试断言能顺手抹平的边界。

能从代码和测试确认的事不能据此推出的事
失败测试关联的所有候选都会退回被退回的每个候选都有缺陷
剩余候选会重新合并重新合并后的树已经重新验证
同一套选择规则用于执行与归属选择规则覆盖了全部运行时关系
原批次类型检查通过删除候选后仍然类型正确

所以我不会把这次改动概括成“更聪明的二分”。二分靠新实验缩小范围;这里靠模型化的影响关系减少实验。它是证据策略的更换,不只是算法加速。

5. 有两种情况,系统没有继续猜⌗

第一种:拿不到可用的失败归属。

verifyMerged 默认把整个批次列为退回对象。只有获得可解析的失败文件、并成功完成归属,才会缩小集合。无法归属到测试文件的类型错误,或归属计算抛错,不会触发无限二分,也不会凭一句“看起来无关”放行。

lane.test.ts 覆盖了无文件归属错误:验证调用一次,整个批次失败,记录中的失败文件列表为空,但保留日志位置。

这里要区分两件事:

  • 归属失败:无法完成这次分析,整个批次退回。
  • 归属结果为空:分析完成,但失败文件不在任何候选的影响范围内,进入已知红灯记录。

第二种:主线变了。

结果复用检查 reuse.base === mainHead。如果主线已经推进,剩余候选需要在新基线上重新验证;推进主线时还有预期基线检查,发现主线移动就重新合并。

对应测试在首次验证期间推进主线,最终断言验证函数被调用两次,剩余候选落地时保留了新主线内容。

这条边界很重要,但不能替前一节的风险背书:它限制的是基线变化,没有消除候选被移除导致的树变化。

6. 红灯、无判定、待修候选,不能挤进同一个状态⌗

另一个值得保留的设计,是把三种失败拆开处理。

状态新版处理
测试失败,能关联候选退回关联候选,携带失败文件和日志
测试失败,但不在候选影响范围记录 known red,不作为全局停线条件
测试机不可用等基础设施无判定候选保持排队,等待测试资源恢复

旧版会把仍然失败的文件拿到合并基线上再跑,判断主线是否本来就红;新版取消这条自动对照路径。因此,“没有归属给本批候选”不能再被写成“已经证实主线原本就失败”。

main-red.ts 的注释和数据语义也随之改变:记录的是观测到失败的提交,不再宣称它证明主线已被检查。每个失败文件可以保存自己的观测提交。

一个后来通过的局部验证,也不会顺手抹掉这些已知红灯。测试明确断言了这一点:没覆盖到,不等于已修好。

基础设施故障则走另一条线。测试依赖候选留在队列;不依赖测试机的热加载、文档或 UI 级别候选可以继续。队列按是否需要测试资源拆批,避免把不相关工作捆在同一辆坏掉的车上。

这里没有把“无判定”包装成候选失败,也没有靠固定等待一段时间就假定机器恢复。等待中的候选重新进入执行前,需要可用性探测。

有限重跑仍然存在:flaky.ts 在完整解析失败日志且类型检查通过后,对失败文件逐个串行重跑一次,取消了旧的失败文件数量截断。重跑必须真正执行到测试,不能拿“零测试、零失败”当绿灯;已经连续停滞的分片也不会再被这里额外重跑。全部恢复时,结果标为 flaky 并上报,而不是假装第一次没有红过。

7. 别把“可以继续”写成“证明没问题”⌗

这个设计最有价值的地方,是让一次失败的输出变得可操作。

失败文件与日志位置进入候选记录,再传到落地结果事件和自动修复续跑的证据里。Agent 不必只收到一句“验证失败”,然后重新翻整条执行链。验证器负责给出观测,归属器负责分配待修对象,修复任务接着处理。

相较于只会不断拆批重跑的实现,这是一种更有上下文的调度方式:系统已经有影响图,就让它参与失败处理,而不是每次从零开始试。

但它也必须承认自己放弃了什么。

如果要求是“最终提交树必须亲自通过验证”,那么剔除候选后仍需重新执行;影响图可以帮助缩小执行范围,却不能顶替这项要求。如果更看重吞吐,接受依据影响范围复用批次证据,那么当前方案有清晰的工程逻辑,但其结论应该叫作“按归属策略允许继续”,而不是“新树已经全绿”。

测试真正跑过什么,系统推断了什么,以及策略允许了什么,是三句话。别为了让面板看起来舒服,把它们合成一句。