Parent: #1936
Problem
All CI-green / mergeable-clean / gate-passed / approvals-satisfied preconditions for a merge are computed once, at staging time, inside planAgentMaintenanceActions (src/settings/agent-actions.ts:331: canMerge = reviewGood && !heldForManualReview && acting("merge") && mergeableClean && approvalsSatisfied && !mergeTerminallyBlocked). For autonomy: auto_with_approval, this evaluation happens exactly once at staging time; the persisted pending row (AgentPendingActionParams, src/types.ts:676-686) carries only { mergeMethod, expectedHeadSha } — no CI state, mergeable state, or reviewDecision.
On accept, decidePendingAgentAction (src/services/agent-approval-queue.ts:20-84) performs exactly one freshness check — head SHA equality (lines 44-56) — then replays the action verbatim through executeAgentMaintenanceActions. That executor's only further guard, fetchPullRequestFreshness → classifyPullRequestFreshness (src/github/pr-freshness.ts:32-55), checks only state === "open" and head-SHA equality — never mergeable_state, CI status, or reviewDecision. The live-fetch helpers that could supply this (fetchLiveMergeableState, fetchLiveReviewDecision in src/github/backfill.ts) exist but have zero callers anywhere in src/.
Failure scenario: a PR is staged for auto_with_approval merge while CI is green and mergeableState is clean. auto_with_approval staged rows have no expiry — before a maintainer clicks accept (hours or days later), the base branch advances and mergeable_state recomputes to dirty, or a non-required check flakes to failing on a re-run, or a reviewer submits CHANGES_REQUESTED. None of these move the head SHA. The maintainer accepts; both freshness checks pass (same head, still open); mergePullRequest fires with the stale justification. GitHub's own branch-protection can catch some of this (a real conflict, or a required check regression) — but only for repos that configure it, and never for a non-required check or a review decision GitHub itself doesn't treat as blocking.
Requirements
- Accepting a staged merge action must re-verify the preconditions that justified staging it — not just that the head SHA is unchanged.
- The re-check must cover mergeable state, CI conclusion, and review decision.
- Must not depend on GitHub-side branch protection being configured, since that's optional per-repo.
Deliverables
- Before calling
executeAgentMaintenanceActions in decidePendingAgentAction, re-fetch live mergeable_state, CI conclusion, and reviewDecision, and treat a flip in any of them the same way a head-SHA mismatch is treated today (supersede/reject) rather than proceeding.
- Prefer re-running
planAgentMaintenanceActions (or an equivalent live disposition check) against fresh data at accept time, and only proceed if it still yields canMerge === true — this closes the gap structurally rather than accumulating ad hoc field checks, and protects the sibling approve/close action classes from the same staleness pattern.
- Add a regression test in
test/unit/agent-approval-queue.test.ts that stages a merge, then mutates the live/persisted PR to mergeableState: "dirty" (and separately: CI failing, reviewDecision CHANGES_REQUESTED) while holding the head SHA fixed, asserting accept is refused rather than executing.
Acceptance criteria
- A staged merge whose mergeable state, CI, or review decision has changed since staging is rejected/superseded on accept, not executed.
- A staged merge whose preconditions are still true at accept time executes normally (no regression to the existing head-SHA-freshness behavior).
Expected outcome
Approval-queue "accept" always reflects the PR's current mergeability, not a snapshot that may be hours or days stale — eliminating a real path to merging a PR that no longer meets its own staged requirements.
Parent: #1936
Problem
All CI-green / mergeable-clean / gate-passed / approvals-satisfied preconditions for a merge are computed once, at staging time, inside
planAgentMaintenanceActions(src/settings/agent-actions.ts:331:canMerge = reviewGood && !heldForManualReview && acting("merge") && mergeableClean && approvalsSatisfied && !mergeTerminallyBlocked). Forautonomy: auto_with_approval, this evaluation happens exactly once at staging time; the persisted pending row (AgentPendingActionParams,src/types.ts:676-686) carries only{ mergeMethod, expectedHeadSha }— no CI state, mergeable state, or reviewDecision.On accept,
decidePendingAgentAction(src/services/agent-approval-queue.ts:20-84) performs exactly one freshness check — head SHA equality (lines 44-56) — then replays the action verbatim throughexecuteAgentMaintenanceActions. That executor's only further guard,fetchPullRequestFreshness→classifyPullRequestFreshness(src/github/pr-freshness.ts:32-55), checks onlystate === "open"and head-SHA equality — never mergeable_state, CI status, or reviewDecision. The live-fetch helpers that could supply this (fetchLiveMergeableState,fetchLiveReviewDecisioninsrc/github/backfill.ts) exist but have zero callers anywhere insrc/.Failure scenario: a PR is staged for
auto_with_approvalmerge while CI is green andmergeableStateisclean.auto_with_approvalstaged rows have no expiry — before a maintainer clicks accept (hours or days later), the base branch advances andmergeable_staterecomputes todirty, or a non-required check flakes to failing on a re-run, or a reviewer submitsCHANGES_REQUESTED. None of these move the head SHA. The maintainer accepts; both freshness checks pass (same head, still open);mergePullRequestfires with the stale justification. GitHub's own branch-protection can catch some of this (a real conflict, or a required check regression) — but only for repos that configure it, and never for a non-required check or a review decision GitHub itself doesn't treat as blocking.Requirements
Deliverables
executeAgentMaintenanceActionsindecidePendingAgentAction, re-fetch live mergeable_state, CI conclusion, and reviewDecision, and treat a flip in any of them the same way a head-SHA mismatch is treated today (supersede/reject) rather than proceeding.planAgentMaintenanceActions(or an equivalent live disposition check) against fresh data at accept time, and only proceed if it still yieldscanMerge === true— this closes the gap structurally rather than accumulating ad hoc field checks, and protects the siblingapprove/closeaction classes from the same staleness pattern.test/unit/agent-approval-queue.test.tsthat stages a merge, then mutates the live/persisted PR tomergeableState: "dirty"(and separately: CI failing, reviewDecisionCHANGES_REQUESTED) while holding the head SHA fixed, asserting accept is refused rather than executing.Acceptance criteria
Expected outcome
Approval-queue "accept" always reflects the PR's current mergeability, not a snapshot that may be hours or days stale — eliminating a real path to merging a PR that no longer meets its own staged requirements.