diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 5ce43d2811c..9cd3fd8822a 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -25,6 +25,93 @@ import { recoveryService } from "../services/recovery/service.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +function makeRecoveryActionRow(overrides: Record = {}) { + const now = new Date("2026-05-09T19:30:00.000Z"); + return { + id: randomUUID(), + companyId: "company-1", + sourceIssueId: "source-1", + recoveryIssueId: null, + kind: "missing_disposition", + status: "active", + ownerType: "agent", + ownerAgentId: "agent-1", + ownerUserId: null, + previousOwnerAgentId: null, + returnOwnerAgentId: null, + cause: "successful_run_missing_issue_disposition", + fingerprint: "missing-disposition:fingerprint", + evidence: {}, + nextAction: "Choose a valid issue disposition.", + wakePolicy: null, + monitorPolicy: null, + attemptCount: 1, + maxAttempts: null, + timeoutAt: null, + lastAttemptAt: now, + outcome: null, + resolutionNote: null, + resolvedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe("issueRecoveryActionService", () => { + it("does not reactivate an action resolved between the active read and update", async () => { + const existingRow = makeRecoveryActionRow({ id: "existing-action", attemptCount: 1 }); + const createdRow = makeRecoveryActionRow({ id: "new-action", attemptCount: 1 }); + const selectResults = [[existingRow], []]; + + const makeSelectQuery = (rows: unknown[]) => ({ + from() { + return this; + }, + where() { + return this; + }, + orderBy() { + return this; + }, + limit() { + return Promise.resolve(rows); + }, + }); + + const fakeDb = { + select: vi.fn(() => makeSelectQuery(selectResults.shift() ?? [])), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => []), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(async () => [createdRow]), + })), + })), + }; + + const result = await issueRecoveryActionService(fakeDb as never).upsertSourceScoped({ + companyId: "company-1", + sourceIssueId: "source-1", + kind: "missing_disposition", + ownerType: "agent", + ownerAgentId: "agent-1", + cause: "successful_run_missing_issue_disposition", + fingerprint: "missing-disposition:fingerprint", + nextAction: "Choose a valid issue disposition.", + }); + + expect(result).toMatchObject({ id: "new-action", status: "active" }); + expect(fakeDb.update).toHaveBeenCalledTimes(1); + expect(fakeDb.insert).toHaveBeenCalledTimes(1); + }); +}); + if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres issue recovery action tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, diff --git a/server/src/services/issue-recovery-actions.ts b/server/src/services/issue-recovery-actions.ts index 067f47bdf1b..30f04ec70a2 100644 --- a/server/src/services/issue-recovery-actions.ts +++ b/server/src/services/issue-recovery-actions.ts @@ -10,6 +10,7 @@ import type { } from "@paperclipai/shared"; const ACTIVE_RECOVERY_ACTION_STATUSES = ["active", "escalated"] as const satisfies readonly IssueRecoveryActionStatus[]; +const MAX_UPSERT_RETRIES = 3; type IssueRecoveryActionRow = typeof issueRecoveryActions.$inferSelect; @@ -128,7 +129,24 @@ export function issueRecoveryActionService(db: Db) { return result; } - async function upsertSourceScoped(input: UpsertIssueRecoveryActionInput): Promise { + async function retryUpsertSourceScoped( + input: UpsertIssueRecoveryActionInput, + retryCount: number, + error?: unknown, + ): Promise { + if (retryCount >= MAX_UPSERT_RETRIES) { + if (error) throw error; + throw new Error( + `Failed to upsert active recovery action for issue ${input.sourceIssueId} after ${MAX_UPSERT_RETRIES} retries`, + ); + } + return upsertSourceScoped(input, retryCount + 1); + } + + async function upsertSourceScoped( + input: UpsertIssueRecoveryActionInput, + retryCount = 0, + ): Promise { const existing = await getActiveForIssue(input.companyId, input.sourceIssueId); const now = new Date(); const ownerType = input.ownerType ?? (input.ownerAgentId ? "agent" : "board"); @@ -159,8 +177,16 @@ export function issueRecoveryActionService(db: Db) { resolvedAt: null, updatedAt: now, }) - .where(eq(issueRecoveryActions.id, existing.id)) + .where( + and( + eq(issueRecoveryActions.id, existing.id), + inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]), + ), + ) .returning(); + if (!updated) { + return retryUpsertSourceScoped(input, retryCount); + } return toReadModel(updated!); } @@ -193,9 +219,7 @@ export function issueRecoveryActionService(db: Db) { return toReadModel(created!); } catch (error) { if (!isUniqueRecoveryActionConflict(error)) throw error; - const raced = await getActiveForIssue(input.companyId, input.sourceIssueId); - if (!raced) throw error; - return upsertSourceScoped(input); + return retryUpsertSourceScoped(input, retryCount, error); } } diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 6ea8660fb92..71451e10f8c 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -132,7 +132,8 @@ describe("IssueRecoveryActionCard", () => { const section = node.querySelector("section[aria-label]"); expect(section?.getAttribute("aria-label")).toBe("Recovery action: needed"); expect(node.textContent).toContain("RECOVERY NEEDED"); - expect(node.textContent).toContain("missing_disposition"); + expect(node.textContent).toContain("Missing Disposition"); + expect(node.textContent).not.toContain("missing_disposition"); expect(node.textContent).toContain("This issue's run finished, but no next step was chosen."); expect(node.textContent).toContain("ClaudeCoder"); expect(node.textContent).toContain("CodexCoder"); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 8313d97e5e9..1ec36065817 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -42,10 +42,10 @@ export interface IssueRecoveryActionCardProps { } const KIND_LABEL: Record = { - missing_disposition: "missing_disposition", - stranded_assigned_issue: "stranded_assigned_issue", - active_run_watchdog: "active_run_watchdog", - issue_graph_liveness: "issue_graph_liveness", + missing_disposition: "Missing Disposition", + stranded_assigned_issue: "Stranded Issue", + active_run_watchdog: "Active Watchdog", + issue_graph_liveness: "Graph Liveness", }; const KIND_HEADLINE: Record = {