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
This commit is contained in:
gsxdsm
2026-06-15 03:54:01 -07:00
parent 601a85b7c9
commit cb91d3fc06
2 changed files with 121 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<void> {
@@ -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) {