周二上午十点,2 号机上的代理交了 PR #101:优惠券从"整单打折"改成"逐行打折",顺手加了一张 VIP15 券,加了一个 coupon_code 字段和对应的 alembic 迁移,配了测试。4 号机审过,6 号机的 Windows 验证没报错,7 号机把它合进主干、部署。CI 全绿。
十二点,对账的人在群里说:今天上午的订单总额和支付渠道对不上,差几分钱,但每单都差。查下来是 #101 的新函数 round_money() 用的是 int() 截断,逐行打折之后每一行都往下抹了零头。
问题定位只花了十分钟。麻烦在于这两个小时里主干没闲着:1–3 号机的代理又合了 12 个 PR。有改 README 的,有给 pricing.py 补类型注解的,有在下单逻辑里加日志的,有写了个报表用上 coupon_code 的,还有一个给新字段加了索引迁移。群里第一反应几乎都是同一句话:"先 git revert #101,止血要紧,修复慢慢来。"
这句话本身没错。很多团队的事故手册写的也是"先回滚,再排查"。我想知道的是:在一个每小时合五六个代理 PR 的主干上,两小时之后这句话还管不管用?如果不管用,是卡在哪一步、换什么办法。
下面的东西都是我在自己机器上跑出来的:两个真实的高频开源仓库上的 revert 曲线,一个能复现上面这个场景的玩具仓库(五种回滚办法逐个试),以及一个从失败版本改出来的小工具。哪些是真跑的、哪些只是模拟,文末单独列。
先看真实仓库:一个提交能单独撤掉多久
我拿了两个提交很密的开源仓库:openai/codex(最近 75 天主干上 3494 个提交,平均每天 46.6 个)和 sst/opencode(1537 个,每天约 20 个,最近三周每周 285–543 个)。每个仓库随机抽 300 个主干提交,用 git merge-tree --write-tree --merge-base=C <后来的主干> C^ 模拟"在 k 个提交之后的主干上 revert C",只看会不会出现文本冲突。

| 之后又合了几个 | codex 仍能干净 revert | opencode 仍能干净 revert | 中位耗时(codex / opencode) |
|---|---|---|---|
| 1 | 93.3% | 94.0% | 0.2h / 0.2h |
| 5 | 86.0% | 85.3% | 1.3h / 2.7h |
| 10 | 81.0% | 79.3% | 3.2h / 5.5h |
| 20 | 77.0% | 73.0% | 7.1h / 11h |
| 50 | 64.3% | 56.7% | 22h / 25h |
| 200 | 44.0% | 39.0% | 4.3 天 / 3.9 天 |
有三点和直觉不一样:
第一,下滑从第一个提交就开始了。紧接着的下一个提交就让 6–7% 的提交无法单独撤掉。不存在"前几个小时随便撤"的安全期,只有"越晚越难"。
第二,"后来有人改过同一个文件"不等于冲突。k=20 时,codex 有 61% 的提交,其文件被别人又改过,但真冲突的只有 23%。git 的三方合并只在改到相邻行时才报冲突,所以光看文件重叠来判断"能不能撤",会严重高估风险,也会让人放弃本来能撤的提交。
第三,冲突一旦出现就不会自己消失。300 × 2 个样本里,没有一个是"k=10 冲突、k=20 又干净了"。所以早做判断永远比晚做便宜。
冲突文件绝大多数是源码和文档(codex 是 .rs 和测试快照 .snap,opencode 是 .ts/.tsx/.mdx)。锁文件反而不多:k=20 时 codex 的 69 个冲突提交里只有 3 个涉及锁文件,opencode 的 81 个里有 8 个。真正把提交焊住的,是后来对同一段代码的改动。这两个仓库我分不出哪些提交是代理写的,所以这里只能说明"高频主干"的规律,不能说明"代理 PR"比人写的更糟。
再看玩具仓库:五种回滚办法逐个试
真实仓库只能测文本冲突,测不了"撤完能不能跑"。所以我按开头的场景搭了一个小仓库 shop:v1.0 有两条 alembic 迁移、整单打折的 pricing.py 和下单的 orders.py。坏 PR #101 就是开头那个,后面 #102–#113 十二个 PR 是我手写的,模仿常见的代理 PR 形态(不是真代理产出的):
- #103:
shipping.py也用上了 #101 新加的round_money() - #104:给
pricing.py全文补类型注解和 docstring(很典型的"顺手整理") - #105:下单 INSERT 处加日志
- #106:报表用上
coupon_code - #107:迁移 0004,给
coupon_code加索引(down_revision = '0003') - #111:把
_line_total改名成line_total,并新增invoice.py依赖它 - 其余是 README、changelog、CLI、版本号、测试微调
每一步 k=0…12,对每种回滚办法依次检查四件事:代码能不能得到(有没有冲突);全量测试(新库)过不过;回归测试(2 件 10.05 元的商品用 SAVE10,应收 18.09 元)过不过;在一份"生产库"副本上 alembic upgrade head 能不能跑通,然后启动做冒烟。

办法一:git revert #101。 我原以为它至少在 k=0 时没问题,结果 k=0 就失败了,而且失败的地方 CI 根本看不到:代码干净地撤掉了,测试全绿,可生产库在 #101 部署时已经跑过迁移 0003,alembic_version 表里写着 0003。revert 把 0003_coupon_code.py 这个文件也删了,部署脚本一跑 alembic upgrade head,就是 Can't locate revision identified by '0003'。CI 用的是新建的空库,从 0001 一路升上来,永远不会遇到这个状态。
k=2 时多了一种失败:#103 的 shipping.py 引用了 shop.money,revert 把这个模块删了,测试报 ModuleNotFoundError。这就是语义依赖:文本上完全不冲突,代码却跑不了。k=3 时 #104 给 pricing.py 补类型注解和 docstring,一共只改了 8 行,改的正是 #101 刚写的那两个函数签名,从此 pricing.py 冲突;k=4 起 orders.py 也冲突。一个零功能的整理 PR,就把坏 PR 焊死在了主干上。
办法二:把 #101 之后的全部 revert。 文本永远干净,代价是丢掉 k 个好 PR,而迁移照样失败。k≥6 时情况更糟:0004 的文件还在,它的 down_revision 指向一个已经被删的 0003,alembic 构建迁移图时直接 KeyError: '0003'。
办法三:重新部署 v1.0 的产物。 这是最快的止血。如果部署流程不跑迁移,程序确实能起来,算出 18.09。但生产库还停在 0003/0004,只要哪天有人跑一次迁移就会炸。更重要的是 12 个 PR 都没了,而它们的作者(另外几台机器上的代理)不知道。
办法四:关开关。 我又建了一个变体:#101 当初把新逻辑放在 PRICING_V2 开关后面,默认开。k=0–9 关掉开关就恢复了,不用部署,也不碰迁移。到 k=10 失败:#111 新增的 invoice.py 是照着"开"的行为写的,开关一关,发票行不再打折,订单总额却还在打折,两边对不上,test_invoice 变红。CI 只测了开关开着的路径,"关"那条路悄悄过期了。而且线上翻开关本身不跑任何测试,这个红要等对账才会再出现一次。
办法五:revert 闭包 + 保留迁移和仍被引用的新文件。 这是下一节要讲的办法。它在所有 k 上都能上线:测试绿、回归测试通过、生产库迁移通过、冒烟通过。代价是连带撤掉几个 PR:k=3 起连带 #104,k=4 起再加 #105,k=10 起再加 #111,最多 3 个。全量回滚则要丢 12 个。
revert 闭包:撤掉坏提交,还得撤掉哪几个
思路很朴素:从坏提交 B 开始,沿主干往后走。每走到一个提交 L,就试着在 L 上把集合 S(一开始只有 B)按新到旧的顺序 revert。如果因为 L 冲突了,就把 L 也加进 S。走到头时,S 就是"要把 B 拿出来,必须一起拿出来的那些提交"。这是贪心算法,不保证最小,但每一步都可以复查。整个过程只用 merge-tree 和 commit-tree 操作 git 对象,不碰工作区,在 7 号机的干净 clone 里随时能跑。
只做到这一步还不够。我在玩具仓库上跑了"只算闭包"(图里没画,结果在 kit 的 results_closure.json 里):每个 k 文本都干净,但从 k=2 起测试照样因为 shop.money 缺失而红,每个 k 的迁移都失败。闭包只解决了文本冲突,另外两类问题它看不见。所以加了两个开关:
--keep-path migrations/:迁移目录永远不跟着撤。0003 的文件留着,库里的版本号就还能找到。字段多出来没关系,旧代码不读它就行。这就是 expand/contract 的思路:结构变更只往前走,回滚的只是行为。--keep-referenced:S 里新增的文件,如果闭包之外的代码还在引用(用git grep -w <文件名>判断),就保留。
--keep-referenced 的第一版其实失败了。最早我写的是 --keep-added,S 里新增的文件一律保留,结果两处红:tests/test_vip.py 留下了,可 VIP15 已经撤了;invoice.py 留下了,可它依赖的 line_total 改名也被撤了,ImportError。"新文件都留"和"新文件都删"都不对,要看闭包外面还有没有人用它。改成"仍被引用才留"之后,12 个 k 全部通过。k=12 时 plan 的输出是:
revert closure for f5f0ba3 feat: per-line percentage coupons, VIP15, store coupon_code (#101) onto 722573db0b: 4 commit(s)
f5f0ba3 feat: per-line percentage coupons, VIP15, store coupon_code (#101)
4e95fa0 chore: type hints and docstrings in pricing (#104)
7082d7b feat: log every order (#105)
4c13efc refactor: make line_total public, add invoice lines (#111)
kept (still referenced): shop/money.py
这个"仍被引用"的判断只是按文件名做的启发式。它在玩具仓库里够用,在真实仓库里一定有漏判和误判,所以 apply 出来的分支必须跑测试,不能直接上线。
回到真实仓库。k=20 时那些没法单独撤的提交(codex 69 个,opencode 81 个),闭包每次都找得到,没有一次卡死;中位大小 codex 3 个、opencode 2 个(包括坏提交本身),最大 14 个。k=50 时也一样:codex 107 个、opencode 130 个冲突提交全部找到了闭包,中位都是 3 个,但长尾变长了,最大的分别要连带 31 和 38 个。这里只验证了文本层面,没有构建、没有跑测试;玩具仓库告诉我们,文本干净之后语义那一层还可能红。
我在 opencode 最近 40 个主干提交(约 26 小时)上跑了一次 drill:40 个里有 9 个已经不能单独撤,闭包 2–9 个提交不等,耗时 3.4 秒。
数据:代码回滚修不了的那部分
上面讲的都是代码。可 #101 上线的两小时里,订单是按错的算法写进库的。我用 #101 的代码写了 500 单随机订单,再拿 v1.0 的算法重算:除去用 VIP15 的 112 单(v1.0 没有这张券,没有"正确答案"可比),剩下 388 单里有 119 单金额不对,合计少收 130 分。不管用哪种办法回滚代码,这 119 行都不会自己变对,只能按存下来的 items 重算,出一份差额清单交给对账,VIP15 的单子要人来定规则。
还有一个看起来很聪明的做法:先 alembic downgrade 0002 把库退回去,再部署普通 revert,迁移不就对上了吗?我试了,确实对上了,代价是 0003 的 downgrade 删掉了 coupon_code 整列,245 行订单的券码就此丢失,而那正是重算差额要用的数据。回滚时不要先 downgrade。
最强的反对意见:直接往前修不就行了?
"代理五分钟就能写出修复,何必回滚?往前修(roll forward)就好。"这个意见很有分量。如果修复显而易见,比如这次把 int() 换成正确的舍入,往前修确实比任何回滚都快,我也会这么做。
但我还是建议把回滚准备好,理由有三个。第一,往前修是在最紧张的时候,往最乱的代码上叠一个新改动,这正是 #104 那种整理 PR 刚改过的地方,审查机还得在几分钟内放行。第二,修复不一定显而易见;定位用了十分钟不代表修复也只要十分钟。第三,往前修和回滚一样修不了那 119 行数据。更现实的做法是:两条路都要能走,先用 plan 看回滚要连带几个 PR。连带 0–2 个,回滚;卡死或者要连带一大串,往前修。
另一个反对意见是"所有行为变更都上开关"。开关确实是最快的止血,玩具实验里 k≤9 时它最好用。但开关有维护成本,而且"关"的那条路会过期(#111)。如果用开关,就必须让 CI 在开关关闭的状态下也跑一遍测试,否则它只是一个在你需要它那天才会坏的按钮。
什么时候不适用
- 合并提交:工具只处理主干上的单父提交(squash/rebase 合并),
drill会跳过合并提交。如果团队用 merge commit,需要改成revert -m 1的逻辑,我没做。 - 破坏性迁移:如果坏 PR 删列、改列名,保留迁移只会保住被破坏后的结构,保不住数据。这种情况只能事先拆成 expand/contract 两步。
- 代码之外的东西:已经发出去的库版本、已经推给客户端的配置、外部系统里的副作用,git 撤不回来。
- 超大仓库:闭包每一步都要做几次 merge-tree。opencode 上 40 个提交的 drill 用了 3.4 秒;几千人的单体仓库我没测。
- 启发式会出错:
--keep-referenced只按文件名匹配,动态 import、配置里写的模块名它看不到。
可以直接抄的部分
7 号机,每次合并后跑一次 drill,把"哪些提交已经不能单独撤、闭包多大"写进 Job Summary。出事时,表已经在那了:
# .github/workflows/revert-drill.yml(只读,contents: read)
on: { push: { branches: [main] } }
permissions: { contents: read }
jobs:
drill:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 300 }
- run: |
git config user.name drill && git config user.email drill@localhost
python3 tools/revert_closure.py drill --last 30 --keep-path migrations/ >> "$GITHUB_STEP_SUMMARY"
出事时的顺序(见下图):

- 有开关先关开关,然后检查之后的 PR 有没有依赖"开"的行为。
revert_closure.py plan <bad> --keep-path migrations/ --keep-referenced,看要连带几个 PR。STUCK(退出码 2)或者连带太多,就往前修。apply --branch rollback/<n>,PR 描述里列出连带撤掉的 PR,@ 它们的作者机器,让它们之后按新主干重做。- 在这个分支上跑全量测试,再在生产库副本上跑一次迁移。红在哪,哪里就是漏掉的语义依赖。
- 数据单独处理:重算窗口内写入的行,出差额清单,不要先 downgrade。
给代理的规则(写进开发机的 AGENTS.md / CLAUDE.md):
- 结构迁移单独一个 PR,只加不删;行为变更另开 PR。这样回滚行为时迁移不用动。
- 不要对 24 小时内别人刚改过的行做格式化、补类型注解、改名这类整理;这类改动攒起来每天合一次。#104 一个这样的 PR,就让坏 PR 从 k=3 起没法单独撤。
- 有风险的行为变更放在开关后面,并且在 CI 里加一个关闭开关的测试任务。
部署流程:不要假设"回滚就是部署上一个产物"。部署前检查 alembic heads 里有没有生产库当前的版本号;没有就拒绝部署,别等 upgrade 炸了才发现。
哪些是真跑的,哪些不是
真跑的:两个开源仓库各 300 个提交的 revert 曲线和 k=20/50 的闭包统计(只是文本层面的合并模拟,没有构建和测试);opencode 最近 40 个提交的 drill;玩具仓库 k=0…12 × 五种办法的全部检查(测试、回归、生产库副本迁移、冒烟);--keep-added 失败的那一版;500 单的数据窗口和 downgrade 实验。
没有真跑的:#102–#113 是我手写的模仿,不是真代理产出的;没有代理参与解决冲突;工作流 YAML 没有在 GitHub Actions 里跑过,只在本地跑了脚本;没在七台机器上演练,Windows 上也没测。
脚本都在这里:revert-closure-kit.tar.gz,包括 revert_closure.py(plan / apply / drill)、工作流、玩具仓库生成器和全部实验脚本。
今天就能做的最小一步:在你的主干上跑 python3 revert_closure.py drill --last 30,看看最近一天合进去的东西里,有几个现在已经没法单独撤了。
Tuesday, 10:00. The agent on machine 2 opens PR #101: coupons switch from "discount the order" to "discount each line", a new VIP15 coupon, a coupon_code column with an alembic migration, tests included. Machine 4 reviews it, machine 6's Windows check passes, machine 7 merges and deploys. CI is green.
12:00. Finance posts in the group chat: this morning's order totals don't match the payment provider. A few cents off, on every order. It's #101: its new round_money() uses int(), which truncates, so with per-line discounts every line rounds down.
Finding it took ten minutes. The problem is that main didn't sit still for those two hours: agents on machines 1–3 merged 12 more PRs. A README edit, type hints added to pricing.py, logging in the order path, a report that reads coupon_code, an index migration on the new column. Almost everyone's first reaction is the same: "Just git revert #101, stop the bleeding, fix it properly later."
That advice isn't wrong; plenty of incident runbooks say "roll back first, investigate second." What I wanted to know: on a main branch taking five or six agent PRs an hour, does it still work two hours later? If not, where does it break, and what do you do instead?
Everything below was run on my machine: revert curves from two real high-velocity open-source repos, a toy repo that reproduces the scenario above (five rollback strategies tried one by one), and a small tool that started as a failed first version. What was actually run versus simulated is listed at the end.
Real repos: how long can a commit come out on its own?
Two busy open-source repos: openai/codex (3494 commits on main in the last 75 days, 46.6/day) and sst/opencode (1537, ~20/day, 285–543/week over the last three weeks). From each, 300 random first-parent commits. For each, git merge-tree --write-tree --merge-base=C <later main> C^ simulates "revert C on main k commits later", looking only at textual conflicts.

| commits merged after | codex still reverts cleanly | opencode still reverts cleanly | median elapsed (codex / opencode) |
|---|---|---|---|
| 1 | 93.3% | 94.0% | 0.2h / 0.2h |
| 5 | 86.0% | 85.3% | 1.3h / 2.7h |
| 10 | 81.0% | 79.3% | 3.2h / 5.5h |
| 20 | 77.0% | 73.0% | 7.1h / 11h |
| 50 | 64.3% | 56.7% | 22h / 25h |
| 200 | 44.0% | 39.0% | 4.3d / 3.9d |
Three things I didn't expect:
The decline starts at the very first commit. The next commit alone makes 6–7% of commits impossible to revert on their own. There's no "safe for the first few hours" window, only "harder the longer you wait."
"Someone touched the same file later" is not a conflict. At k=20, 61% of codex commits had a file changed again by someone else, but only 23% actually conflict. Git's three-way merge only conflicts when edits touch adjacent lines, so judging revertability by file overlap badly overestimates the risk and makes people give up on reverts that would have worked.
Once a conflict appears it never goes away. Across 2 × 300 samples, not one commit went from "conflicts at k=10" back to "clean at k=20". Deciding early is always cheaper than deciding late.
The conflicting files are overwhelmingly source and docs (codex: .rs and test snapshots .snap; opencode: .ts/.tsx/.mdx). Lockfiles are surprisingly rare: at k=20 only 3 of codex's 69 conflicting commits and 8 of opencode's 81 involve a lockfile. What welds a commit into main is later work on the same code. I can't tell from git metadata which of these commits were written by agents, so this describes high-velocity mains, not "agent PRs are worse than human ones".
A toy repo: five rollback strategies, one by one
Real repos only tell you about textual conflicts, not whether the result runs. So I built a small repo, shop, matching the scenario: v1.0 has two alembic migrations, pricing.py with an order-level discount, and orders.py. The bad PR #101 is the one above. Follow-ups #102–#113 are twelve PRs I wrote by hand to mimic common agent PR shapes (not real agent output):
- #103:
shipping.pystarts using #101's newround_money() - #104: type hints and docstrings throughout
pricing.py(a classic drive-by tidy-up) - #105: logging around the order INSERT
- #106: a report that reads
coupon_code - #107: migration 0004, an index on
coupon_code(down_revision = '0003') - #111: rename
_line_total→line_total, plus a newinvoice.pythat depends on it - the rest: README, changelog, CLI, version bump, a test tweak
At every step k = 0…12, each strategy is checked four ways: can you get the code (any conflict); does the full suite pass (fresh DB); does the regression test pass (two items at 10.05 with SAVE10 must total 18.09); does alembic upgrade head work on a copy of the "production" DB, followed by a boot smoke test.

Strategy 1: git revert #101. I expected it to at least work at k=0. It fails at k=0, in a place CI can't see: the code reverts cleanly and tests are green, but the production DB ran migration 0003 when #101 deployed, so alembic_version says 0003. The revert deletes 0003_coupon_code.py, the deploy runs alembic upgrade head, and you get Can't locate revision identified by '0003'. CI builds a fresh DB from 0001 every time and never sees that state.
At k=2 a second failure appears: #103's shipping.py imports shop.money, the revert deletes that module, ModuleNotFoundError. That's a semantic dependency: no textual conflict at all, but the code doesn't run. At k=3, #104 adds type hints and docstrings to pricing.py — 8 lines changed, exactly the two function signatures #101 just wrote — and from then on pricing.py conflicts; from k=4 orders.py does too. One zero-feature tidy-up PR welds the bad PR into main.
Strategy 2: revert everything since #101. Always textually clean, costs you k good PRs, and the migration still fails. From k=6 it's worse: 0004's file is still there, its down_revision points to the deleted 0003, and alembic dies building the graph with KeyError: '0003'.
Strategy 3: redeploy the v1.0 artifact. The fastest way to stop the bleeding. If the deploy doesn't run migrations, the app boots and charges 18.09. But the DB is still at 0003/0004 and will blow up the next time anyone migrates. Worse, all 12 PRs are gone, and their authors (agents on other machines) don't know.
Strategy 4: flip the flag off. In a variant, #101 put the new logic behind PRICING_V2, default on. For k=0–9, flipping it off fixes things with no deploy and no migration. At k=10 it breaks: #111's invoice.py was written against the "on" behavior; with the flag off, invoice lines lose the discount while order totals keep it, and test_invoice goes red. CI only tested the flag-on path; the flag-off path quietly went stale. And flipping a flag in production runs no tests at all, so you'd find out at the next reconciliation.
Strategy 5: revert closure + keep migrations and still-referenced new files. That's the next section. It ships at every k: tests green, regression test passes, prod-copy migration works, smoke test passes. The cost is a few co-reverted PRs: #104 from k=3, plus #105 from k=4, plus #111 from k=10 — at most 3, versus 12 for a full rollback.
Revert closure: what else has to come out with it?
The idea is simple. Start with the bad commit B and walk forward along main. At each commit L, try reverting the set S (initially just B) newest-first on top of L. If L makes that conflict, add L to S. At the end, S is "the commits that have to come out together for B to come out". It's greedy and doesn't guarantee the minimum, but every step can be checked. It runs entirely on git objects (merge-tree and commit-tree) and never touches a work tree, so it runs anytime in a clean clone on machine 7.
That alone isn't enough. On the toy repo, closure-only (not in the chart; results are in the kit's results_closure.json) is textually clean at every k, but tests go red from k=2 because shop.money is gone, and the migration fails at every k. The closure solves textual conflicts and is blind to the other two problems. So two switches:
--keep-path migrations/: the migrations directory never comes out. 0003 stays, so the DB's revision is still found. An extra column is harmless if the old code doesn't read it. That's expand/contract: schema only moves forward; you roll back behavior.--keep-referenced: a file added by S stays if code outside the closure still references it (judged bygit grep -w <file stem>).
The first version of --keep-referenced failed. It started as --keep-added, keeping every file S added, and went red in two places: tests/test_vip.py stayed while VIP15 was reverted, and invoice.py stayed while the line_total rename it depends on was reverted — ImportError. "Keep all new files" and "delete all new files" are both wrong; what matters is whether anything outside the closure still uses it. With "keep only if still referenced", all 12 k pass. At k=12, plan prints:
revert closure for f5f0ba3 feat: per-line percentage coupons, VIP15, store coupon_code (#101) onto 722573db0b: 4 commit(s)
f5f0ba3 feat: per-line percentage coupons, VIP15, store coupon_code (#101)
4e95fa0 chore: type hints and docstrings in pricing (#104)
7082d7b feat: log every order (#105)
4c13efc refactor: make line_total public, add invoice lines (#111)
kept (still referenced): shop/money.py
"Still referenced" is a filename heuristic. It's enough for the toy repo and will certainly miss or over-match in real ones, so the branch apply produces must be tested, never shipped directly.
Back to the real repos. At k=20, every commit that could no longer come out alone (69 in codex, 81 in opencode) had a closure — never stuck. Median size 3 for codex, 2 for opencode (including the bad commit itself), max 14. At k=50 the same holds: all 107 conflicting codex commits and all 130 opencode ones had a closure, median 3 in both, but the tail grows — the largest pull in 31 and 38. That's textual only — no builds, no tests; the toy repo shows the semantic layer can still go red after a clean text revert.
A drill over opencode's last 40 commits on main (~26 hours): 9 of 40 can no longer come out alone, closures of 2–9 commits, 3.4 seconds total.
Data: the part no code rollback fixes
All of that is code. But during #101's two hours, orders were written with the wrong math. I wrote 500 random orders with #101's code and recomputed them with v1.0's: leaving out the 112 VIP15 orders (v1.0 had no such coupon, so there's no "right answer"), 119 of the remaining 388 have the wrong total, 130 cents undercharged in all. No code rollback makes those 119 rows correct. You recompute from the stored items, hand finance a diff list, and a human decides the rule for VIP15 orders.
There's also a clever-looking move: alembic downgrade 0002 first, then deploy the plain revert, and the migration state matches. I tried it, and it does match — at the cost of 0003's downgrade dropping the coupon_code column, losing the coupon codes on 245 orders, which is exactly the data you need to compute the diff. Don't downgrade first.
The strongest objection: why not just roll forward?
"An agent writes the fix in five minutes; why roll back at all?" That carries real weight. When the fix is obvious, like replacing int() with correct rounding here, rolling forward beats any rollback, and I'd do it too.
I'd still keep rollback ready, for three reasons. Rolling forward stacks a new change onto the most tangled code at the most stressful moment — exactly where #104's tidy-up just landed — and the review machines have to approve it in minutes. The fix isn't always obvious; ten minutes to find the cause doesn't mean ten minutes to fix it. And rolling forward doesn't fix the 119 rows either. The practical version: be able to take either road, and run plan first. If the closure pulls in 0–2 PRs, roll back; if it's stuck or pulls in a long tail, roll forward.
The other objection: "put every behavior change behind a flag." Flags are the fastest bleed-stopper — for k≤9 in the toy lab they were the best option. But they cost upkeep, and the off path goes stale (#111). If you use flags, CI has to run the tests with the flag off too; otherwise it's a button that breaks on the one day you need it.
When this doesn't fit
- Merge commits. The tool handles single-parent commits on main (squash/rebase merges);
drillskips merge commits. If your team uses merge commits you needrevert -m 1logic, which I didn't write. - Destructive migrations. If the bad PR drops or renames a column, keeping migrations keeps the damaged schema, not the data. The only fix is splitting into expand/contract beforehand.
- Things outside the repo. Published library versions, config pushed to clients, side effects in external systems — git can't take those back.
- Huge repos. Each closure step runs several merge-trees. 40 commits of opencode took 3.4 s; I haven't tried a monorepo with thousands of contributors.
- The heuristic will be wrong sometimes.
--keep-referencedmatches file names only; dynamic imports and module names in config are invisible to it.
The parts you can copy
Machine 7: run a drill after every merge, writing "which commits can no longer come out alone, and their closure size" to the job summary. When something goes wrong, the table is already there:
# .github/workflows/revert-drill.yml (read-only, contents: read)
on: { push: { branches: [main] } }
permissions: { contents: read }
jobs:
drill:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 300 }
- run: |
git config user.name drill && git config user.email drill@localhost
python3 tools/revert_closure.py drill --last 30 --keep-path migrations/ >> "$GITHUB_STEP_SUMMARY"
The order of operations when it happens (see the chart):

- If there's a flag, flip it off, then check whether later PRs depend on the "on" behavior.
revert_closure.py plan <bad> --keep-path migrations/ --keep-referencedto see how many PRs come along. If STUCK (exit code 2) or too many, roll forward.apply --branch rollback/<n>; list the co-reverted PRs in the PR description and ping their authoring machines to redo them on the new main.- Run the full suite on that branch, then run the migration on a copy of the prod DB. Wherever it goes red is the semantic dependency the closure missed.
- Handle data separately: recompute the rows written in the window, produce a diff list, don't downgrade first.
Rules for the agents (put them in the dev machines' AGENTS.md / CLAUDE.md):
- Schema migrations go in their own PR, additive only; behavior changes in a separate PR. Then rolling back behavior never touches migrations.
- No reformatting, type-hint passes or renames on lines someone else changed in the last 24 hours; batch that kind of tidy-up and merge it once a day. One such PR, #104, made the bad PR un-revertable on its own from k=3.
- Risky behavior changes go behind a flag, with a CI job that runs the tests with the flag off.
Deploys: don't assume "rollback = deploy the previous artifact". Before deploying, check that alembic heads knows the production DB's current revision; if not, refuse to deploy instead of waiting for upgrade to blow up.
What was actually run, and what wasn't
Actually run: the revert curves on 300 commits from each of two open-source repos and the k=20/50 closure stats (textual merge simulation only; no builds or tests); a drill on opencode's last 40 commits; every check in the toy repo for k = 0…12 × five strategies (tests, regression, prod-copy migration, smoke); the failed --keep-added version; the 500-order data window and the downgrade experiment.
Not run: #102–#113 are hand-written imitations, not real agent output; no agent resolved conflicts; the workflow YAML has not been run in GitHub Actions, only the script locally; no drill on seven machines, nothing on Windows.
Everything is here: revert-closure-kit.tar.gz — revert_closure.py (plan / apply / drill), the workflow, the toy-repo builder and all experiment scripts.
The smallest thing you can do today: run python3 revert_closure.py drill --last 30 on your main and see how many of yesterday's merges can no longer come out on their own.