# Two Green Pull Requests, One Red Main: The Stale Merge Ref

Author: Maksym Tytarenko | Date: 2026-09-08 | Category: SaaS Development | Tags: merge refs, continuous integration, ai agents, code review, developer workflow, python
Canonical: https://www.tytarenkoagency.com/blog/two-green-pull-requests-one-red-main-the-stale-merge-ref

> Two of my agent fleet's pull requests passed CI, then their merge turned main red and blocked every open pull request. Git saw no conflict, because there was none: one branch made a function argument required, the other added a call site that the first branch's tree never contained. A walk through the stale merge ref, the diagnosis I got wrong, the convenient fix I refused, and the merge rule I now follow.

## The morning main went red

I run a small fleet of coding agents on my own hardware. A daemon on a dedicated box polls a GitHub Projects board, and each task it claims runs as headless Claude Code inside a Docker container. One Claude writes the code. A second Claude, in a fresh session that never watched the first one work, reviews the result and sends it back for revision rounds until it is satisfied. After that approval an optional cross vendor skeptic, an OpenAI model driven through the Codex CLI, gets a turn at trying to refute the pull request. Only then does a card land in my Telegram with a merge button on it, and only then do I press anything.

On the morning of 7 September I pressed two of those buttons, forty eight minutes apart. Both pull requests were green. Both had survived the reviewer. The second merge turned main red, and every open pull request in the repository lost its checks along with it.

The failure was one line, repeated:

```
TypeError: Controller._drain_defers() missing 1 required keyword-only argument: 'kind'
```

Six tests in `tests/test_review_cycle.py` raised it on the merged tree at `a5d52e0`. A seventh assertion about an unstubbed API call rode along behind them, a cascade rather than a second bug: the type error aborted a cycle mid flight and a later teardown read escaped its stub.

Git had reported no conflict. There was nothing to resolve, no markers, no ours and theirs. The two branches never touched the same lines of the same file.

## Two features that never met

The two changes had been filed twelve seconds apart the previous night, and they were about completely different problems.

The first was a review loop defect. A revise container that pushed nothing was being answered with a full re review of the same head, and the loop repeated until a human noticed. In one case the reviewer read the identical commit three times and then wrote, in its third pass, that the head was still the sha it had already reviewed and that the diff against it was empty. Three full review passes with full context, for one line of new information. The fix was to escalate a no op revise once to the next rung of the model ladder and then park it with a card, instead of buying a fourth identical opinion.

The second was a throughput defect in the deploy drain. Every merge to main restarts the daemon, and before the restart the drain waited on every running container, review and revise containers included, for up to sixty five to ninety minutes. Over two days with twelve merges, and therefore twelve restarts, that built a queue. A security sensitive revise waited more than an hour behind a single review container. Two full reviews sat unstarted for roughly two hours while nothing else moved. The daemon already adopts containers that survive a restart, so the wait was guarding state that the adoption path handled anyway. The fix taught the drain about container classes: hold the window only for executor containers, and let review and revise work through.

To make that possible, the refactor gave `Controller._drain_defers()` a required keyword only argument, `kind`, so that every caller has to declare what class of container it is about to launch. It updated eight call sites in the process.

The ninth call site did not exist in the tree that refactor was written against. It was the escalation path the other branch was adding at the same time.

## Why both pull requests were green

This is the part that is worth sitting with, because it is not an agent problem and it is not new.

A pull request's checks do not run on your branch. They run on a merge ref, a synthetic commit that GitHub builds by merging your head into the current base. That ref is computed when something changes, and it is perfectly happy to be stale. The escalation branch had its merge ref built against a base that did not yet contain the drain refactor. The drain refactor had its merge ref built against a base that did not yet contain the escalation.

![An infographic illustrating how pull request checks work and the concept of stale merge refs.](https://media.tytarenkoagency.com/ai-generated/uploads/20260908_181515_4faeff8b.png)

Each pull request was therefore green against a version of the repository that stopped existing the moment the other one landed. Neither run ever compiled the combined tree, because the combined tree did not exist while either check was running. The first merge, at 03:37 UTC, was fine. The second, at 04:25 UTC, produced a main that no test run had ever seen.

Branch protection did not catch it either, and it was not supposed to. Required checks gate the head of a pull request. They do not re verify the base after it moves, unless you ask for that explicitly. My repository did not ask.

The blast radius is what makes this expensive rather than annoying. Pull request checks run against the base, so a broken base breaks every open pull request's checks at once. My fleet keeps several in flight by design. All of them went red on a mistake none of them contained.

## The diagnosis I filed was wrong

I wrote the bug report at 05:11 UTC, forty six minutes after the merge that caused it, and I got the location wrong.

The report said the missing arguments were in the tests that the escalation branch had landed. It read that way from the traceback, because the failing frames were all in `tests/test_review_cycle.py`. So the ask I handed the fleet was: either give `kind` a safe default, or update every call site in that test file.

Neither was right. No test in that file calls `_drain_defers` directly. The missing argument was in production code, in `Controller._escalate_noop_revise` inside `agent_corp/daemon.py`, on the path the tests drive. The tests were the messenger.

The agent that took the task fixed the real call site and said so in its own report, under a heading it keeps for exactly this: the issue said the callers were in the test file, the caller was actually in the daemon, and here is why the production call site was the reconciliation point. That note is the reason I trust the output. An executor that silently does something other than what the ticket said, and hands back a green check, is worse than one that argues.

It is also a reminder about tracebacks. The frame that raises is not the frame that is wrong. In a codebase where tests drive a state machine, the failing frame is almost never where the contract broke.

## Why I did not take the convenient fix

The obvious repair was to give `kind` a default. One line, no call sites touched, everything green.

It would have been a quiet regression. An unlabelled kind fails closed in the predicate that decides what a drain defers: with nothing to classify, it defers. So a defaulted call site would have parked every escalated revise for the entire drain window, which is up to ninety minutes, on every merge. That is precisely the cost the drain refactor had just been written to remove. The build would have been green and the feature would have been dead.

The fix that shipped was the explicit one: pass `kind="revise"` at the missed call site. The escalated revise runs through the same round machinery as the revise beside it, so it genuinely is a revise container, and the drain does not wait on that class. Both features keep their semantics. The whole change was thirty one lines added and two removed across two files, most of it the regression test.

That test is worth naming, because it encodes the interaction rather than the symptom. It drives the escalation under a live drain lock and asserts two things: that the escalated round still launches on the next rung of the ladder, and that the cycle is not parked as drain deferred. Run it against the broken tree and it reproduces the exact type error. A test that only asserted "no type error" would have passed under the convenient fix too, and would have protected nothing.

The verification came back as one green run of both named suites together, two hundred and forty tests in forty eight seconds, plus a second batch of two hundred and eighteen across every suite that touches the drain gate. The full suite, two hundred and forty two files, exceeds the container's ten minute cap on a single command, so the named suites plus the neighbours are what a fix like this can honestly claim.

And one honest limitation stayed in the report: main being green on the fix commit can only be observed after the merge. The break was invisible on per pull request merge refs, which is the entire point of the story, so the local green of both suites on one tree was the best proxy available before pressing the button.

## What a fleet changes about a very old bug

Cross branch contract breaks are older than continuous integration. What changes when agents write the branches is the rate.

Two issues were filed twelve seconds apart, worked in parallel by two containers, reviewed independently by a skeptical reviewer each, and merged within an hour of each other. Every one of those steps did its job. The reviewer that read the escalation could not have caught this: the drain refactor was not in the tree it was reading. The cross vendor skeptic could not have caught it either, for the same reason. A reviewer who cannot see the other branch cannot review the interaction, and neither of mine could see it.

That is the structural lesson. Review quality does not compose across concurrent branches. You can raise the model tier, add a second vendor, add a third pass, and none of it addresses a defect that exists only in a tree nobody read. The only layer positioned to see it is the one that assembles that tree, which is the merge itself.

There is a related trap in how the fleet reported the failure. The bug was detected because main's own run went red, not because any pull request complained. Every open pull request showed red checks, but they showed them for a reason that had nothing to do with their own diffs, which is a very good way to train yourself to ignore a red check.

## The rule I changed

The repair took forty nine minutes from the report to the merged fix, and main was red for about ninety five minutes total. That is a cheap outcome for a solo operation, and it was cheap because the failure was loud. It could just as easily have been a behaviour change instead of a type error, in which case it would have shipped.

So the rule I now apply is narrow and mechanical. When two pull requests are queued at once and I merge the first, the second one's green check is stale by definition. Before merging it, I make it re run against the new base: an empty push, a rerun of the workflow, or the merge queue if the repository is busy enough to justify one. If both touch the same subsystem, the second one waits for a fresh run, always.

Three other habits came out of the same morning. Treat any keyword only argument added to an existing function as an interface change with a blast radius, not a refactor, and search the open branches rather than only the current tree. When a fix is available in a convenient form and a correct form, check whether the convenient one fails open or fails closed, because a default that silently defers work is worse than a loud crash. And write the regression test against the interaction, not against the exception, because the exception is the easy half to reproduce.

None of that is exotic advice. It is what you already do when a colleague is about to merge on top of you. The thing worth noticing is that a fleet of agents removes the moment where you would have noticed, because nobody is standing at the other desk, and the second merge is just another button.

## FAQ

### What is a stale merge ref and why does it matter?

A pull request's checks do not run on your branch. They run on a synthetic commit that merges your head into the base as it was when the ref was last computed. If the base moves after that, the passing result describes a tree that no longer exists. This is fine most of the time and dangerous exactly when two pull requests change a shared contract at once.

### Why did Git not report a merge conflict?

Git reports conflicts about text, not about meaning. One branch changed a function to require a new keyword only argument. The other branch added a new call to that function somewhere else entirely. No two edits overlapped, so the merge was clean and the result was broken. Semantic conflicts are invisible to the merge algorithm by construction.

### Why did the automated reviewers miss it?

Each reviewer read one branch against a base that did not contain the other branch. The defect existed only in the combined tree, which no reviewer session ever saw. Adding more review passes or a second vendor would not have helped, because the missing input was the other branch, not more scrutiny.

### How do I prevent this without a merge queue?

Force a fresh run on the second pull request after the first one lands, and refuse to merge on a check that predates the current base. A merge queue automates that rule, but the rule itself is what matters, and an empty push achieves it on a repository that merges a few times a day.

### Does this get worse with AI agents writing the branches?

It gets more frequent, not more severe. Agents raise how many branches are open and how quickly they land, which raises the chance that two of them touch the same contract in the same hour. The mitigation is unchanged from human teams: make the base state part of what gets checked before the merge, not after.

## Related reading

- [Four Merge Presses, One Merge: Fixing the Approval Layer of My AI Agent Fleet](https://www.tytarenkoagency.com/blog/four-merge-presses-one-merge-approval-layer)
- [Flagging Tests That Assert Nothing, When All You Can See Is the Diff](https://www.tytarenkoagency.com/blog/flagging-tests-that-assert-nothing)
- [Streamlining AI Agent Deployments: From Frequent Restarts to Efficiency](https://www.tytarenkoagency.com/blog/streamlining-ai-agent-deployments-efficiency)

## Sources

- [docs.github.com](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks)
- [docs.github.com](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)

