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:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user