fix(FN-000): keep workflow processor cleanup best effort

Address PR #1581 feedback by ensuring cleanup transition failures do not hide claimed work identity after runtime dispatch errors.
This commit is contained in:
gsxdsm
2026-06-11 08:37:15 -07:00
parent bb1b692596
commit 0b11bfd793
2 changed files with 48 additions and 6 deletions

View File

@@ -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();
});
});

View File

@@ -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",