diff --git a/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts b/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts index efe5c005f3..2adf4d6d09 100644 --- a/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts +++ b/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts @@ -270,4 +270,42 @@ describe("workflow work processor", () => { }), ]); }); + + it("returns claimed identity when runtime and cleanup transition both fail", async () => { + const task = await store.createTask({ description: "processor double failure task" }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.handoffToReview(task.id, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-processor-double-failure", agentId: "agent-test" }, + now: "2026-06-09T00:00:00.000Z", + }); + const runtime = new WorkflowTaskRuntime({ + store, + primitives: primitives(), + runCustomNode: async () => ({ outcome: "success" }), + }); + vi.spyOn(runtime, "runWorkItem").mockRejectedValue(new Error("sqlite busy")); + vi.spyOn(store, "transitionWorkflowWorkItem").mockImplementation(() => { + throw new Error("cleanup busy"); + }); + + const result = await processDueWorkflowWorkItem(store, runtime, { experimentalFeatures: {} } as any, { + now: "2026-06-09T00:00:00.000Z", + leaseOwner: "processor-a", + leaseDurationMs: 60_000, + kinds: workflowMergeWorkKinds(), + }); + + expect(result).toMatchObject({ + claimed: true, + taskId: task.id, + runtime: { + disposition: "failed", + outcome: "failure", + reason: "workflow-work-item-runtime-error:sqlite busy", + }, + }); + expect(result.workItemId).toBeDefined(); + }); }); diff --git a/packages/engine/src/workflow-work-processor.ts b/packages/engine/src/workflow-work-processor.ts index 2ad2f29b1b..76b8b0734b 100644 --- a/packages/engine/src/workflow-work-processor.ts +++ b/packages/engine/src/workflow-work-processor.ts @@ -43,12 +43,16 @@ export async function processDueWorkflowWorkItem( runtimeResult = await runtime.runWorkItem(dispatch.workItem, settings); } catch (err) { const reason = `workflow-work-item-runtime-error:${err instanceof Error ? err.message : String(err)}`; - store.transitionWorkflowWorkItem?.(dispatch.workItem.id, "failed", { - now: opts.now, - leaseOwner: null, - leaseExpiresAt: null, - lastError: reason, - }); + try { + store.transitionWorkflowWorkItem?.(dispatch.workItem.id, "failed", { + now: opts.now, + leaseOwner: null, + leaseExpiresAt: null, + lastError: reason, + }); + } catch { + // Best-effort cleanup; callers still need the claimed work identity on double-failure. + } runtimeResult = { disposition: "failed", outcome: "failure",