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
This commit is contained in:
gsxdsm
2026-06-12 13:35:49 -07:00
parent 72661faf96
commit 491b04c44b
4 changed files with 85 additions and 11 deletions

View File

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

View File

@@ -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" });

View File

@@ -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", () => {

View File

@@ -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<WorkflowIrNode["kind"]> = 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";