CI Sweeper 的「无限 Flaky 测试循环」事故复盘:如何用每日硬上限与熔断器驯服自动化修复 Agent

原创2026-09-23 19:15:301,857 阅读
文章标签:人工智能AI AgentAgent 工作流Agent 编排代码智能体AI 技能MCP 服务CLI

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),而不是简单的超时不足。随后发生了连锁反应:

  1. Sweeper 针对第一次失败打开了修复 PR;
  2. CI 针对该 PR 运行时,因为同一个 flake 再次失败;
  3. Sweeper 于是又启动了一个新循环,去修复"自己上一次的修复";
  4. 最终在周末期间产出了 30+ 个迭代 PR,每一轮都只是把 setTimeout 的值继续调大;
  5. 团队不得不手动禁用 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)——错误的代码被合并、告警疲劳。

其列举的三大成因,与这次事故逐条对应:

  1. 根因误诊(symptom fixing):把第三方 API 的 flake 当成超时阈值问题,属于"修症状而非修根因";
  2. 把 Flaky 测试当作回归(flake treated as regression):本应归类为"Watch,不自动修复"的间歇性失败,却被当成可修复的确定性回归;
  3. 验证器过弱 / 与实施者共享上下文: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 模式 把分类列为循环的第一步:

  1. 在受监控分支(main、release/*、活跃 PR)上发现 CI 失败;
  2. 对每个新失败:分类——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"

把这套骨架接上防御机制时,需要改造的关键点(结合上文):

  1. 触发即分类:workflow_run 完成后,先跑 triage(flake / 回归 / 基础设施),flake 只记入 ci-sweeper-state.md 的 Watch 区,不进入修复通道;
  2. 每次修复尝试前过熔断器:loop-context --check --ledger loop-ledger.json,退出码 2 立即停手并升级;
  3. 合并前过 gate:loop-gate check --action <type> --paths <changed files>,命中 denylist 或 maxFiles 超限即升级;
  4. 人工交接点(来自 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 清单):

事故复盘的落点不是"CI Sweeper 不好用",而是:自动化修复循环的价值与其收敛机制成正比。当它能在 3 次尝试内收敛、该升级时果断升级,它才是那个让 main 快速转绿的高频、有界、可验证的入门 loop;当它缺少上限与熔断器,它就是一个周末能产出 30 个 PR 的幽灵追猎机。把本文的检查单写进你的 LOOP.md 与 loop-constraints.md,就是对这个事故最好的纪念。

登录后查看全文
loop-engineering