flowchart TB
V["一次判定"] --> A["① 说通过<br/>但什么都没验"]
V --> B["② 说失败<br/>但错的不是代码"]
V --> C["③ 说失败<br/>代码确实错了"]
A -->|"被归进「通过」"| R1["坏代码进主干<br/>几个月后才知道"]
B -->|"被归进「代码错了」"| R2["Agent 改坏正确的代码<br/>而且很有信心"]
C --> R3["正常修复"]
style R1 fill:#f8d7da
style R2 fill:#f8d7da
style R3 fill:#e8f4e8
11 判定的三种失败
一个判定可以怎么坏?
大部分系统的答案是”两种”:它可以说通过,也可以说失败。这个答案漏掉了 最危险的那一种,也没有区分开代价完全不同的另外两种 —— 而这三者的代价 差着数量级,把它们混在一起处理,等于用同一个动作去应对三种不同的情况。
11.1 三种,性质完全不同
第一种是判定说通过,但它其实什么都没验。这是形状 A, 全书会反复回到它。一个跑了零个用例的测试套件、一个因为缺少可执行宿主 而只编译不运行的界面测试、一个被无条件跳过却仍然保留着测试外观的用例 —— 它们都返回”通过”,而且它们返回的是真的通过: 按各自的标准,它们没有任何异常。
第二种是判定说失败,但错的不是代码。 执行器起不来、依赖没同步、 任务超时、构建工具下载失败。红灯是真的,但它指向的方向是错的。
第三种是判定说失败,代码确实错了。 这是唯一一种”正常”的失败, 也是唯一一种你希望看到的 —— 它意味着判定在工作,而且它指对了方向。
大部分系统只区分通过和失败,于是第一种被归进通过,第二种被归进第三种。 这两个归并的代价完全不对称。
第二个后果比它听起来更严重。Agent 拿到一个红灯,它不会怀疑这个红灯的来源, 它会去找代码里可能导致这个红灯的东西,然后改掉 —— 而且它会改得很有信心, 因为它手里确实握着一个红灯。改完之后红灯还在(因为根因在基建), 于是它再改一轮。几轮之后,一段本来正确的代码被改得面目全非, 而那个下载超时的构建工具还是没下载下来。
11.3 让”无法判断”成为一等公民
解法是把结果从两种变成三种:0 是通过,证据完整、未命中策略,继续; 1 是内容违规,候选改动触碰规则,修复对象是这次的 diff; 2 是基建故障,策略读不出来、版本缺失、工具链不可用, 修复对象是检查条件,改业务代码没有意义。
把 1 和 2 分开,是这套东西和普通 lint 最实质的区别。 这两类失败绝不能共用一个重试分支 —— 策略违规重试同一份代码毫无意义, 而基础设施故障下改业务代码也换不回一个可信判定, 它只会让 Agent 在一个本来就无法判断的环境里,把代码越改越乱。
11.3.1 这个区分在代码里长什么样
值得看一眼实现,因为它比任何解释都清楚。判定的类型不是布尔值:
pub enum LaneOutcome {
Passed,
PolicyViolation(String),
InfrastructureFailure(String),
}三态枚举,让非法状态无法表示。你没法”忘记”处理基建故障那一支 —— 编译器会拦住你。这是把一条纪律交给类型系统去守,而不是交给记性。
退出码是这样算出来的:
pub fn exit_code(&self) -> i32 {
match self {
Self::Completed { lanes, .. } if lanes.has_infrastructure_failure() => 2,
Self::Completed { lanes, .. } if lanes.has_policy_violation() => 1,
Self::Completed { .. } => 0,
Self::BaselineGrowth(_) => 1,
Self::InfrastructureFailure(_) => 2,
}
}注意匹配臂的顺序。基建故障排在策略违规前面, 这意味着一次运行如果同时有内容违规和基建故障,它报的是 2 不是 1。
这个顺序是一个刻意的决定,而理由值得说清楚:如果测量不可信, 那么同一次运行里得出的策略结论也不可信。你不能一边说”我的传感器坏了”, 一边说”但我根据它的读数判定你违规了”。章节 18 会给这条规则 一个正式的名字,在这里先记住它的形状:传感器故障压过对象故障。
11.3.2 两条用断言守住的不变量
同一个文件里还有两句 assert,各守一条不变量。
第一句是 assert_ne!(lane, LaneId::Architecture, "architecture outcomes must retain their rich result") —— 架构检查的判定不许被降级成普通结论。 因为架构 lane 的输出带着规则名、扫描数、哨兵读数和具体违规位置, 一旦被压扁成一句”失败了”,Agent 就失去了它自己收敛所需要的全部信息。
第二句是 assert!(lanes.all(|c| c.lane != result.lane), "guardrail lane outcomes must be unique") —— 一个 lane 只能有一个判定。 这是”每份可变状态收敛到单一 writer”用在了判定本身上: 如果同一个 lane 能写两次结论,那么”这次到底过了没有”就取决于谁最后写, 而这正是形状 B。
11.4 真的 exit 2 长什么样
$ bash guardrails/guard check arbiters
could not download Bazel: ... Get "https://releases.bazel.build/..." : EOF
guardrails: failed to build canonical runner
ERROR: Job failed: command terminated with exit code 2
构建工具拉不下来,检查器构建不出来,策略就读不出来。 它没有假装通过,也没有报成策略违规,而是明确地说”这次我判不了”。
这三句话是三个不同的产品决定,而一个系统在这种情况下的默认行为, 暴露了它真正的设计意图:假装通过,优化的是”不要挡住人”; 报成违规,优化的是”不要有未处理的情况”; 说判不了,优化的是“结论必须可信”。
11.5 四道 fail-closed 的证据门
在 CI 里,这套判定被拆成四道独立的门,全部 fail closed: 一道守策略机制自身的契约(检查器有没有坏)、一道守真机 lane 的实际执行 (需要真实设备的那部分跑没跑)、一道守目录与依赖边界(结构对不对)、 一道守对本次 diff 的最终扫描(这次改动碰了什么)。
fail closed 的意思是:判不了等于不通过。
绝大多数系统的默认是反过来的 —— 检查挂了就跳过,理由是 “不能因为工具坏了就挡住所有人”。这个默认值在人的世界里是合理的, 因为人会记得回来补:一个被跳过的检查会留在某个人的心里, 他会在合并前多看两眼。
在 Agent 的世界里它是错的,因为没有人会记得。一个被跳过的检查, 和一个从来不存在的检查,在下一次运行时是同一个东西 —— 而且它比”从来不存在”更糟,因为你以为它在。
11.5.1 fail closed 还不够:门本身也可以被改
这四道门的配置文件开头有三行注释,它们说的是一件比 fail closed 更深一层的事:
# Repository Guardrails runtime jobs. Every job fails closed.
# Merge enforcement additionally requires externally protected required-job
# identities; "Pipelines must succeed" alone cannot protect this mutable include.第二句和第三句在说:“流水线必须通过”这条设置, 保护不了这个文件自己。因为这个定义门的文件就在仓库里, 而一次改动可以同时改代码和改这个文件 —— 把那道门删掉,流水线照样”通过”,因为它现在不包含那道门了。
这是一个很容易被漏掉的层次。fail closed 解决的是 “门在跑但判不了时怎么办”,而它解决不了 “门根本没被跑”—— 后者不是判定失效,是判定不存在, 而这两者在流水线的输出上长得一模一样:一片绿。
正确的补法在第二句里:必需的任务身份必须在仓库之外被保护。 也就是说,“这几个任务必须存在并通过”这条约束, 它的宿主不能是仓库里的文件,只能是代码托管平台的设置 —— 一个改动碰不到的地方。
这条推论可以推广,而它是这一章最实用的一条: 任何一道门的定义,如果和被它守的东西住在同一个可写的地方, 那么它守不住那个东西。检查它的方法只有一个问题 —— 一次改动,能不能同时改掉代码和拦住它的那道门? 能,那道门就是装饰。
11.6 第一种失败为什么最难对付
三种失败里,第二种和第三种都会表现出来 —— 有人被挡住,有人去查, 最终会被处理,因为它们各自都有一个「有人在等」的时刻。第一种不会: 它的全部表现就是一片绿色,而绿色是没有人会去追问的东西。
它有三个性质,让它比另外两种难对付一个数量级。
它没有触发点。后两种失败都有一个明确的时刻:CI 变红了。 第一种没有任何时刻,它是一个持续的、无声的状态。
它会累积。一个坏掉的检查,从它坏掉那天起,之后所有经过它的改动 都没有被真正检查过 —— 而你不知道那天是哪天。
发现它需要主动怀疑。后两种失败会来找你,第一种要你去找它。 “去找一个看起来没问题的东西的问题”,是所有工程活动里 最难被排进优先级的一种。
这三条合起来解释了为什么我用一整章讲传感器故障(章节 18), 以及为什么那一章里的四种机制全都是主动的 —— 量程校验、解析冗余、机内自检,它们都不等异常出现, 它们主动去证明测量还活着。
11.7 三种失败的代价不对称
设计一个判定系统时,最关键的一个决定是:在不确定的时候,往哪边倒。 这个决定应该由代价的不对称性来定,不是由”哪种更常见”来定。
把违规误判成通过,后果是坏代码进主干,而可发现性低 —— 可能几个月后才知道。把通过误判成违规,后果是一次无谓的返工, 可发现性高 —— 当场就知道。把基建故障误判成违规, 后果是改坏正确的代码,可发现性中等。把违规误判成基建故障, 后果是一次无谓的等待,可发现性高。
两个”高可发现性”的误判,代价都是一次浪费; 两个”低或中可发现性”的误判,代价都是错误进入了系统。
所以正确的倾向很清楚:不确定时,往”更严”和”更早说判不了”的方向倒。 这就是四道证据门全部 fail closed 的理由,也是退出码 2 排在退出码 1 前面的理由。代价不对称的时候,“公平”是错的目标。
11.8 从两态到三态需要改什么
具体到实现,这个改动涉及三个地方,而大部分人只改了第一个。
第一是产生判定的一方要能区分。在检查脚本里识别出基建故障的特征, 返回不同的退出码。这一步最容易,也最常被当成全部。
第二是传递判定的一方不能压扁。从检查脚本到 CI 到 Agent, 中间每一层都可能把三态压回两态。典型的压扁点是:CI 配置里写了 || true (把所有失败压成通过)、一个包装脚本只判断”退出码是不是 0”、 报告聚合时只统计”成功/失败”两种。小节 11.3.2 里那句 “架构判定必须保留丰富的结果”守的正是这一类 —— 而它用的是断言, 不是文档,因为压扁通常是无意的,一次重构、一次”简化”就会发生。
11.8.0.1 消费判定的一方要知道怎么用
这一步最常被完全遗漏。 你区分了三态,但如果给 Agent 的指导里没有说”看到退出码 2 不要改代码”, 那么这个区分对它是不存在的。这一条应该放在常驻文件里 —— 它必须无条件生效,而且不遵守它的后果是静默的。
只做第一步的结果是:你付出了成本,没有得到收益。而这个失败模式 很难被发现,因为第一步做完之后一切看起来都在工作 —— 退出码确实是 2 了。 检验方式很简单:随便找一次基建故障导致的失败,看 Agent 那一轮做了什么。 如果它改了代码,那就是没做完。
11.9 为什么第三态不能靠重试来代替
一个很自然的想法是:既然基建故障通常是暂时的, 那自动重试两次不就行了吗?为什么要专门造一个退出码?
重试在很多时候确实有效,而这正是它危险的地方 —— 它把一个应该被观察的量,变成了一个被吸收掉的量。
具体是三处损失。第一,重试掩盖了基建故障的频率: 一次成功的重试不留下任何痕迹,于是 小节 11.2 那个 23.8% 永远算不出来,而算不出来就没法判断 “该不该投入去修基建”。第二,重试掩盖了故障的分布: 如果某一类基建故障从每周一次涨到每天十次, 在纯重试的系统里这个变化完全不可见,直到某天重试也救不回来。
第三处最要紧:重试对第二种失败(真的违规)也会生效。 一次真实的失败被重试两次,如果那次失败恰好是不稳定的 (并发、时序、外部依赖),它有可能在第三次通过 —— 而那次通过是假的。重试没有区分能力, 所以它在吸收噪声的同时也在吸收信号。
正确的组合是先分类,再对其中一类重试: 识别出退出码 2 的那些,对它们重试, 并且把重试的次数和原因记下来。这样三处损失都被补上了 —— 频率可算、分布可见、而真实的违规不会被重试掩盖。
这条推论有一个更一般的形态,值得单独记: 任何”自动吸收异常”的机制,都必须同时产生一条关于它吸收了什么的记录。 静默地吸收异常,等于把一个可观测的状态变成不可观测的 (小节 19.1),而我从头到尾在说这件事的代价。
11.10 三种失败和三层判定的交叉
三种失败可以出现在三层判定的任何一层,而九个格子里有些常见、有些几乎不发生。
| 假绿 | 基建故障 | 真违规 | |
|---|---|---|---|
| 测试 | 常见 | 常见(30%) | 常见 |
| 结构检查 | 少(有哨兵) | 少(19%) | 最多(7% 失败率) |
| 路径不变量 | 少(只扫新增行) | 少 | 少(命中率低) |
读这张表能得到三个判断。测试那一行最需要防护 —— 它同时是假绿最常见、基建故障最多的一层,而这解释了为什么 章节 12 是全书最长的判定章。结构检查是最”干净”的一层 —— 它的失败几乎总是真违规,而这是它性价比高的一部分原因: 一次失败几乎不需要甄别。路径不变量这一层的假绿风险最低 —— 因为它的逻辑最简单(路径匹配加字面模式),而代码越少, 静默失效的可能性越低。
这给出一个反直觉的设计建议:当一层判定的逻辑变复杂时, 它自己的假绿风险在上升 —— 所以复杂的判定需要更多的自检。
11.11 这三种失败在别的地方也成立
退出码三分是从 CI 里长出来的,但这个划分的适用范围比 CI 大得多。 任何一个”产生判断的东西”,都应该有这三种输出。
代码检查是干净、有违规、工具起不来;监控告警是正常、指标越界、 采集器挂了;数据质量检查是数据合格、数据有问题、上游没送数据; 权限判定是允许、拒绝、鉴权服务不可达;健康检查是健康、不健康、 检查本身失败。
每一行的第三列,在大部分实现里都被折叠进了前两列之一。 折叠的方向暴露了设计意图:折叠进”通过”优化的是可用性, 代价是静默失效;折叠进”违规”优化的是保守,代价是误报和错误归因。
监控告警那一行值得单独说,因为它的折叠方向几乎总是错的: 采集器挂了,指标没有数据,而大部分告警规则在”没有数据”时不触发—— 也就是折叠进了”正常”。结果是:监控系统坏掉的时候,它看起来一切正常。
这正好是形状 A,而且它和那个挂了四个月的定时任务是同一个形状 —— 只不过一个发生在监控上,一个发生在自动化上。
所以这一章的判断也可以反过来用:当你看到一个系统只有”好”和”坏” 两种输出时,去找它的第三种状态被折叠到哪里去了 —— 那里通常有一个洞。
11.12 三态之外还需要第四态吗
一个自然的追问:既然两态不够,三态就够了吗?
有两个候选的第四态值得认真看一遍,而结论是都不该加。
第一个候选是”通过但有警告”。 它的诱惑力在于 很多检查确实有一批”不至于拦人但值得看”的发现。 它不该成为一个退出码状态,理由是它的消费方式和另外三态不同: 另外三态各自对应一个明确的下一步动作 (继续 / 改代码 / 等环境),而”有警告”对应的动作是 “看看,可能改也可能不改”—— 一个需要判断的动作。 把它编码进退出码,等于要求 Agent 去做一次判断, 而那正是 小节 11.3 想避开的东西。 这类发现的正确载体是报数模式(小节 13.7), 它在输出里,不在退出码里。
第二个候选是”暂时失败,重试可能会好”。 它比第一个更有道理,因为它确实是一个不同的状态。 它不该独立成一态的理由是它已经被包含在”我判不了”里了—— 一次超时、一次拉取失败、一次执行器起不来, 这些全都是”这次测量没有完成”, 而它们要不要重试是消费方的策略,不是判定的内容。 把重试策略编进判定,会让判定的语义随重试策略变 (小节 11.9)。
三态之所以是对的数字,是因为它们对应的是三种不同的归因: 没问题、你的问题、我的问题。任何一个第四态, 要么归因和已有的某一态相同(那它是输出细节,不是状态), 要么它引入了一个需要判断的动作(那它违背了三态的初衷)。
11.13 给读者的动作
把你 CI 上最近的十次红灯拉出来,逐个归类。
如果第二类(基建故障)占比超过百分之十五,而你的系统没有把它和第一类分开 —— 那么你的 Agent 有超过十分之一的时间在改本来正确的代码。
这是全书投入最小、收益最确定的一条改动:半天,一个退出码。
11.13.1 更细的分类练习
如果你想做得更彻底,对每一次红灯记四个字段:哪一层(测试/结构/路径不变量)、 哪一类(真违规/基建故障)、定位花了多久(从看到红灯到知道原因)、 修复花了多久(从知道原因到转绿)。
第三个字段是最有价值的,因为它直接衡量失败信息的质量。 如果你发现某一层的定位时间特别长,那一层的失败信息需要改善 —— 而这通常比再加一道检查便宜得多,收益也大得多。