From f27ebfd6bb0d2d7cd44bde7c7ef32221b03c7a21 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 00:44:48 -0700 Subject: [PATCH] FN-6051: add workflow graph retry coverage Add retry coverage for built-in coding workflow executor failures. - add execute-node retry tests for transient exceptions, exhausted retries, and clean failure outcomes - verify review nodes use default retry behavior when no per-node retry config exists - cover per-node maxRetries overrides with a custom workflow IR Files changed: packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts | 133 +++++++++++++++++++++ 1 file changed, 133 insertions(+) Fusion-Task-Id: FN-6051 Fusion-Task-Lineage: a2232af7-a86d-4ce4-aab9-a2cc18694f8a --- ...aph-executor-retry-coding-workflow.test.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts diff --git a/packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts b/packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts new file mode 100644 index 0000000000..4c6c942ef4 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest"; +import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core"; +import type { TaskDetail, WorkflowIr } from "@fusion/core"; + +import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js"; + +const task = { id: "FN-6051" } as TaskDetail; + +function settingsOn() { + return { experimentalFeatures: { workflowGraphExecutor: true } }; +} + +describe("WorkflowGraphExecutor built-in coding workflow retries", () => { + it("retries the execute node on exception then succeeds", async () => { + let executeCalls = 0; + const prompt = vi.fn(async (node) => { + if (node.id === "execute") { + executeCalls += 1; + if (executeCalls === 1) throw new Error("transient execute failure"); + } + return { outcome: "success" }; + }); + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + + const result = await executor.run(task, settingsOn(), BUILTIN_CODING_WORKFLOW_IR); + + expect(result.outcome).toBe("success"); + expect(executeCalls).toBe(2); + expect(result.context["node:execute:outcome"]).toBe("success"); + expect(result.visitedNodeIds).toEqual( + expect.arrayContaining(["execute", "workflow-step", "review", "merge"]), + ); + }); + + it("exhausts execute node retries and routes failure to end", async () => { + let executeCalls = 0; + const prompt = vi.fn(async (node) => { + if (node.id === "execute") { + executeCalls += 1; + throw new Error("persistent execute failure"); + } + return { outcome: "success" }; + }); + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + + const result = await executor.run(task, settingsOn(), BUILTIN_CODING_WORKFLOW_IR); + + expect(executeCalls).toBe(2); + expect(result.context["node:execute:outcome"]).toBe("failure"); + expect(result.context["node:execute:value"]).toBe("exception"); + expect(result.context["node:execute:error"]).toBe("persistent execute failure"); + expect(result.outcome).toBe("failure"); + expect(BUILTIN_CODING_WORKFLOW_IR.edges).toContainEqual({ from: "execute", to: "end", condition: "failure" }); + expect(result.visitedNodeIds).toEqual(["start", "planning", "execute"]); + expect(result.visitedNodeIds).not.toContain("workflow-step"); + }); + + it("does not retry when the execute node returns a clean failure outcome", async () => { + let executeCalls = 0; + const prompt = vi.fn(async (node) => { + if (node.id === "execute") { + executeCalls += 1; + return { outcome: "failure", value: "clean-failure" }; + } + return { outcome: "success" }; + }); + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + + const result = await executor.run(task, settingsOn(), BUILTIN_CODING_WORKFLOW_IR); + + expect(executeCalls).toBe(1); + expect(result.context["node:execute:outcome"]).toBe("failure"); + expect(result.context["node:execute:value"]).toBe("clean-failure"); + expect(result.context["node:execute:error"]).toBeUndefined(); + expect(result.outcome).toBe("failure"); + expect(BUILTIN_CODING_WORKFLOW_IR.edges).toContainEqual({ from: "execute", to: "end", condition: "failure" }); + expect(result.visitedNodeIds).toEqual(["start", "planning", "execute"]); + }); + + it("uses the executor default retry count for a review node without maxRetries config", async () => { + let reviewCalls = 0; + const prompt = vi.fn(async (node) => { + if (node.id === "review") { + reviewCalls += 1; + throw new Error("review seam failed"); + } + return { outcome: "success" }; + }); + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + + const result = await executor.run(task, settingsOn(), BUILTIN_CODING_WORKFLOW_IR); + + expect(reviewCalls).toBe(2); + expect(result.context["node:review:outcome"]).toBe("failure"); + expect(result.context["node:review:value"]).toBe("exception"); + expect(result.context["node:review:error"]).toBe("review seam failed"); + expect(result.outcome).toBe("failure"); + expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]); + }); + + it("respects a per-node maxRetries override", async () => { + const ir: WorkflowIr = { + version: "v2", + name: "custom-retry-override", + nodes: [ + { id: "start", kind: "start" }, + { id: "custom", kind: "prompt", config: { maxRetries: 4 } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "custom" }, + { from: "custom", to: "end", condition: "success" }, + ], + }; + let calls = 0; + const prompt = vi.fn(async () => { + calls += 1; + if (calls < 4) throw new Error(`temporary failure ${calls}`); + return { outcome: "success" }; + }); + const executor = new WorkflowGraphExecutor({ + handlers: { prompt }, + maxRetriesPerNode: 2, + }); + + const result = await executor.run(task, settingsOn(), ir); + + expect(calls).toBe(4); + expect(result.outcome).toBe("success"); + expect(result.context["node:custom:outcome"]).toBe("success"); + expect(result.context["node:custom:error"]).toBeUndefined(); + }); +});