MergeGate sweeps for approved PRs whose last trigger was lost (rc#2365)¶
The gap¶
MergeGate runs when a review webhook, a check_run webhook or POST /api/merge sets a condition, and when the
rc#2425 recheck runs after a defer on running checks. That recheck lives
in the pod's memory. If the pod dies, or the last event never arrives, nothing evaluates the PR until an unrelated event
touches it. On 2026-09-23 rig-conductor#2355 was approved at its head (22:23:54) and green (22:24:21); the pod rolled over
at 22:26:39, and the PR merged at 22:35 only because a human re-ran a check.
The policy¶
MergeGateSweepPolicy is pure: it reads the fields of a
merge-gate:{repo}#{pr} hash and the PR's budget, and decides. It sets nothing and triggers nothing itself.
It was merged in #2700. The service below, from #2696, runs it.
Eligibility¶
| A hash is due when | Why |
|---|---|
review-approved is set and its time parses |
The approval is the long-lived prerequisite. ci-passed is not required: a lost final check_run never sets it, and a guard defer on running checks clears it. Merge()'s guard reads the checks itself |
no blocked field |
do-not-merge |
no merged:{repo}#{pr} key |
A merge is in flight or done. The key lives 1 h |
| the newest condition is at least 2 min old | Younger belongs to the attempt its own trigger started |
| the budget allows an attempt | See below |
There is no upper age limit. A PR stays recoverable after an outage over an hour, or after a pre-PUT merged: key from a
pod that died before its PUT expires.
The budget¶
| Trigger identity | The stamp is the newest condition time (review-approved or ci-passed). The budget keeps a watermark, the newest stamp it has seen |
| Fresh budget | Only for a stamp newer than the watermark. When the guard clears ci-passed, the stamp falls back to the approval's time, which is older: the count and the spacing carry on |
| Limit | 12 attempts per trigger |
| Backoff after attempt n | none before the first, then 5, 10, 20, 40, 80 min, then 2 h each: about 15 h in all |
| PR not open | The guard's pr_not_open defer spends the rest of the budget at once: a merged PR whose merged: key expired costs one GitHub read |
Persisted fields¶
| Key | Fields | TTL |
|---|---|---|
merge-gate-sweep:{repo}#{pr} |
attempts, last (ISO 8601 UTC), conditionsAt (the watermark) |
24 h, like the condition hash |
The budget key's -sweep: prefix keeps it out of the merge-gate:* scan. A missing or damaged budget reads as none.
The service¶
MergeGateSweepService runs 2 minutes after start, then every 5 minutes. It SCANs merge-gate:* and reads each hash, its
merged: key and its budget. For each hash the policy says is due, it calls MergeGate.RetryAsync and then writes the
budget with After.
RetryAsync re-checks the flags: review-approved set, no blocked, no merged:. Then it runs Merge(), the path every
webhook takes, and emits MERGE_GATE_MERGED only after a real merge. The location fence
(rig-conductor#2561) and the guard
(rc#2053) decide:
- the PR is open;
- every non-skip check on its head passed;
- Review-E's latest verdict approves that head.
A trigger can only cause an early or redundant attempt, never a wrong merge. CHANGES_REQUESTED at the head stays
fail-closed, and the guard clears review-approved, which ends the sweep for that PR.
The sweep reads only Valkey. Each attempt costs the guard's GitHub reads, one for a PR that is not open.
MergeGateSweep:Enabled=false (env MergeGateSweep__Enabled) turns it off.
Tests¶
MergeGateSweepPolicyTests cover:
- eligibility, including a missing
ci-passed, merged and blocked hashes, and the settle; - old conditions staying recoverable;
- the backoff schedule and its cap;
- spacing within one trigger and a spent budget;
- a cleared newer flag keeping an exhausted budget, and keeping the spacing and the watermark. Both tests fail if any different stamp resets the budget;
- a fresh budget only for a newer condition;
pr_not_open;- the budget round trip and key parsing.
MergeGateTests run the service over the MergeGate harness, with an NSubstitute Valkey, a stub GitHub and a controlled
clock:
- a lost trigger merges on the next sweep and sets no condition;
- a cleared
ci-passedflag, with the in-memory recheck lost and the completion webhook missing, merges once GitHub is green, after one backoff; - a stranded pre-PUT
merged:key: nothing while it stands, then exactly one merge after it expires, more than an hour on; - CHANGES_REQUESTED at the head: the guard defers and clears the approval;
- a closed PR spends its budget;
- a recorded merge, and a condition inside the settle, are left alone.
With the retry call removed, five of these fail.