From 491b04c44b6375f75b74dda0dcaea988cb65dcdc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 13:35:49 -0700 Subject: [PATCH] FN-6289: Treat merge primitives as terminal workflow boundaries Allow the linear workflow compiler to stop at engine-owned merge policy regions. - Exempt merge/retry/branch-group primitive kinds from linearity fan-out checks and step emission. - Validate builtin coding workflow compilation while preserving deferral for stepwise workflows. - Update builtin workflow selection tests and publish the patch changeset for @runfusion/fusion. Files changed: .changeset/fix-workflow-compiler-merge-region.md | 4 +- .../core/src/__tests__/builtin-workflows.test.ts | 8 ++-- .../core/src/__tests__/workflow-compiler.test.ts | 38 ++++++++++++++++++ packages/core/src/workflow-compiler.ts | 46 +++++++++++++++++++--- 4 files changed, 85 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-6289 Fusion-Task-Lineage: 22d6e778-2e92-42b9-bb0d-4cc71d1631c7 --- .../fix-workflow-compiler-merge-region.md | 4 +- .../src/__tests__/builtin-workflows.test.ts | 8 ++-- .../src/__tests__/workflow-compiler.test.ts | 38 +++++++++++++++ packages/core/src/workflow-compiler.ts | 46 +++++++++++++++++-- 4 files changed, 85 insertions(+), 11 deletions(-) diff --git a/.changeset/fix-workflow-compiler-merge-region.md b/.changeset/fix-workflow-compiler-merge-region.md index 9ad1839e01..53a0835e8a 100644 --- a/.changeset/fix-workflow-compiler-merge-region.md +++ b/.changeset/fix-workflow-compiler-merge-region.md @@ -1,5 +1,5 @@ --- -"@fusion/core": patch +"@runfusion/fusion": patch --- -Fix task creation failing with "node 'merge-gate' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)". The built-in coding workflow now models the merge lifecycle as a branching region of merge/retry/branch-group primitives (FN-6035), but the linear workflow compiler still tried to lower those nodes and rejected their fan-out. The compiler now treats the merge-region primitive kinds (merge-gate, merge-attempt, manual-merge-hold, retry-backoff, recovery-router, branch-group-member-integration, branch-group-promotion, pr-merge) as an engine-owned terminal boundary — exempt from the single-edge linearity rule and never lowered to a step — so linear-prefix workflows compile to their pre-merge step list again. +Fix task creation failing with "node 'merge-gate' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)". The built-in coding workflow now models the merge lifecycle as a branching region of merge/retry/branch-group primitives (FN-6035), but the linear workflow compiler still tried to lower those nodes and rejected their fan-out. The compiler now treats the merge-region primitive kinds (merge-gate, merge-attempt, manual-merge-hold, retry-backoff, recovery-router, branch-group-member-integration, branch-group-promotion) as an engine-owned terminal boundary — exempt from the single-edge linearity rule and never lowered to a step — so linear-prefix workflows compile to their pre-merge step list again. diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index ecca0a172c..d0d0bd14e3 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -350,7 +350,7 @@ describe("built-in workflows", () => { await expect(store.deleteWorkflowDefinition("builtin:coding")).rejects.toThrow(/cannot be deleted/i); }); - it("interpreter-deferred built-ins can be selected without compile materialization", async () => { + it("branching built-ins can be selected without throwing", async () => { for (const workflowId of ["builtin:coding", "builtin:stepwise-coding"]) { const task = await store.createTask({ description: `select ${workflowId}`, enabledWorkflowSteps: [] }); @@ -362,7 +362,7 @@ describe("built-in workflows", () => { } }); - it("create-time interpreter-deferred built-in workflowId records selection without throwing", async () => { + it("create-time branching built-in workflowId records selection without throwing", async () => { const task = await store.createTask({ description: "explicit builtin coding", workflowId: "builtin:coding" }); const detail = await store.getTask(task.id); @@ -370,7 +370,7 @@ describe("built-in workflows", () => { expect(store.getTaskWorkflowSelection(task.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); }); - it("interpreter-deferred built-in project defaults fall back without throwing", async () => { + it("branching built-in project defaults do not throw", async () => { await expect(store.createTask({ description: "implicit builtin default" })).resolves.toMatchObject({ description: "implicit builtin default", }); @@ -378,7 +378,7 @@ describe("built-in workflows", () => { await store.setDefaultWorkflowId("builtin:coding"); const codingTask = await store.createTask({ description: "default builtin coding" }); expect((await store.getTask(codingTask.id)).enabledWorkflowSteps ?? []).toEqual([]); - expect(store.getTaskWorkflowSelection(codingTask.id)).toBeUndefined(); + expect(store.getTaskWorkflowSelection(codingTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); await store.setDefaultWorkflowId("builtin:stepwise-coding"); const stepwiseTask = await store.createTask({ description: "default builtin stepwise" }); diff --git a/packages/core/src/__tests__/workflow-compiler.test.ts b/packages/core/src/__tests__/workflow-compiler.test.ts index 4f5a3a1ac8..21086ebfcb 100644 --- a/packages/core/src/__tests__/workflow-compiler.test.ts +++ b/packages/core/src/__tests__/workflow-compiler.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect } from "vitest"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js"; import { compileWorkflowToSteps, + MERGE_REGION_NODE_KINDS, validateLinearity, WorkflowCompileError, + WORKFLOW_INTERPRETER_DEFERRED_SUFFIX, } from "../workflow-compiler.js"; import { serializeWorkflowIr, parseWorkflowIr } from "../workflow-ir.js"; import type { WorkflowIr } from "../workflow-ir-types.js"; @@ -116,10 +120,42 @@ describe("compileWorkflowToSteps (U2)", () => { { from: "b", to: "end", condition: "success" }, ], }; + const err = validateLinearity(ir); + expect(err).toBeInstanceOf(WorkflowCompileError); + expect(err?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); expect(() => compileWorkflowToSteps(ir)).toThrow(WorkflowCompileError); expect(() => compileWorkflowToSteps(ir)).toThrow(/interpreter \(deferred\)/i); }); + it("validates builtin workflow linearity while preserving stepwise interpreter deferral", () => { + expect(validateLinearity(BUILTIN_CODING_WORKFLOW_IR)).toBeNull(); + + const stepwiseErr = validateLinearity(BUILTIN_STEPWISE_CODING_WORKFLOW_IR); + expect(stepwiseErr).toBeInstanceOf(WorkflowCompileError); + expect(stepwiseErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); + }); + + it("compiles the builtin coding workflow without merge-region steps", () => { + const steps = compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR); + const mergeRegionNodeIds = BUILTIN_CODING_WORKFLOW_IR.nodes + .filter((node) => MERGE_REGION_NODE_KINDS.has(node.kind)) + .map((node) => node.id); + + expect(steps.map((step) => step.name)).toEqual([]); + expect(mergeRegionNodeIds).toEqual( + expect.arrayContaining([ + "merge-gate", + "merge-retry", + "merge-manual-hold", + "branch-group-member-integration", + "branch-group-promotion", + "merge-attempt", + "recovery-router", + ]), + ); + expect(steps.some((step) => mergeRegionNodeIds.includes(step.name))).toBe(false); + }); + it("compiles a workflow whose post-review merge region branches into primitives (FN-6035)", () => { // Mirrors the builtin:coding shape: review → merge-gate fans out into the // engine-owned merge/branch-group/retry subgraph. These primitive kinds are a @@ -177,6 +213,8 @@ describe("compileWorkflowToSteps (U2)", () => { }; const err = validateLinearity(ir); expect(err).toBeInstanceOf(WorkflowCompileError); + expect(err?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); + expect(err?.message).toMatch(/disconnected nodes/); }); it("rejects seams that are out of the planning -> execute -> workflow-step -> review -> merge order", () => { diff --git a/packages/core/src/workflow-compiler.ts b/packages/core/src/workflow-compiler.ts index 3dcca86094..0ebad8ab48 100644 --- a/packages/core/src/workflow-compiler.ts +++ b/packages/core/src/workflow-compiler.ts @@ -21,6 +21,24 @@ export function isInterpreterDeferredWorkflowCompileError(error: unknown): boole return error instanceof WorkflowCompileError && error.message.includes(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX); } +/** Workflow-owned merge/retry/recovery policy primitives. The WorkflowStep + * compiler treats this region as a terminal engine-owned boundary: these nodes + * may branch internally, are not emitted as steps, and are not walked by the + * linear step compiler. */ +export const MERGE_REGION_NODE_KINDS: ReadonlySet = new Set([ + "merge-gate", + "merge-attempt", + "manual-merge-hold", + "retry-backoff", + "recovery-router", + "branch-group-member-integration", + "branch-group-promotion", +]); + +function isMergeRegionKind(node: WorkflowIrNode): boolean { + return MERGE_REGION_NODE_KINDS.has(node.kind); +} + /** Seam anchor kinds, encoded on IR nodes as `config.seam`. These map to the * fixed planning → execute → workflow-step → review → merge pipeline and are * not emitted as steps. */ @@ -55,9 +73,11 @@ function mainEdge(edges: WorkflowIrEdge[]): WorkflowIrEdge | undefined { * post-merge chain the WorkflowStep engine can run. Returns a * WorkflowCompileError describing the first problem, or null when compilable. * - * Allowed shape: a single path from start to end. Seam nodes may carry an extra - * `failure` edge to the end node; every other non-terminal node has exactly one - * outgoing edge. Anything else (true branching) requires the deferred interpreter. + * Allowed shape: a single path from start to end or to the engine-owned merge + * policy region. Seam nodes may carry an extra `failure` edge to the end node; + * merge-policy primitives are terminal and may fan out internally; every other + * non-terminal node has exactly one outgoing edge. Anything else (true + * branching) requires the deferred interpreter. */ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { const nodesById = new Map(ir.nodes.map((node) => [node.id, node])); @@ -81,6 +101,10 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { continue; } + if (isMergeRegionKind(node)) { + continue; + } + const seam = seamOf(node); if (seam) { const failureEdges = outs.filter((edge) => edge.condition === "failure"); @@ -132,6 +156,10 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { while (cursor && !visited.has(cursor)) { visited.add(cursor); const node = nodesById.get(cursor); + if (node && isMergeRegionKind(node)) { + reachedTerminal = true; + break; + } const seam = node ? seamOf(node) : undefined; if (seam) { if (seenSeams.has(seam)) { @@ -160,7 +188,9 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null { if (!reachedTerminal) { return new WorkflowCompileError("workflow main path does not reach the end node"); } - const unreached = ir.nodes.filter((node) => !visited.has(node.id) && node.kind !== "end"); + const unreached = ir.nodes.filter( + (node) => !visited.has(node.id) && node.kind !== "end" && !isMergeRegionKind(node), + ); if (unreached.length > 0) { return new WorkflowCompileError( `node '${unreached[0].id}' is not on the main path — disconnected nodes ${WORKFLOW_INTERPRETER_DEFERRED_SUFFIX}`, @@ -222,7 +252,9 @@ function nodeToStepInput(node: WorkflowIrNode, phase: "pre-merge" | "post-merge" * Compile a workflow graph into an ordered list of WorkflowStep inputs ready to * persist and run on the existing engine. User prompt/script/gate nodes become * steps; execute/review seams are skipped; the merge seam is the pre-/post-merge - * boundary. Throws WorkflowCompileError for non-linear graphs. + * boundary; merge-policy primitives form a terminal engine-owned region that is + * skipped and never emitted as steps. Throws WorkflowCompileError for non-linear + * graphs. * * The returned array order is the execution order (it maps directly onto a * task's `enabledWorkflowSteps`). @@ -246,6 +278,10 @@ export function compileWorkflowToSteps(ir: WorkflowIr): WorkflowStepInput[] { const node = nodesById.get(cursor); if (!node) break; + if (isMergeRegionKind(node)) { + break; + } + const seam = seamOf(node); if (seam === "merge") { phase = "post-merge";