Skip to content

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-passed flag, 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.