背景脱敏:一个 Go 微服务仓库,主干上跑着三十多项静态门禁,配了两套自动化:一个「棘轮债务吸收 bot」负责把行数、复杂度类的新增违规吸进基线让主干秒级回绿,一个「流水线失败诊断员」负责给每次主干失败发归因卡片。9 月 3 日一条架构门禁把主干堵了约 13.5 小时、9 条流水线连红,两套自动化都在正常运行,却谁也没接。本文不写后来补的修复护栏(另有一篇),只写怎么找出这个盲区,以及为什么它在设计上必然存在。
现象
一个配置表被两处注册,触发 check_autocfg_convergence 门禁,主干红。时间是晚上,跨夜没人看,第二天早上人工修了一行才回绿。
事后看两套自动化的日志:吸收 bot 正常跑了,输出「无棘轮债务可吸收,本次红为非棘轮失败,交由失败通知归属」然后退出 0;诊断员也正常跑了,卡片发到了群里,分类是「架构/约束门禁红」,建议动作是「按门禁自带修复指引改代码」。两边都按设计行为工作,主干红了 13.5 小时。
四象限:能否吸收 × 是否阻塞
把所有门禁按两个维度摆开:
| 阻塞主干 | 不阻塞主干(吸收后秒级回绿) | |
|---|---|---|
| absorb 能吸收 | (空集) | 棘轮类 15 项:函数行数、文件行数、行宽、尾注释、裸 goroutine、错误码语言…… |
| absorb 不能吸收 | 架构约束门禁、编译红、单测红、proto 检查、helm 校验 | (空集) |
左上角和右下角都是空集,这不是巧合,是定义决定的。「能吸收」的意思是违规可以被写进基线文件、门禁重跑转绿,而这恰恰意味着它不会持续阻塞主干。「阻塞主干」的意思是没有基线可以吞它,只能改代码。absorb 能吸收的都不阻塞,阻塞的 absorb 都吸收不了,两个集合不相交。
清债 bot 的输入源是 absorb 吸进基线后挂出的 ci-debt issue。它的输入永远来自右上角,而事故永远发生在左下角。清债 bot 再勤快,也不可能修到一次主干阻塞。
诊断员呢?它设计上只读:不 retry、不写仓库、不改状态,任何异常吞掉退出 0。它的输出是一张卡片,接收方是人。深夜的卡片就是没人接的卡片。
所以 13.5 小时不是运气差,是左下角这个象限从来没有分配过自动接管者。
一个更隐蔽的盲区:清单里的项没有真的生效
在画四象限之前,主干还因为另一个原因反复卡红过。那次的门禁 proto_view_parity 明明列在 RATCHET_GATES 清单里,属于右上角「能吸收」的那类,主干却一直红。
链条是这样的:吸收脚本用正则 \$ROOT/scripts/ci/..._baseline\.txt 从门禁脚本里抓基线路径,而这个门禁脚本写的是裸相对路径 BASELINE="scripts/ci/proto_view_baseline.txt",正则抓不到,返回空串。吸收函数里这一行:
[[ -n "$base" && -f "$base" ]] || return 0
静默跳过。于是人的提交搞红视图基线 → 吸收轮空 → bot 提交推上去主干仍红 → 第二轮吸收撞上「HEAD 是 bot 提交」的防自触发守卫退出 → 主干保持红,全员 MR 被「流水线必须成功才能合并」挡住。
这件事最值得记的不是那个正则,而是:「红了但没吸收」和「本来就是绿的」在日志里长得一模一样。列进清单不等于生效,中间隔着一次解析,解析失败是静默的。修法是两条:吸收脚本开头加 report_unresolvable_gates,对清单逐项检查能否解析到真实存在的基线文件,解析不到就大声说「该门禁红了也吸收不了」;再加一条不依赖 go 工具链的纯文本测试,断言 RATCHET_GATES 每一项都能解析出真实文件。
诊断员的分类规则怎么设计
诊断员的 L1 层是一组按顺序命中的正则规则,每条是「类别、模式、建议动作」三元组:
CLASSIFY_RULES = [
("棘轮门禁红(absorb 会吸收,入 ci-debt 异步清偿)",
r"check_func_quality|check_line_length|check_file_size|...",
"无需处理主干(bot 自动回绿),归属人按 ci-debt issue 清债"),
("架构/约束门禁红(可自动修)",
"|".join(AUTOFIXABLE_GATES) + r"|收敛门禁失败|门禁失败:",
"按门禁自带修复指引改代码;已自动建 ci-blocking 任务尝试修复"),
("疑似半截提交 / 编译红",
r"undefined:|cannot find package|redeclared|declared and not used",
"检查嫌疑提交作者工作区是否漏交新文件"),
("Go 单测失败", r"--- FAIL|DATA RACE|panic: test", "..."),
("CI 脚本自测红", r"test_check_|test_absorb_", "..."),
("proto/协议检查红", r"check_proto|protoc|\.pb\.go", "..."),
("helm/部署清单红", r"helm|kubeconform|values.*yaml", "..."),
("环境/基础设施疑似", r"timed out|connection refused|No such host", "可直接 Retry"),
]
八个类别,每个类别的第三个字段回答的都是同一个问题:这类失败谁接。第一类是 bot 接;第二类现在是自动修复执行器接(AUTOFIXABLE_GATES 白名单 18 项,单一真源在诊断员文件里,执行器按同仓同 commit 的相对路径加载,加载不到就谁都不修);后面六类是人接,建议动作告诉人往哪看。
三个设计细节:
- 顺序有意义。棘轮类放第一,因为它的信号最明确、处理最便宜,先把「不用管」的筛掉。
- 白名单刻意排除两类:棘轮类(bot 会吸收,放进来是重复)和编译/单测类(机器修不了,或者有掩盖真回归的风险)。白名单的共同特征是「违规点明确到具体符号、脚本自带修复指引、重跑门禁即可确定性验证」。
- 只盯主干。MR 和派生分支的失败不发卡,否则卡片会淹没在噪音里,没人再看。
通用方法
事后把这件事抽象成一个可以照做的步骤:
- 列出所有失败类别。不是拍脑袋列,是从流水线历史 trace 里按错误签名聚类,直到新失败落进已有类别的比例接近 100%。
- 对每个类别问一句「谁接」。答案只能是三种:某个自动化、某个人、或者「没人」。写「人」的要追问:半夜呢?
- 答案是「没人」的就是盲区。要么给它配自动接管者,要么至少让它发出与其他类别不同的告警。
- 对每个自动接管者再问「它的输入源覆盖这个类别吗」。清债 bot 的输入源是 ci-debt issue,这个源头天然不含阻塞级失败。输入源决定能力上限,与 bot 本身多聪明无关。
- 对每份清单加反向检查:清单里的每一项,是否真的被处理了?「不合规」和「没被处理」在日志里不能长得一样。
数据
- 事故:主干阻塞约 13.5 小时,9 条流水线连红,人工修复一行。
- 棘轮清单 15 项,自动修复白名单 18 项,分类规则 8 类。
- 清单静默跳过那次:连续两条流水线保持红,全员 MR 被挡。
- 后来补的自动接管上线前做了一次受控演练:在独立工作树里 revert 掉人的修复重现真实违规,让 headless 模型修,独立复核。487 秒、18 轮、约 1.44 美元,门禁转绿且编译通过。演练里模型最后一句是「门禁还在跑,稍后汇报」,然后就结束了,从未输出约定的完成标记。自报不可信,成败由独立复核裁定,这条在演练里就钉死了。
教训
- 盲区不在自动化的实现里,在自动化的输入源里。清债 bot 永远看不到阻塞级失败,因为它的输入源就是「已被吸收的债」,这是定义决定的,不是 bug。
- 「列进清单」和「真的生效」之间隔着一次解析,解析失败必须留声。任何「维护一份名单 + 按名单遍历」的机制都要配反向检查。
- 分类规则的每一类都要写清楚「谁接」。没写的那类就是没人接的那类。
- 两套自动化各自正常,不等于覆盖完整。要把失败空间画出来,逐格看,而不是逐个工具看。
- 自动修复的成败由独立复核裁定,模型自报的「完成」不作数。