CI Sweeper 的「无限 Flaky 测试循环」事故复盘:如何用每日硬上限与熔断器驯服自动化修复 Agent
CI Sweeper 的「无限 Flaky 测试循环」事故复盘:如何用每日硬上限与熔断器驯服自动化修复 Agent
导读
本文基于 loop-engineering 仓库中 stories/ci-sweeper-infinite-flaky-test.md 的真实生产事故记录,完整复盘一次 CI Sweeper 模式在周末疯狂产出 30+ 个迭代 PR、无限调大 setTimeout 的失控经过,并结合该仓库的 ci-sweeper 模式定义、GitHub Actions 事件驱动示例、loop-context 熔断器、失败模式目录与安全护栏等源码与配置,给出可落地的防御方案:路径 denylist + 每日修复次数硬上限 + 机械式熔断器 + Flake 分类三态。读完你既能理解此类事故的完整因果链,也能照着仓库中的真实配置把 CI Sweeper 从"幽灵追猎机"改造成收敛、可控的自动修复循环。
一、事故现场:一个周末的 30+ 个迭代 PR
这是仓库 stories/ 目录中记录的失败案例(原始记录),场景如下:
- 模式:CI Sweeper
- 工具与节奏:GitHub Actions,在每次
main构建失败时触发(事件驱动,而非轮询)
1.1 起初看起来一切正常(What Worked)
CI Sweeper 成功检测到 main 分支上的 CI 失败,并立即在 e2e/third-party/ 目录提出一个"看起来合法"的修复——增大测试超时时间(test timeout)。这符合 CI Sweeper 模式的典型工作流:发现失败 → 分类 → 提出最小修复。
1.2 失控链条(What Broke)
真正的根因是第三方 API 不稳定(flaky),而不是简单的超时不足。随后发生了连锁反应:
- Sweeper 针对第一次失败打开了修复 PR;
- CI 针对该 PR 运行时,因为同一个 flake 再次失败;
- Sweeper 于是又启动了一个新循环,去修复"自己上一次的修复";
- 最终在周末期间产出了 30+ 个迭代 PR,每一轮都只是把
setTimeout的值继续调大; - 团队不得不手动禁用 GitHub Actions workflow 才让循环停下来。
1.3 代价(Metrics)
| 指标 | 数值 |
|---|---|
| 单周末产生的自动化 PR | ~30 个 |
| 浪费的资源 | 不必要的 token 消耗 + 大量 CI 计算(ghost-chasing,追幽灵) |
1.4 事后教训(Lesson)
绝不要在没有硬性每日运行上限的情况下,让 CI Sweeper 对 flaky 测试自动触发。
仓库记录的两条补救措施:
- 在
LOOP.md中对e2e/third-party/添加 denylist(路径黑名单); - 在整个仓库设置每天最多 3 次自动修复尝试的严格预算上限。
二、根因拆解:为什么一个"智能"循环会失控
这次事故不是偶发 bug,而是 CI Sweeper 模式的四个结构性缺口被同时触发的结果。仓库的 失败模式目录 将这种症状定义为 Infinite Fix Loop(无限修复循环):
症状:同一 PR 或同一 CI 任务被自动修复尝试 5 次以上,且永远无法收敛。 严重级别:S2(Harmful)——错误的代码被合并、告警疲劳。
其列举的三大成因,与这次事故逐条对应:
- 根因误诊(symptom fixing):把第三方 API 的 flake 当成超时阈值问题,属于"修症状而非修根因";
- 把 Flaky 测试当作回归(flake treated as regression):本应归类为"Watch,不自动修复"的间歇性失败,却被当成可修复的确定性回归;
- 验证器过弱 / 与实施者共享上下文:
loop-verifier只看"单次测试偶尔通过"就批准了 diff,没有检查根因。
同目录下的姊妹事故 ci-sweeper-symptom-patching-and-circuit-breaker.md 记录了几乎同构的惨剧:由于遗漏了集成测试的连接池 teardown hook,Sweeper 把连接超时误判为性能阈值问题,把超时从 5000ms 一路调到 30000ms,3 小时内 14 次连续修复、烧掉 1.8M tokens,最终靠 loop-context 熔断器以退出码 2 强制升级到人工才停手。
两者共同指向一个事实:CI Sweeper 的失败不是"检测不到"或"修不动",而是"没有收敛机制"。
三、防御层一:路径 Denylist,物理隔离"禁改区"
事故的直接教训之一就是在 LOOP.md 中对 e2e/third-party/ 加 denylist。仓库的 docs/safety.md 提供了标准的路径黑名单模板(循环在未经人工批准时绝不自动编辑):
**/.env
**/.env.*
**/secrets/**
**/credentials/**
**/*_key*
**/*_secret*
**/.terraform/**
**/k8s/production/**
**/migrations/** # unless explicit migration loop
**/auth/**
**/payments/**
**/billing/**
针对 flaky 场景,应在你的项目里把以下路径加入黑名单:
tests/integration/**、tests/setup.*、jest.config.*、vitest.config.*等测试基础设施(姊妹事故中的经验:对测试运行器超时与共享测试 setup 的自动修改,必须要求显式验证器护栏);- 已知不稳定的第三方集成目录,如本案例的
e2e/third-party/。
该 denylist 要编码进实施技能。minimal-fix 技能 明确规定:Respect denylist paths — escalate instead of editing(尊重黑名单路径——升级而不是编辑),并且"不要修改与 denylist 匹配的文件,向人类升级并附上上下文"。
更关键的是,denylist 不能只停留在"希望 Agent 读过"的散文层面。仓库的 loop-gate 会从 gate.yaml 机械地强制该黑名单:loop-gate check --action <type> --paths <changed files>,命中黑名单时以退出码 2 升级、否则 0 放行——与 loop-context --check 的退出码约定一致,控制脚本可以串联两者。gate.yaml 还设置了 maxFiles: 10:任何改动超过 10 个文件的 diff 一律升级,无论改了什么路径,因为"一个循环提交超大 diff 说明它已经失控"。同时 loop-sync 每次运行都会把 docs/safety.md 的散文与 gate.yaml 的实际策略做对账,防止两者静默漂移。
仓库根目录的 loop-constraints.md 就是这套理念的 dogfood 实例:
## Paths
- Never edit `.env`, `.env.*`, `auth/`, `payments/`, `secrets/`, `credentials/`
- Never edit `tools/loop-audit/src/`, `docs/primitives*.md`, or showcase assets without human review
- Never edit infrastructure configs without human approval
loop-constraints 技能 会在每次运行的开始(早于任何 triage 或动作技能) 读取该文件,把所有规则装入工作记忆,并以一行确认输出(Constraints loaded from loop-constraints.md: N rules active.)作为每次运行的固定开场。
四、防御层二:Flake 三态分类——从"见到失败就修"到"先问是不是 flake"
事故的核心误判,是没能在 triage 阶段把 flaky 测试与真实回归区分开。仓库的 ci-sweeper 模式 把分类列为循环的第一步:
- 在受监控分支(main、release/*、活跃 PR)上发现 CI 失败;
- 对每个新失败:分类——flake vs 真实回归 vs 基础设施问题;
- 若是 flake(以前见过、间歇性出现):加入 Watch,不自动修复;
- 若可行动:开 worktree → implementer 起草修复。
其验证策略进一步明确了一条黄金法则:
Flake 检测:如果同一测试在未改动代码的情况下,重试后失败又通过——不要自动修复。
配套的 loop-triage 技能 规定了四段式输出(High-Priority / Watch / Noise / State Updates),并强调"宁可放进 Watch 或 Noise,也不要制造工作"、"triage 只做信号,不做发明(signal, not invention)"。本案例中 e2e/third-party/ 的间歇性失败理应进入 Watch(监控、暂不行动),却因为缺少这条分流逻辑直接进入自动修复通道。
对于确认的 flake,正确动作是隔离(quarantine)而非改代码:docs/safety.md 的 Flake & Test Safety 一节规定:
- 不要为了 CI 变绿而禁用测试;
- 不要在没有根因备注的情况下盲目增大超时(本案正是这一条被反复违反);
- 通过显式 ticket + 人工批准隔离 flake。
五、防御层三:每日硬上限 + 机械式熔断器
"每天最多 3 次自动修复尝试"是事故复盘给出的关键数字,它对应两个层面的机制。
5.1 尝试次数上限(per-item + daily)
loop-constraints.md 的 Code 一节写得很直白:
## Code
- Always run tests before proposing a fix (`npm run test:tools` for tool changes)
- Never disable tests to make CI green
- Never refactor unrelated code — one fix per run
- Max 3 fix attempts per item; escalate after
- Enforce the attempt limit mechanically: log each try to `loop-ledger.json` and run `loop-context --check` before retrying (see the `loop-guard` skill)
注意"mechanical(机械式)"这个词——上限不是靠提示词自觉,而是靠账本(ledger)+ 熔断器强制。ci-sweeper 模式 的 How the Loop Runs 第 5 步完整描述了这一机制:
每次重试前,
loop-guard运行熔断器(loop-context --check)。如果同一失败重复出现 N 次,或尝试次数超过上限(如 3),熔断器跳闸:升级到人工(附带精简过的上下文摘要),而不是继续循环。
失败模式目录 的 Infinite Fix Loop 缓解清单也逐一对应:硬性尝试上限 → 升级人工;分离验证器模型 / 更高推理强度;triage 中分类 flake 并隔离;在状态文件中记录尝试次数。
5.2 loop-context 熔断器:退出码 2 就是刹车
仓库的 loop-context 工具 是这套机制的核心实现。它是有状态的记忆管理器,在每次迭代前做三件事:Summarize(总结了什么、失败了什么)、Prune(裁剪冗长堆栈、折叠重复错误、丢弃窗口外旧尝试)、Inject(只把必要内容注入下一轮 prompt)。全部是确定性、零依赖的实现——不需要任何 LLM 调用来完成总结与剪枝,所以便宜到可以每次迭代都跑。
它的熔断器(circuit breaker)监控四个信号:迭代次数、token 消耗、停滞(同一错误连续出现 N 次)、无进展(连续失败过多),任一触发即升级人工。
# 熔断器——退出码 0 = 继续,2 = 升级(接入循环的控制流)
loop-context --check --ledger run.json
# 控制脚本内的用法:每次迭代前先检查,跳闸就带着精简摘要退出
if ! loop-context --check --ledger run.json; then
loop-context --inject --ledger run.json > escalation.md # 把干净摘要交给人类
exit 0 # 停止而不是重试
fi
关键参数一览:
| Flag | 默认值 | 含义 |
|---|---|---|
--max-iterations <n> |
10 | 硬性迭代上限 |
--stagnation <n> |
3 | 同一错误连续重复 N 次即升级 |
--no-progress <n> |
5 | 连续失败 N 次即升级 |
--token-budget <n> |
无 | 累计 token 达到上限即升级 |
--window <n> |
5 | 剪枝时保留的尝试数 |
--max-trace-lines <n> |
8 | 剪枝时保留的堆栈行数 |
退出码约定:0 继续 · 2 升级 · 1 错误。账本(ledger)是 JSON 记录,每轮迭代追加一条:
{
"goal": "Get the failing migration test to pass",
"attempts": [
{ "iteration": 1, "action": "run migration", "outcome": "failure",
"error": "Error: connect ECONNREFUSED 127.0.0.1:5432", "tokensUsed": 1500 },
{ "iteration": 2, "action": "run migration again", "outcome": "failure",
"error": "Error: connect ECONNREFUSED 127.0.0.1:5432", "tokensUsed": 1400 }
]
}
outcome 取 success | failure | noop;error 与 tokensUsed 可选。循环启动时初始化一次:{ "goal": "Get CI green on main", "attempts": [] },之后每轮迭代由控制脚本追加一条 attempt,再跑 --check 门控下一轮。
5.3 每日预算(daily cap)跨多次运行累计
--token-budget 只守护单次运行的账本,而事故是跨整个周末的多次运行持续烧钱。loop-context 为此提供了每日预算追踪:
loop-context --check --ledger run.json --daily-budget-from-pattern ci-sweeper \
--budget-level L2 --on-exceed ./scripts/on-budget-exceed.sh
- 每次
--check会把账本最新尝试的tokensUsed累加到按模式区分的状态文件(daily-spend.<pattern-id>.json),日期跨天自动归零(UTC); - 当日累计达到该模式的
suggestedDailyCap时以daily-budget触发升级(单次运行触发的停滞/无进展/token 上限优先级更高); --on-exceed在任何升级时把BreakerDecision以 JSON 形式管道给脚本——这正是 loop-budget.md "On budget exceed" 清单(暂停调度器 → 记录到loop-run-log.md→ 打开维护者 issue)的自动化挂点。
仓库的 loop-budget.md 是这套理念的 dogfood 实例:为每个 loop 定义 Max runs/day、Max tokens/day、Max sub-agent spawns/run 三列预算表,并定义了 loop-pause-all 标签作为全局总开关(kill switch)——事故中团队"手动禁用 workflow"的正规化版本。注意:Agent 不能自行提高 loop-budget.md 中的每日上限,必须由人类显式批准并编辑(见 docs/safety.md 的 Human Gates)。
5.4 从模式自动解析预算
手敲 --token-budget 是瞎猜。loop-cost 已为每个模式与准备级别(readiness level)计算真实单次运行估算,loop-context 可以直接复用:
loop-context --check --ledger run.json --budget-from-pattern ci-sweeper --budget-level L2
显式 --token-budget <n> 始终优先于派生值;模式 id 未知时透传 loop-cost 自身的报错,而不是静默失败。
六、把 CI Sweeper 接到 GitHub Actions 事件上(参考配置)
事故里 sweeper 是"每次 main 构建失败都触发",这正是事件驱动模型的典型用法。仓库的 examples/github-actions/ci-sweeper.yml 展示了标准骨架:
name: CI Sweeper Loop
on:
workflow_run:
workflows: ['*']
types: [completed]
branches: [main]
permissions:
contents: read
pull-requests: write
checks: read
jobs:
sweep:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Record failure context
run: |
echo "Failed workflow: ${{ github.event.workflow_run.name }}"
echo "Head branch: ${{ github.event.workflow_run.head_branch }}"
echo "Run URL: ${{ github.event.workflow_run.html_url }}"
- name: Update ci-sweeper-state.md
run: |
FILE=ci-sweeper-state.md
touch "$FILE"
echo "" >> "$FILE"
echo "### $(date -u +%Y-%m-%dT%H:%M:%SZ) — workflow failure" >> "$FILE"
echo "- Workflow: ${{ github.event.workflow_run.name }}" >> "$FILE"
echo "- Branch: ${{ github.event.workflow_run.head_branch }}" >> "$FILE"
echo "- URL: ${{ github.event.workflow_run.html_url }}" >> "$FILE"
echo "- Loop action: pending agent invocation" >> "$FILE"
- name: Invoke CI Sweeper Agent
uses: cobusgreyling/loop-engineering/tools/loop-action@main
with:
pattern: 'ci-sweeper'
level: 'L2'
sandbox: 'true'
sandbox-shell: 'true'
command: |
echo "::notice::Wire to agent: classify failure, worktree fix, verifier, open PR."
echo "See patterns/ci-sweeper.md and docs/safety.md"
把这套骨架接上防御机制时,需要改造的关键点(结合上文):
- 触发即分类:
workflow_run完成后,先跑 triage(flake / 回归 / 基础设施),flake 只记入ci-sweeper-state.md的 Watch 区,不进入修复通道; - 每次修复尝试前过熔断器:
loop-context --check --ledger loop-ledger.json,退出码2立即停手并升级; - 合并前过 gate:
loop-gate check --action <type> --paths <changed files>,命中 denylist 或maxFiles超限即升级; - 人工交接点(来自 ci-sweeper 模式):基础设施失败(runner OOM、registry 宕机、secrets 缺失)、改动 >5 文件或触及核心架构、安全敏感测试失败、同一失败超出最大尝试次数、需要隔离而非改代码的间歇性 flake。
状态追踪建议使用模式定义的 ci-sweeper-state.md 格式:
## CI Sweeper — Active Failures
Last run: 2026-06-09 14:30 UTC
### main @ abc1234
- Job: test-auth
- Failure: AssertionError in test_refresh_token_expiry
- Attempts: 1/3
- Last action: Minimal fix proposed in worktree fix/ci-auth-refresh
- Status: Waiting for verifier + human
### Resolved (last 7d)
- main @ def5678 — lint fix merged via PR #1250
追踪字段:commit SHA、失败 job、尝试次数、worktree/PR 链接、结果。每次运行结束必须剪枝已解决项——失败模式目录 警告,状态文件不剪枝会让循环"对着幽灵行动"(state rot)。
七、成本意识:为什么"绿了就跑"和"每次全量跑"都不对
事故的代价统计里包含"CI 计算浪费",而 token 成本本可以通过模式定义的成本概况提前建模。ci-sweeper 模式 给出了明确的成本表:
| 场景 | Tokens/运行 | 说明 |
|---|---|---|
| No-op(CI 绿) | ~5k | 必读——绿的时候不要跑完整 sweeper |
| Triage / 分类 | ~50k | 日志解析 + 失败分类 |
| 修复尝试(L2) | ~200k | worktree + implementer + verifier |
节奏:5m–15m · 级别:very-high · 建议每日上限:1M tokens · 必须提前退出
npx @cobusgreyling/loop-cost --pattern ci-sweeper --cadence 15m --level L2
文档同时给出一个醒目的警告:15m 节奏下如果不做提前退出(early-exit),最坏情况每日消耗超过 5M tokens。绝不要在每次 tick 都跑完整 action 路径。 这也解释了事故中"token 浪费"的规模从何而来——每次失败的修复 PR 都会触发 CI 重新运行,而每次运行又是完整的 L2 修复链。
成功指标(来自模式定义)应关注质量而非数量:
- CI 变红到首次提出修复的平均时间;
- 无需人工介入即解决的失败占比(仅限 trivial 场景);
- 重复失败率(同一 job 在 48h 内再次失败)。
八、结语:把这次事故沉淀为团队规则
回顾整条失控链条——把 flake 当回归、修症状不修根因、验证器太弱、没有硬上限、没有熔断器——每一项都在仓库的文档与工具中有对应的防御手段。落地时请对照以下检查单(参照 docs/safety.md 的 Pre-Flight 清单):
- [ ]
e2e/third-party/、tests/setup.*、jest/vitest.config.*等路径已进 denylist(散文层面 + loop-gate 机械强制); - [ ] 每天自动修复尝试上限已设(仓库 dogfood 值:3 次/天/项,loop-constraints.md);
- [ ] 每次重试前必跑
loop-context --check --ledger loop-ledger.json,退出码2即升级; - [ ] triage 阶段 flake 走 Watch + 隔离,不自动修复;
- ] 验证器与实施者分离,验证器必须运行测试并检查根因而非只看 CI 变绿([failure-modes.md 的 Verifier Theater 一节);
- [ ] 每日 token 预算已绑定(
--daily-budget-from-pattern ci-sweeper),loop-pause-all总开关可用; - [ ] 每次运行剪枝状态文件,避免 state rot。
事故复盘的落点不是"CI Sweeper 不好用",而是:自动化修复循环的价值与其收敛机制成正比。当它能在 3 次尝试内收敛、该升级时果断升级,它才是那个让 main 快速转绿的高频、有界、可验证的入门 loop;当它缺少上限与熔断器,它就是一个周末能产出 30 个 PR 的幽灵追猎机。把本文的检查单写进你的 LOOP.md 与 loop-constraints.md,就是对这个事故最好的纪念。