没验出来,不等于代码该回滚

题记:拒绝一次上线,只需要证据不足;撤销一份代码,需要更具体的理由。
1. 一个布尔值,背了两口锅⌗
设想一个自动部署器:候选代码先在隔离环境里预演,通过才重启;没通过,就撤销对应改动。
听起来很稳。问题出在「没通过」。
它可能表示候选启动失败,也可能表示数据库克隆没准备好,还可能表示预演跑到一半,配置已经换了一份。三种情况都不该上线,但只有第一种提供了候选验收失败的证据。
如果把它们全部压成 ok: false,再让部署器执行回退,系统就完成了一次错误推理:
我没有取得有效的通过结论
→ 候选没有通过
→ 候选验收失败
→ 应该撤销代码
第一句成立,后面的推导不成立。
本文讨论的一次部署链路改动,核心不是「失败后多试几次」,而是把两项权限拆开:没有有效证据,不准上线;没有候选失败证据,也不准据此撤销代码。
2. 先问证据还适不适用,再问它是红是绿⌗
预演验证的不是孤零零的源码,而是源码与运行输入的组合。
实现中的指纹包含这些部分,字段定义见 src/rehearsal/fingerprint.ts:
| 部分 | 为什么要进入证据的适用范围 |
|---|---|
| 候选 Git tree | 预演结论必须属于即将部署的代码 |
| 配置摘要 | 同一代码在不同配置下不一定有相同行为 |
| 密钥文件摘要 | 认证输入变化后,旧结果不能直接沿用 |
| 当前 LLM 配置修订 | Agent 的模型运行配置也是输入 |
| Bun 版本 | 运行时变化会影响执行 |
| 锁文件与实际依赖目录摘要 | 声明依赖与安装结果都影响候选 |
关键改动在 RehearsalGate.runCheck,而且主要是判断顺序。
原来的顺序,是预演返回失败就立即交出失败结果;只有预演成功,才比较前后指纹。这意味着:绿灯会被输入漂移作废,红灯却可能逃过同一项检查。
新的顺序可简化为以下伪代码:
before = fingerprint(candidate)
outcome = rehearse(candidate)
after = fingerprint(candidate)
if before != after:
return inputs-changed
if outcome failed:
return classified-failure
return passing-receipt
输入变化既能作废成功,也能作废失败。
这不表示那个失败一定是配置变化造成的。恰恰相反:系统不再声称自己知道原因。既然试验期间输入发生变化,就先撤回这次试验对当前部署的裁决资格,重新取证。
测试把这件事写得很具体:分别让配置、密钥摘要、LLM 配置修订在预演期间改变,同时让第一次预演返回失败。断言不是「候选失败」,而是 inputs-changed;随后在稳定的新输入上重试,可以通过。
这里的进步不是更乐观,而是更少乱下结论。
3. HTTP 拒绝不是 Git 撤销指令⌗
仅在预演内部区分原因还不够。结论必须穿过 HTTP 接口,最终到达有权修改主分支的部署器。
此次实现形成了下面这条链:
预演门
failureKind + changedInputs
│
▼
重启接口的结构化拒绝响应
│
▼
部署客户端 restartRefusal
│
├─ gate-failed → 原有回退路径
└─ gate-inconclusive → 重新取证 / 暂停
rehearsalFailureKind 把失败分成三类:
| 分类 | 含义 | 部署动作 |
|---|---|---|
candidate | 取得了候选验收步骤失败的结果 | 进入原有回退流程 |
inputs-changed | 预演期间输入指纹发生变化 | 按当前输入重新预演 |
infrastructure | 准备失败、环境异常,或没有足够的候选失败信息 | 重试;仍无结论则暂停 |
分类器不是随便找一条红色日志。它先处理输入漂移,其次尊重显式分类;否则寻找第一项失败步骤,跳过 current-release-compat,再判断该步骤是否属于已知的候选验收步骤。
例如,boot、turn、restart-drill 等验收步骤失败,可以归入候选失败;数据库准备失败、未知步骤失败、甚至没有步骤信息,则默认不据此撤销代码。测试还专门覆盖了「数据库准备先失败,后面日志扫描也失败」的组合,避免把后续红灯当成独立的候选罪证。
这一默认值很重要:未知失败依然阻止部署,但不自动获得改写代码历史的权限。
同时,也别把分类器说成因果分析器。启动失败仍可能源于指纹没覆盖的环境因素。这里实现的是可审计的步骤归类,不是对根因的数学证明。
4. 三次之后暂停,不是三次之后认定代码有罪⌗
部署器新增了持久的 paused 阶段。src/landing/deploy.ts 中的策略是:累计三次无有效结论的预检尝试后暂停,不回退代码;只有显式恢复才重新开始尝试。
waiting → restarting ──通过──→ probation
│
├─ 候选验收失败 → reverting
│
└─ 无有效结论
├─ 未满三次 → waiting
└─ 达到三次 → paused
│
显式恢复
▼
waiting
三次不是一个统计置信度。代码和测试没有证明「试三次就足以排除偶发故障」。它只是本实现给自动取证设置的停止点:不无限烧预演资源,也不把重试耗尽伪装成候选失败。
更关键的是暂停会留下来。
普通调度再次调用部署器,不会自动清掉暂停、接着再试。显式恢复才会重置尝试计数、回到等待阶段。否则所谓「最多三次」只是每轮最多三次,调度器转一圈,又是三次,实际上仍然没有停止。
这是一项明确取舍:持久暂停可能延迟更新,需要处理环境或输入问题后恢复;换来的是不凭无效证据撤销代码,也不无限自动重试。
还有一个需要说准确的边界:测试在主分支不变时断言,两次请求针对同一个提交。但部署器原有的跟随主分支逻辑仍在。不能把这项改动概括为「无论期间发生什么,整个批次永远钉死在最初提交」。它保证的是,不因为无结论而主动回退并拆批重落地。
5. 回执不是一次性门票,而是有期限的证据⌗
同一提交还改变了预演回执的语义。
旧语义:同一用途使用过,就不能再次使用。
新语义:指纹一致、没有过期,就可以复用;使用记录仍然写入,但不再使回执失效。
| 约束 | 改动后的行为 |
|---|---|
| 指纹匹配 | 保留 |
| 固定有效期 | 保留,源码常量为 15 分钟 |
| 实际动作前重新核对指纹 | 保留 |
| 同用途只能使用一次 | 移除 |
| 使用后延长有效期 | 不会 |
这不是「所有约束原封不动」。一次性限制确实放宽了。
它表达的是另一种证据模型:一份试验结果的有效性,主要由它证明了哪组输入,以及它有多旧决定,而不是由上一个调用者是否已经看过它决定。
好处是,同样的输入不必因为同一用途已记录过一次,就机械地重跑预演。代价是,在有效期内,系统接受同一份证据被多次使用,而不是要求每个动作都重新产生一份独立试验结果。
测试卡住了最容易悄悄放松的地方:在到期前一刻再次使用仍然成功,但到期时立刻失效。重复使用不会把原定截止时间向后推。
因此它不是滑动续期的永久通行证。与此同时,它也不能证明外部依赖在这段时间内不会变化;指纹与有效期界定的是证据的复用条件,不是对整个世界的冻结。
6. 状态说错了,后续自动化也会跟着错⌗
后端暂停了,界面却只认识「等待、重启、试用、回滚」,那这个状态机仍没有完成。
此次改动补上了界面的「部署已暂停」,并让受阻结果展示具体原因。原先把 stuck 一概描述成「上线失败且回滚受阻」,现在改成较宽的「部署未完成,代码仍保留,需要处理」。
对于无结论暂停,详细信息进一步说明:没有取得有效结论、代码没有回退、需要显式恢复。
这不是润色文案。对人和 Agent 来说,这几种说法会触发不同动作:
- 「已回滚」意味着应检查撤销后的状态。
- 「回滚受阻」意味着应排查撤销流程。
- 「预演无结论,代码未回退」意味着应处理取证条件。
错误摘要能把正确的后端状态重新翻译成错误决策。结构化原因、持久阶段和最终展示必须讲同一件事。
7. 测试钉住的是后果,不只是标签⌗
下表是此次提交中测试源码所规定的行为,不是本文新跑出的一份测试成绩单。
| 测试位置 | 关键断言 |
|---|---|
restart-gate.test.ts | 输入漂移作废失败结果;新输入稳定后可以通过 |
restart-gate.test.ts | 子进程异常、数据库准备失败不产生候选失败结论 |
deploy.test.ts | 漂移与环境故障重试成功时,主分支不变,没有重落地标识,也没有回退步骤 |
deploy.test.ts | 三次无结论后持久暂停;普通再次调用不增加尝试次数;显式恢复后可继续 |
deploy.test.ts | 先漂移、后得到稳定的候选失败时,仍然回退候选 |
runtime-restart-routes.test.ts | 输入漂移响应携带分类及变化字段,且不挂起任务、不退出、不激活候选 |
landing-panel.test.ts | 暂停显示原因,而非伪装成回滚 |
最后那条「先漂移、后稳定失败」尤其重要。没有它,修复很容易滑成「只要出现过环境问题,就一直包庇这个候选」。这里不是免罪,只是要求重新取得适用的证据。
测试没有证明所有环境错误都分类正确,也没有证明指纹覆盖一切变化。前后摘要相同,本身也不足以排除中途变更后又恢复的情况。它们证明的是这些已列出的输入、状态转换和副作用符合约定。
8. 安全不等于多做一次破坏性动作⌗
如果只把安全理解为「出错就回滚」,回滚便会成为一种不需要解释的条件反射。
但部署系统同时握着两种能力:替换运行版本,以及撤销源码改动。它们都需要理由,不能因为前一种能力暂时被拦住,就自动动用后一种。
这次设计值得保留的不是某个重试次数,而是一条更通用的规则:
通过、失败、无结论,是三种不同的证据状态。阻止动作与授权补偿动作,也不是同一项决定。
试验没做成,就修试验条件。候选确实没通过,再处理候选。
别让代码替验不出来的环境背锅。