@@ -15,6 +15,7 @@ import { stopSandboxesByIds } from "../sandbox/stop-ticket-sandboxes.js";
1515import {
1616 IssueTrackerNotFoundError ,
1717 type IssueTrackerAdapter ,
18+ type IssueTrackerMoveTarget ,
1819} from "../adapters/issue-tracker/types.js" ;
1920import type {
2021 ActiveRunEntry ,
@@ -29,6 +30,19 @@ const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
2930const STALE_RESERVATION_MS = 5 * 60 * 1000 ;
3031const ORPHAN_GRACE_MS = 30 * 1000 ;
3132
33+ /**
34+ * A ticket-triggered run can reach a terminal Workflow status without any
35+ * node ever moving its ticket out of the AI column: a `terminate` node with
36+ * terminalStatus "done"/"skipped" (e.g. an injection screen's block path)
37+ * only posts a comment, on purpose - "the block owns the ticket status", not
38+ * the platform. Left alone, the claim below would simply be released while
39+ * the ticket stays in AI, and the very next poll's JQL discovery re-dispatches
40+ * the same ticket forever. This is the platform-level safety net: it does not
41+ * depend on the workflow author remembering update_ticket_status.
42+ */
43+ const STUCK_TICKET_EVICTION_REASON =
44+ "Reconciler moved this ticket to Backlog: its most recent run ended without moving the ticket out of the AI column." ;
45+
3246type TicketCancellationReason = "orphaned_run" | "inflight_claim" ;
3347type TicketCancellationCallback = (
3448 ticketKey : string ,
@@ -143,7 +157,7 @@ export async function reconcileRuns(
143157 const ticketStillInAiColumn =
144158 followsTicketColumn && aiColumnTickets . has ( entry . ticketKey as string ) ;
145159
146- if ( ! followsTicketColumn || ticketStillInAiColumn ) {
160+ if ( ! followsTicketColumn ) {
147161 cleaned += await cleanFinishedRun (
148162 boundEntry ,
149163 runRegistry ,
@@ -154,6 +168,17 @@ export async function reconcileRuns(
154168 continue ;
155169 }
156170
171+ if ( ticketStillInAiColumn ) {
172+ cleaned += await cleanStuckTicketRun (
173+ boundEntry ,
174+ entry . ticketKey as string ,
175+ runRegistry ,
176+ issueTracker ,
177+ onSubjectReleased ,
178+ ) ;
179+ continue ;
180+ }
181+
157182 const ticketKey = entry . ticketKey as string ;
158183 if ( Date . now ( ) - entry . createdAt < ORPHAN_GRACE_MS ) {
159184 logger . info (
@@ -430,6 +455,72 @@ async function cleanFinishedRun(
430455 }
431456}
432457
458+ /**
459+ * A ticket-triggered run whose ticket is STILL in the AI column, exactly like
460+ * every genuinely in-progress run - so this only acts once the world confirms
461+ * the run itself is terminal. At that point the graph is done and nobody
462+ * moved the ticket, so the platform evicts it to Backlog instead of quietly
463+ * releasing the claim: releasing without evicting is what let the next poll's
464+ * JQL discovery see the same ticket and dispatch a second run.
465+ *
466+ * Reuses cancelRunDetailed - the exact machinery the "ticket already left AI"
467+ * branch below uses - which already tolerates a run that is already terminal
468+ * (workflowRun.cancel() throws, the status re-read confirms it, and the move +
469+ * release still complete). No "canceled" notification is fired for this path:
470+ * the run may well have succeeded (e.g. an injection screen's "done" block),
471+ * so telling an operator it was canceled would be a lie.
472+ */
473+ async function cleanStuckTicketRun (
474+ entry : ActiveRunEntry & { runId : string } ,
475+ ticketKey : string ,
476+ runRegistry : RunRegistryAdapter ,
477+ issueTracker : IssueTrackerAdapter | undefined ,
478+ onSubjectReleased ?: SubjectReleasedCallback ,
479+ ) : Promise < number > {
480+ try {
481+ const status = await getRun ( entry . runId ) . status ;
482+ if ( ! TERMINAL_STATUSES . has ( status ) ) return 0 ;
483+ } catch ( error ) {
484+ logger . warn (
485+ {
486+ subjectKey : entry . subjectKey ,
487+ runId : entry . runId ,
488+ error : error instanceof Error ? error . message : String ( error ) ,
489+ } ,
490+ "reconcile_run_status_unreachable_owner_retained" ,
491+ ) ;
492+ return 0 ;
493+ }
494+
495+ if ( ! issueTracker ) return 0 ;
496+
497+ const backlogTarget : IssueTrackerMoveTarget = env . JIRA_BACKLOG_TRANSITION_ID
498+ ? { name : env . COLUMN_BACKLOG , transitionId : env . JIRA_BACKLOG_TRANSITION_ID }
499+ : env . COLUMN_BACKLOG ;
500+
501+ const result = await cancelRunDetailed (
502+ ticketKey ,
503+ entry . runId ,
504+ runRegistry ,
505+ issueTracker ,
506+ backlogTarget ,
507+ onSubjectReleased ,
508+ STUCK_TICKET_EVICTION_REASON ,
509+ ) ;
510+ if ( ! result . cancelled ) {
511+ logger . warn (
512+ { ticketKey, runId : entry . runId } ,
513+ "reconcile_stuck_ticket_evict_unconfirmed" ,
514+ ) ;
515+ return 0 ;
516+ }
517+ logger . info (
518+ { ticketKey, runId : entry . runId } ,
519+ "reconcile_evicted_stuck_ticket_from_ai_column" ,
520+ ) ;
521+ return 1 ;
522+ }
523+
433524async function cleanupAndRelease (
434525 entry : ActiveRunEntry & { runId : string } ,
435526 runRegistry : RunRegistryAdapter ,
0 commit comments