Parent: #1936
Problem
executeAgentMaintenanceActions (src/services/agent-action-executor.ts) calls fetchPullRequestFreshness before every live action, but classifyPullRequestFreshness (src/github/pr-freshness.ts:32-55) only compares live state/head.sha against the plan's expectedHeadSha. The CI aggregate and mergeable_state that actually drove the merge-vs-close decision in maybeRunAgentMaintenance (src/queue/processors.ts:1621-1629, 1668-1717) are fetched once, seconds to tens-of-seconds before actuation, and are never re-read at the moment of actuation.
GitHub's own PUT /pulls/{n}/merge does enforce branch-protection required checks server-side (a backstop, when configured) — but PATCH /pulls/{n} for close has no server-side gate at all, and fires purely off the stale-but-headSha-matching plan. Notably, the two-pass "flag then verify" mechanism that exists for linked-issue-hard-rule closes (agent-actions.ts:344-364) does not cover heuristic CI-failed closes — those are one-shot.
Failure scenario: a required check finishes GREEN a few seconds after maybeRunAgentMaintenance already fetched the CI aggregate as failed/pending (a flaky read, or the check posts mid-flight). The planner computes a heuristic close (CI-failed). The freshness guard only checks headSha/state — unchanged — so it passes, and closePullRequest executes even though CI is actually green by the time of the PATCH. There is no second look; the PR is closed and the contributor gets no further re-evaluation.
Requirements
- A
close action driven by a CI-failure heuristic must not fire if live CI has since turned green.
- The fix should add re-verification with minimal added latency (the freshness check already does one network round-trip).
Deliverables
- Extend the freshness re-check so a heuristic, CI-driven
close (closeKind: "heuristic") re-derives live CI state (reusing the existing refreshLiveCiAggregate/live-CI-rollup helpers) immediately before the mutation, and denies the action (audit outcome: "CI state changed since planning") if the live state no longer shows failed.
- Consider applying the same live re-check to
merge as defense-in-depth alongside GitHub's branch-protection backstop, for repos that don't configure required checks.
- Add a regression test in
test/unit/agent-action-executor.test.ts covering: CI is failed at planning time, flips to passed before actuation, headSha unchanged — assert the close is denied.
Acceptance criteria
- A heuristic CI-driven close is denied (not executed) if live CI has turned green in the window between planning and actuation.
- No added false-denials for the common case where CI state is genuinely unchanged.
Expected outcome
A PR that becomes green in the narrow window between CI-aggregate fetch and actuation is never closed on stale information — actuation always reflects a check performed at the moment of the mutation, not seconds-to-tens-of-seconds earlier.
Parent: #1936
Problem
executeAgentMaintenanceActions(src/services/agent-action-executor.ts) callsfetchPullRequestFreshnessbefore every live action, butclassifyPullRequestFreshness(src/github/pr-freshness.ts:32-55) only compares livestate/head.shaagainst the plan'sexpectedHeadSha. The CI aggregate andmergeable_statethat actually drove the merge-vs-close decision inmaybeRunAgentMaintenance(src/queue/processors.ts:1621-1629,1668-1717) are fetched once, seconds to tens-of-seconds before actuation, and are never re-read at the moment of actuation.GitHub's own
PUT /pulls/{n}/mergedoes enforce branch-protection required checks server-side (a backstop, when configured) — butPATCH /pulls/{n}forclosehas no server-side gate at all, and fires purely off the stale-but-headSha-matching plan. Notably, the two-pass "flag then verify" mechanism that exists for linked-issue-hard-rule closes (agent-actions.ts:344-364) does not cover heuristic CI-failed closes — those are one-shot.Failure scenario: a required check finishes GREEN a few seconds after
maybeRunAgentMaintenancealready fetched the CI aggregate asfailed/pending(a flaky read, or the check posts mid-flight). The planner computes a heuristicclose(CI-failed). The freshness guard only checks headSha/state — unchanged — so it passes, andclosePullRequestexecutes even though CI is actually green by the time of the PATCH. There is no second look; the PR is closed and the contributor gets no further re-evaluation.Requirements
closeaction driven by a CI-failure heuristic must not fire if live CI has since turned green.Deliverables
close(closeKind: "heuristic") re-derives live CI state (reusing the existingrefreshLiveCiAggregate/live-CI-rollup helpers) immediately before the mutation, and denies the action (audit outcome: "CI state changed since planning") if the live state no longer shows failed.mergeas defense-in-depth alongside GitHub's branch-protection backstop, for repos that don't configure required checks.test/unit/agent-action-executor.test.tscovering: CI isfailedat planning time, flips topassedbefore actuation, headSha unchanged — assert the close is denied.Acceptance criteria
Expected outcome
A PR that becomes green in the narrow window between CI-aggregate fetch and actuation is never closed on stale information — actuation always reflects a check performed at the moment of the mutation, not seconds-to-tens-of-seconds earlier.