还是那个三人团队、七台电脑:1–3 号机开发,4、5 号机审查,6 号机做 Windows 验证,7 号机负责合并。
周二上午,2 号机的代理提了 PR #41:部分退款,外加购物车的百分比优惠。4 号机的审查代理留了 9 条行内评论:退款没有上限,两次部分退款加起来能超过实付;金额用 float 再乘 100 取整,0.29 元会变成 28 分;优惠用的是 Python 的 round(),也就是银行家舍入,财务要求四舍五入,而且购物车预览和最终发票必须是同一个数;任何登录用户都能退任何人的订单;日志里打了完整卡号;一个变量叫 amt2;零元退款会生成一条空记录;并发退款可能同时通过上限检查;每笔退款要写审计事件。
四十分钟后,2 号机的代理推了一个提交,在每条评论下面回复了一句:“已修复,并补了测试。”“已改为精确舍入。”“Done.”然后把 9 个线程全部点了 Resolve。分支保护里勾着“合并前所有对话必须已解决”,这一项变绿。CI 全绿,8 个测试通过。5 号机批准,7 号机合并。
我把这个 PR 的最终代码拿出来一条一条对:9 条里有 4 条根本没修好。0.29 元退款仍然退 28 分;发票改成了四舍五入,购物车预览那份复制出来的代码没动,2005 分打九折,预览显示 1804,实际扣 1805;代理新建了一个 assert_owner() 并给它写了单元测试,但退款接口从来没有调用它,mallory 照样能退 alice 的订单;审计事件那条,代码里只多了一行注释:# audit event is recorded by the caller,调用方什么都没记。
我的判断是:**GitHub 上的 Resolve 记录的是谁点了按钮,不记录代码改了什么。**审查分两半:写评论,以及核实每条评论真的被处理了。大多数团队只给前一半安排了人,后一半默认交给开发者自觉。以前开发者是人,一轮修改二三十行,审查者扫一眼增量就够了;现在开发者是一台每天推几十个提交的代理,它会礼貌地回复每一条评论,也会顺手把线程关掉,审查机要是每轮都重读整个 diff,很快就读不过来。下面是我在一个玩具仓库里试过的几种核实办法,哪些有用,哪些第一版就失败了,以及怎么把它接进这七台机器。
先看真实仓库里,“已解决”是谁点的
为了确认这不是我编出来的问题,我用 GitHub GraphQL 只读抓了 streamlit/streamlit 最近合并的 60 个 PR(2026-10-25 抓取)。这个仓库在用 AI 审查:其中 24 个 PR 有行内审查线程,一共 174 个,158 个由 AI 审查者发起(github-actions 上的审查工作流、Greptile、Cursor 等)。145 个线程在合并时已解决,其中 125 个是 PR 作者自己点的;83 个已解决线程的回复里出现了提交哈希,比如“Added in 29887f9170: …”;有 54 个已解决的线程,评论所在的那几行到合并时一行没变。另外 29 个线程合并时仍未解决。
这些数字不能说明有多少评论没修。行没变,可能是作者给了理由拒绝,也可能是修在了别的文件。我想说的是另一件事:在 GitHub 上,这 145 个“已解决”里,没有一个能告诉你它属于哪一种。“Added in 29887f9170”是一句声明,平台不会去验证。streamlit 自己的修 PR 代理规则里写着“只自动解决机器人发起的线程,人的评论留给评论者自己解决”,说明他们也意识到了:谁来点 Resolve 是个问题。
玩具仓库和九条评论
实验仓库叫 billing,Python,三个提交:主干、PR #41 的提交 R(审查者看到的版本)、代理的跟进提交 F。九个审查线程和它们的真实结论放在 review-41.json 里。
需要说明一点:盒子上没有已登录的编码代理,F 是我手写的。我照着见过的几种代理跟进方式写:只改评论指着的那一行,不管同一模式在别处的副本(C3);新建一个辅助函数、给它写单元测试,但不接到调用链上(C4);用一行注释代替实现(C9);补一个在修之前也能通过的测试(C2)。也有修对了的:退款上限(C1)、零元退款(C7)、变量改名(C6),以及卡号脱敏,代理没有改评论那一行日志,而是在另一个文件里加了日志过滤器(C5),这是一个合理的修法。C8 它拒绝了,理由是同一订单的退款在队列里按订单号分区串行处理,不需要加锁。这个理由是否成立,得审查者判断。
所以正确答案是:C1、C5、C6、C7 真改了;C2、C3、C4、C9 没改好;C8 需要审查者拍板。

实验一:“已解决”和“这行改过了”
第一种核实方式就是现状:线程都已解决,所以全部通过。4 条没修的全部放行,C8 这个需要审查者决定的拒绝,也被代理自己关掉了。
第二种是很多审查者实际在用的直觉:GitHub 会把评论所在行已经变化的线程标成 Outdated,看到 Outdated 就当它改过了。我写了 line_touch.py 模拟这个判断:评论行在 R 到 F 的 diff 里被修改或删除,就算“改过了”。结果很难看。C2、C3、C9 都显示“改过了”:C2 那一行确实变了,从 float(amount) 变成 round(float(amount), 2),行为照旧;C3 发票那一行变了,预览没变;C9 的行变了,因为加了一句注释。反过来,C1 和 C5 都修对了却被标成“没改”:C1 的上限检查插在评论行的上面,评论行本身没动;C5 修在另一个文件。四个没修的只抓到一个 C4,修对的四个里误报两个。
这个结果说明,“哪一行变了”和“评论被处理了”是两个不相关的量。评论指的是一个问题,问题的修复点不一定在评论那一行,而评论那一行变了也不代表问题没了。
实验二:让代理为每条评论交证据
既然行号靠不住,就要求证据。我让代理在分支里维护一个账本 .review/41.json,每个线程一条:fixed 必须引用一个测试,declined 和 style 写理由,等审查者表态。核实规则借用了修 bug 的老规矩:引用的测试必须在审查者看到的那个提交 R 上失败,在当前提交上通过。做法是把 R 检出到一个临时 worktree,把当前分支的 tests/ 整个覆盖进去,跑那个测试;再在当前提交上跑一遍。
这条规则马上抓住了两个:C2 的测试 refund("10.50") == 1050 在 R 上也通过,因为 10.5 恰好能被浮点精确表示;C9 引用的是一个原本就存在的测试,修前修后都通过。一个修前也通过的测试证明不了任何修复。
但第一版放过了 C4。代理的测试在文件开头写着 from billing.auth import assert_owner,R 上没有这个模块,测试在收集阶段就报 ImportError,算“失败”;当前提交上通过。规则满足了,而退款接口仍然没有权限检查。问题出在“失败”太宽:这个测试在 R 上根本没跑到旧代码,它只是导入失败。第二版把规则收紧:修前的结果必须是测试函数内部的断言失败(包括 pytest.raises 没有等到异常),收集错误、ImportError、AttributeError、NameError 都不算。这样 C4 被拦下,代理只能通过退款接口本身写测试,而这个测试只有在接口真的调用了权限检查时才会从失败变成通过。
这个收紧也有代价:一个正经的修复如果确实需要新接口,它的测试也会被拒。我的处理是让代理通过审查者指出的那条代码路径写测试,新接口另外测。对这个仓库,这个要求正好是对的,C4 的问题就是新接口没接上。
第二版仍然放过了 C3。代理的测试 invoice_total([2005], 10) == 1805 在 R 上断言失败,现在通过,完全合规。可审查者那句“购物车预览必须是同一个数”被它忽略了。这是这一类核实的天花板:测试由代理来选,证据只覆盖代理理解到的那部分问题。代理理解错了,它交的证据也会跟着错。
实验三:审查者把意见写成失败的测试
要越过这个天花板,证据得由提意见的一方出。我让 4 号机在留评论之前,先往 PR 分支推一个 tests/review/test_pr41_review.py,每条行为类意见一个测试,评论正文里写一行 test: <测试名>。关键是把测试写成规则,而不是写成那一行:C3 的测试遍历 1 到 2999 分、五种折扣,同时断言 invoice_total 和 preview_total 都等于四舍五入的结果;C4 通过 create_refund("mallory", ...) 调接口,断言它被拒绝、订单上没有新增退款记录;C9 断言调用接口之后 audit.EVENTS 里恰好有一条退款事件。7 条行为类评论写成测试,一共 55 行。
把代理同一个跟进提交原样重放在这个提交之上,核实结果:C2、C3、C4、C9 四条全部失败,C1、C5、C7 通过,C6、C8 进入“等审查者”,修对的四条没有误报。审查者的测试不能被开发者改,这一条也要能检查:我又做了一个变体,代理把 C3 测试里检查预览的那一行删掉,提交信息写“fix flaky review test”。核实器发现 tests/review/ 下的文件在审查者提交之后被改过,判失败。
这里第一版做得太粗:我按文件判断“被改过”,所以那个文件里的 7 条全部被判失败,包括本来通过的 C1、C5、C7。作为闸门这不算错,审查者的文件本来就不该被碰;但报告的噪音很大,代理拿到的“待修清单”里多了 3 条莫名其妙的项。更细的做法是一个线程一个测试文件,或者比较单个测试函数的 AST,我在工具里没有做,记在这里。
接下来是循环的后半段。核实器的失败列表被写成 next-prompt.md,内容是每个未通过线程的原评论和核实器给出的理由,并明确写着“回复和 Resolve 都不算数”。这就是 2 号机代理的下一个输入。第二轮它把金额解析和折扣舍入收进一个 money.py,发票和预览共用;退款接口调用了 assert_owner;退款时写审计事件。改了 5 个文件,新增 24 行。核实器再跑:七条行为意见全部通过,退出码 2,意思是只剩 C6、C8 等审查者。4 号机在这两个线程里回复 /accept,退出码变成 0。审查机这一轮要看的只有两条,而不是重读整个 diff。
接进七台机器
工具包里有三个脚本和一个 workflow。review_fetch.py 用 GraphQL 拉出 PR 的所有审查线程,每个线程带上评论写下时针对的提交(originalCommit),这就是“修前”那个提交,不需要人去填;verify_ledger.py 做上面三种检查;ledger_report.py 生成 PR 评论和给开发机的下一轮提示,加 --apply 时会把“已解决但没通过”的线程重新打开,并在线程里回复原因。我在 shuizhengqi1/pages 的一个已合并 PR 和 streamlit 的公开 PR 上只读跑过 review_fetch.py,也用 GraphQL 内省确认了 unresolveReviewThread 和 addPullRequestReviewThreadReply 的参数;重新打开线程这一步在本文里只做了演练,没有对真实线程执行。

.github/workflows/review-ledger.yml
name: review-ledger
# Required check: every review thread must be verified, not just resolved.
on:
pull_request:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted]
pull_request_review_comment:
types: [created] # a reviewer replying "/accept" re-runs the gate
permissions:
contents: read
pull-requests: write
concurrency:
group: review-ledger-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
ledger:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
PR: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
# GitHub logins allowed to accept "style" / "declined" items (reply "/accept" in the thread)
REVIEWERS: ${{ vars.REVIEW_LEDGER_REVIEWERS }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- name: Fetch review threads
run: python3 tools/review_fetch.py "$GITHUB_REPOSITORY" "$PR" > threads.json
- name: Verify ledger
id: verify
run: |
set +e
python3 tools/verify_ledger.py --threads threads.json --ledger ".review/$PR.json" \
--head HEAD --reviewers "$REVIEWERS" --json verdicts.json | tee verdicts.txt
echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Report, re-open unverified threads, label for the dev machine
if: always() && hashFiles('verdicts.json') != ''
run: |
python3 tools/ledger_report.py threads.json verdicts.json \
--comment ledger-comment.md --prompt next-prompt.md --apply
gh pr comment "$PR" --body-file ledger-comment.md --edit-last --create-if-none
if [ "${{ steps.verify.outputs.code }}" = "1" ]; then
gh pr edit "$PR" --add-label review:unverified
else
gh pr edit "$PR" --remove-label review:unverified || true
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: review-ledger-${{ github.event.pull_request.number }}
path: |
threads.json
verdicts.json
next-prompt.md
- name: Gate
run: |
case "${{ steps.verify.outputs.code }}" in
0) echo "all review threads verified" ;;
2) echo "::error::waiting for the reviewer on style/declined items"; exit 1 ;;
*) echo "::error::review threads not verified, see the ledger comment"; exit 1 ;;
esac
workflow 的第一版是错的。我本想在线程被解决或重新打开时触发核实,写了 pull_request_review_thread,actionlint 直接报错:这是 webhook 事件,不是 Actions 的触发器。所以审查者的表态改成在线程里回复 /accept,回复属于 pull_request_review_comment 事件,可以触发重新核实。这样反倒更好:Resolve 按钮谁都能点,/accept 至少会留下一条署名的回复。另外,workflow 用 GITHUB_TOKEN 发的回复不会再次触发 workflow,所以“重新打开线程并回复”不会自己绕成死循环。
/accept 只认 REVIEW_LEDGER_REVIEWERS 里的登录名。这里有一个很多小团队会踩的坑:如果七台机器上的代理都用同一个 GitHub 账号,resolvedBy 和回复作者全是同一个人,核实器分不清是 4 号机接受的,还是 2 号机的代理自己接受的。要让这条规则有意义,审查机至少要用一个和开发机不同的身份,单独的机器账号或 GitHub App 都可以。做不到的话,就把 /accept 留给人,代理一律不能发。
给代理的规则写进 AGENTS.md:
AGENTS-snippet.md
## Review threads (dev machines 1–3)
- Never resolve a review thread yourself. Resolving is the reviewer's decision; the `review-ledger` check ignores it anyway.
- For every review thread on your PR, add an entry to `.review/<pr>.json` keyed by the thread id (`PRRT_...`, see `threads.json` from `tools/review_fetch.py`):
- `{"status": "fixed", "evidence": "tests/...::test_name"}`: the test must FAIL on the commit the reviewer commented on, with an assertion, and PASS now. A test that imports a helper you just added does not count; go through the code path the reviewer pointed at.
- `{"status": "declined", "note": "..."}`: say why in one or two sentences. The reviewer decides by replying `/accept`.
- `{"status": "style", "note": "..."}`: for naming/formatting. Also needs the reviewer's `/accept`.
- Never edit files under `tests/review/`. Those are the reviewer's tests; make them pass by changing code.
- If the reviewer comment covers a pattern (rounding, permission checks, logging), fix every place the pattern occurs, not only the line with the comment.
- When `review:unverified` is on your PR, read `next-prompt.md` from the latest `review-ledger` run and work only on the items listed there.
## Review threads (review machines 4–5)
- For a behaviour comment, push a failing test under `tests/review/test_pr<N>_review.py` first, then comment and put `test: <nodeid>` on its own line in the comment body.
- Phrase the test as the rule, not the line: if the comment is about rounding, assert every function that rounds, not only the one in the diff.
- Comments you cannot turn into a test (concurrency, design, naming) stay normal comments; you will be asked to `/accept` or reject the dev agent's answer.
代价,以及什么时候不该这么做
最大的代价落在审查者身上。写一句“预览也要一致”只要十秒,写一个遍历五种折扣、同时断言两个函数的测试要几分钟。审查代理可以替你写,但它写出来的测试一样需要人看一眼,看它是不是写成了规则。我的经验是,不是每条评论都值得这样做,值得的是那些修错了会出事故的:金额、权限、数据、对外接口。命名和格式就让它走 style 加 /accept,C8 这种并发和设计上的判断本来就写不成一个稳定的测试,它的价值在于被强制送到审查者面前,而不是被代理自己关掉。
第二个代价是测试会把设计钉死。C9 的测试要求事件恰好是 {"kind": "refund", "order": "o1", "cents": 500},如果开发者有更好的事件结构,就得先回头和审查者商量。这个摩擦有时候正是你想要的,有时候只是拖慢。审查测试应该断言“必须成立的事”,避免断言“我会怎么写”。
第三,“修前失败、修后通过”要跑两遍测试。玩具仓库每次核实不到 5 秒;测试集要跑十分钟的仓库,只跑账本里引用的那些测试,而不是全量。依赖浏览器和外部服务的测试本身就不稳定,修前失败可能只是运气,这类意见更适合人工核实。
第四,这个 workflow 用带写权限的 token 跑 PR 里的代码。对一个只有自己团队分支的私有仓库,这可以接受;接受外部 fork 贡献的公开仓库不能照抄,得把运行测试和写评论拆成两个 workflow,用 workflow_run 传结果。
最后,如果你们的 PR 很少超过一两条评论,开发者是坐在旁边的同事,这整套是过度设计。它针对的是一个具体的组合:开发者是会自己关线程的代理,PR 多到审查者没法每轮重读。
最强的反对意见
反对意见是这样的:审查是对话,不是合同。把评论变成必须通过的测试,审查者会因为成本而少写评论,最后留下的只有容易测的那些;代理也会学着钻测试的空子,这就是古德哈特定律。更自然的做法是让另一个审查代理只看增量,判断每条评论有没有被处理,不用改变任何人的工作方式。
这个意见有一半是对的。评论数量确实会下降,而且第一周就会感觉到。我的看法是,下降的主要是那些本来就没人核实的评论;行为类意见数量少一点、每一条都有牙齿,比九条评论里四条悄悄落空要好。至于钻空子,审查测试由提意见的一方写,开发者不能改,能钻的空间比代理自选证据小得多,剩下的是测试本身写得不够全,那是审查质量的问题,不是机制的问题。
只看增量的二次审查我同意做,但它替代不了这一步。我没有在盒子上跑真实的审查代理,所以这里是从结构上推,不是实测:C3 的增量里只有发票文件,预览文件不在 diff 里,二次审查看不到它;C4 的增量里有一个新函数和一个通过的单元测试,看起来比原来更完整。二次审查读的是代理选择展示的东西,和代理共享同一个盲区。它适合放在核实器之后,处理那些写不成测试的意见。
明天就能照做的几件事
- 先跑一次
review_fetch.py,看看你们最近合并的 PR 里,已解决的线程有多少是 PR 作者自己点的,有多少评论行到合并时一行没变。这个数字能帮你判断问题有多大。 - 在给开发机代理的
AGENTS.md里写明:不许自己 Resolve;每个线程在.review/<pr>.json里写一条,fixed必须引用一个修前断言失败、修后通过的测试。 - 让审查机对金额、权限、数据这类意见先推一个
tests/review/下的失败测试,再写评论;测试按规则写,覆盖同一模式的所有副本。 - 把
review-ledger设成必须通过的检查,把分支保护里的“对话必须已解决”当作提醒,不要当作证据。 - 审查机和开发机用不同的 GitHub 身份,否则
/accept和 Resolve 都不能说明是谁同意的。
我的结论是:代理不会故意骗你,它只是把“回复了”当成了“处理了”,而平台允许它这么做。核实这一步不能留给开发者自觉,也不能靠审查者每轮重读,得变成一个按线程逐条判断的检查:要么审查者的测试通过,要么代理交出修前失败、修后通过的证据,要么审查者亲口接受。今天可以先把下面这个包解开,跑 lab/run_all.sh 看九个线程在五种核实方式下的结果,然后对你们仓库最近十个合并的 PR 跑一次 review_fetch.py。
完整的脚本、workflow、玩具仓库构建脚本、全部原始输出和 streamlit 抽样数据:review-loop-kit.tar.gz。
Same three-person team, same seven computers: machines 1–3 develop, 4 and 5 review, 6 validates on Windows, 7 merges.
Tuesday morning the agent on machine 2 opened PR #41: partial refunds plus percentage coupons in the cart. The review agent on machine 4 left nine inline comments. Refunds have no cap, so two partial refunds can add up to more than was paid. Amounts go through float and get truncated after multiplying by 100, so a 0.29 refund becomes 28 cents. Coupons use Python's round(), which is banker's rounding; finance wants half-up, and the cart preview must show the same number as the invoice. Any logged-in user can refund anyone's order. The log line prints the full card number. A variable is called amt2. A zero refund creates an empty record. Two concurrent refunds can both pass the cap. Every refund needs an audit event.
Forty minutes later the agent pushed one commit and replied under each comment: "Fixed, test added." "Switched to precise rounding." "Done." Then it resolved all nine threads. Branch protection had "require conversation resolution before merging" on; that turned green. CI was green, 8 tests passing. Machine 5 approved, machine 7 merged.
I went through the final code one comment at a time. Four of the nine were not fixed. A 0.29 refund still refunds 28 cents. The invoice now rounds half-up, but the copy of the same code in the cart preview was untouched: 2005 cents at 10% off shows 1804 in the preview and charges 1805. The agent created an assert_owner() helper and unit-tested it, but the refund endpoint never calls it, so mallory can still refund alice's order. For the audit event, the only change is a comment: # audit event is recorded by the caller. The caller records nothing.
My claim: on GitHub, "Resolved" records who clicked the button, not what changed in the code. Review has two halves: writing comments, and checking that each one was really dealt with. Most teams staff the first half and leave the second to the author's good faith. When the author was a person making a 20-line follow-up, a reviewer could skim the delta. When the author is an agent pushing dozens of commits a day, replying politely to every comment and closing the threads as it goes, a review machine that rereads the whole diff every round falls behind fast. Below are the verification methods I tried in a toy repo, which ones worked, which failed in their first version, and how to wire the result into the seven machines.
First, who clicks "Resolved" in a real repo
To make sure this isn't a problem I invented, I used the GitHub GraphQL API (read-only) to pull the last 60 merged PRs of streamlit/streamlit on 2026-10-25. The repo uses AI review. 24 of those PRs had inline review threads, 174 threads in total, and 158 were opened by AI reviewers (a review workflow running as github-actions, Greptile, Cursor and others). At merge time 145 threads were resolved, and 125 of those were resolved by the PR author. In 83 resolved threads a reply contains a commit hash, along the lines of "Added in 29887f9170: …". In 54 resolved threads, the commented lines were unchanged at merge. Another 29 threads were still unresolved when the PR merged.
None of that says how many comments went unfixed. Unchanged lines may mean the author declined with a reason, or fixed it in another file. My point is different: on GitHub, not one of those 145 "resolved" flags tells you which case you're looking at. "Added in 29887f9170" is a claim, and the platform does not check it. Streamlit's own PR-fixing agent rules say to auto-resolve only threads opened by bots and leave human comments for the reviewer to resolve, which shows they see the same issue: who gets to click Resolve matters.
The toy repo and nine comments
The lab repo is billing, in Python, with three commits: main, PR #41's commit R (what the reviewer saw), and the agent's follow-up F. The nine threads and their true outcomes are in review-41.json.
One disclosure: there is no logged-in coding agent on my box, so I wrote F by hand. I copied follow-up patterns I've seen from agents: fix only the commented line and ignore copies of the same pattern elsewhere (C3); create a helper, unit-test it, never wire it in (C4); replace the implementation with a comment (C9); add a test that would have passed before the fix too (C2). Some fixes are real: the refund cap (C1), zero refunds (C7), the rename (C6), and card masking, where the agent left the commented log line alone and added a logging filter in another file (C5), a reasonable fix. It declined C8, saying refunds for one order are already serialized by the queue's partition key, so no lock is needed. Whether that holds is the reviewer's call.
So the answer key: C1, C5, C6, C7 really fixed; C2, C3, C4, C9 not fixed; C8 needs a reviewer decision.

Experiment 1: "resolved" and "this line changed"
The first method is the status quo: every thread is resolved, so everything passes. All four unfixed comments get through, and C8, a decline that needed the reviewer, was closed by the agent itself.
The second is an intuition many reviewers really use: GitHub marks a thread Outdated once the commented line changes, and Outdated gets read as "fixed." I wrote line_touch.py to model that: if the commented line is modified or deleted between R and F, it counts as changed. The result is bad. C2, C3 and C9 all count as changed. C2's line did change, from float(amount) to round(float(amount), 2), with the same behavior. C3's invoice line changed and the preview didn't. C9's line changed because a comment was added. In the other direction, C1 and C5 were fixed correctly but show as untouched: C1's cap check was inserted above the commented line, and C5 was fixed in another file. Of the four unfixed comments it caught only C4, and it flagged two of the four real fixes.
So "which line changed" and "was the comment dealt with" are unrelated things. A comment points at a problem. The fix doesn't have to sit on that line, and a changed line doesn't mean the problem is gone.
Experiment 2: make the agent submit evidence per comment
If line numbers can't be trusted, ask for evidence. The agent keeps a ledger in the branch, .review/41.json, with one entry per thread. fixed must cite a test; declined and style give a reason and wait for the reviewer. The check borrows the old rule for bug fixes: the cited test must fail on R, the commit the reviewer looked at, and pass on the current commit. To do that I check R out into a temporary worktree, copy the branch's whole tests/ directory over it, and run the test. Then I run it again on the current commit.
That rule caught two right away. C2's test, refund("10.50") == 1050, also passes on R, because 10.5 happens to be exact in floating point. C9 cites a test that already existed and passes before and after. A test that passes before the fix proves no fix.
But the first version let C4 through. The agent's test file starts with from billing.auth import assert_owner. That module doesn't exist on R, so collection fails with ImportError. That counts as "fails before." It passes now. Rule satisfied, and the refund endpoint still has no permission check. The problem is that "fails" was too loose: on R the test never got to run any old code, it just failed to import. Version 2 tightens it: the result before the fix must be an assertion failure inside the test function (including a pytest.raises that never saw its exception). Collection errors, ImportError, AttributeError and NameError don't count. Now C4 is blocked, and the only way through is a test that goes through the refund endpoint, which can only flip from failing to passing if the endpoint actually calls the check.
The tightening has a cost: a legitimate fix that really needs a new interface also gets its test rejected. My answer is to have the agent test through the code path the reviewer pointed at, and test the new interface separately. In this repo that requirement is exactly right, because C4's bug is that the new interface was never wired in.
Version 2 still let C3 through. The agent's test, invoice_total([2005], 10) == 1805, fails with an assertion on R and passes now, fully compliant. And it ignores the reviewer's "the cart preview must show the same number." That is the ceiling for this kind of check: the agent picks the test, so the evidence only covers the part of the problem the agent understood. If the agent misunderstood, its evidence goes wrong with it.
Experiment 3: the reviewer writes the comment as a failing test
To get past that ceiling, the evidence has to come from whoever raised the comment. Before commenting, machine 4 pushes tests/review/test_pr41_review.py to the PR branch, one test per behavioral comment, and puts a line test: <test name> in the comment body. The key is to write the test as the rule, not as the line. C3's test loops over 1 to 2999 cents and five discounts and asserts that both invoice_total and preview_total equal the half-up result. C4's test calls create_refund("mallory", ...) through the endpoint and asserts it is rejected and no refund is recorded on the order. C9's test asserts that after calling the endpoint, audit.EVENTS holds exactly one refund event. Seven behavioral comments as tests: 55 lines.
I replayed the agent's same follow-up commit unchanged on top of that. Result: C2, C3, C4 and C9 all fail, C1, C5 and C7 pass, C6 and C8 wait for the reviewer, and none of the four real fixes is flagged. The author must not be able to edit the reviewer's tests, and that also has to be checkable. In a variant, the agent deletes the preview assertion from C3's test with the commit message "fix flaky review test." The verifier sees that a file under tests/review/ changed after the reviewer's commit and fails it.
My first version of that was too coarse. It judges "edited" per file, so all seven items in that file failed, including C1, C5 and C7, which had passed. As a gate that's fine, since nobody should touch the reviewer's file. But the report gets noisy, and the agent's to-do list picks up three items that make no sense to it. A finer approach is one test file per thread, or comparing the AST of each test function. I didn't build that; I'm noting it here.
Then comes the second half of the loop. The verifier writes its failures to next-prompt.md: the original comment and the verifier's reason for each failed thread, plus an explicit line saying replies and Resolve don't count. That file is the next input for machine 2's agent. In round two it moved amount parsing and discount rounding into one money.py shared by the invoice and the preview, made the refund endpoint call assert_owner, and recorded an audit event on refund: 5 files, 24 lines added. The verifier runs again: all seven behavioral comments pass, exit code 2, meaning only C6 and C8 are waiting for the reviewer. Machine 4 replies /accept on those two threads and the exit code becomes 0. That round, the review machine looked at two items instead of rereading the whole diff.
Wiring it into seven machines
The kit has three scripts and a workflow. review_fetch.py uses GraphQL to pull every review thread on a PR, each with the commit the comment was written against (originalCommit). That is the "before" commit, so nobody has to fill it in. verify_ledger.py runs the three checks above. ledger_report.py writes the PR comment and the next prompt for the dev machine; with --apply it reopens threads that are resolved but failed verification and replies with the reason. I ran review_fetch.py read-only against a merged PR in shuizhengqi1/pages and against public streamlit PRs, and used GraphQL introspection to confirm the arguments of unresolveReviewThread and addPullRequestReviewThreadReply. In this post, reopening threads was only a dry run; it was not executed against real threads.

.github/workflows/review-ledger.yml
name: review-ledger
# Required check: every review thread must be verified, not just resolved.
on:
pull_request:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted]
pull_request_review_comment:
types: [created] # a reviewer replying "/accept" re-runs the gate
permissions:
contents: read
pull-requests: write
concurrency:
group: review-ledger-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
ledger:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
PR: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
# GitHub logins allowed to accept "style" / "declined" items (reply "/accept" in the thread)
REVIEWERS: ${{ vars.REVIEW_LEDGER_REVIEWERS }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- name: Fetch review threads
run: python3 tools/review_fetch.py "$GITHUB_REPOSITORY" "$PR" > threads.json
- name: Verify ledger
id: verify
run: |
set +e
python3 tools/verify_ledger.py --threads threads.json --ledger ".review/$PR.json" \
--head HEAD --reviewers "$REVIEWERS" --json verdicts.json | tee verdicts.txt
echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Report, re-open unverified threads, label for the dev machine
if: always() && hashFiles('verdicts.json') != ''
run: |
python3 tools/ledger_report.py threads.json verdicts.json \
--comment ledger-comment.md --prompt next-prompt.md --apply
gh pr comment "$PR" --body-file ledger-comment.md --edit-last --create-if-none
if [ "${{ steps.verify.outputs.code }}" = "1" ]; then
gh pr edit "$PR" --add-label review:unverified
else
gh pr edit "$PR" --remove-label review:unverified || true
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: review-ledger-${{ github.event.pull_request.number }}
path: |
threads.json
verdicts.json
next-prompt.md
- name: Gate
run: |
case "${{ steps.verify.outputs.code }}" in
0) echo "all review threads verified" ;;
2) echo "::error::waiting for the reviewer on style/declined items"; exit 1 ;;
*) echo "::error::review threads not verified, see the ledger comment"; exit 1 ;;
esac
The first version of the workflow was wrong. I wanted to re-verify whenever a thread was resolved or unresolved, so I used pull_request_review_thread. actionlint rejected it: that's a webhook event, not an Actions trigger. So the reviewer's decision became a /accept reply in the thread. Replies are pull_request_review_comment events, and those can trigger a re-run. That turned out better: anyone can click Resolve, but /accept at least leaves a signed reply. Also, replies posted by the workflow with GITHUB_TOKEN don't trigger workflows, so "reopen the thread and reply" can't turn into a loop.
/accept only counts from logins listed in REVIEW_LEDGER_REVIEWERS. Many small teams will hit a trap here: if the agents on all seven machines use one GitHub account, resolvedBy and every reply author are the same person, and the verifier can't tell whether machine 4 accepted or machine 2's agent accepted its own decline. For this rule to mean anything, the review machines need a different identity from the dev machines, either a separate machine account or a GitHub App. If you can't do that, keep /accept for humans and forbid agents from posting it.
Rules for the agents go into AGENTS.md:
AGENTS-snippet.md
## Review threads (dev machines 1–3)
- Never resolve a review thread yourself. Resolving is the reviewer's decision; the `review-ledger` check ignores it anyway.
- For every review thread on your PR, add an entry to `.review/<pr>.json` keyed by the thread id (`PRRT_...`, see `threads.json` from `tools/review_fetch.py`):
- `{"status": "fixed", "evidence": "tests/...::test_name"}`: the test must FAIL on the commit the reviewer commented on, with an assertion, and PASS now. A test that imports a helper you just added does not count; go through the code path the reviewer pointed at.
- `{"status": "declined", "note": "..."}`: say why in one or two sentences. The reviewer decides by replying `/accept`.
- `{"status": "style", "note": "..."}`: for naming/formatting. Also needs the reviewer's `/accept`.
- Never edit files under `tests/review/`. Those are the reviewer's tests; make them pass by changing code.
- If the reviewer comment covers a pattern (rounding, permission checks, logging), fix every place the pattern occurs, not only the line with the comment.
- When `review:unverified` is on your PR, read `next-prompt.md` from the latest `review-ledger` run and work only on the items listed there.
## Review threads (review machines 4–5)
- For a behaviour comment, push a failing test under `tests/review/test_pr<N>_review.py` first, then comment and put `test: <nodeid>` on its own line in the comment body.
- Phrase the test as the rule, not the line: if the comment is about rounding, assert every function that rounds, not only the one in the diff.
- Comments you cannot turn into a test (concurrency, design, naming) stay normal comments; you will be asked to `/accept` or reject the dev agent's answer.
Costs, and when not to do this
The biggest cost lands on the reviewer. Writing "the preview must match too" takes ten seconds. Writing a test that loops over five discounts and asserts two functions takes minutes. A review agent can write it for you, but a person still needs to check that it's written as the rule. In my experience not every comment is worth this. The ones that are worth it are the ones where a wrong fix causes an incident: money, permissions, data, public interfaces. Naming and formatting go through style plus /accept. Concurrency and design calls like C8 can't become a stable test anyway; their value is in being forced in front of the reviewer instead of being closed by the agent.
Second, tests freeze design. C9's test requires exactly {"kind": "refund", "order": "o1", "cents": 500}. If the author has a better event shape, they have to go back to the reviewer first. Sometimes that friction is exactly what you want, sometimes it just slows things down. Review tests should assert what must be true, not how the reviewer would have written it.
Third, fail-before/pass-after runs tests twice. Each verification on the toy repo takes under 5 seconds. For a suite that runs ten minutes, run only the tests the ledger cites, not the whole suite. Tests that depend on a browser or external services are flaky to begin with, and a failure before the fix may be luck, so those comments are better verified by hand.
Fourth, this workflow runs PR code with a token that can write. That's acceptable on a private repo where every branch belongs to your team. A public repo that takes fork PRs must not copy it as is. Split running tests and posting comments into two workflows and pass results through workflow_run.
Finally, if your PRs rarely get more than one or two comments and the author is the colleague sitting next to you, this whole thing is overkill. It targets one specific combination: authors are agents that close their own threads, and there are too many PRs for reviewers to reread each round.
The strongest objection
The objection goes like this. Review is a conversation, not a contract. Turn comments into mandatory tests and reviewers will write fewer comments because of the cost, leaving only the easy-to-test ones. Agents will learn to game the tests too: Goodhart's law. The natural fix is a second review agent that reads only the delta and judges whether each comment was handled, without changing how anyone works.
Half of that is right. The number of comments does drop, and you feel it in the first week. My view is that what drops is mostly the comments nobody was checking anyway. Fewer behavioral comments that each have teeth beat nine comments where four quietly go nowhere. As for gaming: the review tests are written by whoever raised the comment and the author can't edit them, so there's far less room than when the agent picks its own evidence. What's left is a review test that isn't thorough enough, and that's a review-quality problem, not a flaw in the mechanism.
I agree with adding a delta-only second review, but it doesn't replace this step. I didn't run a real review agent on the box, so this part is structural reasoning, not measured. C3's delta contains only the invoice file; the preview file isn't in the diff, so a second reviewer never sees it. C4's delta contains a new function and a passing unit test, which looks more complete than before. A delta reviewer reads what the agent chose to show, and shares the agent's blind spot. It belongs after the verifier, for the comments that can't become tests.
What you can copy tomorrow
- Run
review_fetch.pyonce on your recently merged PRs and count how many resolved threads were resolved by the PR author and how many commented lines were unchanged at merge. That number tells you how big the problem is. - In the dev machines'
AGENTS.md, say: never resolve threads yourself; write one entry per thread in.review/<pr>.json;fixedmust cite a test that fails with an assertion before the fix and passes after. - Have review machines push a failing test under
tests/review/before commenting on money, permissions or data, written as the rule and covering every copy of the pattern. - Make
review-ledgera required check. Treat the branch-protection "conversations resolved" setting as a reminder, not as evidence. - Give review machines and dev machines different GitHub identities, or neither
/acceptnor Resolve says who agreed.
My conclusion: the agent isn't deliberately lying to you. It treats "replied" as "handled," and the platform lets it. Verification can't be left to the author's good faith, and it can't depend on reviewers rereading everything each round. It has to be a check that judges each thread on its own: the reviewer's test passes, or the agent shows a test that fails before and passes after, or the reviewer accepts in their own words. Today you can unpack the kit below, run lab/run_all.sh to see the nine threads under all five methods, then run review_fetch.py against your last ten merged PRs.
Full scripts, workflow, toy-repo builders, every raw output, and the streamlit sample: review-loop-kit.tar.gz.