CI 修复智能体:交付物是分类,不是补丁。
别的编码智能体都得自己去把失败找出来,这一个则是被人把复现端到面前的——这既是团队最先想到它的原因,也是它最可能把事情弄得更糟的原因。一个分不清「是我这个 diff 弄坏的」与「基线分支本来就是红的」「跑测的机器挂了」「这个测试本来就 flaky」的 CI 修复智能体,会为一个并非自己造成的失败推上一个看起来很合理的修复——烧掉一轮 CI、把一次已有的批准重置掉,并且教会团队:这个机器人的推送是噪声。先把分类器建好,并且给它「什么都不该改」这个结论的权限。
四个类别,每一个要的证据都不一样。
一条红色流水线不是一份缺陷报告。它是一次观察,而这次观察容得下四种互不相容的解释;这个智能体的全部价值,就在于在它动笔之前正确地在四者之间做出选择:
- 由这个 diff 造成。被测的这次变更弄坏了什么。这是唯一一个智能体该去编辑这次变更本身的类别,也是所有人默认假设的那个类别。
- 基线上本就存在。同一项检查在你合并自的那个分支上也是红的,所以 diff 是无辜的,而在这里推一个修复就是修在了错误的 pull request 上。正确的产出是一条注明基线提交的说明;如果别处已经存在修复,就把它移植过来,而不是干等。
- 环境或基础设施。拉代码失败、镜像仓库超时、跑测机被回收、某个容器镜像挪了位置。没有任何测试主体跑起来过,所以根本没有可供推理的断言,从这种输入生成一份代码变更纯属编造。
- 非确定性。同样的输入下,这个测试时过时不过。这是测试里的一个真实缺陷;而它同时也是一个「被逼着必须产出点什么」的智能体,在解释不了某次失败时一定会去够的那个类别——这正是它在四者中需要最严证据门槛的原因。
把类别写进智能体输出 schema 的一个必填字段,并要求附上证据,就像 结构化输出 对待任何其他分类问题那样。一个响应类型是「一份补丁」的智能体,已经先行判定答案是第一类了——就像一个读完调用栈就吐出 diff 的调试智能体是在 做模式匹配而不是在调试。把「不改动;理由如下」做成一等的、被奖励的结果。
推送之前先证明因果,因为这项检查很便宜,而错误的推送不便宜。
把第一类与第二类分开的那份证据既机械又没人去跑:把这项失败的检查同时对着合并基线跑一遍。两次运行,结果就是决定性的——两边都红说明 diff 无辜;基线绿而 head 红说明它不无辜。在一个有合并队列的仓库里,基线那个结果你其实已经有了,它就躺在一小时前别人的 pull request 触发的某次构建里,你要做的只是把它查出来。
- 先复现,再修。智能体应当能够按需让这次失败重现——这正是 补丁生成与测试驱动循环 所坚持的纪律。如果它复现不出来,那它就没有定位到;一份建立在日志片段上的补丁,是一个套着 diff 外衣的猜测。
- 双向证明这个修复。那项失败的检查必须在打补丁前失败、打补丁后通过,且在同一个提交、同一台跑测机上。「现在测试套件是绿的」这句话,同样兼容于「智能体改了个不相干的东西,而真正的成因自己好了」。
- 老实给替代方案定价。多跑两次 CI 花的是几分钟算力。一次错误的推送花掉的是一整条流水线、一份作废的评审、一次在人已经签过字的 pull request 上被重置的批准,以及团队对这个机器人下一句话打折程度的一次永久性上调。这个不对称大得惊人,而它就是「值得花掉那两次运行」的全部论证。
「flaky」不是根因,而重跑预算是。
重跑一个失败的 job,是这个智能体手边最容易拿到、也最容易被滥用的动作。一次成功的重跑只确立了一件事:在这个提交上,这次失败不是确定性的。它并不告诉你责任在测试而不在代码,更绝不构成结案的许可——产品里一个货真价实的竞态条件,与测试里一个 flaky 的断言,表现完全一样,而后者是那个让人舒服的结论。
- 只有一次重跑,且只用在它能裁决的情形上。把它花在:job 在任何测试主体执行之前就死了、同一个提交此前通过过,或者用来确认一次疑似的基线分支失败。第二次失败就是真的;把第二次重跑当作违反规程,而不是当作一次重试。
- 绝不为了拿到绿色而跳过、禁用、隔离或放宽任何测试。这是本页价值最高的一条护栏,因为那是通往绿色的最短路径,而一个以绿色为优化目标的智能体一定会找到它。被删掉的断言是这个循环里三个诚实骗子之一,而当这次删除与一个真实的修复出现在同一个 diff 里时,它是看不见的。
- 绝不用空提交或关闭再打开 pull request 来「踢」CI。两者都是在重新掷骰子,却同时产出了一份声称「干过活了」的证物。
- 在职责范围内就把 flake 修掉。非确定性是有成因的——共享 fixture、对着挂钟做的断言、无序集合、被某个残留进程占住的端口——而一份说得出成因的修复是正当的交付物。一条隔离标注不是。
把每一次重跑记为一次分类事件,而不是一次重试。在整个仓库范围内累积的「每个测试的非确定性比率」,能把「大家都知道那个是 flaky 的」变成一份排好序的待办清单;而它正是一份人真正能据以行动的 失败分类学 的输入。
智能体能改什么,取决于它选了哪一类。
权限应当是「分类及其背后证据」的函数,而不是一个全局开关。这条规则要防的失败模式,是智能体把一个 pull request 越改越宽——去修某件真实但不相干的事——那正是一个两行的变更变得无法评审的过程:
- 第一类,且在 diff 触及的代码里——修掉并推送。这是这个智能体存在的理由,也是唯一一个它的权限毫无争议的情形。
- 第一类,在 diff 弄坏了但没有触及的代码里——修掉并推送,把改动保持到最小。一个因签名变更而挂掉的下游调用方,仍然是你这个 diff 的责任。
- 第二类,且已经存在修复——把这个修复移植过来、推送,并说明你做了什么。一旦基线带上了它,这次移植就是空操作;而等别人的 pull request 合并,仍然是在等。
- 第二类但尚无修复,或第三类——不要推送。写下是哪项检查失败、在哪个基线提交上、以及什么证据确立了它与这次变更无关。一条点名阻塞项的评论是一个结果;沉默不是。
- 生成文件、锁文件、快照——用仓库自己的工具重新生成,绝不手改;而且不论 CI 是否转绿,一份手工编辑过的锁文件都应被当作一次失败的运行。
把这一切串起来的边界是:智能体可以改被测的代码,也可以改测试自身的缺陷,但永远不可以改测试对行为的断言。这两者一旦被混为一谈,你就有了一个把 bug 认证为正确的智能体——那正是 测试生成智能体 从另一头撞上的那个失败。
你是在跟一个还在动的分支赛跑,所以修复必须幂等。
与一个针对固定生产事故工作的调试智能体不同,CI 修复智能体作业的对象是一个别人还在往里推的分支,以及一个在它脚下移动的基线。由此推出三件事,三件都是运维层面而非智力层面的:
- 推送前重新核对 head。你诊断出的那次失败,可能已经被一个在你工作期间落地的提交修好了,往它上面推只会产出一个令人困惑的空操作或一次冲突。要读当前的 head,而不是那个触发了你的 head。
- 绝不在不属于你的分支上重写历史。不 rebase、不 amend、不 force-push——同事本地的检出是一个你真能弄坏的真实东西,相比之下一个合并提交什么也不花。在智能体自己创建的分支上,照仓库贡献指南说的办。
- 按失败去重,而不是按事件去重。CI 会反复发出同一个失败,webhook 也会送达不止一次,所以要用(检查名,head 提交)作为智能体这份工作的键,并让第二次送达成为空操作。没有这一条,你会得到两个智能体为一次失败推两份修复——那是 幂等 问题最难堪的一种形态。
- 给循环设上限。修、推、挂、修、推是一个没有自然终点的循环。给每个 pull request 一个硬性的尝试次数上限,并让超限成为一次带诊断的上报,参见 规划与终止——三次失败的尝试本身就是信息,而越过它继续下去,烧的既是 CI 容量也是 预算。
去量错误的推送,而不是转绿的构建。
「转绿的构建数」是这里的虚荣指标,而且它比没用还糟——因为把它推上去的最快办法,恰恰是你最需要阻止的那种行为。真正描述这个智能体有没有在帮忙的数字是:
- 错误推送率。没有修好那次失败的推送,或者修的是这个 diff 根本没造成的失败。这是团队真正感受得到的那个数,也是决定三个月后还有没有人读这个智能体评论的那个数。
- 分类准确率,由人抽样。每周取二十次失败,独立打标。要盯的具体错误是第二类与第四类之间的混淆——把一次基线分支的破坏叫作 flake——因为正是它会让一个真实的回归稳稳坐在 main 上。
- 从红到一份正确诊断的时长,与「到转绿的时长」分开统计。诊断才是产品;补丁有时候根本不需要。
- 每次失败花掉的重跑数。如果它悄悄爬到一以上,说明这个智能体已经发现重跑比思考更便宜。
- 智能体提交里被删掉的断言或被跳过的测试数。应该是零,应该有告警,而且应该被机械地检查,而不是等到评审时才看。