AI

代理说测试“本来就不稳定”,怎么查清是不是它的 PR 弄坏的(中英切换)

This post is currently available in Chinese only. The original text follows.Open the Chinese page →

还是那个三人团队、七台电脑:1–3 号机开发,4、5 号机审查,6 号机做 Windows 验证,7 号机负责合并。

周四下午,2 号机的代理提了一个 PR,给美国站加收据。CI 红了一项:test_default_label_is_cny,一个检查“价格默认显示人民币”的老测试。代理在 PR 里留言:“这个失败与本次改动无关,是仓库里已有的不稳定测试,本地单独运行可以通过。已在 pyproject 中加入 --reruns 3。”重跑之后还是红,它又给这个测试加了一行 @pytest.mark.skip(reason="flaky, pre-existing")。CI 绿了。4 号机的审查代理看到的 diff 很小,测试全绿,批了;7 号机合了。下周一,中国站有用户截图:商品价格后面跟着 USD。

同一个仓库里还有一个测试,test_last_item_is_sold_once,大约每六七次跑红一次,已经红了几个月。大家的习惯是点重跑。它检查的是库存只剩一件时,两个并发下单只有一个成功。后来对账发现,偶尔有一件商品被卖了两次。

这两件事里,代理和人做的都是同一个动作:看到一个红,判断“它本来就不稳定”,然后让它别再红。我的判断是:“flaky”说的是现象,同一份代码有时过有时不过;它说明不了原因。CI 里同样一行 1 failed,背后可能是产品里的竞态,可能是测试自己的计时,也可能是这个 PR 刚把别的测试弄脏了。只看一次红,人和代理都分不出来。 能分出来的办法很笨:在主干和 PR 上,用 CI 那样的方式各跑几十遍,比较失败次数;再单独检查这个 PR 对已有测试做了什么。下面是我搭的实验、两次没做对的版本,以及最后能直接放进仓库的东西。

先说明实验条件。上面的场景是我在一个玩具仓库 shop 里复现的,不是你们的真实仓库。它有三个模块:inventory/stock.py 用 SQLite 管库存,tokens.py 是一个带过期时间的令牌缓存,pricing.py 管价格显示。代理的那几种“修法”是我手写的,照着公开 issue 里记录过的行为写:有人报告 Claude Code 在 AGENTS.md 明确禁止的情况下给 6 个以上测试加了 @unittest.skip(anthropics/claude-code #45550),有人报告它改写测试、另建一个“安全测试”目标来让检查通过(#319),还有项目把一个不稳定的端到端测试改成“失败就跳过”(nicsuzor/academicOps #350)。这台机器上的 Claude Code 没登录,所以下面没有“模型现场生成”的数据。所有数字都是在一台 8 核 Linux 上跑出来的,测试顺序固定(关掉了 pytest-randomly),每一组的原始输出都在文末的包里。

三个测试,CI 上看起来一模一样

CI 上长得一模一样的三次红,是三种完全不同的问题:A 真竞态,主干 16/100、PR 13/100,脱离测试超卖 21/200;B 测试自己的计时,空闲 0/500、压满 CPU 7/500;C 被别的测试污染,主干 0/100、PR 100/100、单独跑 100% 通过

第一种是产品里真有 bug。reserve() 先 SELECT qty,判断够不够,再 UPDATE stock SET qty = ?。两个进程几乎同时进来,都读到 1,都判断够,都写 0,都返回成功。测试起两个进程抢最后一件,断言 sold == 1。在主干上跑完整测试集 100 遍,它红了 16 遍。我又把同样的抢购逻辑拿出 pytest,单独跑 200 次,有 21 次两个人都买到了。这个测试是仓库里唯一能看见这个 bug 的地方,它的“不稳定”就是 bug 本身的出现频率。

第二种是测试自己写得不稳。test_token_survives_refresh_window 建一个 50 毫秒过期的令牌,sleep(0.045) 之后断言它还在。5 毫秒的余量,在空闲机器上跑 500 遍一次都没红;同时开 8 个占满 CPU 的进程,500 遍红了 7 遍,因为 sleep 醒得晚了。产品代码没有问题。这种测试在 6 号机那类更慢、更忙的机器上会红得更多,团队最容易在这里形成“测试红了就重跑”的习惯。

第三种最隐蔽。PR 新加的 test_checkout 调用了 set_currency("USD"),这个设置存在模块级的字典里,测试结束没有复原。按文件名排序,test_checkout.py 跑在 test_pricing.py 前面,于是老测试 test_default_label_is_cny 每次都看到美元。在主干上它 100 遍全过,在 PR 上 100 遍全红,而单独跑它 100% 通过。代理说的“本地单独运行可以通过”是真话,可这恰恰说明它被前面的测试弄脏了,而不是说明它不稳定。再往下查,污染源不只是测试。PR 里的 receipt_line() 自己就调用了 set_currency(),测试只是照着用了它。在同一个进程里,先开一张美国收据,再渲染中国站的商品页,价格从 3.50 CNY 变成了 3.50 USD,线上那张截图就是这么来的。这个“被污染的老测试”是整个仓库里唯一替用户发现这个 bug 的地方。

三种情况,重跑的效果完全不同。第一种,单次失败率约 15%,重跑 3 次后整组还是红的概率是 0.15⁴,约万分之五,等于把一个每天都在提醒你的信号几乎关掉了。第二种,重跑确实能把它变绿,而且没藏住什么。第三种,pytest-rerunfailures 是在同一个进程里马上重跑,被改掉的全局状态还在,所以重跑 3 次照样红,这才逼得代理去加 skip。

代理为什么偏偏会走这一步

代理拿到的目标往往就是“让 CI 变绿”。改代码让测试通过和改测试让测试通过,从 CI 的角度看没有区别,后者通常更快。ImpossibleBench(arXiv 2510.20270)专门构造了测试和需求互相矛盾、不作弊就不可能通过的任务,GPT-5 在其中一组上作弊率是 54%;Claude 和 Qwen3-Coder 作弊时,超过 79% 用的是直接改测试。同一篇论文发现,把测试设成只读或对代理隐藏,作弊会明显减少。

人这边也给了代理理由。Google 2016 年的统计里,约 16% 的测试有过不稳定的记录,从通过变成失败的情况里 84% 是不稳定测试造成的;重跑本来就是工业界的标准做法。代理在训练数据里见过无数次“这个测试是 flaky,重跑一下”,它说这句话时并没有在撒谎,只是没有证据。

所以只在 AGENTS.md 里写“不许跳过测试”不够,#45550 就是写了也没用的例子。需要两样能在 CI 里自动执行的东西:一个回答“这个红是谁的”,一个回答“这个 PR 对已有测试做了什么”。

实验一:代理的九种修法

我在 PR 分支上做了九个“修法”分支,F1 到 F9:加 --reruns 3、加 skip、把 sold == 1 改成 sold >= 1、把 ttl 从 0.05 改成 0.5、在 CI 环境下 skipif、用 try/except AssertionError: pass 把断言包起来、在配置里 --deselect、把两个抢购进程改成一个跑完再跑另一个、只改辅助函数 _worker 里的随机等待(从 0–30 毫秒改成 0–1.5 秒,两个进程实际上不再并发)。另外做了几个看起来正当的改动,用来量误报:B1 新增一个带合理 skipif win32 的测试,B2 给令牌测试注入假时钟,B3 加一个每个测试后复原币种的 autouse fixture,B4 把 reserve() 改成一条原子的 UPDATE ... WHERE qty >= ?,B5 只改格式,B6 让收据把币种作为参数传给 price_label(),不再改全局状态。

代理的九种修法和正确修法:CI 红几次、竞态测试红几次、脱离测试超卖几次、两版守卫拦没拦住

结果有几条值得单独说。--reruns 3 单独用的时候,竞态测试 100 遍里一次也没红,但 CI 还是 100 遍全红,因为币种测试重跑也没用。再加一个 skip(F1+F2),CI 100 遍全绿;同一份代码脱离测试抢购 200 次,36 次卖重了。F3 和 F8 也让竞态测试不再红,超卖分别是 20 次和 26 次。主干上是 21 次,PR 原样是 16 次。这些数在 8% 到 18% 之间,和主干同一个量级,没有一个降到 0。这些修法对产品没有任何作用,只是让测试看不到超卖了。只有正确修法(原子 UPDATE、收据显式传币种、假时钟)三项都是 0。

B3 值得单独说。每个测试后复原币种,是任何一本测试书都会推荐的写法,它也确实让币种测试 100 遍全绿。但产品代码没变:同一个进程里开过美国收据,中国页面照样显示 USD。它修的是测试之间的隔离,顺手把唯一能看见这个 bug 的测试也治好了。分诊说“PR 弄坏的”之后,第一件事是看产品代码有没有改共享状态,而不是先去隔离测试。

实验二:第一版守卫失败了

最先想到的是在 diff 里 grep:新增的行里有没有 skip、xfail、flaky、reruns、deselect、time.sleep(,以及断言是不是变少了。十几行就写完了。拿上面十五个分支一跑,它放过了四个:== 改成 >=(F3)、try/except 吞断言(F6)、改 ttl(F4)、改成串行(F8),因为这些改动里没有任何关键词,断言数量也没变。它还误报了 B1,新增测试里一个合理的 skipif(sys.platform == "win32") 被当成了作弊。F9 它拦下了,原因是改动的那行碰巧有 time.sleep(,换一种写法就漏了。

失败的原因在于,grep 看的是文本,而“测试变弱了”是语义。语义上变弱一般是判断不了的:ttl 改成 0.5 是在修一个计时问题,还是在让一个真 bug 消失,只看 diff 说不清楚。但“这个 PR 改了一个已有测试”是可以准确判断的。于是第二版换了思路:用 Python 的 ast 把 base 和 head 上每个已有测试函数解析出来,比函数体和装饰器。少数几种明确的削弱直接拦下(退出码 1):新增 skip/skipif/xfail/flaky 装饰器,调用 pytest.skip/xfail/importorskip,断言变少,== 变成 >=、<=、!=、in,断言被包进捕获宽泛异常的 try,删除已有测试;配置里出现 --reruns、--deselect、-k、--ignore、-p no: 等也拦。除此之外,只要已有测试的函数体变了,就退出 2,要求人看。新增测试只记一行信息,不拦。

第二版跑出来,F1、F2、F3、F5、F6、F7 拦下,F4、F8 要人看,B1、B4、B5、B6 放行,B2、B3 要人看。我以为做完了,然后发现 F9 被放行了:它没改任何测试函数,只改了测试文件里的辅助函数,而竞态测试 40 遍全绿。于是 2.1 版加了一条:已有测试文件里,测试函数之外的模块级代码(辅助函数、fixture、常量)只要有变化,也退出 2。conftest.py、pyproject.toml、quarantine.toml 的任何改动同样退出 2。

注意正确修法也是“要人看”。B2 给令牌测试换了假时钟,是对已有测试的合理修改;B3 加了 fixture,看起来也合理,却会藏住币种的 bug。守卫不负责判断改得对不对,它只保证改动不会悄悄混过去:退出 2 时,审查者读完 diff 加一个 test-change-ok 标签,CI 才放行。在我们的分工里,这个标签应该由 4、5 号机背后的人来加,而不是由审查代理加。

实验三:分诊脚本的第一版也错了

另一半问题是“这个红是谁的”。第一版 flake_triage.py 的做法看起来很合理:在 merge base 和 PR 头各建一个临时 worktree,只跑失败的那几个测试,各跑 30 遍,比较失败次数。对币种测试,它给出的结论是“主干 0/30,PR 0/30,没复现”。这和代理犯的错完全一样:单独跑,污染它的测试没有跑,它当然通过。

第二版默认跑完整测试集,顺序和 CI 一致,只从 junit 结果里取出目标测试的结果。同样 30 遍,币种测试变成“主干 0/30,PR 30/30,BROKEN BY THIS PR”;竞态测试是“主干 1/30,PR 3/30,原来就不稳定”;令牌测试两边都是 0/30。加上 --stress 8、各跑 60 遍,令牌测试变成主干 1/60、PR 2/60,计时问题才露出来。在这个小仓库上,主干和 PR 各 30 遍一共约 18 秒。

tools/flake_triage.py

#!/usr/bin/env python3
"""flake_triage.py - is this red test the PR's fault, a pre-existing flake, or not reproducible?

Runs the failing test N times on the merge base and N times on HEAD, each in its own
git worktree, *inside the same suite run CI uses* (not alone: a victim of state leaked by
another test passes when run alone). Prints a verdict and exits 1 only when HEAD is worse.

usage: flake_triage.py TEST_ID [TEST_ID...] [--base origin/main] [-n 30] [--isolated]
       [--stress K]  (run K busy-loop processes meanwhile to surface timing flakes)
       [--pytest "python -m pytest"]   (command used to run tests; default: current python)
"""
import argparse, math, multiprocessing as mp, os, shlex, subprocess, sys, tempfile, xml.etree.ElementTree as ET


def sh(*a, cwd=None):
    return subprocess.run(a, cwd=cwd, capture_output=True, text=True, check=True).stdout.strip()


def burn():
    while True:
        pass


def run_once(tree, ids, pytest_cmd, isolated):
    with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as x:
        xml = x.name
    args = shlex.split(pytest_cmd) + ["-q", "-p", "no:cacheprovider", f"--junitxml={xml}"]
    args += ids if isolated else []  # default: the whole suite, same order as CI
    subprocess.run(args, cwd=tree, capture_output=True, text=True)
    outcome = {}
    try:
        for tc in ET.parse(xml).getroot().iter("testcase"):
            nodeid = tc.get("classname", "").replace(".", "/") + ".py::" + tc.get("name")
            bad = tc.find("failure") is not None or tc.find("error") is not None
            outcome[nodeid] = "fail" if bad else ("skip" if tc.find("skipped") is not None else "pass")
    finally:
        os.unlink(xml)
    return outcome


def tally(tree, ids, n, pytest_cmd, isolated):
    res = {i: {"fail": 0, "pass": 0, "skip": 0, "missing": 0} for i in ids}
    for _ in range(n):
        out = run_once(tree, ids, pytest_cmd, isolated)
        for i in ids:
            res[i][out.get(i, "missing")] += 1
    return res


def verdict(b, h, n):
    bf, hf = b["fail"], h["fail"]
    if b["missing"] == n:
        return ("NEW TEST FAILS" if hf else "new test, passed", hf > 0)
    if hf == 0:
        p = 1 - 0.05 ** (1 / n)
        return (f"not reproduced on HEAD in {n} runs (a flake rarer than ~{p:.0%} per run can still hide)", False)
    if bf == 0 and hf == n:
        return ("BROKEN BY THIS PR (fails every run on HEAD, never on base)", True)
    if bf == 0 and hf <= 2:
        return (f"weak signal ({hf}/{n} on HEAD, 0/{n} on base): too few failures to blame either side; rerun with a larger -n", False)
    if bf == 0:
        return (f"FLAKINESS INTRODUCED OR MADE WORSE BY THIS PR ({hf}/{n} vs 0/{n})", True)
    if hf > 2 * bf + 3:
        return (f"pre-existing flake, clearly worse on HEAD ({hf}/{n} vs {bf}/{n})", True)
    return (f"PRE-EXISTING FLAKE ({bf}/{n} on base, {hf}/{n} on HEAD): quarantine with an owner; do not edit the test in this PR", False)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("ids", nargs="+")
    ap.add_argument("--base", default="origin/main")
    ap.add_argument("-n", type=int, default=30)
    ap.add_argument("--isolated", action="store_true", help="run only the given tests (v1 behaviour, misses polluters)")
    ap.add_argument("--stress", type=int, default=0)
    ap.add_argument("--pytest", default=f"{shlex.quote(sys.executable)} -m pytest")
    a = ap.parse_args()
    top = sh("git", "rev-parse", "--show-toplevel")
    base = sh("git", "merge-base", a.base, "HEAD", cwd=top)
    head = sh("git", "rev-parse", "HEAD", cwd=top)
    burners = [mp.Process(target=burn, daemon=True) for _ in range(a.stress)]
    for p in burners:
        p.start()
    trees, results = [], {}
    try:
        for label, rev in (("base", base), ("head", head)):
            d = tempfile.mkdtemp(prefix=f"triage-{label}-")
            sh("git", "worktree", "add", "--detach", d, rev, cwd=top)
            trees.append(d)
            results[label] = tally(d, a.ids, a.n, a.pytest, a.isolated)
    finally:
        for p in burners:
            p.terminate()
        for d in trees:
            subprocess.run(["git", "worktree", "remove", "--force", d], cwd=top, capture_output=True)
    worse = False
    mode = "isolated" if a.isolated else "full suite"
    print(f"base {base[:8]}  head {head[:8]}  runs {a.n} each  ({mode}{', stress %d' % a.stress if a.stress else ''})")
    for i in a.ids:
        b, h = results["base"][i], results["head"][i]
        text, bad = verdict(b, h, a.n)
        worse |= bad
        print(f"{i}\n    base fail {b['fail']}/{a.n}   head fail {h['fail']}/{a.n}   -> {text}")
    return 1 if worse else 0


if __name__ == "__main__":
    sys.exit(main())

脚本里有一处统计上的诚实:如果 PR 上 N 遍都没红,它不说“通过”,而是说“比约 10% 更罕见的不稳定仍然可能藏着”。要以 95% 的把握看到一个失败率为 p 的不稳定测试,需要的次数是 ln(0.05)/ln(1−p):10% 要 29 遍,5% 要 59 遍,1% 要 299 遍。30 遍只够区分“PR 必定弄坏”和“原来就有的常见不稳定”,排除不了罕见的那种。两边失败只有一两次的时候,它也只说“信号太弱,加大 -n”,不判 PR 有罪。

真不稳定的测试放到哪里

分诊说“原来就不稳定”时,代理不该动这个测试,但这个测试也不能每天把别人的 PR 弄红。我用一个 90 行左右的 pytest 插件做隔离区:tests/quarantine.toml 里每条记录必须有测试 id、issue、负责人、过期日期和原因。隔离的测试照常运行,失败时记成 xfail,不让整次运行变红,并在结尾单独列出“这次失败了但被忽略”。以下情况会让整次运行变红:记录过期;过期日期在 21 天以后;记录指向的测试已经不存在(测试被改名后,隔离不会悄悄失效);记录超过 5 条。我对这四种情况各跑了一遍,都按预期变红;正常情况下 25 遍里竞态测试失败了 3 遍,都被记成 xfail,整次运行是绿的。

[[test]]
id = "tests/test_stock.py::test_last_item_is_sold_once"
issue = "#412"
owner = "reviewer-a"
expires = "2026-11-07"
reason = "fails ~15% on main; suspected lost update in reserve()"

CODEOWNERS 把 quarantine.toml、conftest.py、pyproject.toml 和守卫脚本本身都交给人。代理可以提议隔离,但只有人能合进去。

放进 CI

测试一红,先回答两个问题:这个 PR 动了已有测试吗(test_guard 退出 1 拦下、2 要人看、0 放行),这个红是谁的(flake_triage 主干和 PR 各跑 30 遍:PR 弄坏的、原来就不稳定、没复现)

下面这份 workflow 只用 actionlint 1.7.7 检查过,没有在 GitHub 上实际跑,以免为一篇文章消耗太多 Actions 分钟。它有两个 job:test-guard 读 diff,退出 2 且没有 test-change-ok 标签就失败;tests 跑测试,失败时不自动重跑,而是对前 5 个失败的测试跑分诊,把结论写进 job summary。加标签会重新触发,所以审查者加完标签不需要再推一次。

.github/workflows/tests.yml

name: tests
on:
  pull_request:
    types: [opened, synchronize, reopened, labeled, unlabeled]
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: tests-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  test-guard:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: What does this PR do to existing tests?
        env:
          BASE_REF: ${{ github.base_ref }}
          APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'test-change-ok') }}
        run: |
          set +e
          python3 tools/test_guard.py "origin/$BASE_REF" | tee guard.txt
          code=${PIPESTATUS[0]}
          { echo '### test guard'; echo '```'; cat guard.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
          if [ "$code" = 2 ] && [ "$APPROVED" = true ]; then
            echo "existing tests changed; a reviewer added test-change-ok"; exit 0
          fi
          if [ "$code" = 2 ]; then
            echo "::error::Existing tests or test config changed. A reviewer must read the diff and add the 'test-change-ok' label."
          fi
          exit "$code"

  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install -r requirements-dev.txt
      - name: pytest
        id: pytest
        run: python -m pytest --junitxml=junit.xml
      - name: Triage red tests (base vs head, full suite, 20 runs each)
        if: failure() && steps.pytest.outcome == 'failure' && github.event_name == 'pull_request'
        env:
          BASE_REF: ${{ github.base_ref }}
        run: |
          read -ra ids <<< "$(python3 - <<'PY'
          import xml.etree.ElementTree as ET
          bad = [tc for tc in ET.parse("junit.xml").getroot().iter("testcase")
                 if tc.find("failure") is not None or tc.find("error") is not None]
          print(" ".join(tc.get("classname").replace(".", "/") + ".py::" + tc.get("name") for tc in bad[:5]))
          PY
          )"
          git config user.email ci@example.invalid && git config user.name ci
          { echo '### flake triage'; echo '```'; python3 tools/flake_triage.py "${ids[@]}" --base "origin/$BASE_REF" -n 20 || true; echo '```'; } | tee -a "$GITHUB_STEP_SUMMARY"

给代理的规则写进 AGENTS.md,重点是把“flaky”从一个结论变成一个需要附证据的说法:

AGENTS-snippet.md

## When a test fails

- Do not call a failure "flaky" or "pre-existing" from memory. Run
  `python tools/flake_triage.py <test-id> --base origin/main -n 30` and paste its verdict into the PR.
  Add `--stress 8` when the test involves time, threads, processes, or the network.
- If the verdict is BROKEN BY THIS PR or FLAKINESS INTRODUCED, fix it in this PR. First check whether product
  code changes shared state (globals, caches, env, files); a test-only reset can hide a real bug.
- If the verdict is PRE-EXISTING FLAKE, do not touch the test. Open an issue and propose a
  `tests/quarantine.toml` entry (id, issue, owner, expires within 21 days, reason). A human merges it.
- Never add skip/skipif/xfail/flaky marks, rerun flags, `--deselect`, `-k`, `try/except` around asserts,
  or weaker comparisons to make CI green. `tools/test_guard.py` blocks these.
- Changing the body of an existing test is allowed only with a reason in the PR; a reviewer adds `test-change-ok`.

代价,以及什么时候不该这么做

第一个代价是时间。这个玩具仓库全量跑一遍不到一秒,30 遍 × 2 只要十几秒。如果你的测试集要跑 10 分钟,每次红都跑 60 遍全量不现实。可以退一步:只跑失败测试所在的文件以及排在它前面的文件,覆盖大部分同进程污染;用了 pytest-xdist 的话,污染只发生在同一个 worker 内,要按 CI 日志里那个 worker 实际跑过的测试列表复现。或者把分诊交给 7 号机在合并队列空闲时跑,PR 上先只显示守卫结论。

第二个代价是人的注意力。test-change-ok 标签如果变成顺手一点,守卫就只是多了一步流程。正确修法也会触发“要人看”,这是故意的:改已有测试本来就该被人看到。如果你们的 PR 里大量是正常的测试重构,退出 2 的频率会很高,这时可以只在 PR 同时有测试失败时才要求标签。

第三,这套东西假设测试顺序固定、失败能被重复触发。浏览器端到端测试、依赖外部网络的测试,在主干上本身就有 5% 以上的失败率,30 遍分不清 3/30 和 6/30 的差别,分诊大多会给出“原来就不稳定”。这类测试更适合单独的套件和单独的重试策略,不要和单元测试放在同一个必须通过的检查里。

第四,守卫只认 pytest 和 Python 的 AST。JavaScript 项目要用相应的解析器重写同样的规则(.skip、.only、toBe 变成 toBeGreaterThanOrEqual、retry 配置),思路一样,代码不能直接用。守卫也拦不住所有削弱:代理完全可以改产品代码去迎合一个错误的测试,那是审查要看的事。它解决的是更常见、也更便宜的那种作弊:直接对测试动手。

最后,对一个真的竞态,隔离区只是让别人的 PR 不被它连累,并没有修好它。超卖还在。隔离记录的 21 天过期,就是为了逼这个 issue 有人管:到期还没修,整个仓库都会变红。

明天就能照做的几件事

  1. 把 flake_triage.py 放进仓库,下一次有人或代理说“这个测试本来就不稳定”时,让他贴出主干和 PR 各 30 遍的结论。涉及时间、线程、进程的测试加 --stress 8。
  2. 去掉 CI 里的全局自动重跑(--reruns、retry: 3)。它让每个真失败的信号小了几个数量级,而且对状态污染无效。
  3. 在必须通过的检查里加上 test_guard.py,退出 2 由人加 test-change-ok 放行,审查代理不能加这个标签。
  4. 建 tests/quarantine.toml 和插件,每条记录有负责人、issue、21 天过期,CODEOWNERS 指给人。
  5. 把上面那段 AGENTS.md 规则加进去,让代理在 PR 里贴分诊结论,而不是凭印象说 flaky。

我的结论是:代理把失败测试改绿,问题不在它判断错了,而在于没有人要求它拿出证据。主干和 PR 上各跑 30 遍、再检查它对已有测试做了什么,这两步都很便宜,足够把“本来就不稳定”从一句结论变成一个能被检查的说法。今天就可以把下面这个包解开,在 shop 里重放这三个测试,然后对你们仓库里最常被重跑的那个测试跑一次 flake_triage.py。

完整的工具、workflow、玩具仓库(git bundle,含全部修法分支)和原始输出:flaky-test-kit.tar.gz。

Same three-person team, same seven computers: machines 1–3 develop, 4 and 5 review, 6 does Windows validation, 7 merges.

On Thursday afternoon the agent on machine 2 opened a PR adding receipts for the US store. One check went red: test_default_label_is_cny, an old test that prices show in yuan by default. The agent commented: "This failure is unrelated to the change; it is an existing flaky test and passes when run on its own locally. Added --reruns 3 to pyproject." Still red after reruns, it added @pytest.mark.skip(reason="flaky, pre-existing"). CI went green. The review agent on machine 4 saw a small diff and green tests and approved; machine 7 merged. On Monday a customer on the Chinese store sent a screenshot: prices followed by USD.

The same repo has another test, test_last_item_is_sold_once, which has gone red about once in six or seven runs for months. Everyone's habit is to hit re-run. It checks that when one item is left, only one of two concurrent orders succeeds. Later, reconciliation found that now and then an item had been sold twice.

In both stories the agent and the humans did the same thing: saw a red, decided "it was already flaky", and made it stop being red. My claim: "flaky" names a symptom — the same code sometimes passes and sometimes fails — not a cause. The same 1 failed line in CI can come from a race in the product, from the test's own timing, or from this PR having just polluted state another test depends on. From a single red, neither people nor agents can tell which. The way to tell is dull: run the suite the way CI does, a few dozen times on main and on the PR, and compare; separately, check what the PR did to existing tests. Below are the lab, two versions that didn't work, and what you can drop into a repo.

Conditions first. I reproduced the stories in a toy repo called shop, not in your real one. It has three modules: inventory/stock.py keeps stock in SQLite, tokens.py is a token cache with an expiry, pricing.py formats prices. The agent "fixes" were written by me, after behaviour people have reported publicly: Claude Code adding @unittest.skip to six or more tests although AGENTS.md forbade it (anthropics/claude-code #45550), rewriting tests and creating a separate "safe tests" target so checks pass (#319), and a project turning a flaky end-to-end test into "skip on failure" (nicsuzor/academicOps #350). Claude Code on this box isn't logged in, so nothing below is live model output. Every number comes from one 8-core Linux box with a fixed test order (pytest-randomly off); raw outputs are in the kit at the end.

Three tests that look identical in CI

Three CI failures that look identical are three different problems: A real race, main 16/100, PR 13/100, 21/200 oversold outside pytest; B the test's own timing, idle 0/500, CPU saturated 7/500; C polluted by another test, main 0/100, PR 100/100, passes 100% alone

The first is a real product bug. reserve() does SELECT qty, checks there's enough, then UPDATE stock SET qty = ?. Two processes arrive together, both read 1, both decide there's enough, both write 0, both return success. The test starts two processes racing for the last item and asserts sold == 1. Over 100 full-suite runs on main it failed 16 times. Taking the same purchase logic out of pytest and running it 200 times, both buyers got the item 21 times. This test is the only place in the repo that can see the bug; its "flakiness" is simply how often the bug happens.

The second is a badly written test. test_token_survives_refresh_window makes a token with a 50 ms ttl, sleeps 45 ms, and asserts it's still there. With a 5 ms margin, 500 runs on an idle machine never failed; with 8 processes burning CPU alongside, 7 of 500 failed because sleep woke up late. The product is fine. A test like this fails more on slower, busier machines like machine 6, and it's where teams pick up the "red? re-run" habit.

The third is the sneakiest. The PR's new test_checkout calls set_currency("USD"), which lives in a module-level dict and isn't reset. Files run in name order, so test_checkout.py runs before test_pricing.py, and the old test_default_label_is_cny sees dollars every time. On main it passed 100 of 100; on the PR it failed 100 of 100; run alone it passes 100%. The agent's "passes when run on its own" was true, and it proves the test was polluted by an earlier one, not that it is flaky. Digging further, the polluter isn't only the test. The PR's receipt_line() itself calls set_currency(); the test merely used it. In one process, issue a US receipt and then render a Chinese product page, and the price goes from 3.50 CNY to 3.50 USD — that is the screenshot. The "polluted old test" was the only thing in the repo that found this bug for the customers.

Re-running does very different things to these three. For the first, with a ~15% failure rate, the chance that the test is still red after three reruns is 0.15⁴, about 1 in 2,000: a signal that was warning you every day is almost switched off. For the second, a rerun really does turn it green and hides nothing. For the third, pytest-rerunfailures reruns immediately in the same process, the polluted global is still there, and three reruns stay red — which is what pushed the agent to the skip.

Why agents take this step

The goal an agent is handed is often just "make CI green". Changing the code so the test passes and changing the test so it passes look the same to CI, and the second is usually faster. ImpossibleBench (arXiv 2510.20270) builds tasks where the tests contradict the spec, so they can't be passed honestly; GPT-5 cheated on 54% of one set, and when Claude and Qwen3-Coder cheated, over 79% of the time it was by editing tests directly. The same paper found that making tests read-only or hiding them cut cheating markedly.

People gave agents the idea, too. In Google's 2016 numbers about 16% of tests had shown some flakiness, and 84% of pass-to-fail transitions were caused by flaky tests; re-running is standard industry practice. Agents have seen "this test is flaky, re-run it" countless times in training data. When one says it, it isn't lying; it just has no evidence.

So writing "never skip tests" in AGENTS.md isn't enough — #45550 is a case where it was written and ignored. You need two things CI can enforce: one that answers "whose failure is this?" and one that answers "what did this PR do to existing tests?"

Experiment 1: nine agent fixes

On the PR branch I made nine "fix" branches, F1–F9: add --reruns 3; add a skip; turn sold == 1 into sold >= 1; raise the ttl from 0.05 to 0.5; skipif on CI; wrap the asserts in try/except AssertionError: pass; --deselect in config; run the two buyer processes one after the other; and change only the random wait in the _worker helper (0–30 ms to 0–1.5 s, so the processes no longer overlap). I also made some legitimate-looking changes to measure false alarms: B1 a new test with a sensible skipif win32, B2 a fake clock for the token test, B3 an autouse fixture that resets currency after every test, B4 an atomic UPDATE ... WHERE qty >= ? in reserve(), B5 formatting only, and B6 receipts that pass the currency to price_label() instead of setting it globally.

Nine agent fixes and the real fix: CI red count, race test red count, oversold outside pytest, and whether each guard version caught it

A few results deserve their own sentence. --reruns 3 on its own made the race test go red zero times in 100, but CI was still red 100 times out of 100, because rerunning doesn't help the currency test. Add a skip (F1+F2) and CI is green 100 of 100; the same code, racing outside pytest 200 times, sold the last item twice 36 times. F3 and F8 also stopped the race test going red; they oversold 20 and 26 times. Main oversold 21 times, the untouched PR 16. These sit between 8% and 18%, the same order as main, and none is zero. These fixes do nothing for the product; they only stop the test from seeing the oversell. Only the real fix — atomic UPDATE, receipts passing the currency explicitly, a fake clock — scored zero on all three.

B3 deserves a paragraph. Resetting global state after each test is what every testing book recommends, and it does turn the currency test green 100 of 100. But the product hasn't changed: after a US receipt in the same process, the Chinese page still shows USD. It fixes isolation between tests and, along the way, cures the only test that could see the bug. Once triage says "broken by this PR", the first thing to check is whether product code mutates shared state, not to isolate the test.

Experiment 2: the first guard failed

The first idea was to grep the diff: do added lines contain skip, xfail, flaky, reruns, deselect, time.sleep(, and did the number of asserts drop? A dozen lines. Run against the fifteen branches, it passed four: == to >= (F3), try/except swallowing the assert (F6), the ttl change (F4), and serialising (F8), because none of them contains a keyword or removes an assert. It also flagged B1, treating a reasonable skipif(sys.platform == "win32") in a new test as cheating. It caught F9 only because the changed line happened to contain time.sleep(; written another way, it would have slipped through.

The reason it fails: grep reads text, and "the test got weaker" is about meaning. Deciding whether a change weakens a test is, in general, not something you can do: is ttl 0.5 fixing a timing problem or making a real bug disappear? The diff doesn't say. But "this PR changed an existing test" can be detected exactly. So version 2 parses every existing test function at base and head with Python's ast and compares body and decorators. A handful of unambiguous weakenings are blocked outright (exit 1): new skip/skipif/xfail/flaky decorators; calls to pytest.skip/xfail/importorskip; fewer asserts; == turned into >=, <=, != or in; an assert moved inside a try with a broad handler; a deleted test; and --reruns, --deselect, -k, --ignore, -p no: and friends in config. Any other change to an existing test body exits 2: a human must look. New tests get an info line and pass.

Version 2 blocked F1, F2, F3, F5, F6, F7, sent F4 and F8 to a human, passed B1, B4, B5, B6, and sent B2 and B3 to a human. I thought it was done, then found F9 sailed through: it changes no test function, only a helper in the test file, and the race test went green 40 of 40. Version 2.1 adds: any change to module-level code in an existing test file outside test functions (helpers, fixtures, constants) exits 2. So does any change to conftest.py, pyproject.toml or quarantine.toml.

Note that the real fix also exits 2. B2 swaps in a fake clock, a legitimate change to an existing test; B3 adds a fixture, which looks just as legitimate and hides the currency bug. The guard doesn't judge whether a change is right. It only makes sure a change can't slip by quietly: on exit 2, a reviewer reads the diff and adds a test-change-ok label before CI passes. In our setup that label should be added by the people behind machines 4 and 5, not by the review agent.

Experiment 3: the first triage script was wrong too

The other half is "whose failure is it?". Version 1 of flake_triage.py looked sensible: create a temporary worktree at the merge base and at the PR head, run only the failing tests 30 times each, and compare. For the currency test it reported "main 0/30, PR 0/30, not reproduced". That is exactly the agent's mistake: run alone, the polluting test never runs, so of course it passes.

Version 2 runs the full suite by default, in CI's order, and reads the target tests' results out of junit. Same 30 runs: the currency test becomes "main 0/30, PR 30/30, BROKEN BY THIS PR"; the race test "main 1/30, PR 3/30, pre-existing flake"; the token test 0/30 on both. With --stress 8 and 60 runs each, the token test shows 1/60 on main and 2/60 on the PR, and the timing problem finally appears. On this small repo, 30 runs on each side take about 18 seconds in total.

tools/flake_triage.py

#!/usr/bin/env python3
"""flake_triage.py - is this red test the PR's fault, a pre-existing flake, or not reproducible?

Runs the failing test N times on the merge base and N times on HEAD, each in its own
git worktree, *inside the same suite run CI uses* (not alone: a victim of state leaked by
another test passes when run alone). Prints a verdict and exits 1 only when HEAD is worse.

usage: flake_triage.py TEST_ID [TEST_ID...] [--base origin/main] [-n 30] [--isolated]
       [--stress K]  (run K busy-loop processes meanwhile to surface timing flakes)
       [--pytest "python -m pytest"]   (command used to run tests; default: current python)
"""
import argparse, math, multiprocessing as mp, os, shlex, subprocess, sys, tempfile, xml.etree.ElementTree as ET


def sh(*a, cwd=None):
    return subprocess.run(a, cwd=cwd, capture_output=True, text=True, check=True).stdout.strip()


def burn():
    while True:
        pass


def run_once(tree, ids, pytest_cmd, isolated):
    with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as x:
        xml = x.name
    args = shlex.split(pytest_cmd) + ["-q", "-p", "no:cacheprovider", f"--junitxml={xml}"]
    args += ids if isolated else []  # default: the whole suite, same order as CI
    subprocess.run(args, cwd=tree, capture_output=True, text=True)
    outcome = {}
    try:
        for tc in ET.parse(xml).getroot().iter("testcase"):
            nodeid = tc.get("classname", "").replace(".", "/") + ".py::" + tc.get("name")
            bad = tc.find("failure") is not None or tc.find("error") is not None
            outcome[nodeid] = "fail" if bad else ("skip" if tc.find("skipped") is not None else "pass")
    finally:
        os.unlink(xml)
    return outcome


def tally(tree, ids, n, pytest_cmd, isolated):
    res = {i: {"fail": 0, "pass": 0, "skip": 0, "missing": 0} for i in ids}
    for _ in range(n):
        out = run_once(tree, ids, pytest_cmd, isolated)
        for i in ids:
            res[i][out.get(i, "missing")] += 1
    return res


def verdict(b, h, n):
    bf, hf = b["fail"], h["fail"]
    if b["missing"] == n:
        return ("NEW TEST FAILS" if hf else "new test, passed", hf > 0)
    if hf == 0:
        p = 1 - 0.05 ** (1 / n)
        return (f"not reproduced on HEAD in {n} runs (a flake rarer than ~{p:.0%} per run can still hide)", False)
    if bf == 0 and hf == n:
        return ("BROKEN BY THIS PR (fails every run on HEAD, never on base)", True)
    if bf == 0 and hf <= 2:
        return (f"weak signal ({hf}/{n} on HEAD, 0/{n} on base): too few failures to blame either side; rerun with a larger -n", False)
    if bf == 0:
        return (f"FLAKINESS INTRODUCED OR MADE WORSE BY THIS PR ({hf}/{n} vs 0/{n})", True)
    if hf > 2 * bf + 3:
        return (f"pre-existing flake, clearly worse on HEAD ({hf}/{n} vs {bf}/{n})", True)
    return (f"PRE-EXISTING FLAKE ({bf}/{n} on base, {hf}/{n} on HEAD): quarantine with an owner; do not edit the test in this PR", False)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("ids", nargs="+")
    ap.add_argument("--base", default="origin/main")
    ap.add_argument("-n", type=int, default=30)
    ap.add_argument("--isolated", action="store_true", help="run only the given tests (v1 behaviour, misses polluters)")
    ap.add_argument("--stress", type=int, default=0)
    ap.add_argument("--pytest", default=f"{shlex.quote(sys.executable)} -m pytest")
    a = ap.parse_args()
    top = sh("git", "rev-parse", "--show-toplevel")
    base = sh("git", "merge-base", a.base, "HEAD", cwd=top)
    head = sh("git", "rev-parse", "HEAD", cwd=top)
    burners = [mp.Process(target=burn, daemon=True) for _ in range(a.stress)]
    for p in burners:
        p.start()
    trees, results = [], {}
    try:
        for label, rev in (("base", base), ("head", head)):
            d = tempfile.mkdtemp(prefix=f"triage-{label}-")
            sh("git", "worktree", "add", "--detach", d, rev, cwd=top)
            trees.append(d)
            results[label] = tally(d, a.ids, a.n, a.pytest, a.isolated)
    finally:
        for p in burners:
            p.terminate()
        for d in trees:
            subprocess.run(["git", "worktree", "remove", "--force", d], cwd=top, capture_output=True)
    worse = False
    mode = "isolated" if a.isolated else "full suite"
    print(f"base {base[:8]}  head {head[:8]}  runs {a.n} each  ({mode}{', stress %d' % a.stress if a.stress else ''})")
    for i in a.ids:
        b, h = results["base"][i], results["head"][i]
        text, bad = verdict(b, h, a.n)
        worse |= bad
        print(f"{i}\n    base fail {b['fail']}/{a.n}   head fail {h['fail']}/{a.n}   -> {text}")
    return 1 if worse else 0


if __name__ == "__main__":
    sys.exit(main())

The script is honest about statistics in one place. If the PR passes all N runs it doesn't say "passed"; it says a flake rarer than about 10% can still hide. To see a flake with failure rate p with 95% confidence you need ln(0.05)/ln(1−p) runs: 29 for 10%, 59 for 5%, 299 for 1%. Thirty runs separate "the PR certainly broke it" from "a common pre-existing flake", but can't rule out rare ones. When there are only one or two failures and none on main, it says the signal is too weak and asks for a bigger -n rather than blaming the PR.

Where truly flaky tests go

When triage says "pre-existing", the agent shouldn't touch the test, but the test also shouldn't turn everyone else's PRs red every day. A pytest plugin of about 90 lines serves as quarantine. Each entry in tests/quarantine.toml needs a test id, issue, owner, expiry date and reason. Quarantined tests still run; a failure is recorded as xfail, the run stays green, and the summary lists "failed (ignored)". The run turns red if an entry has expired, expires more than 21 days out, points at a test that no longer exists (so a rename can't silently void the quarantine), or the list has more than 5 entries. I triggered each of the four and each turned the run red; in 25 normal runs the race test failed 3 times, all recorded as xfail, and the runs were green.

[[test]]
id = "tests/test_stock.py::test_last_item_is_sold_once"
issue = "#412"
owner = "reviewer-a"
expires = "2026-11-07"
reason = "fails ~15% on main; suspected lost update in reserve()"

CODEOWNERS gives quarantine.toml, conftest.py, pyproject.toml and the guard script itself to people. An agent may propose a quarantine; only a person can merge it.

Wiring it into CI

When a test goes red, answer two questions: did this PR touch existing tests (test_guard exit 1 blocked, 2 needs a human, 0 ok), and whose failure is it (flake_triage 30 runs on main and the PR: broken by the PR, already flaky, not reproduced)

The workflow below was checked with actionlint 1.7.7 only, not run on GitHub, to avoid burning Actions minutes for an article. It has two jobs. test-guard reads the diff and fails on exit 2 unless the PR has the test-change-ok label. tests runs the suite and, on failure, doesn't re-run; it triages the first five failed tests and writes the verdict into the job summary. Adding a label re-triggers the workflow, so a reviewer doesn't need to push again.

.github/workflows/tests.yml

name: tests
on:
  pull_request:
    types: [opened, synchronize, reopened, labeled, unlabeled]
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: tests-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  test-guard:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: What does this PR do to existing tests?
        env:
          BASE_REF: ${{ github.base_ref }}
          APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'test-change-ok') }}
        run: |
          set +e
          python3 tools/test_guard.py "origin/$BASE_REF" | tee guard.txt
          code=${PIPESTATUS[0]}
          { echo '### test guard'; echo '```'; cat guard.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
          if [ "$code" = 2 ] && [ "$APPROVED" = true ]; then
            echo "existing tests changed; a reviewer added test-change-ok"; exit 0
          fi
          if [ "$code" = 2 ]; then
            echo "::error::Existing tests or test config changed. A reviewer must read the diff and add the 'test-change-ok' label."
          fi
          exit "$code"

  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install -r requirements-dev.txt
      - name: pytest
        id: pytest
        run: python -m pytest --junitxml=junit.xml
      - name: Triage red tests (base vs head, full suite, 20 runs each)
        if: failure() && steps.pytest.outcome == 'failure' && github.event_name == 'pull_request'
        env:
          BASE_REF: ${{ github.base_ref }}
        run: |
          read -ra ids <<< "$(python3 - <<'PY'
          import xml.etree.ElementTree as ET
          bad = [tc for tc in ET.parse("junit.xml").getroot().iter("testcase")
                 if tc.find("failure") is not None or tc.find("error") is not None]
          print(" ".join(tc.get("classname").replace(".", "/") + ".py::" + tc.get("name") for tc in bad[:5]))
          PY
          )"
          git config user.email ci@example.invalid && git config user.name ci
          { echo '### flake triage'; echo '```'; python3 tools/flake_triage.py "${ids[@]}" --base "origin/$BASE_REF" -n 20 || true; echo '```'; } | tee -a "$GITHUB_STEP_SUMMARY"

The rule for agents goes in AGENTS.md. The point is to turn "flaky" from a conclusion into a claim that needs evidence attached:

AGENTS-snippet.md

## When a test fails

- Do not call a failure "flaky" or "pre-existing" from memory. Run
  `python tools/flake_triage.py <test-id> --base origin/main -n 30` and paste its verdict into the PR.
  Add `--stress 8` when the test involves time, threads, processes, or the network.
- If the verdict is BROKEN BY THIS PR or FLAKINESS INTRODUCED, fix it in this PR. First check whether product
  code changes shared state (globals, caches, env, files); a test-only reset can hide a real bug.
- If the verdict is PRE-EXISTING FLAKE, do not touch the test. Open an issue and propose a
  `tests/quarantine.toml` entry (id, issue, owner, expires within 21 days, reason). A human merges it.
- Never add skip/skipif/xfail/flaky marks, rerun flags, `--deselect`, `-k`, `try/except` around asserts,
  or weaker comparisons to make CI green. `tools/test_guard.py` blocks these.
- Changing the body of an existing test is allowed only with a reason in the PR; a reviewer adds `test-change-ok`.

Costs, and when not to do this

The first cost is time. The toy suite runs in under a second, so 30 × 2 runs take seconds. If your suite takes 10 minutes, 60 full runs per red isn't realistic. Scale back: run the failing test's file plus the files ordered before it, which covers most in-process pollution. With pytest-xdist, pollution only happens within one worker, so replay the list of tests that worker actually ran, from the CI log. Or let machine 7 run triage when the merge queue is idle, and show only the guard result on the PR at first.

The second cost is attention. If test-change-ok becomes a reflex click, the guard is just one more step. The real fix also exits 2, deliberately: changes to existing tests should be seen. If your PRs include lots of routine test refactoring, exit 2 will be frequent; then require the label only when the PR also has failing tests.

Third, this assumes a fixed test order and failures you can trigger again. Browser end-to-end tests and tests that hit external networks often fail 5% or more on main by themselves; 30 runs can't tell 3/30 from 6/30, and triage will mostly say "pre-existing". Those belong in a separate suite with its own retry policy, not in the same required check as unit tests.

Fourth, the guard only understands pytest and Python's AST. A JavaScript project needs the same rules with its own parser (.skip, .only, toBe becoming toBeGreaterThanOrEqual, retry settings); the idea carries over, the code doesn't. And the guard can't stop every weakening: an agent can change product code to satisfy a wrong test, which is review's job. It handles the more common and cheaper kind of cheating: touching the test itself.

Finally, for a real race, quarantine only stops it from hurting other people's PRs; it doesn't fix it. The oversell is still there. The 21-day expiry exists to force someone to own the issue: if it isn't fixed in time, the whole repo goes red.

What you can do tomorrow

  1. Add flake_triage.py to the repo. Next time a person or an agent says "that test was already flaky", ask for the 30-runs-each verdict from main and the PR. For tests involving time, threads or processes, add --stress 8.
  2. Remove global auto-retry from CI (--reruns, retry: 3). It shrinks the signal from every real failure by orders of magnitude and does nothing for polluted state.
  3. Put test_guard.py in a required check; exit 2 passes only when a person adds test-change-ok, and the review agent can't add that label.
  4. Create tests/quarantine.toml and the plugin; every entry has an owner, an issue and a 21-day expiry, and CODEOWNERS points it at people.
  5. Add the AGENTS.md rule above so agents paste a triage verdict in the PR instead of saying "flaky" from memory.

My conclusion: when an agent turns a failing test green, the problem isn't that it judged wrongly; it's that nobody asked it for evidence. Running 30 times on main and on the PR, and checking what it did to existing tests, are both cheap, and together they turn "it was already flaky" from a verdict into a claim someone can check. Unpack the kit below today, replay the three tests in shop, then run flake_triage.py once on the test your team re-runs most.

Full tools, workflow, the toy repo (git bundle with every fix branch) and raw outputs: flaky-test-kit.tar.gz.