diff --git a/.changeset/fn-6648-completion-finalize-paused-flag.md b/.changeset/fn-6648-completion-finalize-paused-flag.md new file mode 100644 index 0000000000..290d1b19fb --- /dev/null +++ b/.changeset/fn-6648-completion-finalize-paused-flag.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix completed tasks being parked failed in in-review with a spurious "engine abort during pause/resume — operator action required" error (FN-6648; recurrence of FN-6478/FN-6568/FN-6625/FN-6644/FN-6647). The paused-after-completion graceful-exit path finalizes a fully completed task to in-review while leaving a non-user `paused` flag set; `handleGraphFailure`'s completion-finalized guards required `paused !== true`, so the trailing graph failure was misclassified as an operator-action pause abort once the volatile completion markers were lost. The classifier now recognizes finalized completions regardless of a lingering non-user pause flag, while genuine user/global pauses and in-progress tasks are unaffected. diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts index 9bca2adbb6..55f811c9d4 100644 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ b/packages/engine/src/__tests__/executor-recovery.test.ts @@ -1612,6 +1612,40 @@ describe("TaskExecutor bounded recovery retries", () => { expectBenignAlreadyAdvanced(store, column); }); + it("treats a completed in-review row as benign even with a lingering NON-user paused flag (FN-6648)", async () => { + /* + FNXC:WorkflowLifecycle 2026-06-18-16:25: + FN-6648 (FN-6638 recurrence): the paused-after-completion graceful-exit + path finalizes a fully completed task to in-review while leaving a + NON-user `paused: true` flag set (handoffToReview/applyInReviewEnterEffects + clear status/blockedBy but never `paused`). Worst case: the volatile + completionFinalized marker is lost (execute re-entry) AND provenance is + overwritten to hard-cancel by teardown — only persisted evidence remains. + This must resolve benignly, NOT park the completed task as an + operator-action "engine abort during pause/resume" failure. + */ + const store = createMockStore(); + const task = makeCompletedTask(); + store.getTask.mockResolvedValue({ + ...task, + column: "in-review", + paused: true, + userPaused: false, + status: undefined, + error: null, + }); + const executor = new TaskExecutor(store, "/tmp/test", {}); + (executor as any).markPausedAborted("FN-001", "hard-cancel"); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["execute"], + }); + + expectBenignAlreadyAdvanced(store); + }); + it("preserves explicit user-pause parking even when durable completion state exists", async () => { const store = createMockStore(); const task = makeCompletedTask(); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 3dff64ddda..ab20a2b985 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -6468,7 +6468,18 @@ export class TaskExecutor { && live.status == null && live.error == null && live.userPaused !== true - && live.paused !== true + // FNXC:WorkflowLifecycle 2026-06-18-16:20: + // FN-6648: do NOT require `paused !== true` here. The + // paused-after-completion graceful-exit path (executor ~8748/8194) + // finalizes a FULLY COMPLETED task to in-review while leaving a + // NON-user `paused: true` flag set — handoffToReview / + // applyInReviewEnterEffects clear status/blockedBy/overlapBlockedBy + // but never `paused`. Requiring `paused !== true` made this clean + // completion unrecognizable, so `genuinePauseAbort` parked it failed + // with the spurious "engine abort during pause/resume" error + // (FN-6638 recurrence). `userPaused`/global-pause are still excluded, + // and `persistedCompletedProgress` + `persistedCompletionFinalizeLog` + // + status/error == null keep this scoped to genuine completions. && abortProvenance !== "global-pause" && !mergeSeamAborted && persistedCompletionFinalizeLog, @@ -6478,14 +6489,21 @@ export class TaskExecutor { completionFinalized && live.column !== "in-progress" && !live.userPaused - && live.paused !== true + // FN-6648: `paused !== true` intentionally dropped here too — the + // suppression is already gated on `completionFinalized` (completed + // steps + finalize-to-review evidence) plus userPaused/global-pause + // exclusions, so a lingering non-user post-completion pause flag must + // not defeat it. See alreadyFinalizedToReview note above. && abortProvenance !== "global-pause" && !mergeSeamAborted, ); const genuinePauseAbort = Boolean( live.userPaused || abortProvenance === "global-pause" - || (live.paused && !mergeSeamAborted) + // FN-6648: gate the bare `paused` clause on the completion-finalize + // suppression so a completed task carrying a non-user post-completion + // pause flag is not parked as an operator-action failure. + || (live.paused && !mergeSeamAborted && !suppressFinalizedCompletionAbort) || (pausedAborted && !mergeSeamAborted && !completionFinalizeAborted && !suppressFinalizedCompletionAbort), ); if (genuinePauseAbort) {