A green, approved PR can be permanently un-mergeable — silently
merge_blocked_sha is set by handleMergeFailure (src/services/agent-action-executor.ts ~971-1008) and suppresses all future merge planning while mergeBlockedSha === headSha (src/settings/agent-actions.ts ~1056, ~1064). No code path ever sets it back to null — it is head-scoped (src/db/repositories.ts ~4216-4223), so the only escape is the contributor pushing a new commit.
classifyMergeFailure (src/services/merge-failure.ts ~70-85) marks as terminal: 401, generic 403, 405, 409, and merge-conflict text. But several of those are global and transient:
- 401 = App key rotation / token blip — fleet-wide, affects every in-flight merge at once.
- 409 = "required status check absent" — can be a checks-still-registering race.
- 403 rate-limit/abuse texts are "possibly transient" but convert to terminal after
MERGE_RETRY_CAP = 5 sweeps, i.e. a sustained rate-limit window terminally blocks everything that passed through it.
Compounding bug: mergeAttemptCount is never reset. The bump at repositories.ts ~4139-4152 gates only the increment, and schema.ts ~378-383 explicitly omits it from the GitHub-sync SET clause — contradicting its own documentation ("a new commit's attempts start fresh"). So after one exhaustion, every subsequent head is one-strike-terminal on any transient failure.
Why it's silent: mergeBlockedReason is never rendered on any public or panel surface (readers are the planner, audit, and PostHog only). With review_state_label enabled the PR even keeps a ready-to-merge label. A contributor looking at a green, approved, ready-to-merge PR has no signal and no reason to push. The re-gate sweep cannot rescue it either: a PR regated once is permanently sweep-ineligible (src/settings/agent-sweep.ts ~166-174, #never-endless-reregate) and repair priority excludes clean-mergeable PRs (processors.ts ~1160-1167).
Net: one token rotation can strand every in-flight merge in the fleet, permanently, with no visible cause.
Fix
- Reset
mergeAttemptCount when headSha changes — restores the documented invariant.
- Distinguish terminal-by-commit from terminal-by-infra. Reserve persistent
merge_blocked for genuinely commit-specific causes (real conflict). For global-transient classes (401, 403 secondary-rate-limit) use a TTL re-probe: retry once installation health shows the token/permission recovered.
- Surface
mergeBlockedReason on the panel, and drop the misleading ready-to-merge label while blocked.
- Add a maintainer "clear merge block" action.
Acceptance
- Simulated 401 during merge → after token recovery, the PR merges autonomously with no new commit.
- A genuine merge conflict still blocks terminally until the contributor rebases.
A green, approved PR can be permanently un-mergeable — silently
merge_blocked_shais set byhandleMergeFailure(src/services/agent-action-executor.ts~971-1008) and suppresses all future merge planning whilemergeBlockedSha === headSha(src/settings/agent-actions.ts~1056, ~1064). No code path ever sets it back to null — it is head-scoped (src/db/repositories.ts~4216-4223), so the only escape is the contributor pushing a new commit.classifyMergeFailure(src/services/merge-failure.ts~70-85) marks as terminal: 401, generic 403, 405, 409, and merge-conflict text. But several of those are global and transient:MERGE_RETRY_CAP = 5sweeps, i.e. a sustained rate-limit window terminally blocks everything that passed through it.Compounding bug:
mergeAttemptCountis never reset. The bump atrepositories.ts~4139-4152 gates only the increment, andschema.ts~378-383 explicitly omits it from the GitHub-sync SET clause — contradicting its own documentation ("a new commit's attempts start fresh"). So after one exhaustion, every subsequent head is one-strike-terminal on any transient failure.Why it's silent:
mergeBlockedReasonis never rendered on any public or panel surface (readers are the planner, audit, and PostHog only). Withreview_state_labelenabled the PR even keeps a ready-to-merge label. A contributor looking at a green, approved, ready-to-merge PR has no signal and no reason to push. The re-gate sweep cannot rescue it either: a PR regated once is permanently sweep-ineligible (src/settings/agent-sweep.ts~166-174, #never-endless-reregate) and repair priority excludes clean-mergeable PRs (processors.ts~1160-1167).Net: one token rotation can strand every in-flight merge in the fleet, permanently, with no visible cause.
Fix
mergeAttemptCountwhenheadShachanges — restores the documented invariant.merge_blockedfor genuinely commit-specific causes (real conflict). For global-transient classes (401, 403 secondary-rate-limit) use a TTL re-probe: retry once installation health shows the token/permission recovered.mergeBlockedReasonon the panel, and drop the misleading ready-to-merge label while blocked.Acceptance