Merge remote-tracking branch 'origin/main' into latest-1717

# Conflicts:
#	packages/engine/src/executor.ts
This commit is contained in:
gsxdsm
2026-06-23 17:40:35 -07:00
58 changed files with 4412 additions and 1280 deletions

View File

@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
import type { TaskDetail } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
/*
FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
Built-in-level execution coverage for U6: the coding workflow now expresses the
pre-merge browser-verification step as an `optional-group` (default OFF). This is
the dead-toggle / two-task divergence guard at the BUILT-IN level (not just the
generic construct): two coding tasks identical except `enabledWorkflowSteps` must
diverge — the one including the group id runs the browser-verification prompt node
pre-merge; the sibling runs NONE and still reaches review. Real executor runs (not
traversal-only) so a mock-masked dead path cannot pass.
The inner template node id is `browser-verification-step` (distinct from the group
id `browser-verification` per the U1 template-node-id collision rule), and its
materialized visited id is `browser-verification::browser-verification-step`.
*/
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
const GROUP_ID = "browser-verification";
const INNER_STEP_VISITED_ID = "browser-verification::browser-verification-step";
function codingTask(enabledWorkflowSteps?: string[]): TaskDetail {
return {
id: "FN-CODING",
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
} as unknown as TaskDetail;
}
/** Count how many times the inner browser-verification prompt node ran. A prompt
* handler keyed on the inner template node id; everything else succeeds. */
function makeExecutor(onInnerStep: () => void) {
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
if (node.id === "browser-verification-step") onInnerStep();
return { outcome: "success" };
});
return new WorkflowGraphExecutor({ handlers: { prompt } });
}
describe("builtin coding browser-verification optional-group (U6)", () => {
it("two-task divergence: the enabled task runs browser-verification pre-merge; the disabled task does not", async () => {
// Enabled.
let enabledRuns = 0;
const enabledResult = await makeExecutor(() => {
enabledRuns++;
}).run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
// Disabled (no enabledWorkflowSteps).
let disabledRuns = 0;
const disabledResult = await makeExecutor(() => {
disabledRuns++;
}).run(codingTask(), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
// The browser-verification step ran exactly once when enabled, never when off.
expect(enabledRuns).toBe(1);
expect(disabledRuns).toBe(0);
// Enabled: the inner template node is visited pre-merge (before review).
expect(enabledResult.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
const innerIdx = enabledResult.visitedNodeIds.indexOf(INNER_STEP_VISITED_ID);
const reviewIdxEnabled = enabledResult.visitedNodeIds.indexOf("review");
const executeIdxEnabled = enabledResult.visitedNodeIds.indexOf("execute");
expect(executeIdxEnabled).toBeLessThan(innerIdx);
expect(innerIdx).toBeLessThan(reviewIdxEnabled);
// Disabled: the group node is traversed (bypassed) but its body never runs;
// both tasks reach the same downstream review node.
expect(disabledResult.visitedNodeIds).toContain(GROUP_ID);
expect(disabledResult.visitedNodeIds).not.toContain(INNER_STEP_VISITED_ID);
expect(disabledResult.visitedNodeIds).toContain("review");
expect(enabledResult.visitedNodeIds).toContain("review");
});
it("a browser-verification failure surfaces as the group's outcome and routes its failure edge to end", async () => {
// The inner step fails → the group's failure edge (browser-verification → end)
// fires, so review is never reached.
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
if (node.id === "browser-verification-step") return { outcome: "failure", value: "verify-failed" };
return { outcome: "success" };
});
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const result = await executor.run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
expect(result.context[`node:${GROUP_ID}:outcome`]).toBe("failure");
expect(result.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
// The group's only two outgoing edges are `success → review` and
// `failure → end`; the inner-step failure routes the failure edge, so review
// is skipped. (`end` is a terminal node the executor does not record in
// visitedNodeIds, so the routing is asserted via the group's failure outcome
// above + review being unreachable here.)
expect(result.visitedNodeIds).not.toContain("review");
});
});

View File

@@ -131,7 +131,11 @@ describe("fast mode workflow/runtime invariants", () => {
});
});
it("graph executor with builtin:coding selection skips the workflow-step seam in fast mode", async () => {
// U6: the coding built-in's pre-merge browser-verification optional-group is
// default-OFF (the task sets no enabledWorkflowSteps), so it is bypassed — its
// group node is visited but its body never runs and runWorkflowSteps is not
// called. Fast mode is irrelevant to a bypassed group; the seam is simply gone.
it("graph executor with builtin:coding selection bypasses the disabled browser-verification group", async () => {
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult());
const seams = {
@@ -154,7 +158,9 @@ describe("fast mode workflow/runtime invariants", () => {
const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toContain("workflow-step");
expect(result.visitedNodeIds).toContain("browser-verification");
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
expect(result.visitedNodeIds).not.toContain("workflow-step");
expect(runWorkflowSteps).not.toHaveBeenCalled();
expect(seams.review).toHaveBeenCalledTimes(1);
expect(seams.merge).toHaveBeenCalledTimes(1);

View File

@@ -30,7 +30,7 @@ import {
type WorkflowIr,
} from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import { WorkflowGraphExecutor, type WorkflowNodeResult } from "../workflow-graph-executor.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
@@ -81,13 +81,17 @@ function makeFakeStore(steps: TaskStep[]) {
};
}
/** Build a TaskDetail with N pending steps. */
function taskWithSteps(n: number): TaskDetail {
/** Build a TaskDetail with N pending steps and an optional enabled-group set. */
function taskWithSteps(n: number, enabledWorkflowSteps?: string[]): TaskDetail {
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
}));
return { id: "FN-STEPWISE", steps } as unknown as TaskDetail;
return {
id: "FN-STEPWISE",
steps,
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
} as unknown as TaskDetail;
}
/**
@@ -157,9 +161,15 @@ async function runStepwiseGraph(
onReset?: (active: ForeachActiveContext) => void;
captureResetResult?: (ok: boolean, reason?: string) => void;
workflowStep?: WorkflowLegacySeams["workflowStep"];
// U6: ids of optional-group nodes enabled for this task (e.g.
// "browser-verification"); seeds task.enabledWorkflowSteps.
enabledWorkflowSteps?: string[];
// U6: handler for non-seam custom nodes — the browser-verification
// optional-group's inner prompt node runs through this.
runCustomNode?: (nodeId: string) => Promise<WorkflowNodeResult>;
} = {},
): Promise<{ trajectory: TrajectoryEntry[]; outcome: string; result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>> }> {
const task = taskWithSteps(stepCount);
const task = taskWithSteps(stepCount, opts.enabledWorkflowSteps);
const fake = makeFakeStore(task.steps as TaskStep[]);
const reviewCursor = new Map<number, number>();
@@ -211,6 +221,10 @@ async function runStepwiseGraph(
const executor = new WorkflowGraphExecutor({
seams,
signal: opts.signal,
// U6: non-seam custom nodes (the browser-verification optional-group's inner
// prompt node) route here. Default: success no-op.
runCustomNode: async (node) =>
opts.runCustomNode ? opts.runCustomNode(node.id) : { outcome: "success" },
getTaskSteps: () => task.steps as TaskStep[],
// parse-steps reads PROMPT.md; produce headings matching the step count so the
// real builtin chain runs end-to-end. writeSteps is a no-op (steps pre-set).
@@ -578,44 +592,57 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => {
expect(result.visitedNodeIds).toContain("merge");
});
// ── Pre-merge workflow-step seam (optional-step execution, R1) ─────────────
// ── Pre-merge browser-verification optional-group (U6, R-3 run-once) ────────
it("runs the pre-merge workflow-step seam exactly once after the foreach (enabled steps execute)", async () => {
// This is the dead-toggle guard: without a workflow-step seam node on the
// success path, a stepwise task's enabledWorkflowSteps (e.g. browser
// verification) would never run. Wire a workflowStep spy and assert the graph
// invokes it once, between the foreach and review.
let workflowStepCalls = 0;
const BROWSER_VERIFICATION_STEP_VISITED_ID = "browser-verification::browser-verification-step";
it("runs the pre-merge browser-verification optional-group EXACTLY ONCE after the foreach when enabled", async () => {
// R-3 run-once guarantee + dead-toggle guard: the optional-group sits on the
// post-foreach success path. A stepwise task whose enabledWorkflowSteps
// includes the group id runs the inner browser-verification prompt node ONCE
// after all step instances complete — never per step-instance.
let browserVerificationCalls = 0;
const { outcome, result } = await runStepwiseGraph(
3,
[["APPROVE"], ["APPROVE"], ["APPROVE"]],
{
workflowStep: async () => {
workflowStepCalls++;
enabledWorkflowSteps: ["browser-verification"],
runCustomNode: async (nodeId) => {
if (nodeId === "browser-verification-step") browserVerificationCalls++;
return { outcome: "success" };
},
},
);
expect(outcome).toBe("success");
// The seam ran ONCE post-foreach — not per step-instance (3 steps here).
expect(workflowStepCalls).toBe(1);
expect(result.visitedNodeIds).toContain("workflow-step");
// Ordering: all step instances complete before the workflow-step seam, which
// ONCE post-foreach — not per step-instance (3 steps here).
expect(browserVerificationCalls).toBe(1);
expect(result.visitedNodeIds).toContain("browser-verification");
expect(result.visitedNodeIds).toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
// Ordering: all step instances complete before the group's inner step, which
// precedes review.
const seamIdx = result.visitedNodeIds.indexOf("workflow-step");
const groupStepIdx = result.visitedNodeIds.indexOf(BROWSER_VERIFICATION_STEP_VISITED_ID);
const reviewIdx = result.visitedNodeIds.indexOf("review");
const lastStepIdx = result.visitedNodeIds.map((id) => id.startsWith("steps#")).lastIndexOf(true);
expect(lastStepIdx).toBeLessThan(seamIdx);
expect(seamIdx).toBeLessThan(reviewIdx);
expect(lastStepIdx).toBeLessThan(groupStepIdx);
expect(groupStepIdx).toBeLessThan(reviewIdx);
});
it("treats the workflow-step seam as a no-op pass-through when no steps are enabled", async () => {
// No workflowStep seam wired → the handler skips to success and routes to
// review, leaving the trajectory identical to the pre-seam behavior.
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]]);
it("bypasses the browser-verification optional-group (inert) when it is not enabled", async () => {
// Disabled (no enabledWorkflowSteps): the group node is traversed but its
// template body never runs — the inner prompt node is not visited and the
// custom-node runner is never invoked for it. Routes straight to review.
let browserVerificationCalls = 0;
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]], {
runCustomNode: async (nodeId) => {
if (nodeId === "browser-verification-step") browserVerificationCalls++;
return { outcome: "success" };
},
});
expect(outcome).toBe("success");
expect(result.visitedNodeIds).toContain("workflow-step");
expect(browserVerificationCalls).toBe(0);
expect(result.visitedNodeIds).toContain("browser-verification");
expect(result.visitedNodeIds).not.toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
expect(result.visitedNodeIds).toContain("review");
});
});

View File

@@ -1,11 +1,16 @@
// ─────────────────────────────────────────────────────────────────────────────
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
// executor reproduces the workflow-native planning → execute → workflow-step
// → review → merge seam
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
// executor reproduces the workflow-native planning → execute → review → merge
// seam sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
// cover per-step / updateStep-trajectory parity.
//
// FNXC:WorkflowOptionalGroup 2026-06-21-15:10 (U6): the legacy `workflow-step`
// seam was retired from the coding built-in; pre-merge browser-verification is
// now a default-OFF `optional-group`. With no `enabledWorkflowSteps` on the task
// the group is BYPASSED, so the lifecycle seam sequence is planning → execute →
// review → merge (no workflow-step seam).
//
// The stepwise per-step trajectory + merge-blocker-window parity (legacy
// step-session path vs the stepwise foreach graph) is owned by the sibling
// suite `stepwise-workflow-parity.test.ts`. Keep the two concerns separate.
@@ -41,9 +46,7 @@ function runLegacy(seams: WorkflowLegacySeams) {
const execute = await seams.execute(task, {});
events.push(`execute:${execute.outcome}`);
if (execute.outcome !== "success") return events;
const workflowStep = await seams.workflowStep?.(task, {}) ?? { outcome: "success" as const };
events.push(`workflow-step:${workflowStep.outcome}`);
if (workflowStep.outcome !== "success") return events;
// U6: no workflow-step seam — browser-verification is a bypassed optional-group.
const review = await seams.review(task, {});
events.push(`review:${review.outcome}`);
if (review.outcome !== "success") return events;
@@ -98,7 +101,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.outcome).toBe("failure");
expect(legacyEvents).toEqual(["planning:success", "execute:success", "workflow-step:success", "review:success", "merge:failure"]);
expect(legacyEvents).toEqual(["planning:success", "execute:success", "review:success", "merge:failure"]);
});
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
@@ -198,11 +201,11 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
// Bind the invariant to actual executor behavior (PR #1432 review): the
// observation below derives from the run-captured seam sequence, so seam
// drift fails here instead of being masked by a hard-coded literal.
expect(stages).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
expect(stages).toEqual(["planning", "execute", "review", "merge"]);
// Legacy authoritative observation: a clean run that lands in `done`/merged.
const legacyObs = buildWorkflowObservation({
stageTransitions: ["triage", "planning", "execute", "workflow-step", "review", "merge"],
stageTransitions: ["triage", "planning", "execute", "review", "merge"],
terminalColumn: "done",
terminalStatus: "done",
reviewVerdict: "approve",

View File

@@ -27,9 +27,14 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
expect(result.outcome).toBe("success");
expect(executeCalls).toBe(2);
expect(result.context["node:execute:outcome"]).toBe("success");
// U6: the legacy `workflow-step` seam is gone; the pre-merge browser-verification
// optional-group is bypassed here (task has no enabledWorkflowSteps), so its
// group node is visited but its template body is not.
expect(result.visitedNodeIds).toEqual(
expect.arrayContaining(["execute", "workflow-step", "review", "merge"]),
expect.arrayContaining(["execute", "browser-verification", "review", "merge"]),
);
expect(result.visitedNodeIds).not.toContain("workflow-step");
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
});
it("exhausts execute node retries and routes failure to end", async () => {
@@ -52,7 +57,7 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
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");
expect(result.visitedNodeIds).not.toContain("browser-verification");
});
it("does not retry when the execute node returns a clean failure outcome", async () => {
@@ -95,7 +100,9 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
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"]);
// U6: with browser-verification disabled (bypassed), the group node sits
// between execute and review where the workflow-step seam used to.
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review"]);
});
it("respects a per-node maxRetries override", async () => {

View File

@@ -19,6 +19,10 @@ const mergeRegionEntries: Array<{ id: string; kind: WorkflowIrNodeKind }> = [
];
const rawMergeRegionNodeIds = mergeRegionEntries.map((entry) => entry.id);
// U6: the task carries no `enabledWorkflowSteps`, so the pre-merge
// browser-verification optional-group is BYPASSED — its node is visited but its
// body never runs. The legacy `workflowStep` seam is retained here only for shape
// (it is no longer reached by the migrated coding IR).
function createSeams(overrides: Partial<WorkflowLegacySeams> = {}): WorkflowLegacySeams {
return {
planning: async () => ({ outcome: "success" }),
@@ -62,7 +66,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
expect(result.outcome).toBe("success");
expect(merge).toHaveBeenCalledOnce();
expect(calls).toEqual(["merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
expect(result.context["node:merge:outcome"]).toBe("success");
expectNoRawMergeRegionVisits(result.visitedNodeIds);
});
@@ -75,7 +79,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
expect(result.outcome).toBe("failure");
expect(merge).toHaveBeenCalledOnce();
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
expect(result.context["node:merge:outcome"]).toBe("failure");
expect(result.context["node:merge:value"]).toBe("FileScopeViolationError");
expectNoRawMergeRegionVisits(result.visitedNodeIds);
@@ -94,7 +98,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
expect(result.outcome).toBe("failure");
expect(merge).not.toHaveBeenCalled();
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review"]);
expect(result.visitedNodeIds).not.toContain("merge");
expectNoRawMergeRegionVisits(result.visitedNodeIds);
});
@@ -109,7 +113,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
expect(result.outcome).toBe("success");
expect(merge).toHaveBeenCalledOnce();
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
expectNoRawMergeRegionVisits(result.visitedNodeIds);
},
);

View File

@@ -0,0 +1,223 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, WorkflowIr } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
/*
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
Execution-level coverage for the run-once/bypass dispatch (U2). The contract that
guards the dead-toggle failure mode is the TWO-TASK DIVERGENCE test: two tasks
identical except `enabledWorkflowSteps` must diverge — the enabled one runs the
template's nodes, the disabled one runs NONE and still reaches the same downstream
node. These are real executor runs (not traversal-only) so a mock-masked dead path
cannot pass.
*/
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
/** A graph with one `optional-group` between `before` and `after`. The group's
* template runs a single `optstep` prompt when the group is enabled. */
function optionalGroupIr(): WorkflowIr {
return {
version: "v2",
name: "optional-group-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "before", kind: "prompt", config: { prompt: "before" } },
{
id: "group",
kind: "optional-group",
config: {
name: "Browser verification",
defaultOn: false,
template: {
nodes: [{ id: "optstep", kind: "prompt", config: { prompt: "verify" } }],
edges: [],
},
},
},
{ id: "after", kind: "prompt", config: { prompt: "after" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "before" },
{ from: "before", to: "group" },
{ from: "group", to: "after", condition: "success" },
{ from: "after", to: "end" },
],
};
}
/** A graph with a two-node template so we can prove a single pass walks all
* template nodes once (not per-step, not looped). */
function multiNodeGroupIr(): WorkflowIr {
return {
version: "v2",
name: "optional-group-multi",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "group",
kind: "optional-group",
config: {
defaultOn: false,
template: {
nodes: [
{ id: "a", kind: "prompt", config: { prompt: "a" } },
{ id: "b", kind: "gate", config: { prompt: "b" } },
],
edges: [{ from: "a", to: "b" }],
},
},
},
{ id: "after", kind: "prompt", config: { prompt: "after" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "group" },
{ from: "group", to: "after", condition: "success" },
{ from: "after", to: "end" },
],
};
}
function taskWith(enabled: string[] | undefined): TaskDetail {
return { id: "FN-OG", enabledWorkflowSteps: enabled } as TaskDetail;
}
describe("WorkflowGraphExecutor optional-group", () => {
it("two-task divergence: only the task whose enabledWorkflowSteps includes the group id runs the template; the sibling runs none and both reach downstream", async () => {
const ir = optionalGroupIr();
const enabledCalls: string[] = [];
const enabledExecutor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
enabledCalls.push(node.id);
return { outcome: "success" };
},
},
});
const enabledResult = await enabledExecutor.run(taskWith(["group"]), settingsOn(), ir);
const disabledCalls: string[] = [];
const disabledExecutor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
disabledCalls.push(node.id);
return { outcome: "success" };
},
},
});
const disabledResult = await disabledExecutor.run(taskWith([]), settingsOn(), ir);
// Enabled task executed the template node; disabled did not.
expect(enabledCalls).toContain("optstep");
expect(disabledCalls).not.toContain("optstep");
// The materialized template id is recorded only for the enabled run.
expect(enabledResult.visitedNodeIds).toContain("group::optstep");
expect(disabledResult.visitedNodeIds).not.toContain("group::optstep");
// Both still reach the same downstream node.
expect(enabledCalls).toContain("after");
expect(disabledCalls).toContain("after");
expect(enabledResult.visitedNodeIds).toContain("after");
expect(disabledResult.visitedNodeIds).toContain("after");
expect(enabledResult.outcome).toBe("success");
expect(disabledResult.outcome).toBe("success");
});
it("runs an enabled group's template exactly once (single pass, not per-step/looped)", async () => {
const runTemplate = vi.fn<WorkflowNodeHandler>(async () => ({ outcome: "success" }));
const executor = new WorkflowGraphExecutor({
handlers: { prompt: runTemplate, gate: runTemplate },
});
const result = await executor.run(taskWith(["group"]), settingsOn(), multiNodeGroupIr());
// Each template node ran exactly once; plus the downstream `after`.
const templateRuns = runTemplate.mock.calls
.map(([node]) => node.id)
.filter((id) => id === "a" || id === "b");
expect(templateRuns).toEqual(["a", "b"]);
expect(result.visitedNodeIds.filter((id) => id === "group::a")).toHaveLength(1);
expect(result.visitedNodeIds.filter((id) => id === "group::b")).toHaveLength(1);
expect(result.context["node:group:outcome"]).toBe("success");
expect(result.outcome).toBe("success");
});
it("disabled group is inert: downstream outcome/context identical to the group not being there", async () => {
const ir = optionalGroupIr();
const handler: WorkflowNodeHandler = async () => ({ outcome: "success" });
// Run with the group disabled.
const withGroup = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
const disabledResult = await withGroup.run(taskWith([]), settingsOn(), ir);
// Reference graph: identical but with the group node removed (before → after).
const refIr: WorkflowIr = {
...ir,
nodes: ir.nodes.filter((n) => n.id !== "group"),
edges: [
{ from: "start", to: "before" },
{ from: "before", to: "after" },
{ from: "after", to: "end" },
],
};
const refExecutor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
const refResult = await refExecutor.run(taskWith([]), settingsOn(), refIr);
expect(disabledResult.outcome).toBe(refResult.outcome);
// Downstream node outcome is identical in both graphs.
expect(disabledResult.context["node:after:outcome"]).toBe(refResult.context["node:after:outcome"]);
expect(disabledResult.context["node:before:outcome"]).toBe(refResult.context["node:before:outcome"]);
// No template node executed.
expect(disabledResult.visitedNodeIds).not.toContain("group::optstep");
});
it("a template-node failure inside an enabled group surfaces as the group's outcome and routes its outcome: edge", async () => {
const ir = optionalGroupIr();
// Route the group's failure value to a dedicated recovery node.
ir.nodes.push({ id: "recover", kind: "prompt", config: { prompt: "recover" } });
ir.edges.push({ from: "group", to: "recover", condition: "outcome:boom" });
const calls: string[] = [];
const handler: WorkflowNodeHandler = async (node) => {
calls.push(node.id);
if (node.id === "optstep") return { outcome: "failure", value: "boom" };
return { outcome: "success" };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
const result = await executor.run(taskWith(["group"]), settingsOn(), ir);
// The group's outcome reflects the template failure.
expect(result.context["node:group:outcome"]).toBe("failure");
expect(result.context["node:group:value"]).toBe("boom");
// The outcome: edge routed to recover, NOT the success edge to `after`.
expect(calls).toContain("recover");
expect(calls).not.toContain("after");
});
it("treats a stale/unknown enabled id as not-enabled (group bypassed, no crash)", async () => {
const ir = optionalGroupIr();
const calls: string[] = [];
const handler: WorkflowNodeHandler = async (node) => {
calls.push(node.id);
return { outcome: "success" };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
// enabledWorkflowSteps references a since-removed group id, not "group".
const result = await executor.run(taskWith(["stale-group-id"]), settingsOn(), ir);
expect(calls).not.toContain("optstep");
expect(calls).toContain("after");
expect(result.outcome).toBe("success");
});
});

View File

@@ -245,7 +245,9 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
// U6: the coding built-in no longer carries a `workflow-step` seam; its
// pre-merge browser-verification optional-group is default-OFF and bypassed.
expect(calls).toEqual(["planning", "execute", "review", "merge"]);
expect(result.reason).toBeUndefined();
expect(getWorkflowDefinition).not.toHaveBeenCalled();
});

View File

@@ -229,7 +229,9 @@ describe("WorkflowTaskRuntime", () => {
const result = await runtime.run(attachmentTask, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
// U6: the coding built-in no longer runs a `workflow-step` seam; the pre-merge
// browser-verification optional-group is default-OFF and bypassed (no call).
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
expect(observed.executedTasks).toHaveLength(1);
expect(observed.executedTasks[0]?.attachments).toEqual(attachments);
});
@@ -293,31 +295,52 @@ describe("WorkflowTaskRuntime", () => {
const result = await runtime.run(task, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
// U6: no `workflow-step` seam; the bypassed browser-verification group node
// sits between execute and review in the visited sequence.
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
});
it("stops the built-in workflow before review when workflow-step remediation is scheduled", async () => {
it("runs the pre-merge browser-verification optional-group once when enabled, before review", async () => {
// U6: replaces the prior workflow-step-remediation test. With the group ENABLED
// (task.enabledWorkflowSteps includes "browser-verification"), the inner
// browser-verification-step prompt node runs once pre-merge, recorded as a
// custom-node call; the group then routes success → review → merge.
const calls: string[] = [];
const runtime = new WorkflowTaskRuntime({
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
},
primitives: recordingPrimitives(calls, {
workflowStep: { outcome: "success", value: "remediation-scheduled" },
}),
primitives: recordingPrimitives(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
});
const result = await runtime.run(task, flagOff);
const enabledTask = { ...task, enabledWorkflowSteps: ["browser-verification"] } as TaskDetail;
const result = await runtime.run(enabledTask, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step"]);
expect(calls).toEqual([
"planning",
"prepare-worktree",
"execute",
"custom:browser-verification-step",
"review",
"merge",
]);
expect(result.visitedNodeIds).toEqual([
"start",
"planning",
"execute",
// The group container node, then its inner template step (run once).
"browser-verification",
"browser-verification::browser-verification-step",
"review",
"merge",
]);
});
it("fails selected workflow lookup misses instead of running the built-in workflow", async () => {

View File

@@ -176,9 +176,13 @@ export {
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
// FNXC:MergerUnification 2026-06-22-00:00: @deprecated must sit on aiMergeTask's own
// export so IDE/type-aware tooling flags only aiMergeTask, not the helpers it shares with
// runAiMerge (those are NOT deprecated). A single @deprecated on the multi-member block
// would mark every symbol below as deprecated.
/** @deprecated Use runAiMerge — aiMergeTask is the soft-deprecated legacy path. */
export { aiMergeTask } from "./merger.js";
export {
aiMergeTask,
listAutostashOrphans,
applyAutostashBySha,
dropAutostashBySha,

View File

@@ -38,7 +38,7 @@ import {
type ForeachEnvironment,
type WorkflowStepInstancePersistence,
} from "./workflow-graph-foreach.js";
import { runLoop } from "./workflow-graph-loop.js";
import { runLoop, runOptionalGroup } from "./workflow-graph-loop.js";
export type WorkflowNodeOutcome = "success" | "failure";
@@ -473,6 +473,52 @@ export class WorkflowGraphExecutor {
return await traverseChildren(node, result);
}
if (node.kind === "optional-group") {
/*
* FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
* Run-once-or-bypass dispatch. The enable decision is read from the
* per-task `enabledWorkflowSteps` facet, keyed by THIS group node's id
* (KTD-2). Enabled → walk the template subgraph EXACTLY ONCE via
* `runOptionalGroup` (single pass, no iteration/rework). Disabled →
* pass through: traverse the group's children with a synthetic
* success result WITHOUT executing any template node, so a disabled
* group is byte-inert vs the group not being there. Two tasks
* identical except `enabledWorkflowSteps` therefore diverge here:
* the enabled one runs the body, the disabled one runs none and
* still reaches the same downstream node.
*/
const enabled = task.enabledWorkflowSteps?.includes(node.id) ?? false;
if (!enabled) {
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: record the group's own
// outcome on bypass too (mirrors the enabled path + every other node
// kind), so a downstream node reading `node:<id>:outcome` from context
// sees "success" rather than undefined — disabled is fully inert, not
// just edge-routing-inert.
context[`node:${node.id}:outcome`] = "success";
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: route a disabled group
// as a plain success with NO distinguishing value — a non-empty value
// could let an `outcome:*` edge preempt the success edge in
// traverseChildren, breaking the "disabled == node absent" inertness
// invariant. (Code review: CodeRabbit.)
return await traverseChildren(node, { outcome: "success" });
}
const groupResult = await runOptionalGroup(node, {
context,
runTemplateNode: (tNode, sig, contextOverride) =>
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
signal: this.deps.signal,
});
visitedNodeIds.push(...groupResult.visitedNodeIds);
const result: WorkflowNodeResult = {
outcome: groupResult.outcome,
value: groupResult.value,
};
context[`node:${node.id}:outcome`] = result.outcome;
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
return await traverseChildren(node, result);
}
const result = await this.executeNodeWithRetries(node, task, settings, context, ir);
if (result.contextPatch) Object.assign(context, result.contextPatch);
context[`node:${node.id}:outcome`] = result.outcome;

View File

@@ -1,4 +1,4 @@
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowLoopConfig } from "@fusion/core";
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowLoopConfig, WorkflowOptionalGroupConfig } from "@fusion/core";
import { WorkflowIrError } from "@fusion/core";
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
@@ -208,3 +208,87 @@ export async function runLoop(
};
return { outcome: "failure", value: "loop-iteration-exhausted", visitedNodeIds };
}
/*
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
An enabled `optional-group` runs its `template` subgraph EXACTLY ONCE (single pass — no iteration, no rework budget; rework edges are validation-forbidden inside the template). This reuses the loop's template-walk primitives (`buildOutgoing`, `findTemplateEntry`, `shouldTraverseEdge`) but caps the walk at one pass. The disabled/bypass decision lives in the executor branch (read from per-task `enabledWorkflowSteps`); this helper only runs the body when enabled.
A template-node failure surfaces as the group's outcome so the group's `failure`/`outcome:` edges route, mirroring `runLoop`'s node-failure short-circuit.
*/
export interface OptionalGroupEnvironment {
context: Record<string, unknown>;
runTemplateNode: (
node: WorkflowIrNode,
signal?: AbortSignal,
contextOverride?: Record<string, unknown>,
) => Promise<WorkflowNodeResult>;
shouldTraverseEdge: (edge: WorkflowIrEdge, source: WorkflowNodeResult) => boolean;
signal?: AbortSignal;
}
export interface OptionalGroupRunResult {
outcome: WorkflowNodeOutcome;
value?: string;
visitedNodeIds: string[];
}
function resolveOptionalGroupTemplate(
node: WorkflowIrNode,
): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
const cfg = (node.config ?? {}) as Partial<WorkflowOptionalGroupConfig>;
if (!cfg.template || !Array.isArray(cfg.template.nodes) || !Array.isArray(cfg.template.edges)) {
throw new WorkflowIrError(`optional-group node '${node.id}' has no template subgraph`);
}
return cfg.template;
}
/**
* Walk an enabled optional-group's template subgraph once. Mirrors a single
* loop iteration: entry → follow matching edges → stop at the template exit (no
* outgoing matching edge). Materialized visited ids use a `<groupId>::<templateNodeId>`
* scheme so they are distinguishable from top-level ids and parseable back to the
* template node. The group's own outcome is the last template node's outcome.
*/
export async function runOptionalGroup(
groupNode: WorkflowIrNode,
env: OptionalGroupEnvironment,
): Promise<OptionalGroupRunResult> {
const template = resolveOptionalGroupTemplate(groupNode);
const templateById = new Map(template.nodes.map((n) => [n.id, n]));
const outgoing = buildOutgoing(template.edges);
const entry = findTemplateEntry(template.nodes, template.edges, groupNode.id);
const visitedNodeIds: string[] = [];
const groupContext: Record<string, unknown> = { ...env.context };
let current: WorkflowIrNode | undefined = entry;
let lastResult: WorkflowNodeResult = { outcome: "success" };
while (current) {
if (env.signal?.aborted) {
return { outcome: "failure", value: "aborted", visitedNodeIds };
}
const materializedId = `${groupNode.id}::${current.id}`;
visitedNodeIds.push(materializedId);
lastResult = await env.runTemplateNode(current, env.signal, groupContext);
if (lastResult.contextPatch) Object.assign(groupContext, lastResult.contextPatch);
groupContext[`node:${current.id}:outcome`] = lastResult.outcome;
if (lastResult.value !== undefined) groupContext[`node:${current.id}:value`] = lastResult.value;
if (lastResult.outcome === "failure") {
// Publish accumulated template context, then surface the failure as the
// group's outcome so its failure/outcome: edges route.
Object.assign(env.context, groupContext);
return { outcome: "failure", value: lastResult.value, visitedNodeIds };
}
const edges: WorkflowIrEdge[] = outgoing.get(current.id) ?? [];
const matching: WorkflowIrEdge[] = edges.filter((edge: WorkflowIrEdge) =>
env.shouldTraverseEdge(edge, lastResult),
);
current = matching.length > 0 ? templateById.get(matching[0].to) : undefined;
}
// Single pass complete: publish the template's context onto the shared context.
Object.assign(env.context, groupContext);
return { outcome: lastResult.outcome, value: lastResult.value, visitedNodeIds };
}