11import type { AgentActionClass , AutoMaintainPolicy , AutoMergeMethod , AutonomyPolicy } from "../types" ;
22import { AI_JUDGMENT_BLOCKER_CODES , type GateCheckConclusion } from "../rules/advisory" ;
33import { DEFAULT_AUTO_MAINTAIN_POLICY , autonomyRequiresApproval , isActingAutonomyLevel , resolveAutonomy } from "./autonomy" ;
4+ import { assessMergeableState , derivePrDisposition } from "./pr-disposition" ;
45import { changedPathsHittingGuardrail , isGuardrailHit } from "../signals/change-guardrail" ;
56import { AGENT_LABEL_PENDING_CLOSURE } from "../review/linked-issue-hard-rules" ;
67import { REVIEW_THREAD_BLOCKER_CODE } from "../review/review-thread-findings" ;
@@ -966,7 +967,8 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
966967 // The gate verdict is authoritative. Green CI is still required for merge/approve, but it does not rewrite an AI
967968 // or review-thread blocker into success once the gate has classified it as blocking.
968969 const conclusion : GateCheckConclusion = input . conclusion ;
969- const isConflict = input . pr . mergeableState === "dirty" ; // conflicts with base — can't merge as-is
970+ // #8759: the raw mergeable_state string is interpreted ONLY by assessMergeableState — one shared meaning.
971+ const isConflict = assessMergeableState ( input . pr . mergeableState ) === "conflict" ; // conflicts with base — can't merge as-is
970972 // True when an unresolved GitHub review thread is (at least one of) this close's justifications -- the SAME
971973 // staleness class as isConflict above (#3863), just triggered by a contributor clicking "Resolve conversation"
972974 // on GitHub instead of the base branch becoming mergeable again. A mixed blocker set (thread + something else)
@@ -1020,33 +1022,34 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
10201022 // would silently MERGE straight through the escalation instead of being held. When `close` IS acting, the
10211023 // dedicated close branch below handles it and this term is redundant (harmless: both paths agree the PR
10221024 // must not silently merge).
1023- // Unstable mergeable state (#8758, the #8711 silent-stall fix): GitHub reports "unstable" when every REQUIRED
1024- // check is green but some non-required check/status is not — exactly the state where canMerge below
1025- // self-suppresses (mergeableClean requires "clean") while, pre-#8758, nothing else held, labeled, or explained.
1026- // Folding it into heldForManualReview downgrades the would-approve/would-merge into the SAME loud
1027- // held-for-review disposition every other merge-suppressing hold gets: no approve claiming "gate satisfied",
1028- // no ready-to-merge label, and a manual-review label + comment naming the culprit check. Deliberately ONLY
1029- // "unstable": "dirty" is the close path (isConflict), "behind" belongs to the rebase rail, and
1030- // "blocked"/"unknown"/absent stay approvable (the approval itself can be the unblocking act — see the approve
1031- // block's own doc comment — and a transient null must not spray hold labels). Every consumer that acts on this
1032- // flag is conjoined with reviewGood, so a red-CI/failed-gate PR's close is never softened by this term.
1033- const mergeableStateUnstable = input . pr . mergeableState === "unstable" ;
1034- const heldForManualReview =
1035- guardrailHit ||
1036- input . migrationCollisionHold !== undefined ||
1037- input . unlinkedIssueMatchHold !== undefined ||
1038- ( input . advisoryCheckHold !== undefined && input . advisoryCheckHold . length > 0 ) ||
1039- mergeableStateUnstable ||
1040- ( input . unlinkedIssueMatchClose !== undefined && ! acting ( "close" ) ) ;
1025+ // #8759: the hold/approve/merge core now comes from the SHARED disposition module — the same
1026+ // derivation the unified comment's bridge reads — so the four surfaces can never again disagree on
1027+ // what a raw mergeable_state means (#8711's root class). The unstable-hold semantics are #8758's,
1028+ // unchanged (see derivePrDisposition's own doc + the MergeableAssessment contract): "dirty" stays the
1029+ // close path, "behind" stays the rebase rail's, "blocked"/"unknown" stay approvable, "unstable" holds
1030+ // loudly. The disposition's wouldApprove/wouldMerge feed the approve/merge gates below, still
1031+ // conjoined with the planner-private terms (autonomy, idempotency, approvals, terminal-block) that
1032+ // are not disposition. reviewGood is computed here (moved up from beside canMerge — same formula,
1033+ // gate passes AND CI green) because the disposition needs it.
1034+ const reviewGood = gatePassing && ciPassed ;
1035+ const disposition = derivePrDisposition ( {
1036+ mergeableState : input . pr . mergeableState ,
1037+ reviewGood,
1038+ guardrailHit,
1039+ migrationCollisionHold : input . migrationCollisionHold !== undefined ,
1040+ unlinkedIssueMatchHold : input . unlinkedIssueMatchHold !== undefined ,
1041+ advisoryCheckHold : input . advisoryCheckHold !== undefined && input . advisoryCheckHold . length > 0 ,
1042+ unlinkedIssueMatchCloseWithoutCloseActing : input . unlinkedIssueMatchClose !== undefined && ! acting ( "close" ) ,
1043+ } ) ;
1044+ const heldForManualReview = disposition . heldForManualReview ;
1045+ const mergeableStateUnstable = disposition . heldForUnstableMergeState ;
10411046 const labels = resolveAgentDispositionLabels ( input ) ;
10421047 // Canonical (reviewbot non-content-gate) policy, tuned to the operator's minimize-manual goal: merge-or-close
10431048 // with high accuracy; manual review is the RARE exception. A PR is "review-good" when the gate passes AND CI is
10441049 // green — that's the only thing that earns an auto-merge or an approve. Everything else, for a CONTRIBUTOR, is a
10451050 // one-shot CLOSE (taopedia model: resolve + open a fresh PR). The guardrail is handled SEPARATELY: it converts
10461051 // would-approve/would-merge dispositions into a manual hold.
10471052 const ciUnverified = input . ciState === "unverified" ;
1048- const reviewGood = gatePassing && ciPassed ;
1049- const mergeableClean = input . pr . mergeableState === "clean" ;
10501053 // RC3: a prior merge attempt failed terminally for THIS exact head SHA (403/405/409/conflict) → never re-plan
10511054 // the merge; it can't complete for this commit. A new commit makes the live head differ from mergeBlockedSha.
10521055 const mergeTerminallyBlocked = input . pr . mergeBlockedSha != null && input . pr . headSha != null && input . pr . mergeBlockedSha === input . pr . headSha ;
@@ -1055,7 +1058,9 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
10551058 // reviewDecision to APPROVED, so reviewDecision alone can't dedup). A new commit makes the heads differ →
10561059 // approve may fire again. Absent approved-head SHA (never approved by the bot) ⇒ not idempotent-skipped.
10571060 const alreadyApprovedThisHead = input . pr . approvedHeadSha != null && input . pr . headSha != null && input . pr . approvedHeadSha === input . pr . headSha ;
1058- const canMerge = reviewGood && ! heldForManualReview && acting ( "merge" ) && mergeableClean && approvalsSatisfied && ! mergeTerminallyBlocked ;
1061+ // #8759: disposition.wouldMerge = reviewGood && !held && exactly-clean — the shared core; the terms
1062+ // conjoined here (autonomy, approvals, terminal-block) are planner-private state, not disposition.
1063+ const canMerge = disposition . wouldMerge && acting ( "merge" ) && approvalsSatisfied && ! mergeTerminallyBlocked ;
10591064 // CLOSE a contributor PR ONLY on a REAL adverse signal — a confirmed gate FAILURE, red CI, or a base
10601065 // CONFLICT. NEVER close merely because CI is UNVERIFIED (a fork whose Actions await approval, or unreadable
10611066 // checks) or otherwise not-yet-mergeable — those are HELD for review, not killed (#harm-stop fork-false-close).
@@ -1348,7 +1353,9 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
13481353 // An `unstable` PR is excluded too, via heldForManualReview's mergeableStateUnstable term (#8758): the merge
13491354 // below would self-suppress on it, and approve firing while merge silently never comes was exactly #8711's
13501355 // "approved, labeled ready, never merged, nobody told" incident. */
1351- if ( reviewGood && ! heldForManualReview && ! linkedIssueCloseInFlight && ! isConflict && acting ( "approve" ) && input . pr . reviewDecision !== "APPROVED" && ! alreadyApprovedThisHead ) {
1356+ // #8759: disposition.wouldApprove = reviewGood && !held && not-a-conflict — the shared core the executor's
1357+ // live recheck mirrors; the terms conjoined here are planner-private (close-in-flight, autonomy, idempotency).
1358+ if ( disposition . wouldApprove && ! linkedIssueCloseInFlight && acting ( "approve" ) && input . pr . reviewDecision !== "APPROVED" && ! alreadyApprovedThisHead ) {
13521359 actions . push ( {
13531360 actionClass : "approve" ,
13541361 requiresApproval : approval ( "approve" ) ,
0 commit comments