Files
fusion/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts
gsxdsm 080a8e7134 FN-8464: guard baseline capture against invalid worktrees
Prevent baseline Git probes from using stale or non-directory task worktrees.

- Gate baseline capture on an existing worktree directory
- Defer graph step projection until worktree acquisition completes
- Cover missing, non-directory, and filesystem-race worktree paths
- Add a patch changeset for the operator-facing fix

Files changed:
 .changeset/fn-8464-baseline-cwd.md                 |   7 ++
 .../__tests__/executor-fast-mode-workflows.test.ts | 100 ++++++++++++++++++++-
 .../engine/src/__tests__/executor-test-helpers.ts  |   6 +-
 packages/engine/src/__tests__/step-runner.test.ts  |  62 +++++++++++++
 packages/engine/src/executor.ts                    |  16 +++-
 packages/engine/src/step-runner.ts                 |  24 +++++
 6 files changed, 210 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8464

Fusion-Task-Lineage: e4116dd0-decd-4f9d-87f0-e695cc7f182b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-21 19:40:36 -07:00

979 lines
40 KiB
TypeScript

// @ts-nocheck
// FN-6226 surface enumeration: engine-only behavior, so desktop/mobile
// breakpoints are N/A. These tests cover legacy seams, graph runtime
// primitives, custom graph prompt/script/gate nodes under a custom workflow
// selection, builtin/default selection behavior via the legacy seam, fast /
// standard / undefined executionMode data states, and the executor tool
// injection surface (fn_review_step is deleted in both modes) vs mandatory fn_task_done.
import { describe, it, expect, vi, beforeEach } from "vitest";
import "./executor-test-helpers.js";
import { getBuiltinWorkflow } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { WorkflowGraphTaskRunner } from "../workflow-graph-task-runner.js";
import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflow-node-handlers.js";
import {
createMockStore,
mockedCreateFnAgent,
mockedExistsSync,
mockedExec,
mockedStatSync,
resetExecutorMocks,
} from "./executor-test-helpers.js";
const now = "2026-06-10T00:00:00.000Z";
function task(overrides: Record<string, unknown> = {}) {
return {
id: "FN-6226",
title: "Fast mode workflow task",
description: "exercise fast mode",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# Task\n## Steps\n### Step 1\n- [ ] do it",
createdAt: now,
updatedAt: now,
...overrides,
};
}
function makeExecutorForTask(liveTask = task()) {
const store = createMockStore();
store.getTask.mockImplementation(async (id: string) => ({ ...liveTask, id }));
store.getSettings.mockResolvedValue({
autoMerge: false,
experimentalFeatures: { workflowGraphExecutor: true },
});
return { store, executor: new TaskExecutor(store, "/tmp/test") };
}
/*
FNXC:EngineTests 2026-07-19-15:05 (U10b):
Review gates are graph nodes, so one `execute()` opens several agent sessions and the FIRST one is a review
node (Plan Review), not the implementation session. The tool-injection requirement spans the whole run:
`fn_task_done` must be present SOMEWHERE (the implementation session must retain the only completion path),
and `fn_review_step` must appear on NO session in either execution mode. Union the tools across every
session instead of indexing call 0, which now inspects a review node's readonly toolset.
*/
function allSessionToolNames(): string[] {
return mockedCreateFnAgent.mock.calls.flatMap(([opts]: any[]) =>
((opts?.customTools ?? []) as any[]).map((tool) => tool?.name),
);
}
function workflowResult() {
return { allPassed: true, results: [] };
}
describe("fast mode workflow/runtime invariants", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExistsSync.mockReturnValue(true);
});
/*
FNXC:WorkflowSelection 2026-07-14-17:06:
The executor must pass its asynchronously resolved PostgreSQL workflow selection into the graph runner. Re-reading through the synchronous compatibility method would replace a custom graph with builtin:coding.
*/
/*
FNXC:EngineTests 2026-07-19-18:20 (U10b):
Graph ownership is unconditional: the entry point is `executeWorkflowGraph` and it returns void
because it can no longer decline a task. The requirement under test is unchanged — the async
selection the executor resolved is what the runner sees — so only the seam name/return moved.
*/
it("reuses the asynchronous PostgreSQL workflow selection inside the graph runner", async () => {
const selected = { workflowId: "WF-async-custom", stepIds: ["review"] };
const { store, executor } = makeExecutorForTask(task());
store.getTaskWorkflowSelection = vi.fn(() => undefined);
store.getTaskWorkflowSelectionAsync = vi.fn(async () => selected);
store.getWorkflowDefinition = vi.fn(async () => ({
id: selected.workflowId,
name: "Async custom",
ir: {
version: "v1",
name: "Async custom",
nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end" }],
edges: [{ from: "start", to: "end" }],
},
}));
const run = vi.spyOn(WorkflowGraphTaskRunner.prototype, "run").mockImplementation(async function () {
expect((this as any).deps.store.getTaskWorkflowSelection()).toEqual(selected);
await expect((this as any).deps.store.getTaskWorkflowSelectionAsync()).resolves.toEqual(selected);
return { disposition: "completed", outcome: "success", visitedNodeIds: ["start"] };
});
try {
await expect((executor as any).executeWorkflowGraph(task())).resolves.toBeUndefined();
expect(store.getTaskWorkflowSelectionAsync).toHaveBeenCalledWith("FN-6226");
expect(store.getTaskWorkflowSelection).not.toHaveBeenCalled();
expect(run).toHaveBeenCalledTimes(1);
} finally {
// Prototype spy: restore even on failure so it cannot leak into sibling runner tests.
run.mockRestore();
}
});
it("graph executor with a custom workflow skips custom pre-merge prompt/gate nodes in fast mode", async () => {
const { store, executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true });
const definition = {
id: "WF-fast-custom",
name: "Fast custom",
description: "custom workflow",
kind: "workflow",
layout: {},
createdAt: now,
updatedAt: now,
ir: {
version: "v1",
name: "Fast custom",
nodes: [
{ id: "start", kind: "start" },
{ id: "custom-review", kind: "prompt", config: { prompt: "Review this" } },
{ id: "custom-gate", kind: "gate", config: { prompt: "Gate this", gateMode: "gate" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "custom-review" },
{ from: "custom-review", to: "custom-gate" },
{ from: "custom-gate", to: "end" },
],
},
};
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: "WF-fast-custom", stepIds: [] }),
getWorkflowDefinition: vi.fn(async () => definition),
},
seams: (executor as any).createAuthoritativeWorkflowSeams({}),
primitives: (executor as any).createAuthoritativeWorkflowPrimitives({ experimentalFeatures: { workflowGraphExecutor: true } }),
runCustomNode: (node, nodeTask, context) => (executor as any).runGraphCustomNode(node, nodeTask, {}, undefined),
});
const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toEqual(["start", "custom-review", "custom-gate"]);
expect(executeStep).not.toHaveBeenCalled();
expect(executeScript).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-6226",
"Fast mode — custom graph node 'custom-review' skipped",
undefined,
undefined,
);
});
it("falls back to the runner task when prepareWorktree cannot trust the live row", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ ...task({ id: "FN-OTHER", worktree: "/tmp/wrong" }) });
const executor = new TaskExecutor(store, "/tmp/test");
const result = await (executor as any)
.createAuthoritativeWorkflowPrimitives({ experimentalFeatures: { workflowGraphExecutor: true } })
.prepareWorktree(
{ run: { taskId: "FN-6226" }, node: { node: { id: "execute" }, context: {} } },
task({ id: "FN-6226", worktree: "/tmp/right", branch: "fusion/fn-6226" }),
);
expect(result).toMatchObject({
outcome: "success",
data: {
worktreePath: "/tmp/right",
branchName: "fusion/fn-6226",
},
});
});
it("does not project a fresh graph step or capture its baseline before the executor creates its worktree", async () => {
let liveTask = task({
steps: [{ name: "Preflight", status: "pending" }],
worktree: undefined,
branch: undefined,
baseCommitSha: undefined,
});
const store = createMockStore();
store.getTask.mockImplementation(async () => liveTask);
const executor = new TaskExecutor(store, "/tmp/project-root");
const runGraphTaskStep = vi.spyOn(executor as any, "runGraphTaskStep").mockImplementation(async () => {
expect(store.updateStep).not.toHaveBeenCalled();
liveTask = {
...liveTask,
worktree: "/tmp/project-root/.worktrees/fresh-step",
branch: "fusion/fn-6226",
baseCommitSha: "fresh-worktree-base",
steps: [{ name: "Preflight", status: "done" }],
};
return { success: true };
});
const result = await (executor as any)
.createAuthoritativeWorkflowPrimitives({ experimentalFeatures: { workflowGraphExecutor: true } })
.runTaskStep(
{
run: { taskId: liveTask.id },
node: {
node: { id: "steps#0:step-execute" },
context: {
[FOREACH_ACTIVE_CONTEXT_KEY]: {
foreachNodeId: "steps",
stepIndex: 0,
instanceId: "steps#0",
},
},
},
},
liveTask,
0,
);
expect(runGraphTaskStep).toHaveBeenCalledTimes(1);
expect(store.updateStep).not.toHaveBeenCalled();
expect(result).toEqual({
outcome: "success",
baselineSha: "fresh-worktree-base",
checkpointId: undefined,
});
});
it("defers truthy missing and non-directory worktrees until acquisition", async () => {
for (const [worktree, exists, directory] of [
["/tmp/fn-8464-missing-worktree", false, false],
["/tmp/fn-8464-file-worktree", true, false],
]) {
let liveTask = task({
steps: [{ name: "Preflight", status: "pending" }],
worktree,
baseCommitSha: undefined,
});
const store = createMockStore();
store.getTask.mockImplementation(async () => liveTask);
mockedExistsSync.mockReturnValue(exists);
mockedStatSync.mockReturnValue({ isDirectory: () => directory } as any);
const executor = new TaskExecutor(store, "/tmp/project-root");
vi.spyOn(executor as any, "runGraphTaskStep").mockImplementation(async () => {
expect(store.updateStep).not.toHaveBeenCalled();
liveTask = { ...liveTask, worktree: "/tmp/acquired", baseCommitSha: "acquired-base" };
return { success: true };
});
const result = await (executor as any).runProjectedGraphTaskStep(
liveTask,
liveTask,
0,
{ foreachNodeId: "steps", stepIndex: 0, instanceId: "steps#0" },
);
expect(result).toMatchObject({ outcome: "success", baselineSha: "acquired-base" });
expect(mockedExec).not.toHaveBeenCalled();
}
});
it("defers a worktree whose directory stat throws instead of propagating a cwd race", async () => {
let liveTask = task({
steps: [{ name: "Preflight", status: "pending" }],
worktree: "/tmp/fn-8464-stat-race",
baseCommitSha: undefined,
});
const store = createMockStore();
store.getTask.mockImplementation(async () => liveTask);
mockedStatSync.mockImplementation(() => {
throw new Error("simulated removal race");
});
const executor = new TaskExecutor(store, "/tmp/project-root");
vi.spyOn(executor as any, "runGraphTaskStep").mockImplementation(async () => {
expect(store.updateStep).not.toHaveBeenCalled();
liveTask = { ...liveTask, worktree: "/tmp/acquired", baseCommitSha: "acquired-base" };
return { success: true };
});
await expect(
(executor as any).runProjectedGraphTaskStep(
liveTask,
liveTask,
0,
{ foreachNodeId: "steps", stepIndex: 0, instanceId: "steps#0" },
),
).resolves.toMatchObject({ outcome: "success", baselineSha: "acquired-base" });
expect(mockedExec).not.toHaveBeenCalled();
});
it("captures a pre-step baseline when the projected worktree is a directory", async () => {
const liveTask = task({
steps: [{ name: "Preflight", status: "pending" }],
worktree: "/tmp/fn-8464-existing-worktree",
});
const store = createMockStore();
store.getTask.mockResolvedValue(liveTask);
mockedExistsSync.mockReturnValue(true);
mockedStatSync.mockReturnValue({ isDirectory: () => true } as any);
mockedExec.mockImplementation((_command: string, _options: unknown, callback: any) => {
callback(null, "existing-head\n", "");
return {} as any;
});
const executor = new TaskExecutor(store, "/tmp/project-root");
const runGraphTaskStep = vi
.spyOn(executor as any, "runGraphTaskStep")
.mockResolvedValue({ success: true });
const result = await (executor as any).runProjectedGraphTaskStep(
liveTask,
liveTask,
0,
{ foreachNodeId: "steps", stepIndex: 0, instanceId: "steps#0" },
);
expect(result).toMatchObject({ outcome: "success", baselineSha: "existing-head" });
expect(runGraphTaskStep).toHaveBeenCalledOnce();
expect(store.updateStep).toHaveBeenCalledWith("FN-6226", 0, "in-progress", { source: "graph" });
expect(mockedExec).toHaveBeenCalledWith("git rev-parse HEAD", { cwd: liveTask.worktree }, expect.any(Function));
});
it("applies missing-worktree step ordering through the legacy graph seam", async () => {
let liveTask = task({
steps: [{ name: "Preflight", status: "pending" }],
worktree: "/tmp/fn-8464-legacy-missing-worktree",
baseCommitSha: undefined,
});
mockedExistsSync.mockReturnValue(false);
const store = createMockStore();
store.getTask.mockImplementation(async () => liveTask);
const executor = new TaskExecutor(store, "/tmp/project-root");
vi.spyOn(executor as any, "runGraphTaskStep").mockImplementation(async () => {
expect(store.updateStep).not.toHaveBeenCalled();
liveTask = {
...liveTask,
worktree: "/tmp/project-root/.worktrees/fresh-step",
baseCommitSha: "fresh-worktree-base",
steps: [{ name: "Preflight", status: "done" }],
};
return { success: true };
});
const active = {
foreachNodeId: "steps",
stepIndex: 0,
instanceId: "steps#0",
};
const result = await executor.createAuthoritativeWorkflowSeams({} as any).stepExecute?.(
liveTask,
{ [FOREACH_ACTIVE_CONTEXT_KEY]: active },
);
expect(store.updateStep).not.toHaveBeenCalled();
expect(result).toMatchObject({
outcome: "success",
contextPatch: {
[FOREACH_ACTIVE_CONTEXT_KEY]: {
baselineSha: "fresh-worktree-base",
checkpointId: undefined,
},
},
});
});
it("fast builtin:coding still parses and executes steps while disabled optional groups stay inert", async () => {
const calls: string[] = [];
const prompt = "# Task\n\n## Steps\n\n### Step 1: Do the work\n- [ ] edit files";
const taskSteps = [{ name: "Do the work", status: "pending" }];
const seams = {
planning: vi.fn(async () => {
calls.push("plan");
return { outcome: "success", value: "planned" };
}),
execute: vi.fn(async () => {
calls.push("legacy-execute");
return { outcome: "success", value: "implemented" };
}),
review: vi.fn(async () => {
calls.push("review");
return { outcome: "success", value: "approved" };
}),
merge: vi.fn(async () => {
calls.push("merge");
return { outcome: "success", value: "merged" };
}),
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
stepExecute: vi.fn(async (_task, context) => {
calls.push(`step-execute:${context["foreach:active"]?.stepIndex}`);
return { outcome: "success", value: "step-done" };
}),
};
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)),
},
seams,
parseStepsDeps: {
readArtifact: async (_target, key) => key === "PROMPT.md" ? prompt : undefined,
writeSteps: async (target) => {
calls.push("parse");
target.steps = taskSteps;
},
},
runCustomNode: vi.fn(async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success", value: "custom-ok" };
}),
});
const result = await runner.run(task({
id: "FN-6226",
executionMode: "fast",
enabledWorkflowSteps: [],
prompt,
}), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toContain("parse");
expect(result.visitedNodeIds).toContain("steps#0:step-execute");
expect(result.visitedNodeIds).toContain("browser-verification");
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
expect(result.visitedNodeIds).toContain("code-review");
expect(result.visitedNodeIds).not.toContain("code-review::code-review-step");
expect(result.visitedNodeIds).not.toContain("workflow-step");
expect(calls).toContain("parse");
expect(calls).toContain("step-execute:0");
expect(calls).not.toContain("legacy-execute");
/*
FNXC:WorkflowFastMode 2026-07-01-00:00:
The default built-in now resolves to the stepwise final-review workflow. In raw fast-mode compatibility runs, default-on review groups are skipped as custom nodes and the legacy review seam is not invoked; the merge seam remains the lifecycle suffix assertion.
*/
expect(seams.review).not.toHaveBeenCalled();
expect(seams.merge).toHaveBeenCalledTimes(1);
});
it("raw fast mode skips skill executor nodes when primitives are unavailable", async () => {
const runCustomNode = vi.fn(async () => ({ outcome: "success", value: "ran-skill" }));
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: "WF-fast-skill", stepIds: [] }),
getWorkflowDefinition: vi.fn(async () => ({
id: "WF-fast-skill",
name: "Fast skill",
description: "custom skill workflow",
kind: "workflow",
layout: {},
createdAt: now,
updatedAt: now,
ir: {
version: "v1",
name: "Fast skill",
nodes: [
{ id: "start", kind: "start" },
{ id: "skill-review", kind: "prompt", config: { executor: "skill", skillName: "compound-engineering:ce-code-review" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "skill-review" },
{ from: "skill-review", to: "end", condition: "success" },
],
},
})),
},
seams: {
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
review: vi.fn(async () => ({ outcome: "success", value: "approved" })),
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
},
runCustomNode,
});
const result = await runner.run(task({ executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toEqual(["start", "skill-review"]);
expect(runCustomNode).not.toHaveBeenCalled();
});
it("raw fast mode still invokes non-executable review seam nodes", async () => {
const review = vi.fn(async () => ({ outcome: "success" as const, value: "approved" }));
const runCustomNode = vi.fn(async () => ({ outcome: "failure" as const, value: "unexpected-custom-node" }));
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: "WF-fast-review-seam", stepIds: [] }),
getWorkflowDefinition: vi.fn(async () => ({
id: "WF-fast-review-seam",
name: "Fast review seam",
description: "custom review seam workflow",
kind: "workflow",
layout: {},
createdAt: now,
updatedAt: now,
ir: {
version: "v1",
name: "Fast review seam",
nodes: [
{ id: "start", kind: "start" },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "review" },
{ from: "review", to: "end", condition: "success" },
],
},
})),
},
seams: {
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
review,
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
},
runCustomNode,
});
const result = await runner.run(task({ executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toEqual(["start", "review"]);
expect(review).toHaveBeenCalledTimes(1);
expect(runCustomNode).not.toHaveBeenCalled();
});
it("fast builtin:coding executes explicitly selected optional-group template nodes", async () => {
const calls: string[] = [];
const prompt = "# Task\n\n## Steps\n\n### Step 1: Do the work\n- [ ] edit files";
const taskSteps = [{ name: "Do the work", status: "pending" }];
const seams = {
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
review: vi.fn(async () => ({ outcome: "success", value: "approved" })),
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
stepExecute: vi.fn(async () => ({ outcome: "success", value: "step-done" })),
};
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)),
},
seams,
parseStepsDeps: {
readArtifact: async (_target, key) => key === "PROMPT.md" ? prompt : undefined,
writeSteps: async (target) => {
target.steps = taskSteps;
},
},
runCustomNode: vi.fn(async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success", value: "APPROVE" };
}),
});
const result = await runner.run(task({
id: "FN-7283",
executionMode: "fast",
enabledWorkflowSteps: ["browser-verification"],
prompt,
}), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toContain("browser-verification::browser-verification-step");
expect(calls).toContain("custom:browser-verification-step");
expect(result.visitedNodeIds).toContain("code-review");
expect(result.visitedNodeIds).not.toContain("code-review::code-review-step");
});
it("blocks fast builtin:coding merge when parsed implementation proof is missing", async () => {
const liveTask = task({
id: "FN-7271",
executionMode: "fast",
enabledWorkflowSteps: [],
column: "in-progress",
steps: [],
prompt: "# Task\n\n## Steps\n\n### Step 1: Do the work\n- [ ] edit files",
});
const store = createMockStore();
store.getTask.mockResolvedValue(liveTask);
store.getTaskWorkflowSelection = vi.fn(() => ({ workflowId: "builtin:coding", stepIds: [] }));
store.getWorkflowDefinition = vi.fn(async (id: string) => getBuiltinWorkflow(id));
store.moveTask.mockResolvedValue({ ...liveTask, column: "in-review" });
const executor = new TaskExecutor(store, "/tmp/test") as any;
const mergeRequester = vi.fn(async () => ({ merged: true }));
executor.setMergeRequester(mergeRequester);
const result = await executor.createAuthoritativeWorkflowPrimitives({ autoMerge: true }).requestMerge(
{
run: { runId: "FN-7271:builtin:coding", taskId: "FN-7271", workflowId: "builtin-stepwise-final-review-coding" },
node: { node: { id: "merge" } },
},
liveTask,
);
expect(result).toMatchObject({
outcome: "failure",
value: "implementation-incomplete",
data: { reason: "implementation-incomplete" },
});
expect(mergeRequester).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-7271",
expect.stringContaining("Workflow merge blocked before requester: implementation did not run"),
undefined,
undefined,
);
});
it("allows noCommitsExpected builtin:coding merge even when parsed implementation steps are empty", async () => {
const liveTask = task({
id: "FN-1165-NOOP",
executionMode: "fast",
enabledWorkflowSteps: [],
column: "in-progress",
steps: [],
noCommitsExpected: true,
branch: null,
worktree: null,
prompt: "# Task\n\n## Steps\n\n### Step 1: Decide\n- [ ] Record no-code decision",
});
const inReviewTask = { ...liveTask, column: "in-review" } as typeof liveTask;
const doneTask = {
...liveTask,
column: "done",
mergeDetails: {
mergeConfirmed: true,
noOpMerge: true,
noOpReason: "no-commits-expected",
},
} as typeof liveTask;
const store = createMockStore();
store.getTask.mockResolvedValue(liveTask);
store.getTaskWorkflowSelection = vi.fn(() => ({ workflowId: "builtin:coding", stepIds: [] }));
store.getWorkflowDefinition = vi.fn(async (id: string) => getBuiltinWorkflow(id));
store.moveTask
.mockResolvedValueOnce(inReviewTask)
.mockResolvedValueOnce(doneTask);
const executor = new TaskExecutor(store, "/tmp/test") as any;
const mergeRequester = vi.fn(async () => ({
task: inReviewTask,
merged: true,
noOp: true,
mergeConfirmed: true,
reason: "no-commits-expected",
}));
executor.setMergeRequester(mergeRequester);
const result = await executor.createAuthoritativeWorkflowPrimitives({ autoMerge: true }).requestMerge(
{
run: { runId: "FN-1165-NOOP:builtin:coding", taskId: "FN-1165-NOOP", workflowId: "builtin-stepwise-final-review-coding" },
node: { node: { id: "merge" } },
},
liveTask,
);
expect(result).toMatchObject({ outcome: "success", value: "merge-noop" });
expect(mergeRequester).toHaveBeenCalledWith("FN-1165-NOOP", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(store.logEntry).not.toHaveBeenCalledWith(
"FN-1165-NOOP",
expect.stringContaining("implementation did not run"),
undefined,
undefined,
);
expect(store.moveTask).toHaveBeenCalledWith("FN-1165-NOOP", "done", expect.objectContaining({ preserveProgress: true }));
});
it("fast builtin:coding executes plain Steps-section headings from fast triage specs", async () => {
const calls: string[] = [];
const prompt = `# Task
## Steps
### Preflight
- [ ] inspect
### Implementation
- [ ] edit
### Testing & Verification
- [ ] test
`;
const seams = {
planning: vi.fn(async () => ({ outcome: "success", value: "planned" })),
execute: vi.fn(async () => ({ outcome: "success", value: "implemented" })),
review: vi.fn(async () => ({ outcome: "success", value: "approved" })),
merge: vi.fn(async () => ({ outcome: "success", value: "merged" })),
schedule: vi.fn(async () => ({ outcome: "success", value: "scheduled" })),
stepExecute: vi.fn(async (_task, context) => {
calls.push(`step-execute:${context["foreach:active"]?.stepIndex}`);
return { outcome: "success", value: "step-done" };
}),
};
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn(async (id: string) => getBuiltinWorkflow(id)),
},
seams,
parseStepsDeps: {
readArtifact: async (_target, key) => key === "PROMPT.md" ? prompt : undefined,
writeSteps: async (target, steps) => {
target.steps = steps;
},
},
runCustomNode: vi.fn(async () => ({ outcome: "success" })),
});
const result = await runner.run(task({
id: "FN-7260",
executionMode: "fast",
enabledWorkflowSteps: [],
prompt,
}), { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.disposition).toBe("completed");
expect(result.visitedNodeIds).toContain("steps#0:step-execute");
expect(result.visitedNodeIds).toContain("steps#1:step-execute");
expect(result.visitedNodeIds).toContain("steps#2:step-execute");
expect(calls).toEqual(["step-execute:0", "step-execute:1", "step-execute:2"]);
expect(seams.merge).toHaveBeenCalledTimes(1);
});
it.each([
["standard", "standard"],
["undefined", undefined],
["null", null],
])("runs custom pre-merge prompt nodes in %s execution mode", async (_label, executionMode) => {
const { executor } = makeExecutorForTask(task({ executionMode, worktree: "/tmp/wt" }));
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
const result = await (executor as any).runGraphCustomNode(
{ id: "custom-review", kind: "prompt", config: { prompt: "Review this" } },
task({ executionMode }),
{},
undefined,
);
expect(result.outcome).toBe("success");
expect(result.value).toBe("passed");
expect(executeStep).toHaveBeenCalledTimes(1);
});
it.each(["prompt", "script", "gate"])("skips custom %s nodes in fast mode before workflow-step execution", async (kind) => {
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true });
const config = kind === "script" ? { scriptName: "lint" } : { prompt: "check" };
const result = await (executor as any).runGraphCustomNode(
{ id: `custom-${kind}`, kind, config },
task({ executionMode: "fast" }),
{},
undefined,
);
expect(result).toMatchObject({ outcome: "success", value: "workflow-step-skipped" });
expect(executeStep).not.toHaveBeenCalled();
expect(executeScript).not.toHaveBeenCalled();
});
it.each([
["completion-summary id", { id: "completion-summary", kind: "prompt", config: { prompt: "summarize" } }],
["summaryTarget task", { id: "custom-summary", kind: "prompt", config: { prompt: "summarize", summaryTarget: "task" } }],
])("does not skip completion summary nodes in fast mode by %s", async (_label, node) => {
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "Done." });
const result = await (executor as any).runGraphCustomNode(
node,
task({ executionMode: "fast" }),
{},
undefined,
);
expect(result).toMatchObject({ outcome: "success", value: "passed" });
expect(executeStep).toHaveBeenCalledTimes(1);
});
it.each(["prompt", "script", "gate"])("executes optional-group template %s nodes in fast mode", async (kind) => {
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true });
const executeScript = vi.spyOn(executor as any, "executeScriptWorkflowStep").mockResolvedValue({ success: true });
const config = kind === "script" ? { scriptName: "lint" } : { prompt: "check" };
const result = await (executor as any).runGraphCustomNode(
{ id: `custom-${kind}`, kind, config },
task({ executionMode: "fast" }),
{},
undefined,
{ "workflow:optionalGroupActive": "browser-verification" },
);
expect(result).toMatchObject({ outcome: "success" });
if (kind === "script") {
expect(executeScript).toHaveBeenCalledTimes(1);
expect(executeStep).not.toHaveBeenCalled();
} else {
expect(executeStep).toHaveBeenCalledTimes(1);
expect(executeScript).not.toHaveBeenCalled();
}
});
it("does not bypass await-input custom graph nodes in fast mode", async () => {
const { executor } = makeExecutorForTask(task({ executionMode: "fast" }));
const awaitInput = vi.spyOn(executor as any, "runAwaitInputNode").mockResolvedValue({ outcome: "success", value: "awaiting-input" });
const result = await (executor as any).runGraphCustomNode(
{ id: "human", kind: "prompt", config: { awaitInput: true } },
task({ executionMode: "fast" }),
{},
undefined,
);
expect(result.value).toBe("awaiting-input");
expect(awaitInput).toHaveBeenCalledTimes(1);
});
// U4 (KTD-2): the legacy `workflow-step` seam and `runWorkflowStep` primitive
// were removed, so the two it.each blocks that drove them directly (fast-mode
// skip + standard-mode run) are gone. Fast-mode skip of workflow gates is now
// covered above by the custom-node tests ("skips custom %s nodes in fast mode")
// and by builtin-coding-workflow-step-results.test.ts (graph recording path).
it("re-enters graph recovery for fast completed tasks with unsatisfied explicit optional steps", async () => {
const liveTask = task({
id: "FN-7283-RECOVERY",
executionMode: "fast",
enabledWorkflowSteps: ["browser-verification"],
worktree: "/tmp/wt",
baseCommitSha: "base",
steps: [{ name: "Do it", status: "done" }],
workflowStepResults: [],
});
const { executor } = makeExecutorForTask(liveTask);
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]);
const graph = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined);
const recovered = await executor.recoverCompletedTask(liveTask as any);
expect(recovered).toBe(true);
expect(graph).toHaveBeenCalledWith(liveTask);
});
/*
FNXC:EngineTests 2026-07-19-18:20 (U10b):
The requirement under test is a store that CANNOT resolve a workflow selection (minimal/older embedded
adapters). The shared harness supplies both selection readers, so the reader-less shape is reconstructed
explicitly here — otherwise the graph resolves builtin:coding and this branch is never reached.
The park is now UNCONDITIONAL: with the legacy execute fallback deleted, the graph is the only executor,
so a store that cannot resolve a workflow must park loudly whether or not the task has explicit
`enabledWorkflowSteps` — the old "no enabled steps means nothing to gate" carve-out would now silently run
nothing. Both step shapes are asserted so the carve-out cannot be reintroduced.
*/
it.each([
["explicit optional steps", ["browser-verification"]],
["no enabled steps", []],
])("fails closed when the store cannot resolve workflow selection (%s)", async (_label, enabledWorkflowSteps) => {
const liveTask = task({
id: "FN-7283-MINIMAL-STORE",
executionMode: "fast",
enabledWorkflowSteps,
worktree: "/tmp/wt",
});
const store = createMockStore();
store.getTask.mockResolvedValue(liveTask);
delete (store as any).getTaskWorkflowSelection;
delete (store as any).getTaskWorkflowSelectionAsync;
const executor = new TaskExecutor(store, "/tmp/test") as any;
const graphFailure = vi.spyOn(executor, "handleGraphFailure").mockResolvedValue(undefined);
await executor.executeWorkflowGraph(liveTask);
expect(graphFailure).toHaveBeenCalledWith(liveTask, expect.objectContaining({
disposition: "failed",
outcome: "failure",
reason: expect.stringContaining("workflow-selection-api-unavailable"),
}));
});
it("skips graph recovery for fast completed tasks with no explicit optional steps", async () => {
const liveTask = task({
id: "FN-7283-RECOVERY-EMPTY",
executionMode: "fast",
enabledWorkflowSteps: [],
worktree: "/tmp/wt",
baseCommitSha: "base",
steps: [{ name: "Do it", status: "done" }],
workflowStepResults: [],
});
const { store, executor } = makeExecutorForTask(liveTask);
vi.spyOn(executor as any, "captureModifiedFiles").mockResolvedValue([]);
const graph = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined);
const recovered = await executor.recoverCompletedTask(liveTask as any);
expect(recovered).toBe(true);
expect(graph).not.toHaveBeenCalled();
expect(store.handoffToReview).toHaveBeenCalledWith(
"FN-7283-RECOVERY-EMPTY",
expect.objectContaining({ evidence: expect.objectContaining({ reason: "completed-task-recovered" }) }),
);
});
/*
FNXC:WorkflowReviewGates 2026-07-19-02:40:
U10 (R9) tombstone: `fn_review_step` is deleted outright. Neither fast nor standard mode may
inject it — review gates are graph nodes, and a second in-session review authority is exactly
the duplicate-Plan-Review defect the cutover removes. `fn_task_done` stays mandatory in both.
*/
it("keeps fn_task_done mandatory while never injecting fn_review_step in fast mode", async () => {
mockedCreateFnAgent.mockImplementation(async (opts: any) => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
sessionManager: {
getLeafId: vi.fn().mockReturnValue("leaf"),
branchWithSummary: vi.fn(),
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
},
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
},
capturedTools: opts.customTools,
}));
const store = createMockStore();
store.getTask.mockResolvedValue(task({ id: "FN-TOOLS", executionMode: "fast" }));
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(task({ id: "FN-TOOLS", executionMode: "fast" }));
expect(allSessionToolNames()).toContain("fn_task_done");
expect(allSessionToolNames()).not.toContain("fn_review_step");
});
it("never injects fn_review_step in standard mode either", async () => {
mockedCreateFnAgent.mockImplementation(async (opts: any) => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
sessionManager: {
getLeafId: vi.fn().mockReturnValue("leaf"),
branchWithSummary: vi.fn(),
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
},
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
},
capturedTools: opts.customTools,
}));
const store = createMockStore();
store.getTask.mockResolvedValue(task({ id: "FN-TOOLS", executionMode: "standard" }));
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(task({ id: "FN-TOOLS", executionMode: "standard" }));
expect(allSessionToolNames()).toContain("fn_task_done");
expect(allSessionToolNames()).not.toContain("fn_review_step");
});
});