You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An unstable merge state summons a human, and then latches
GitHub reports mergeable_state: "unstable" for two different situations: a non-required check is failing, and checks are still running. The gate treats both as a manual-review hold. On a repo meant to run autonomously that is the wrong answer in both cases — a pending PR is simply not ready yet, and a failing one has already been answered by CI, which will answer again on the next push. Neither needs a maintainer.
Observed on #10098 and JSONbored/metagraphed#8838: both carried manual-review, neither was a guardrail hit, and #10098 sat open with finished (red) CI until a human stripped the label by hand.
Why it holds, and why guardrail escalation does not save it
heldBy is the list of declared hold inputs (MERGE_HOLD_INPUTS). unstableHolds is not one of them — it is OR-ed in separately. Two consequences:
The escalation config cannot release it.releasedHolds in derivePrDisposition contains exactly one key, guardrailHit. Routing guardrail-path PRs to a stronger model with onCleanReview: proceed releases the guardrail term and nothing else, so a PR held by unstableHolds stays held no matter how the escalated review comes back. Worse, guardrailEscalationCleared requires reviewGood, which requires green CI — so on a red-CI PR it can never fire at all.
heldForManualReview is not advisory. Three separate mechanisms read it or the label it produces:
the executor denies merge and approve while the label is present;
merge-train.ts:152 filters !sibling.heldForManualReview, so a held PR is evicted from the train entirely — which is why these PRs get skipped with no merge-train comment while other PRs merge past them;
section 1b's release condition (noManualReviewHoldWanted) also tested !mergeableStateUnstable.
That last one is the latch: the label is applied while CI is red, and then cannot be lifted because CI is red. Nothing short of a human removing it clears the state.
The fix
An unstable merge state stops being a manual-review hold. It does not become mergeable.
heldForManualReview = heldBy.length > 0 — the declared table becomes the whole definition, so heldBy and heldForManualReview finally agree.
wouldMerge is untouched — it already required mergeable === "clean".
Both label sites in agent-actions.ts stop emitting: the merge-autonomy fallback (section 1f) is removed outright, and section 2's disposition ternary returns null — neithermanual-review nor ready-to-merge, because claiming "ready" on a PR the merge will self-suppress on is the other half of fix(review): keep a fetched file distinguishable from an omitted one under thin budget #8711.
!mergeableStateUnstable comes out of noManualReviewHoldWanted, so the latch releases and the planner takes back labels it applied.
The contributor still learns the state: the unified comment refuses "safe to merge" via mergeStateHeld, and the PR's own Checks tab names the unhappy checks — always current, unlike a bot comment posted once per pass on every in-flight PR.
Not in scope
deriveUnifiedStatus renders mergeStateHeld as "Manual Review" in the comment's verdict box. That wording is now inconsistent with the disposition, but it is text on a comment — it blocks nothing and the same code path serves dirty/behind. Filed separately rather than widened into this change.
An unstable merge state summons a human, and then latches
GitHub reports
mergeable_state: "unstable"for two different situations: a non-required check is failing, and checks are still running. The gate treats both as a manual-review hold. On a repo meant to run autonomously that is the wrong answer in both cases — a pending PR is simply not ready yet, and a failing one has already been answered by CI, which will answer again on the next push. Neither needs a maintainer.Observed on #10098 and JSONbored/metagraphed#8838: both carried
manual-review, neither was a guardrail hit, and #10098 sat open with finished (red) CI until a human stripped the label by hand.Why it holds, and why guardrail escalation does not save it
derivePrDispositioncomputes:heldByis the list of declared hold inputs (MERGE_HOLD_INPUTS).unstableHoldsis not one of them — it is OR-ed in separately. Two consequences:releasedHoldsinderivePrDispositioncontains exactly one key,guardrailHit. Routing guardrail-path PRs to a stronger model withonCleanReview: proceedreleases the guardrail term and nothing else, so a PR held byunstableHoldsstays held no matter how the escalated review comes back. Worse,guardrailEscalationClearedrequiresreviewGood, which requires green CI — so on a red-CI PR it can never fire at all.heldBystays empty, so the hold reachesdecision_recordswith no cause andreason_codefalls through to the gate conclusion — this is a large share of the 518 holds filed underreason_code: "success"that orb(ledger): a hold records reason_code 'success', so the manual-review surface cannot be split by cause #9991 went looking for.Why the PR then gets stuck
heldForManualReviewis not advisory. Three separate mechanisms read it or the label it produces:merge-train.ts:152filters!sibling.heldForManualReview, so a held PR is evicted from the train entirely — which is why these PRs get skipped with no merge-train comment while other PRs merge past them;noManualReviewHoldWanted) also tested!mergeableStateUnstable.That last one is the latch: the label is applied while CI is red, and then cannot be lifted because CI is red. Nothing short of a human removing it clears the state.
The fix
An unstable merge state stops being a manual-review hold. It does not become mergeable.
heldForManualReview = heldBy.length > 0— the declared table becomes the whole definition, soheldByandheldForManualReviewfinally agree.wouldApprovegains an explicit&& !unstableHolds. This is load-bearing: it used to ride on the hold term, and dropping that term without restating it would let approve fire while merge self-suppressed — exactly fix(review): keep a fetched file distinguishable from an omitted one under thin budget #8711 ("approved, labeled ready, never merged, nobody told"). It is keyed onunstableHoldsand not onmergeable === "unstable", so fix(ops): scope the PagerDuty cooldown to rows that actually paged #9810's ignored-check dismissal stays approvable.wouldMergeis untouched — it already requiredmergeable === "clean".agent-actions.tsstop emitting: the merge-autonomy fallback (section 1f) is removed outright, and section 2's disposition ternary returnsnull— neithermanual-reviewnorready-to-merge, because claiming "ready" on a PR the merge will self-suppress on is the other half of fix(review): keep a fetched file distinguishable from an omitted one under thin budget #8711.!mergeableStateUnstablecomes out ofnoManualReviewHoldWanted, so the latch releases and the planner takes back labels it applied.The contributor still learns the state: the unified comment refuses "safe to merge" via
mergeStateHeld, and the PR's own Checks tab names the unhappy checks — always current, unlike a bot comment posted once per pass on every in-flight PR.Not in scope
deriveUnifiedStatusrendersmergeStateHeldas "Manual Review" in the comment's verdict box. That wording is now inconsistent with the disposition, but it is text on a comment — it blocks nothing and the same code path servesdirty/behind. Filed separately rather than widened into this change.