From cb91d3fc067d53e85b9cb5a94692defd7c5021da Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 03:54:01 -0700 Subject: [PATCH] FN-6482: preserve awaiting graph failure states Preserve resumable workflow graph waits instead of parking them as execute failures. - Classify awaiting user input and CLI approval node values before terminal graph failure handling.\n- Read foreach container context values for step-execute instances.\n- Cover awaiting graph exits and genuine step-execute-unwired failures in executor recovery tests.\n\nFiles changed:\n .../engine/src/__tests__/executor-recovery.test.ts | 87 ++++++++++++++++++++++\n packages/engine/src/executor.ts | 34 +++++++++\n 2 files changed, 121 insertions(+) Fusion-Task-Id: FN-6482 Fusion-Task-Lineage: 2443d4cd-1307-470a-b456-2f3b44b9cc83 --- .../src/__tests__/executor-recovery.test.ts | 87 +++++++++++++++++++ packages/engine/src/executor.ts | 34 ++++++++ 2 files changed, 121 insertions(+) diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts index 8d053f91df..a1d1959004 100644 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ b/packages/engine/src/__tests__/executor-recovery.test.ts @@ -944,6 +944,93 @@ describe("TaskExecutor bounded recovery retries", () => { expect(store.handoffToReview).not.toHaveBeenCalled(); }); + it.each([ + ["plain execute", ["execute"], "awaiting-user-input", { "node:execute:value": "awaiting-user-input" }, "Workflow graph run ended awaiting user input at node 'execute' — awaiting state preserved"], + ["progress then execute", ["plan", "execute"], "awaiting-cli-approval", { "node:execute:value": "awaiting-cli-approval" }, "Workflow graph run ended awaiting CLI approval at node 'execute' — awaiting state preserved"], + ["step-execute foreach seam", ["foreach#0:step-execute"], "awaiting-user-input", { "node:foreach:value": "awaiting-user-input" }, "Workflow graph run ended awaiting user input at node 'foreach#0:step-execute' — awaiting state preserved"], + ] as const)( + "preserves awaiting graph failure values instead of terminal execute parking: %s", + async (_name, visitedNodeIds, value, context, message) => { + const store = createMockStore(); + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; + store.getTask.mockResolvedValue({ + ...task, + column: "in-progress", + paused: false, + status: value, + error: null, + }); + const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); + const executor = new TaskExecutor(store, "/tmp/test", {}); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds, + context, + }); + + expect(store.logEntry).toHaveBeenCalledWith("FN-001", message, undefined, undefined); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: value, paused: true }, undefined); + expect(store.logEntry.mock.calls.map((call) => call[1]).join("\n")).not.toContain("Workflow graph terminated with failure at node"); + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ status: "failed" }), + expect.anything(), + ); + expect(store.handoffToReview).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Workflow graph terminated with failure at node")); + warnSpy.mockRestore(); + }, + ); + + it("preserves genuine step-execute-unwired failures as terminal graph failures", async () => { + const store = createMockStore(); + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps: [{ name: "Step 1", status: "pending" }], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; + store.getTask.mockResolvedValue({ ...task, column: "in-progress", paused: false, status: undefined, error: null }); + const warnSpy = vi.spyOn(executorLog, "warn").mockImplementation(() => undefined); + const executor = new TaskExecutor(store, "/tmp/test", {}); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["foreach#0:step-execute"], + context: { "node:foreach#0:step-execute:value": "step-execute-unwired" }, + }); + + const message = "Workflow graph terminated with failure at node 'foreach#0:step-execute'"; + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: message, status: "failed" }, undefined); + expect(store.handoffToReview).toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ evidence: expect.objectContaining({ reason: "workflow-graph-failed" }) }), + ); + warnSpy.mockRestore(); + }); + /* FNXC:WorkflowLifecycle 2026-06-15-01:38: FN-6478 established that a workflow graph exit while paused is benign only while the task remains in-progress. If the live row already advanced to in-review or another non-execution column, the executor must preserve explicit user pauses and autoMerge:false terminal review state while surfacing an operator-actionable workflow failure instead of the generic pause-preserved log. diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 6fae544a68..5e1aa888c3 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -6310,6 +6310,26 @@ export class TaskExecutor { || latestAction === "Resuming execution after unpause"; } + private graphFailureValue(result: WorkflowGraphTaskRunResult): string | undefined { + const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1]; + if (!failedNode || !result.context) return undefined; + const value = result.context[`node:${failedNode}:value`]; + if (typeof value === "string") return value; + const foreachInstanceDelimiter = failedNode.indexOf("#"); + if (foreachInstanceDelimiter === -1) return undefined; + /* + FNXC:WorkflowLifecycle 2026-06-15-03:23: + Foreach step-execute failures record instance ids in visitedNodeIds, but the graph walk stores the failed value on the foreach container context key. Check that container key before classifying execute-node failures so awaiting operator states from step-execute are preserved instead of parked as terminal graph failures. + */ + const foreachContainerNode = failedNode.slice(0, foreachInstanceDelimiter); + const containerValue = result.context[`node:${foreachContainerNode}:value`]; + return typeof containerValue === "string" ? containerValue : undefined; + } + + private isAwaitingGraphFailureValue(value: string | undefined): value is "awaiting-user-input" | "awaiting-cli-approval" { + return value === "awaiting-user-input" || value === "awaiting-cli-approval"; + } + /** Terminal failure of a graph run: record the error and park the task in * review so a human can act — never leave it invisible in in-progress. */ private async handleGraphFailure(task: Task, result: WorkflowGraphTaskRunResult): Promise { @@ -6354,6 +6374,20 @@ export class TaskExecutor { return; } const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1]; + const failureValue = this.graphFailureValue(result); + if (this.isAwaitingGraphFailureValue(failureValue)) { + /* + FNXC:WorkflowLifecycle 2026-06-15-12:00: + Awaiting-input and awaiting-CLI-approval workflow node values are resumable operator waits, not terminal execute failures. Classify the node value before the generic graph-failure sink so a stale or partially reloaded pause flag cannot park a legitimately runnable task in review with the execute-node symptom. + */ + const benignMessage = `Workflow graph run ended awaiting ${failureValue === "awaiting-cli-approval" ? "CLI approval" : "user input"} at node '${failedNode ?? "unknown"}' — awaiting state preserved`; + executorLog.log(`${task.id}: ${benignMessage}`); + await this.store.logEntry(task.id, benignMessage, undefined, this.getRunContextFor(task.id)); + if (live.status !== failureValue || !live.paused) { + await this.store.updateTask(task.id, { status: failureValue, paused: true }, this.getRunContextFor(task.id)); + } + return; + } if (this.isTransientResumeAfterRestartGraphFailure(live, result)) { const priorRetries = live.graphResumeRetryCount ?? 0; if (priorRetries < MAX_TRANSIENT_GRAPH_RESUME_RETRIES) {