Skip to content

Commit 9bb842b

Browse files
committed
fix(worker): prevent manual dispatch auto-enrolment
1 parent 85a3763 commit 9bb842b

12 files changed

Lines changed: 418 additions & 27 deletions

File tree

apps/worker/src/db/queries/runs-read.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
isRunRecordedFailed,
2020
hasDurableRunPublication,
2121
fetchRunModels,
22+
findLiveRunClaimByRunId,
2223
} from "./runs-read.js";
2324

2425
/** Minimal fixture: attributeRunModel only reads `.nodeId` / `.manifest.model.id`. */
@@ -537,6 +538,26 @@ describe("isRunRecordedFailed", () => {
537538
});
538539
});
539540

541+
describe("findLiveRunClaimByRunId", () => {
542+
it("returns the manual ticket metadata needed for atomic cancellation withdrawal", async () => {
543+
await db.insert(activeRuns).values({
544+
subjectKey: "ticket:jira:AIW-274",
545+
ticketKey: "AIW-274",
546+
ownerToken: "owner-manual",
547+
runId: "wrun_manual",
548+
state: "bound",
549+
runKind: "manual_ticket",
550+
});
551+
552+
await expect(findLiveRunClaimByRunId(db, "wrun_manual")).resolves.toEqual({
553+
subjectKey: "ticket:jira:AIW-274",
554+
ticketKey: "AIW-274",
555+
ownerToken: "owner-manual",
556+
kind: "manual_ticket",
557+
});
558+
});
559+
});
560+
540561
describe("hasDurableRunPublication", () => {
541562
it("requires publication evidence on the exact run", async () => {
542563
await seed({ runId: "wrun_empty", status: "running" });

apps/worker/src/db/queries/runs-read.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
WorkflowRow,
1212
} from "@shared/contracts";
1313
import type { Db } from "../client.js";
14+
import type { RunKind } from "../../adapters/run-registry/types.js";
1415
import { activeRuns, workflowOwnedBranches, workflowRuns } from "../schema.js";
1516
import { attributeRunModel } from "../../lib/overview/attribute-run-model.js";
1617

@@ -216,20 +217,31 @@ export async function hasDurableRunPublication(db: Db, runId: string): Promise<b
216217
* claims (one row per active subject) and has no index on run_id, but the table
217218
* is tiny, so the scan is cheap. Returns the subject and its exact owner token
218219
* so an operator cancel can drive cancelSubjectRunDetailed for a run that has no
219-
* ticket at all (a webhook or schedule trigger). Null when no live claim carries
220-
* this run id: the run is already terminal or unknown, and the caller falls back
221-
* to workflow_runs.
220+
* ticket at all (a webhook or schedule trigger). The ticket and kind let manual
221+
* ticket cancellation withdraw its AI-column enrolment before release. Null
222+
* when no live claim carries this run id: the run is already terminal or
223+
* unknown, and the caller falls back to workflow_runs.
222224
*/
223225
export async function findLiveRunClaimByRunId(
224226
db: Db,
225227
runId: string,
226-
): Promise<{ subjectKey: string; ownerToken: string } | null> {
228+
): Promise<{
229+
subjectKey: string;
230+
ticketKey: string | null;
231+
ownerToken: string;
232+
kind: RunKind;
233+
} | null> {
227234
const [row] = await db
228-
.select({ subjectKey: activeRuns.subjectKey, ownerToken: activeRuns.ownerToken })
235+
.select({
236+
subjectKey: activeRuns.subjectKey,
237+
ticketKey: activeRuns.ticketKey,
238+
ownerToken: activeRuns.ownerToken,
239+
kind: activeRuns.runKind,
240+
})
229241
.from(activeRuns)
230242
.where(eq(activeRuns.runId, runId))
231243
.limit(1);
232-
return row ?? null;
244+
return row ? { ...row, kind: row.kind as RunKind } : null;
233245
}
234246

235247
/**

apps/worker/src/lib/cancel-run.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ const state = vi.hoisted(() => ({
1919
warn: vi.fn(),
2020
}));
2121

22+
vi.mock("../../env.js", () => ({
23+
env: {
24+
COLUMN_AI: "AI",
25+
COLUMN_BACKLOG: "Backlog",
26+
JIRA_BACKLOG_TRANSITION_ID: undefined,
27+
},
28+
}));
29+
2230
vi.mock("workflow/api", () => ({ getRun: state.getRun }));
2331
vi.mock("workflow/runtime", () => ({
2432
getWorld: () => ({ steps: { list: state.listSteps } }),
@@ -33,7 +41,10 @@ vi.mock("../clarifications/store.js", () => ({
3341
vi.mock("../approvals/store.js", () => ({
3442
retireApprovalCancellation: state.retireApproval,
3543
}));
36-
vi.mock("./ticket-transition.js", () => ({ moveTicketForRun: state.moveTicket }));
44+
vi.mock("./ticket-transition.js", () => ({
45+
moveTicketForRun: state.moveTicket,
46+
withdrawTicketFromAiForRun: state.moveTicket,
47+
}));
3748
vi.mock("./telemetry/run-telemetry.js", () => ({
3849
recordRunStatusReason: state.recordStatusReason,
3950
markRunBlockedOnCancel: state.markBlockedOnCancel,
@@ -344,6 +355,44 @@ describe("cancelRunById", () => {
344355
);
345356
});
346357

358+
it("withdraws a cancelled manual ticket before releasing its claim", async () => {
359+
state.findLiveClaim.mockResolvedValue({
360+
subjectKey: "ticket:jira:PROJ-1",
361+
ticketKey: "PROJ-1",
362+
ownerToken: "owner-a",
363+
kind: "manual_ticket",
364+
});
365+
const runRegistry = registry(active({ kind: "manual_ticket" }));
366+
const issueTracker = {} as IssueTrackerAdapter;
367+
368+
await expect(
369+
cancelRunById(outerDb, "run-1", {
370+
actorLabel: "operator kate",
371+
runRegistry,
372+
issueTracker,
373+
}),
374+
).resolves.toEqual({
375+
outcome: "cancelled",
376+
subjectKey: "ticket:jira:PROJ-1",
377+
});
378+
expect(state.moveTicket).toHaveBeenCalledWith({
379+
db: outerDb,
380+
issueTracker,
381+
ticketKey: "PROJ-1",
382+
aiColumn: expect.any(String),
383+
target: expect.any(String),
384+
owner: expect.objectContaining({
385+
subjectKey: "ticket:jira:PROJ-1",
386+
ownerToken: "owner-a",
387+
runId: "run-1",
388+
}),
389+
requiredOwnerState: "cancelling",
390+
});
391+
expect(state.moveTicket.mock.invocationCallOrder[0]).toBeLessThan(
392+
vi.mocked(runRegistry.releaseCancellation).mock.invocationCallOrder[0]!,
393+
);
394+
});
395+
347396
// The irreversible cancel already landed and the claim is released, so a
348397
// transient settle failure must never surface as a throw (E4 would map it to
349398
// 500) or flip the outcome; the cron backstops the row, like the park sibling.

apps/worker/src/lib/cancel-run.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export interface CancelRunByIdResult {
188188
export interface CancelRunByIdDeps {
189189
actorLabel: string;
190190
runRegistry: RunRegistryAdapter;
191+
issueTracker?: IssueTrackerAdapter;
191192
}
192193

193194
/**
@@ -217,13 +218,48 @@ export async function cancelRunById(
217218
const claim = await findLiveRunClaimByRunId(db, runId);
218219
if (claim) {
219220
const reason = `cancelled by ${actorLabel}`;
220-
const result = await cancelSubjectRunDetailed(
221-
claim.subjectKey,
222-
{ ownerToken: claim.ownerToken, runId },
223-
runRegistry,
224-
undefined,
225-
reason,
226-
);
221+
if (claim.kind === "manual_ticket" && (!claim.ticketKey || !opts.issueTracker)) {
222+
logger.warn(
223+
{ subjectKey: claim.subjectKey, runId },
224+
"cancel_manual_ticket_withdrawal_unavailable",
225+
);
226+
return { outcome: "unconfirmed", subjectKey: claim.subjectKey };
227+
}
228+
const result = claim.kind === "manual_ticket"
229+
? await cancelOwnedSubject(
230+
claim.subjectKey,
231+
{ ownerToken: claim.ownerToken, runId },
232+
runRegistry,
233+
undefined,
234+
async (owner) => {
235+
const [{ env }, { withdrawTicketFromAiForRun }] = await Promise.all([
236+
import("../../env.js"),
237+
import("./ticket-transition.js"),
238+
]);
239+
await withdrawTicketFromAiForRun({
240+
db,
241+
issueTracker: opts.issueTracker!,
242+
ticketKey: claim.ticketKey!,
243+
aiColumn: env.COLUMN_AI,
244+
target: env.JIRA_BACKLOG_TRANSITION_ID
245+
? {
246+
name: env.COLUMN_BACKLOG,
247+
transitionId: env.JIRA_BACKLOG_TRANSITION_ID,
248+
}
249+
: env.COLUMN_BACKLOG,
250+
owner,
251+
requiredOwnerState: "cancelling",
252+
});
253+
},
254+
reason,
255+
)
256+
: await cancelSubjectRunDetailed(
257+
claim.subjectKey,
258+
{ ownerToken: claim.ownerToken, runId },
259+
runRegistry,
260+
undefined,
261+
reason,
262+
);
227263
// alreadyTerminal implies cancelled, so it must be checked first: the run
228264
// reached a terminal Workflow status on its own and keeps that outcome, so
229265
// no status is written (only the lingering claim was released).

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,19 @@ describe("dispatchTicket owner reservation", () => {
295295
expect(result).toEqual({ started: false, reason: "wrong_project_key" });
296296
});
297297

298-
it("returns already_claimed when the subject reservation loses", async () => {
299-
const result = await dispatchTicket("PROJ-42", adapters(registry({ reserveResult: false })), 3);
298+
it("does not auto-enrol a subject already claimed by a manual dispatch", async () => {
299+
const manualClaim = entry({
300+
subjectKey: "ticket:jira:PROJ-42",
301+
ticketKey: "PROJ-42",
302+
ownerToken: "owner:manual",
303+
runId: "run-manual",
304+
kind: "manual_ticket",
305+
});
306+
const connected = adapters(registry({ initial: [manualClaim] }));
307+
const result = await dispatchTicket("PROJ-42", connected, 3);
300308
expect(result).toEqual({ started: false, reason: "already_claimed" });
309+
expect(connected.issueTracker.fetchTicket).not.toHaveBeenCalled();
310+
expect(mockStart).not.toHaveBeenCalled();
301311
});
302312

303313
it("returns at_capacity without reserving when bound capacity is full", async () => {

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const mockHasDurableRunPublication = vi.fn();
2626
const mockDb = {} as Db;
2727
const mockStopSandboxesByIds = vi.fn();
2828
const mockListWorkflowSteps = vi.fn();
29+
const mockAssertActiveRunOwnerState = vi.hoisted(() => vi.fn());
2930
vi.mock("workflow/api", () => ({ getRun: (...args: any[]) => mockGetRun(...args) }));
3031
vi.mock("workflow/runtime", () => ({
3132
getWorld: () => ({
@@ -51,6 +52,9 @@ vi.mock("./run-start-lifecycle.js", () => ({
5152
vi.mock("../sandbox/stop-ticket-sandboxes.js", () => ({
5253
stopSandboxesByIds: (...args: any[]) => mockStopSandboxesByIds(...args),
5354
}));
55+
vi.mock("./active-run-owner.js", () => ({
56+
assertActiveRunOwnerState: (...args: any[]) => mockAssertActiveRunOwnerState(...args),
57+
}));
5458

5559
function entry(overrides: Partial<ActiveRunEntry> = {}): ActiveRunEntry {
5660
return {
@@ -135,6 +139,7 @@ describe("reconcileRuns owner-CAS recovery", () => {
135139
cursor: null,
136140
hasMore: false,
137141
});
142+
mockAssertActiveRunOwnerState.mockResolvedValue(undefined);
138143
});
139144

140145
it("leaves a fresh unbound reservation for its candidate", async () => {
@@ -349,6 +354,72 @@ describe("reconcileRuns owner-CAS recovery", () => {
349354
expect(onTicketCancelled).not.toHaveBeenCalled();
350355
});
351356

357+
it.each(["Review", "Done"])(
358+
"releases a terminal manual ticket without overwriting live Jira %s from a stale AI snapshot",
359+
async (liveStatus) => {
360+
const manual = entry({ kind: "manual_ticket" });
361+
const runRegistry = registry([manual]);
362+
const tracker = issueTracker(liveStatus);
363+
mockGetRun.mockReturnValue({ status: Promise.resolve("completed") });
364+
const onReleased = vi.fn();
365+
const { reconcileRuns } = await import("./reconcile.js");
366+
367+
await expect(
368+
reconcileRuns(
369+
new Set(["PROJ-1"]),
370+
runRegistry,
371+
tracker,
372+
undefined,
373+
onReleased,
374+
undefined,
375+
mockDb,
376+
),
377+
).resolves.toEqual({ cancelled: 0, cleaned: 1 });
378+
expect(mockCancelRunDetailed).not.toHaveBeenCalled();
379+
expect(tracker.fetchTicket).toHaveBeenCalledOnce();
380+
expect(tracker.moveTicket).not.toHaveBeenCalled();
381+
expect(mockAssertActiveRunOwnerState).toHaveBeenCalledWith(
382+
mockDb,
383+
manual,
384+
"bound",
385+
);
386+
expect(runRegistry.release).toHaveBeenCalledWith(
387+
manual.subjectKey,
388+
manual.ownerToken,
389+
manual.runId,
390+
);
391+
expect(onReleased).toHaveBeenCalledWith(manual.subjectKey);
392+
},
393+
);
394+
395+
it("retains a manual claim when stale-snapshot withdrawal cannot be confirmed", async () => {
396+
const manual = entry({ kind: "manual_ticket" });
397+
const runRegistry = registry([manual]);
398+
const tracker = issueTracker("AI");
399+
const moveError = new Error("response lost");
400+
vi.mocked(tracker.fetchTicket)
401+
.mockResolvedValueOnce({ trackerStatus: "AI" } as never)
402+
.mockResolvedValueOnce({ trackerStatus: "AI" } as never);
403+
vi.mocked(tracker.moveTicket).mockRejectedValue(moveError);
404+
mockGetRun.mockReturnValue({ status: Promise.resolve("completed") });
405+
const { reconcileRuns } = await import("./reconcile.js");
406+
407+
await expect(
408+
reconcileRuns(
409+
new Set(["PROJ-1"]),
410+
runRegistry,
411+
tracker,
412+
undefined,
413+
undefined,
414+
undefined,
415+
mockDb,
416+
),
417+
).resolves.toEqual({ cancelled: 0, cleaned: 0 });
418+
expect(tracker.moveTicket).toHaveBeenCalledWith("PROJ-1", "Backlog");
419+
expect(tracker.fetchTicket).toHaveBeenCalledTimes(2);
420+
expect(runRegistry.release).not.toHaveBeenCalled();
421+
});
422+
352423
it("does not evict a ticket-triggered run that is still executing", async () => {
353424
const bound = entry();
354425
const runRegistry = registry([bound]);

0 commit comments

Comments
 (0)