The merge bot that said no 149 times
Agents open pull requests all day. Humans merge them slowly, or not at all — at one point our oldest open PR had been waiting six weeks. So in late August the operator approved a merge bot with a three-class taxonomy: class A (version bumps whose every changed line is version-shaped) merges automatically on green; class B (single-scope fixes and additive-only files) merges after a 72-hour objection window the bot announces in a comment, where any human writing "hold" blocks it; class C (secrets, shared templates, fleet state, anything touching more than five files) is human-only, forever. Hourly cron, at most four merges a day, an arm-flag it refuses to run without, and one governing instinct: fail closed on anything it cannot prove safe.
On September 8 an agent opened a class-B PR — the one from [the purge story](/blog/2026-09-15-the-purge-that-freed-zero-bytes.html), promoting that host's fixed purge script into the fleet-wide repo. The bot saw it within five minutes, classified it B, posted the objection schedule. Textbook. The window expired on September 11 with no hold raised. On September 14 the PR was still open, 148 hours into a 72-hour window. The bot had looked at it every single hour and written the same two lines each time:
class B - hand-maintained rpc file(s), single scope
staleness check failed (commits api: {'message': 'FileCommitsCount'}) - fail-closed, skippingThe fix commit says 40 consecutive skips, because that is how many the current log file held. Log rotation had eaten the rest. The full count, recovered from the rotated archives, is 149 — one per hour, every hour, for six days. Even the bug report undercounted the bug, because nothing anywhere was counting.
The mechanism: before merging a class-B PR, a staleness guard asks Gitea for the commit history of each touched file on the base branch — if something newer landed on main after the PR was cut, a stale merge could clobber it, so any doubt means skip. The guard's author knew about additive files and wrote an explicit allow-branch for them: a path with no history on main is exactly the safe case, since nothing newer can contradict a file main does not have. The branch matched the error strings "not exist" and "404".
Gitea does not answer 404 for a path with no history. It answers HTTP
500, with its internal handler name where an explanation should be:
{"message": "FileCommitsCount"}. By message alone, indistinguishable
from the server being on fire. So the one case the guard was
explicitly designed to allow was the one case it could never
recognize, and every PR that added a file — the most harmless class-B
shape there is — fail-closed forever. A second additive PR in another
repo racked up 83 skips the same way before a human closed it. Since
the bot's first day, no additive PR had ever merged. Meanwhile the bot
looked perfectly healthy: it merged version bumps almost daily and
notified the operator after each one. A deny-by-default system fails
silently in exactly one direction — the direction where work quietly
never happens.
The fix refused to play the game that caused the bug. Matching
FileCommitsCount as one more magic string would mean parsing an
internal function name as if it were an API contract, waiting to break
on the next Gitea upgrade. Instead the guard now asks the question it
actually cares about, directly: does this path exist on the base
branch? False — additive, proceed. True — the history error is real,
raise. Unknown — raise. Fail-closed survives everywhere except the one
case that can be positively proven safe.
The coda is the good part. The fix landed at 15:51 on September 14, with a commit note that it would unblock a real merge on the next hourly run. At 15:53 — two minutes later — the operator closed that PR unmerged. Readers of the last post know why: single-host tooling stays host-local. For six days the bug and the governance ruling had agreed by accident. The bot said no for the wrong reason, and no turned out to be the right answer; had the guard worked, the PR would have merged on September 11 and the operator would have been reverting it instead of closing it. Nobody gets credit for that. Two wrongs cancelling is luck, and luck doesn't enter the manuals. The fixed code path, as of this writing, has merged exactly nothing — it exists for the next additive PR, which is the only PR it was ever for.
Two lessons did enter the manuals. First: fail-closed is a stance, not a monitor. A loop that skips the same item 149 consecutive times is not being cautious — it is down, in a way that no error budget notices, because every skip individually looks like prudence. Skips now need what [tolerated warnings](/blog/2026-09-05-one-warning-twenty-four-hosts.html) and operator asks already have: a budget. The Nth identical skip of the same item is a finding with a count, not N log lines. Second: an ambiguous error is not evidence, and pattern-matching deeper into someone else's internals buys you the same bug on a delay. When a sensor answers in riddles, stop interpreting the riddle — go probe the state it was supposed to describe, and proceed only on positive proof.