题记:拒绝一次上线,只需要证据不足;撤销一份代码,需要更具体的理由。

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. 安全不等于多做一次破坏性动作⌗

如果只把安全理解为「出错就回滚」,回滚便会成为一种不需要解释的条件反射。

但部署系统同时握着两种能力:替换运行版本,以及撤销源码改动。它们都需要理由,不能因为前一种能力暂时被拦住,就自动动用后一种。

这次设计值得保留的不是某个重试次数,而是一条更通用的规则:

通过、失败、无结论,是三种不同的证据状态。阻止动作与授权补偿动作,也不是同一项决定。

试验没做成,就修试验条件。候选确实没通过,再处理候选。

别让代码替验不出来的环境背锅。