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:
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user