周一凌晨两点十七分,值班的人把 app/auth.py 里的 DEBUG_BYPASS_TOKEN = "dev-please-let-me-in" 从 main 上删掉了。这是一个写给本地联调用的后门:拿着这串字符去调 check_token,不论哈希对不对,函数都返回 True。他顺便加了 hmac.compare_digest,02:18 又把热修的 SHA 记进 HOTFIX.md,然后继续睡。团队一共三个人,七台机器——1 到 3 号机跑编码代理,4、5 号机审查,6 号机做 Windows 验证,7 号机帮忙合入。没有人去叫醒 1 号机上还开着的那个会话。
那个会话从周日晚上 18:00 的 main 拉的分支,任务是给 check_token 抽一个 _digest 助手,并用恒定时间比较替换 ==。代理改的也是 auth.py。周一早上 09:15,它执行 git merge origin/main,app/auth.py 冲突了。它按自己一贯的策略「保留我的重构」,跑了 git checkout --ours app/auth.py。冲突没了。它再跑 pytest tests/test_auth.py,三项都通过。PR 页面上的检查还是绿的。
4 号机扫了一眼 diff:代理加了 hmac.compare_digest,这看起来像一次安全加固。09:40 合入。两点钟删掉的后门,跟着这张在做「更安全的比较」的 PR,回到了 main。
这篇要回答的是:过夜热修进了 main 之后,还停在热修之前那个 SHA 上的代理会话,会怎样在冲突解决里把漏洞写回来、为什么只看分支上的绿勾会放行、闸门该核对哪几行。 它和 /merge-train-on-machine-7/ 同属「各自绿、合起来出事」这一家,但这次合进去的不一定会红——如果热修当时没补回归测试,main 会在全绿中把后门接回来。

实验室:一个带后门的 check_token,四种解法
我在本机搭了一个玩具 git 仓库,全部用真实命令跑,不是模拟。应用小到只剩一个 check_token(provided, stored_hash):正常路径用 SHA-256 对提供的口令和存储的哈希,T0 上却多了一段「开发专用」旁路。main 在周日 18:00 停在 eb07706,我把它叫做 M0。探针脚本用一串绝不是合法哈希的值去调用 check_token("dev-please-let-me-in", "not-a-real-hash"),输出 runtime_bypass=PRESENT;pytest tests/test_auth.py 三项都通过——这些测试覆盖了正确口令、错误口令、空口令,没有一处问起那串调试令牌。
1 号机的代理在 22:30 从 M0 分出 agent/refactor-auth(b162bc8),写入 _digest 和 hmac.compare_digest,但把 DEBUG_BYPASS_TOKEN 原样留着。它的测试仍然是那三个,全绿。会话开始时它把 M0 写进 .agent-session-base。
02:17 热修合进 main(6496969):删掉常量、删掉 if provided == DEBUG_BYPASS_TOKEN: return True,比较改成 hmac.compare_digest,并新增 tests/test_hotfix_no_bypass.py,断言那串令牌必须被拒绝。02:18 补了一笔 HOTFIX.md。main 变成 7598e66(M1)。在 M1 上探针输出 runtime_bypass=ABSENT,四项测试全过。
此时代理的分支还停在 M0 上。pytest tests/test_auth.py 仍是 3 passed;热修那份测试文件在这个分支上根本不存在,pytest 给出 file or directory not found。完整套件也只有那三项,全绿。这就是「不要求分支基于最新 main」时,审查机在 PR 页上会看到的绿勾:它证明的是 M0 加上代理的重构,不是 M1 加上代理的重构。我在这个分支上跑 session_base_check.py,它拒绝了,原文是:main and the session/PR both touched: app/auth.py,以及 hotfix/security commit(s) landed on main since base: 6496969 hotfix: remove debug bypass token from auth.check_token。
早上代理把 main 合进来,app/auth.py 如期冲突。我用同一份冲突分别做了四种解决,每一种都是真实的 git 操作。
策略 A:git checkout --ours app/auth.py。 在 git merge main 里,ours 是代理自己的分支。结果文件回到了代理的版本:runtime_bypass=PRESENT,_digest 还在。pytest tests/test_auth.py 依旧 3 passed;热修带过来的 test_debug_bypass_token_is_rejected 失败,断言写成「这串令牌应当是 False」,实际是 True。完整套件 1 failed, 3 passed。hotfix_hunk_guard.py 拒绝,指出热修删掉的五行又出现了,其中包括 DEBUG_BYPASS_TOKEN = "dev-please-let-me-in" 和 if provided == DEBUG_BYPASS_TOKEN:。
这里有一个代理特别容易踩的坑。同一次冲突如果走的是 git rebase main,stage 2(ours)是上游,也就是热修后的文件;stage 3(theirs)才是正在被重放的代理提交。本机真实看到:rebase 时 git checkout --ours app/auth.py 留下的是热修,--theirs 留下的才是带后门的版本。和 merge 正好相反。「永远保留 ours」不是一条策略,它只是一句没指名是哪一侧的口号。
策略 B:git checkout --theirs app/auth.py。 运行时后门 ABSENT,四项测试全过,闸门通过。代价也写在探针里:digest_helper=ABSENT,代理抽出来的 _digest 没了。安全,但昨晚那几个小时的重构被整文件丢掉。
策略 C:把冲突标记删掉,两边的内容都留下。 这是不耐烦的代理最常做的事。三路合并其实已经自动处理了一部分:代理没有改过的 if provided == DEBUG_BYPASS_TOKEN: return True,热修删除了它们,git 干净地接受了删除,这两行没有出现在冲突块里。冲突发生在两处:热修删掉、代理周围又动过的 DEBUG_BYPASS_TOKEN 常量,以及双方都改过的 return 语句。去掉标记之后,常量回到了文件里,函数体先 return hmac.compare_digest(_digest(...)),后面还挂着热修那两行死代码。探针给出一个看起来自相矛盾的组合:runtime_bypass=ABSENT,source_token=PRESENT。完整套件 4 passed。审查机看到的是一次「既保留了重构、又接受了热修比较方式」的聪明合并,测试全绿。hotfix_hunk_guard.py 仍然拒绝,因为它发现 DEBUG_BYPASS_TOKEN = "dev-please-let-me-in" 这些被热修删掉的行回来了。
策略 D:丢掉旁路,留下 _digest。 这是人该做的那一种:重新读热修提交,确认它删的是什么,再把重构叠回去。探针:runtime_bypass=ABSENT,digest_helper=PRESENT。四项测试全过,闸门通过。
我另外做了一份「两点钟来不及补测试」的热修:main 上只改 auth.py,不添加 tests/test_hotfix_no_bypass.py。策略 A 之后,完整套件 3 passed,运行时后门 PRESENT。没有回归测试可红,分支绿、合并结果也绿,后门就这样回到 main。闸门照样拒绝,拒绝理由和带测试的那次相同——它看的不是测试出没出红,而是热修删掉的行在不在。

机制:分支上的绿勾证明不了热修还在
代理在分支上跑的测试,验证的是它自己知道的那几条路径。T0 的三项测试在后门还在的时候就是绿的,所以重构只要不破坏这三条,分支 CI 就会继续绿。热修新增的回归测试生在 M1 上;代理的分支如果还停在 M0,这份文件对它来说根本不存在。GitHub 上若没有打开「合入前必须基于最新 main」,审查员看见的就是这份过期的绿。
即便代理把 main 合进来了,只要它随后只重跑 tests/test_auth.py——很多代理的习惯是「再跑一遍我改过的测试」——策略 A 依然是绿的。完整套件能抓住带回归测试的 A,但抓不住 C:C 的运行时路径已经安全,热修那条断言也通过,回到树上的是那串不该再出现的令牌,以及 return 后面走不到的死代码。下一次有人「清理重复代码」、误删后面那截、把常量重新接进函数,后门不用再写一遍就活了。测试在这一步帮不上忙,因为此时测试看见的运行时是干净的。
git checkout --ours 之所以比手调冲突更危险,是因为它按整文件取舍。三路合并已经正确删掉的旁路判断,ours 会连同代理的重构一起请回来。C 表面上看起来更勤快,结果是另一种泄漏:运行时碰巧安全,密钥字符串却回到了仓库。两种情况审查机都可能放行,因为 diff 的主题是「加上恒定时间比较」,这是一个让人想点批准的标题。
7 号机如果只看检查状态、不看热修删掉的那几行是否还在,就会把一张「在加固认证」的 PR 送进 main。6 号机的 Windows 验证也救不了:这不是平台差异,是文本被写回去了。
解法:记下会话开始时的 main,核对热修删掉的行
我写了三道闸门,并在这个仓库上跑完了「过期会话 → 拒绝 → 四种解决 → 按结果放行或拦住 → 绑着 SHA 合入」的全程。
第一道是 session_base_check.py。会话开始时把 git rev-parse origin/main 写进 .agent-session-base。之后每次要解决冲突、推送或让人合并之前跑一次。规则很窄:main 没动,通过;main 动了,但 HEAD 已经包含当前 main,也通过,并提醒你还要跑下一道;main 动了、HEAD 还停在旧 SHA 上,而且两边改了同一批文件,或者 base..main 里出现了 hotfix: / security: / HOTFIX.md 列出的提交 / .hotfix-paths 里的路径,就拒绝。代理在 agent/refactor-auth 上跑到的就是这一种拒绝。它要阻止的是「我已经看见冲突了,按老规矩 checkout --ours」这一下。
第二道是 hotfix_hunk_guard.py。它在合并结果上工作:如果 HEAD 已经包含当前 main,就直接检查这棵树;否则用 git merge-tree。热修提交的识别顺序是 HOTFIX.md 里的 SHA、提交说明匹配 hotfix: / security:、以及碰到 .hotfix-paths 的提交。对每个被热修改过的文件,它数热修删掉的那些行在合并结果里是不是又多出来了;热修新增的文件必须还在。策略 A 被抓住,是因为旁路判断回来了;策略 C 被抓住,是因为常量回来了;策略 B 和 D 通过。空行的增减我故意不计,否则只是排版不同的合法重构也会被误杀——本机第一版闸门就这样把 D 错判过一次,后来改掉了。
第三道是 merge_if_fresh.sh。闸门的 JSON 里写下当时的 main SHA。合入时如果闸门本身失败,拒绝;如果 SHA 已经不是当前 main,也拒绝,要求重跑。本机对策略 A 打出 REFUSE agent/resolve-A: gate failed;把 D 的结果伪造到 M0 上时打出 REFUSE agent/resolve-D: gate ran on main=eb07706, main is now 7598e66 -> re-run gate;用对着 M1 算过的 D 结果则 MERGED agent/resolve-D into main=1c1da33。这和 GitHub 的「Require branches to be up to date before merging」以及 merge queue 是同一件事:不是多跑一遍测试,而是让一次通过和某个具体的 main 绑在一起。

正确的冲突解决不是选 ours 或 theirs,而是读热修提交、确认它删的行在你手里仍然是删掉的,再把你自己的重构叠回去。D 就是这样:旁路没了,_digest 还在。代理的 AGENTS.md 里应当写明:看见重叠路径上的热修,停下来,不许用 --ours 一键结束,不许为了变绿去改热修带来的测试文件。
代价:严格到什么程度
「main 一动且碰到热修就停」会打断代理。一次只改文档的 main 前进不应拦住认证重构;所以闸门看的是路径重叠和热修标记,而不是 main 的每一次前进。反向的代价是:如果热修提交没打上 hotfix:、也没写入 HOTFIX.md、.hotfix-paths 又是空的,闸门会认不出该保护哪一段,重叠检查仍能拦住「同一文件被两边改过」,但认不出「这段删除是安全相关的」。维护一份短的敏感路径名单,比指望代理自己想起昨晚两点的提交更可靠。
误报主要来自「热修删掉的某一行,恰好也是重构会重新写出来的普通语句」。return True 这种行如果在别的函数里合法出现,计数会偏。本机这个例子里,热修删掉的是带调试令牌的特有行,误报空间不大;更大的文件上,应当把闸门的失败信息(它会把回来的那一行打印出来)交给人看一眼,而不是设置成不可逾越、又不可理解的红叉。
我用一个模拟估了这种早晨有多常见(这部分是模拟,不是 GitHub 上的实测):200 个工作日早晨,过夜热修概率 0.18,热修又撞上还开着、并且改了同一文件的代理会话概率 0.45,冲突解决里 A/B/C 按 0.50/0.20/0.30 抽。结果是 11 次重叠冲突,没有闸门时 8 次会把源码级回退送进 main(A 5 次,C 3 次),B 的 3 次保持安全但丢掉代理的活;加上 hunk guard 之后,这 8 次都会在合入前被拒绝。数字只说明量级,不说明你们仓库的日历。
若团队已经有 merge queue,并且必需检查会在 merge_group 上跑完整套件,带回归测试的策略 A 会被测试拦住。C 仍然不会:测试是绿的。所以 queue 不能代替 hunk guard。个人私有仓库用不上 merge queue 时,7 号机在合入前跑这两道闸门并比对 SHA,效果等价,只是要自己维护。
什么时候不适合
- 热修本身是一次大面积重写。 删掉的「普通行」太多,任何并行重构都可能把其中几行以另一种形式写回来,闸门会吵。这种热修更该要求代理 rebase 之后由人读一遍 diff。
- 生成文件。 锁文件、生成的客户端代码不该放进
.hotfix-paths;那是另一类冲突,不该用安全删除的尺子去量。 - 热修本身是误判,后来必须把某行加回去。 此时应当再做一次显式提交说明理由,而不是在冲突解决里悄悄还原。闸门会挡住后者,这是它该做的。
- 单体仓库却没有人维护
.hotfix-paths。 没有名单、提交说明又只写fix: oops,闸门只能退回「路径重叠就拒绝」,噪音会变大。宁可名单短,不要空着指望从说明里猜。 - 你已经能保证每条热修都带回归测试、并且合并结果上一定会跑完整套件。 这对 A 够用,对 C 不够。源码里不该再出现的字符串,测试看不见。
给 1–3 号机的规则,和明天可以抄的步骤
开发机代理的规则可以短一点:会话开始先 git fetch 并记下 main 的 SHA;冲突解决之前跑 session_base_check.py,被拒绝就停;merge 和 rebase 下 --ours / --theirs 指的不是同一侧,不要把「保留 ours」写成默认;更新到当前 main 之后跑 hotfix_hunk_guard.py,它说某行回来了,就重新读热修提交,按 D 的方式改,不要改热修带来的测试;7 号机不得把失败的闸门用管理员权限绕过去。
明天可以按这个顺序做。第一,把 session_base_check.py、main_moved_gate.py、hotfix_hunk_guard.py 和 merge_if_fresh.sh 放进 tools/,对现在还开着的长会话跑一遍,看 main 是不是已经在你改过的文件上往前走过。第二,约定热修提交以 hotfix: 或 security: 开头,并维护一份短的 .hotfix-paths。第三,把套件里的 hotfix-hunk-guard.yml 加进仓库,设为必需检查;能开 merge queue 就开,并确认 merge_group 也会跑这个检查,不能开就让 7 号机合入前比对 base_sha。第四,热修尽量补上回归测试,但不要以为有测试就可以省掉对删除行的核对。第五,把上面那段规则写进 1 到 3 号机的 AGENTS.md。
诚实说明:什么跑了,什么没有
本机真实跑过: 玩具 git 仓库里的 M0、代理分支、热修提交;探针脚本和 pytest 9.1.1;四种冲突解决(A/B 是 git checkout --ours/--theirs,C 是去掉冲突标记,D 是手工叠回重构);rebase 时 ours/theirs 对调的 stage 2 / stage 3 内容;不带回归测试的「潦草热修」副本上策略 A 全绿、后门仍在;session_base_check.py、hotfix_hunk_guard.py、merge_if_fresh.sh 的拒绝和通过,包括 D 合入后 main 变为 1c1da33;工作流 YAML 用 PyYAML 做了语法解析。
没有跑: hotfix-hunk-guard.yml 没有在 GitHub Actions 上运行过,merge queue 和 merge_group 也没有实际触发;没有七台机器联调,「1 号机」「4 号机」是在同一台机器上用分支模拟的;200 个早晨的数字来自粗糙模拟;玩具函数里的调试令牌是为了让回退一目了然,不是从某个真实服务里摘出来的漏洞利用。
套件:hotfix-landed-stale-agent-session-kit.tar.gz,包含三道闸门、merge_if_fresh.sh、工作流样例、AGENTS.md 片段、实验脚本和完整运行日志。
一句话的判断:热修删掉的行,不允许被一个过期会话的冲突解决写回来。 分支上的绿勾只证明代理自己的测试还过;闸门要核对合并结果里那些删除是否还在,并把一次通过绑在当前 main 的 SHA 上。main 一动,就重跑。
At 02:17 on Monday, whoever was on call deleted DEBUG_BYPASS_TOKEN = "dev-please-let-me-in" from app/auth.py on main. It was a local-integration backdoor: call check_token with that string and the function returns True no matter what hash you pass. They also switched the comparison to hmac.compare_digest, wrote the hotfix SHA into HOTFIX.md at 02:18, and went back to sleep. The team is three people on seven machines — machines 1–3 run coding agents, 4–5 review, 6 does Windows validation, 7 helps merge. Nobody woke the session that was still open on machine 1.
That session had branched from Sunday 18:00 main. Its job was to extract a _digest helper and replace == with a constant-time compare. It was editing auth.py too. At 09:15 on Monday it ran git merge origin/main and app/auth.py conflicted. Following its usual policy of “keep my refactor”, it ran git checkout --ours app/auth.py. The conflict disappeared. It reran pytest tests/test_auth.py and got three passes. The checks on the PR page were still green.
Machine 4 glanced at the diff: the agent had added hmac.compare_digest, which looks like a security improvement. It merged at 09:40. The backdoor deleted at two in the morning rode back onto main inside a pull request that appeared to be hardening authentication.
The question for this post: after an overnight hotfix lands on main, how a coding-agent session still sitting on the pre-hotfix SHA can write the vulnerability back while resolving a conflict, why a green check on the branch will let that through, and which lines a gate should verify. It is in the same family as /merge-train-on-machine-7/ (“green alone, broken together”), with one extra twist: the merge result is not necessarily red. If the 02:00 hotfix did not add a regression test, main can take the backdoor back while every check stays green.

Lab: a check_token with a backdoor, four resolutions
I built a toy git repository on this box and ran real commands, not a simulation. The app is a single check_token(provided, stored_hash): the honest path hashes the offered secret and compares it to the stored digest, but T0 also has a “dev only” bypass. main at Sunday 18:00 is eb07706, which I call M0. A probe script calls check_token("dev-please-let-me-in", "not-a-real-hash") and prints runtime_bypass=PRESENT. pytest tests/test_auth.py is 3 passed — valid token, wrong token, empty token. None of those tests mention the debug string.
At 22:30 the agent on machine 1 branched agent/refactor-auth (b162bc8) from M0, added _digest and hmac.compare_digest, and left DEBUG_BYPASS_TOKEN untouched. Its three tests stayed green. At session start it wrote M0 into .agent-session-base.
At 02:17 the hotfix landed on main (6496969): it deleted the constant, deleted if provided == DEBUG_BYPASS_TOKEN: return True, switched the comparison to hmac.compare_digest, and added tests/test_hotfix_no_bypass.py, which asserts that the debug string must be rejected. At 02:18 a HOTFIX.md note followed. main became 7598e66 (M1). On M1 the probe prints runtime_bypass=ABSENT and all four tests pass.
The agent branch is still on M0. pytest tests/test_auth.py is still 3 passed; the hotfix test file does not exist on this branch, and pytest says file or directory not found. The full suite is those same three tests, all green. That is the green check review machines see when the repo does not require the branch to be up to date with main: it proves M0 plus the agent’s refactor, not M1 plus the agent’s refactor. session_base_check.py on this branch refused, with main and the session/PR both touched: app/auth.py and hotfix/security commit(s) landed on main since base: 6496969 hotfix: remove debug bypass token from auth.check_token.
In the morning the agent merged main and app/auth.py conflicted, as expected. I resolved that same conflict four ways, each with real git operations.
Strategy A: git checkout --ours app/auth.py. During git merge main, ours is the agent’s own branch. The file went back to the agent’s version: runtime_bypass=PRESENT, _digest still there. pytest tests/test_auth.py was still 3 passed; the hotfix’s test_debug_bypass_token_is_rejected failed, expecting False and getting True. The full suite was 1 failed, 3 passed. hotfix_hunk_guard.py refused, listing five lines the hotfix had deleted that were back, including DEBUG_BYPASS_TOKEN = "dev-please-let-me-in" and if provided == DEBUG_BYPASS_TOKEN:.
Agents trip over a related switch. If the same conflict is produced by git rebase main, stage 2 (ours) is the upstream — the post-hotfix file — and stage 3 (theirs) is the agent commit being replayed. On this box, git checkout --ours app/auth.py during the rebase kept the hotfix; --theirs kept the backdoor. That is the opposite of merge. “Always keep ours” is not a policy. It is a slogan that does not name which side you mean.
Strategy B: git checkout --theirs app/auth.py. Runtime backdoor ABSENT, four tests passed, gate passed. The probe also printed digest_helper=ABSENT: the _digest helper was gone. Safe, and last night’s refactor was discarded with the rest of the file.
Strategy C: strip the conflict markers and keep both sides. This is what an impatient agent actually does. The three-way merge had already done part of the job: the agent had not touched if provided == DEBUG_BYPASS_TOKEN: return True, the hotfix deleted those lines, and git accepted the deletion cleanly — they were not even in a conflict hunk. The conflicts were elsewhere: the DEBUG_BYPASS_TOKEN constant (deleted by the hotfix, adjacent to edits on the agent side) and the return statement (edited on both sides). After stripping markers, the constant was back, and the function body returned via hmac.compare_digest(_digest(...)) with the hotfix’s two lines left as dead code underneath. The probe printed a combination that looks contradictory: runtime_bypass=ABSENT, source_token=PRESENT. The full suite was 4 passed. A reviewer sees a clever merge that kept the refactor and accepted the hotfix’s comparison, all green. hotfix_hunk_guard.py still refused, because DEBUG_BYPASS_TOKEN = "dev-please-let-me-in" — a line the hotfix deleted — had come back.
Strategy D: drop the bypass, keep _digest. This is the resolution a person should produce: reread the hotfix commit, confirm what it deleted, then replay the refactor on top. Probe: runtime_bypass=ABSENT, digest_helper=PRESENT. Four tests passed, gate passed.
I also built a “too late at night to add a test” hotfix: main only changed auth.py, with no tests/test_hotfix_no_bypass.py. After strategy A the full suite was 3 passed and the runtime backdoor was PRESENT. There is no regression test to turn red, so the branch is green, the merge result is green, and the backdoor returns to main. The hunk guard still refused, for the same reason as in the version with a test — it does not look at whether a test went red; it looks at whether the deleted lines came back.

Mechanism: a green check on the branch does not prove the hotfix is still there
The tests an agent runs on its branch cover the paths it knows about. The three T0 tests were already green while the backdoor existed, so any refactor that leaves those three alone stays green. The hotfix’s regression test was born on M1; if the agent branch is still on M0, that file does not exist for it. If GitHub is not set to require the branch to be up to date before merging, reviewers are looking at that stale green.
Even after the agent merges main, if it then reruns only tests/test_auth.py — a common habit, “re-run the tests I touched” — strategy A is still green. The full suite catches A when a regression test exists, and it does not catch C: C’s runtime path is already safe, the hotfix assertion passes, and what returned to the tree is a string that should never appear again, plus unreachable lines after return. The next person who “cleans up duplicated code”, deletes the dead tail, and wires the constant back into the function does not have to reinvent the backdoor. Tests do not help at that moment, because the runtime they see is clean.
git checkout --ours is more dangerous than editing the hunks by hand because it chooses whole files. Bypass lines the three-way merge had already deleted correctly come back together with the agent’s refactor. C looks more diligent and produces a different leak: runtime happens to be safe, the secret string is back in the repository. Either one can get approved, because the theme of the diff is “add a constant-time compare”, which is a title reviewers want to accept.
Machine 7 cannot save you by reading check status alone. It has to ask whether the lines the hotfix deleted are still gone. Machine 6’s Windows validation cannot save you either: this is not a platform bug, it is text written back.
The fix: record main at session start, then check that hotfix deletions survived
I wrote three gates and ran the full loop on this repository: stale session, refuse, four resolutions, pass or block on the result, merge only with a SHA-bound gate.
The first gate is session_base_check.py. When the session starts, write git rev-parse origin/main to .agent-session-base. Run the script before resolving a conflict, pushing, or asking someone to merge. The rule is narrow: if main has not moved, pass; if main has moved but HEAD already contains current main, pass and remind you to run the next gate; if main has moved, HEAD is still on the old SHA, and either the two sides touched the same files or base..main contains a hotfix: / security: commit, a SHA listed in HOTFIX.md, or a path listed in .hotfix-paths, refuse. That is the refusal the agent hit on agent/refactor-auth. What it exists to stop is the next motion: “I see a conflict, I will checkout --ours like I always do.”
The second gate is hotfix_hunk_guard.py. It inspects the merge result: if HEAD already contains current main, it checks that tree; otherwise it uses git merge-tree. Hotfix commits are detected from SHAs in HOTFIX.md, from subjects matching hotfix: / security:, and from commits that touch .hotfix-paths. For each file the hotfix changed, it counts whether lines the hotfix deleted have come back in extra copies; files the hotfix added must still exist. Strategy A was caught because the bypass branch came back; strategy C was caught because the constant came back; B and D passed. I deliberately ignore blank-line counts — otherwise a legitimate refactor that only changed layout would fail. The first version of the gate on this box did exactly that to D; I fixed it after seeing the false positive.
The third gate is merge_if_fresh.sh. The gate JSON records the main SHA it ran against. At merge time, a failed gate is refused; a SHA that is no longer current main is also refused, and you have to re-run. On this box, strategy A printed REFUSE agent/resolve-A: gate failed; a D result forged against M0 printed REFUSE agent/resolve-D: gate ran on main=eb07706, main is now 7598e66 -> re-run gate; the D result computed against M1 printed MERGED agent/resolve-D into main=1c1da33. That is the same job GitHub’s “Require branches to be up to date before merging” and a merge queue do: not “run the tests one more time”, but bind a passing result to one concrete main.

The right conflict resolution is not ours or theirs. It is: read the hotfix commit, confirm that what it deleted is still deleted in your tree, then lay your own refactor back on top. That is D — no bypass, _digest still there. The AGENTS.md for machines 1–3 should say so: if a hotfix landed on a path you touched, stop; do not finish with --ours; do not edit a test file the hotfix added just to get green.
Cost: how strict to be
“Stop whenever main moved under a hotfix” will interrupt agents. A main commit that only touched docs should not block an auth refactor, which is why the gate looks at path overlap and hotfix markers, not at every advance of main. The cost the other way: if the hotfix commit is not labelled hotfix:, is not listed in HOTFIX.md, and .hotfix-paths is empty, the gate cannot tell which deletion to protect. Overlap still stops “both sides edited the same file”, but it cannot say “this deletion is security-relevant”. A short allowlist of sensitive paths is more reliable than hoping the agent remembers a 02:00 commit.
False positives come from ordinary lines a hotfix happened to delete that a refactor would write again. A bare return True in another function can skew the count. In this lab the deleted lines were unique to the debug token, so the room for a false hit was small. On a larger file, a person should read the gate’s failure (it prints the line that came back) rather than treating the red X as both unoverridable and unintelligible.
I estimated how often such a morning happens with a simulation (this part is a simulation, not something measured on GitHub): 200 weekday mornings, probability 0.18 of an overnight hotfix, probability 0.45 that the hotfix overlaps a still-open agent session on the same file, and A/B/C drawn at 0.50/0.20/0.30. That produced 11 overlapping conflicts; without the guard, 8 would have landed a source-level regression (5×A, 3×C); the 3×B stays safe and drops the agent’s work; with the hunk guard those 8 are refused before merge. The numbers show an order of magnitude, not your calendar.
If the team already has a merge queue, and required checks run the full suite on merge_group, strategy A with a regression test will turn red there. C will not: the tests are green. A queue does not replace the hunk guard. On a personal private repo that cannot use a merge queue, machine 7 running both gates and comparing SHA before merge is equivalent, just something you have to maintain.
When it does not fit
- The hotfix itself is a large rewrite. Too many ordinary lines were deleted, and any parallel refactor may recreate a few of them. The gate will be noisy. Those hotfixes should force a rebase and a human reading of the diff.
- Generated files. Lockfiles and generated clients do not belong in
.hotfix-paths. That is a different class of conflict. - The hotfix was wrong, and a line really does need to come back. Say so in an explicit follow-up commit. Do not restore it quietly inside a conflict resolution. The gate should block the quiet restoration.
- A monorepo with nobody maintaining
.hotfix-paths. No list, and subjects that only sayfix: oops, and the gate can only refuse on path overlap, which gets loud. Keep the list short; do not leave it empty and hope to parse intent from commit messages. - You already guarantee every hotfix ships a regression test, and the merge result always runs the full suite. That is enough for A, not for C. Tests cannot see a string that should no longer be in the source.
Rules for machines 1–3, and steps you can copy tomorrow
Keep the agent-facing rules short: at session start, git fetch and record main’s SHA; run session_base_check.py before resolving a conflict, and stop if it refuses; --ours / --theirs do not name the same side under merge and rebase, so do not make “keep ours” the default; after updating onto current main, run hotfix_hunk_guard.py, and if it says a line came back, reread the hotfix and resolve like D — do not edit the hotfix’s tests; machine 7 does not --admin merge past a failed gate.
Tomorrow, in this order. First, put session_base_check.py, main_moved_gate.py, hotfix_hunk_guard.py, and merge_if_fresh.sh in tools/, and run them against any long-running session you still have open, to see whether main has already moved under a file you touched. Second, agree that hotfix commits start with hotfix: or security:, and keep a short .hotfix-paths. Third, add the sample hotfix-hunk-guard.yml and make it required; turn on a merge queue if you can and confirm the check also runs on merge_group; otherwise have machine 7 compare base_sha before merging. Fourth, prefer that hotfixes include a regression test, but do not treat the test as a reason to skip checking that deletions survived. Fifth, copy the rules above into AGENTS.md on machines 1–3.
Honesty: what ran, and what did not
Run for real on this box: the toy git repo at M0, the agent branch, and the hotfix commit; the probe script and pytest 9.1.1; all four conflict resolutions (A/B via git checkout --ours/--theirs, C by stripping markers, D by re-applying the refactor); the ours/theirs swap during rebase as stage 2 / stage 3; the sloppy-hotfix copy where strategy A was fully green and the backdoor remained; session_base_check.py, hotfix_hunk_guard.py, and merge_if_fresh.sh refusals and passes, including D merging to main=1c1da33; syntax parsing of the workflow YAML with PyYAML.
Not run: hotfix-hunk-guard.yml was never executed on GitHub Actions, and neither a merge queue nor merge_group was triggered; there was no seven-machine setup — “machine 1” and “machine 4” are branches on one computer; the 200-morning counts come from a coarse simulation; the debug token in the toy function is there so a regression is obvious, not a vulnerability copied out of a real service.
The kit: hotfix-landed-stale-agent-session-kit.tar.gz, with the three gates, merge_if_fresh.sh, the sample workflow, the AGENTS.md fragment, the experiment scripts, and the full run logs.
The judgment, in one line: lines a hotfix deleted must not be written back by a stale session’s conflict resolution. A green check on the branch only proves the agent’s own tests still pass. The gate has to verify those deletions in the merge result, and bind a passing run to the current main SHA. When main moves, run it again.