Skip to content

Commit dd610e9

Browse files
authored
fix(worker): evict ticket stuck in AI column when its run terminates without a move (AIW-289) (#296)
1 parent 1a1c59a commit dd610e9

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

apps/worker/src/lib/reconcile.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,76 @@ describe("reconcileRuns owner-CAS recovery", () => {
306306
expect(mockCancelRunDetailed).not.toHaveBeenCalled();
307307
});
308308

309+
it("evicts a ticket to Backlog instead of silently releasing the claim once a stuck run goes terminal", async () => {
310+
// Reproduces AIW-289: a `terminate` node (e.g. an injection screen's block
311+
// path) can end a run as "done"/"skipped" without ever moving the ticket
312+
// out of AI. Simply releasing the claim here would leave the ticket
313+
// sitting in AI with nobody bound to it, so the very next poll's JQL
314+
// discovery re-dispatches a second run on the same ticket. Exactly one run
315+
// per ticket depends on this branch evicting it instead.
316+
const bound = entry();
317+
const runRegistry = registry([bound]);
318+
const tracker = issueTracker("AI");
319+
mockGetRun.mockReturnValue({ status: Promise.resolve("completed") });
320+
mockCancelRunDetailed.mockResolvedValue({
321+
cancelled: true,
322+
released: true,
323+
alreadyTerminal: true,
324+
});
325+
const onReleased = vi.fn();
326+
const onTicketCancelled = vi.fn();
327+
const { reconcileRuns } = await import("./reconcile.js");
328+
329+
expect(
330+
await reconcileRuns(
331+
new Set(["PROJ-1"]),
332+
runRegistry,
333+
tracker,
334+
onTicketCancelled,
335+
onReleased,
336+
),
337+
).toEqual({ cancelled: 0, cleaned: 1 });
338+
expect(mockCancelRunDetailed).toHaveBeenCalledWith(
339+
"PROJ-1",
340+
"run-1",
341+
runRegistry,
342+
tracker,
343+
"Backlog",
344+
onReleased,
345+
expect.stringContaining("moved this ticket to Backlog"),
346+
);
347+
// Genuinely a "done"/success outcome for the injection-block scenario, so
348+
// it must not be reported to operators as a cancellation.
349+
expect(onTicketCancelled).not.toHaveBeenCalled();
350+
});
351+
352+
it("does not evict a ticket-triggered run that is still executing", async () => {
353+
const bound = entry();
354+
const runRegistry = registry([bound]);
355+
const tracker = issueTracker("AI");
356+
mockGetRun.mockReturnValue({ status: Promise.resolve("running") });
357+
const { reconcileRuns } = await import("./reconcile.js");
358+
359+
expect(
360+
await reconcileRuns(new Set(["PROJ-1"]), runRegistry, tracker),
361+
).toEqual({ cancelled: 0, cleaned: 0 });
362+
expect(mockCancelRunDetailed).not.toHaveBeenCalled();
363+
expect(runRegistry.release).not.toHaveBeenCalled();
364+
});
365+
366+
it("retains a stuck ticket's claim when the eviction cannot be confirmed", async () => {
367+
const bound = entry();
368+
const runRegistry = registry([bound]);
369+
const tracker = issueTracker("AI");
370+
mockGetRun.mockReturnValue({ status: Promise.resolve("completed") });
371+
mockCancelRunDetailed.mockResolvedValue({ cancelled: false, released: false });
372+
const { reconcileRuns } = await import("./reconcile.js");
373+
374+
expect(
375+
await reconcileRuns(new Set(["PROJ-1"]), runRegistry, tracker),
376+
).toEqual({ cancelled: 0, cleaned: 0 });
377+
});
378+
309379
it("lets a pending clarification win over an older answered round", async () => {
310380
const parked = entry();
311381
const runRegistry = registry([parked]);

apps/worker/src/lib/reconcile.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { stopSandboxesByIds } from "../sandbox/stop-ticket-sandboxes.js";
1515
import {
1616
IssueTrackerNotFoundError,
1717
type IssueTrackerAdapter,
18+
type IssueTrackerMoveTarget,
1819
} from "../adapters/issue-tracker/types.js";
1920
import type {
2021
ActiveRunEntry,
@@ -29,6 +30,19 @@ const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
2930
const STALE_RESERVATION_MS = 5 * 60 * 1000;
3031
const 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+
3246
type TicketCancellationReason = "orphaned_run" | "inflight_claim";
3347
type 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+
433524
async function cleanupAndRelease(
434525
entry: ActiveRunEntry & { runId: string },
435526
runRegistry: RunRegistryAdapter,

0 commit comments

Comments
 (0)