Files
fusion/packages/engine/src/__tests__/workflow-graph-optional-group.test.ts
gsxdsm e75c788db2 FN-8794: add workflow review-kind markers
Classify workflow review nodes so review feedback can target their persisted results.

- validate and type explicit plan/code review-kind markers in workflow IR
- expose review-kind selection in the workflow editor and document the authoring contract
- persist markers through task workflow routes and graph execution with regression coverage

Files changed:
 .changeset/fn-8794-review-kind.md                  |   7 +
 docs/dashboard-guide.md                            |   4 +
 docs/workflow-steps.md                             |  10 +
 .../__tests__/workflow-ir-optional-group.test.ts   |  17 ++
 packages/core/src/__tests__/workflow-ir.test.ts    |  83 +++++++++
 packages/core/src/index.gate.ts                    |   2 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/types.ts                         |   2 +
 packages/core/src/types/workflow/workflow-steps.ts |   8 +
 .../src/workflows/builtin-code-review-group.ts     |   1 +
 .../src/workflows/builtin-plan-review-group.ts     |   1 +
 packages/core/src/workflows/workflow-ir-types.ts   |   5 +
 packages/core/src/workflows/workflow-ir.ts         |  24 +++
 .../app/components/WorkflowNodeEditor.tsx          |  17 ++
 .../__tests__/WorkflowNodeEditor.test.tsx          | 206 +++++++++++++++++++++
 .../dashboard/src/__tests__/routes-tasks.test.ts   | 166 ++++++++++++++++-
 .../src/routes/register-task-workflow-routes.ts    |  82 ++++++--
 .../workflow-graph-optional-group.test.ts          | 177 +++++++++++++++++-
 .../src/workflows/workflow-graph-executor.ts       |  20 +-
 19 files changed, 801 insertions(+), 33 deletions(-)

Fusion-Task-Id: FN-8794

Fusion-Task-Lineage: a7752469-59bc-406f-82e8-6ffde6c392ef

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-04 23:36:32 -07:00

1261 lines
48 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR, upsertWorkflowStepResult } from "@fusion/core";
import type { TaskDetail, WorkflowIr, WorkflowStepResult } from "@fusion/core";
import {
PLAN_REVIEW_PROVIDER_FAILURE_HOLD_VALUE,
WorkflowGraphExecutor,
type WorkflowNodeHandler,
} from "../workflows/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;
}
function reviseGroupIr(options: { phase?: "pre-merge" | "post-merge"; gateMode?: "advisory" | "gate"; maxRevisions?: number | "unbounded" } = {}): WorkflowIr {
return {
version: "v2",
name: "optional-group-revise-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "group",
kind: "optional-group",
config: {
name: options.phase === "post-merge" ? "Post-merge verification" : "Code Review",
defaultOn: true,
phase: options.phase,
maxRevisions: options.maxRevisions,
template: {
nodes: [{ id: "review", kind: options.gateMode === "gate" ? "gate" : "prompt", config: { prompt: "review" } }],
edges: [],
},
},
},
{ id: "after", kind: "prompt", config: { prompt: "after" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "group" },
{ from: "group", to: "after", condition: "success" },
{ from: "group", to: "end", condition: "failure" },
{ from: "after", to: "end" },
],
};
}
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("uses defaultOn only when enabledWorkflowSteps is missing, while explicit empty disables", async () => {
const ir = optionalGroupIr();
const group = ir.nodes.find((node) => node.id === "group");
if (group?.config) group.config.defaultOn = true;
const calls: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return { outcome: "success" };
},
},
});
const unsetResult = await executor.run(taskWith(undefined), settingsOn(), ir);
const explicitEmptyResult = await executor.run(taskWith([]), settingsOn(), ir);
expect(calls.filter((id) => id === "optstep")).toHaveLength(1);
expect(unsetResult.visitedNodeIds).toContain("group::optstep");
expect(explicitEmptyResult.visitedNodeIds).not.toContain("group::optstep");
});
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");
});
it("pre-merge advisory REVISE requests a bounded fix and aborts forward traversal when scheduled", async () => {
const calls: string[] = [];
const records: unknown[] = [];
const requestFix = vi.fn(async () => true);
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
if (node.id === "review") {
return { outcome: "success", value: "REVISE", contextPatch: { output: "Fix the review finding" } };
}
return { outcome: "success" };
},
},
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
requestPreMergeOptionalStepFix: requestFix,
});
const ir = reviseGroupIr();
const group = ir.nodes.find((node) => node.id === "group");
if (!group) throw new Error("review group missing");
group.config = { ...group.config, reviewKind: "code" };
const result = await executor.run(taskWith(["group"]), settingsOn(), ir);
expect(requestFix).toHaveBeenCalledWith("FN-OG", {
stepName: "Code Review",
feedback: "Fix the review finding",
phase: "pre-merge",
status: "advisory_failure",
verdict: "REVISE",
nodeId: "group",
maxRevisions: undefined,
});
expect(calls).not.toContain("after");
expect(result.context["node:group:fixScheduled"]).toBe(true);
expect(records).toEqual(expect.arrayContaining([
expect.objectContaining({ workflowStepId: "group", status: "pending", reviewKind: "code" }),
expect.objectContaining({ workflowStepId: "group", status: "advisory_failure", verdict: "REVISE", output: "Fix the review finding", reviewKind: "code" }),
]));
});
it("preserves a typed missing-artifact failure through the optional-group remediation seam", async () => {
const requestFix = vi.fn(async () => true);
const executor = new WorkflowGraphExecutor({
handlers: {
gate: async (node) => node.id === "review"
? {
outcome: "failure",
value: "required-artifact-missing:PROMPT.md",
contextPatch: { output: "PROMPT.md could not be loaded" },
}
: { outcome: "success" },
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["group"]), settingsOn(), reviseGroupIr({ gateMode: "gate" }));
expect(requestFix).toHaveBeenCalledWith("FN-OG", expect.objectContaining({
stepName: "Code Review",
status: "failed",
failureValue: "required-artifact-missing:PROMPT.md",
}));
expect(result.context["node:group:fixScheduled"]).toBe(true);
});
it("threads optional-group maxRevisions into the pre-merge fix seam", async () => {
const requestFix = vi.fn(async () => true);
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => node.id === "review"
? { outcome: "success", value: "REVISE", contextPatch: { output: "custom finding" } }
: { outcome: "success" },
},
requestPreMergeOptionalStepFix: requestFix,
});
await executor.run(taskWith(["group"]), settingsOn(), reviseGroupIr({ maxRevisions: "unbounded" }));
expect(requestFix).toHaveBeenCalledWith("FN-OG", expect.objectContaining({
nodeId: "group",
maxRevisions: "unbounded",
}));
});
it("falls through unchanged when the pre-merge fix seam is absent or declines", async () => {
for (const requestFix of [undefined, vi.fn(async () => false)] as const) {
const calls: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
if (node.id === "review") return { outcome: "success", value: "REVISE", contextPatch: { output: "still advisory" } };
return { outcome: "success" };
},
},
...(requestFix ? { requestPreMergeOptionalStepFix: requestFix } : {}),
});
const result = await executor.run(taskWith(["group"]), settingsOn(), reviseGroupIr());
expect(calls).toContain("after");
expect(result.context["node:group:fixScheduled"]).toBeUndefined();
if (requestFix) expect(requestFix).toHaveBeenCalledOnce();
}
});
it("requests fixes for pre-merge gate REVISE but not post-merge, non-REVISE, or fast-mode skipped outcomes", async () => {
const requestFix = vi.fn(async () => true);
const gateExecutor = new WorkflowGraphExecutor({
handlers: {
gate: async () => ({ outcome: "failure", value: "REVISE", contextPatch: { output: "gate finding" } }),
prompt: async () => ({ outcome: "success" }),
},
requestPreMergeOptionalStepFix: requestFix,
});
await gateExecutor.run(taskWith(["group"]), settingsOn(), reviseGroupIr({ gateMode: "gate" }));
expect(requestFix).toHaveBeenLastCalledWith("FN-OG", expect.objectContaining({ status: "failed", feedback: "gate finding" }));
requestFix.mockClear();
const postMergeCalls: string[] = [];
const postMergeExecutor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
postMergeCalls.push(node.id);
if (node.id === "review") return { outcome: "success", value: "REVISE", contextPatch: { output: "post merge finding" } };
return { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: requestFix,
});
await postMergeExecutor.run(taskWith(["group"]), settingsOn(), reviseGroupIr({ phase: "post-merge" }));
expect(requestFix).not.toHaveBeenCalled();
expect(postMergeCalls).toContain("after");
const approveExecutor = new WorkflowGraphExecutor({
handlers: {
prompt: async () => ({ outcome: "success", value: "APPROVE_WITH_NOTES", contextPatch: { output: "notes only" } }),
},
requestPreMergeOptionalStepFix: requestFix,
});
await approveExecutor.run(taskWith(["group"]), settingsOn(), reviseGroupIr({ maxRevisions: "unbounded" }));
expect(requestFix).not.toHaveBeenCalled();
const fastExecutor = new WorkflowGraphExecutor({
handlers: {
prompt: async (_node, context) => context.task.executionMode === "fast"
? { outcome: "success", value: "workflow-step-skipped" }
: { outcome: "success", value: "REVISE", contextPatch: { output: "would revise outside fast mode" } },
},
requestPreMergeOptionalStepFix: requestFix,
});
await fastExecutor.run({ ...taskWith(["group"]), executionMode: "fast" } as TaskDetail, settingsOn(), reviseGroupIr());
expect(requestFix).not.toHaveBeenCalled();
});
it("routes hard Plan Review failures into the pre-merge replan seam before execution continues", async () => {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];
const records: unknown[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "plan-review-hard-failure",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "plan-review",
kind: "optional-group",
config: {
name: "Plan Review",
defaultOn: true,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review plan" } }],
edges: [],
},
},
},
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "plan-review" },
{ from: "plan-review", to: "execute", condition: "success" },
{ from: "plan-review", to: "end", condition: "failure" },
{ from: "execute", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return node.id === "plan-review-step"
? { outcome: "failure" }
: { outcome: "success" };
},
},
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["plan-review"]), settingsOn(), ir);
expect(requestFix).toHaveBeenCalledWith("FN-OG", {
stepName: "Plan Review",
feedback: "Plan Review failed before execution. Re-run triage to revise PROMPT.md before implementation continues.",
phase: "pre-merge",
status: "failed",
verdict: "REVISE",
nodeId: "plan-review",
maxRevisions: undefined,
});
expect(calls).not.toContain("execute");
expect(result.context["node:plan-review:fixScheduled"]).toBe(true);
expect(records).toEqual(expect.arrayContaining([
expect.objectContaining({ workflowStepId: "plan-review", status: "failed" }),
]));
});
it("keeps transient Plan Review provider failures in place without synthesizing REVISE", async () => {
const requestFix = vi.fn(async () => true);
const records: unknown[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => node.id === "plan-review-step"
? {
outcome: "failure",
value: "exception",
contextPatch: {
output: "Unable to select a usable model after 2 attempts (429 Too Many Requests)",
},
}
: { outcome: "success" },
},
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(
taskWith(["plan-review"]),
settingsOn(),
BUILTIN_CODING_WORKFLOW_IR,
);
expect(requestFix).not.toHaveBeenCalled();
expect(result.outcome).toBe("failure");
expect(result.context["node:plan-review:value"]).toBe(PLAN_REVIEW_PROVIDER_FAILURE_HOLD_VALUE);
expect(result.visitedNodeIds).toContain("plan-review");
expect(result.visitedNodeIds).not.toContain("plan-replan");
expect(result.visitedNodeIds).not.toContain("execute");
expect(records).toEqual(expect.arrayContaining([
expect.objectContaining({
workflowStepId: "plan-review",
status: "failed",
output: expect.stringContaining("Unable to select a usable model"),
}),
]));
});
it("keeps Plan Review task-storage read failures in place without sending the task to planning", async () => {
const requestFix = vi.fn(async () => true);
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => node.id === "plan-review-step"
? {
outcome: "failure",
value: "required-artifact-read-failed:PROMPT.md",
contextPatch: { output: "PROMPT.md task storage read failed" },
}
: { outcome: "success" },
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["plan-review"]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
expect(requestFix).not.toHaveBeenCalled();
expect(result.outcome).toBe("failure");
expect(result.context["node:plan-review:value"]).toBe(PLAN_REVIEW_PROVIDER_FAILURE_HOLD_VALUE);
expect(result.visitedNodeIds).not.toContain("plan-replan");
});
it("uses an explicit graph replan node for Plan Review REVISE and does not execute before replan completes", async () => {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "plan-review-explicit-replan-route",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "plan-review",
kind: "optional-group",
config: {
name: "Plan Review",
defaultOn: true,
reworkRegion: true,
maxReworkCycles: 3,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review plan" } }],
edges: [],
},
},
},
{
id: "plan-replan",
kind: "prompt",
config: {
name: "Plan Replan",
workflowAction: "plan-replan",
forWorkflowStepId: "plan-review",
},
},
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "plan-review" },
{ from: "plan-review", to: "execute", condition: "success" },
{ from: "plan-review", to: "plan-replan", condition: "failure" },
{ from: "plan-replan", to: "plan-review", condition: "success", kind: "rework" },
{ from: "execute", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return node.id === "plan-review-step"
? { outcome: "failure", value: "REVISE", contextPatch: { output: "missing acceptance criteria" } }
: { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["plan-review"]), settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain("plan-review");
expect(result.visitedNodeIds).toContain("plan-replan");
expect(result.visitedNodeIds).not.toContain("execute");
expect(calls).not.toContain("execute");
expect(requestFix).toHaveBeenCalledWith("FN-OG", expect.objectContaining({
nodeId: "plan-review",
feedback: "missing acceptance criteria",
verdict: "REVISE",
}));
expect(result.context["node:plan-review:fixScheduled"]).toBe(true);
expect(result.context["node:plan-replan:value"]).toBe("remediation-scheduled");
});
it("does not synthesize a Plan Review replan from advisory malformed output", async () => {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];
const records: unknown[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "plan-review-advisory-malformed",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "plan-review",
kind: "optional-group",
config: {
name: "Plan Review",
defaultOn: true,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review plan" } }],
edges: [],
},
},
},
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "plan-review" },
{ from: "plan-review", to: "execute", condition: "success" },
{ from: "execute", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return node.id === "plan-review-step"
? { outcome: "success", value: "advisory_failure", contextPatch: { output: "malformed output — no verdict extracted" } }
: { outcome: "success" };
},
},
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["plan-review"]), settingsOn(), ir);
expect(requestFix).not.toHaveBeenCalled();
expect(calls).toContain("execute");
expect(result.outcome).toBe("success");
expect(records).toEqual(expect.arrayContaining([
expect.objectContaining({
workflowStepId: "plan-review",
status: "advisory_failure",
output: "malformed output — no verdict extracted",
}),
]));
});
it("skips Plan Review in the execution graph when triage already passed it", async () => {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];
const logs: string[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "plan-review-already-passed",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "plan-review",
kind: "optional-group",
config: {
name: "Plan Review",
defaultOn: true,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review plan" } }],
edges: [],
},
},
},
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "plan-review" },
{ from: "plan-review", to: "execute", condition: "success" },
{ from: "execute", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return { outcome: "success" };
},
},
logTaskEntry: (summary) => { logs.push(summary); },
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run({
...taskWith(["plan-review"]),
id: "FN-plan-review-passed",
workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", phase: "pre-merge", status: "passed" }],
} as TaskDetail, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(calls).toEqual(["execute"]);
expect(requestFix).not.toHaveBeenCalled();
expect(logs).toContain("[pre-merge] Workflow step already satisfied: Plan Review");
});
it.each(["passed", "live pending lease"] as const)(
"reruns Plan Review when a dependency superseded the prior %s and its old completion log",
async (priorState) => {
const calls: string[] = [];
const logs: string[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "plan-review-superseded",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "plan-review",
kind: "optional-group",
config: {
name: "Plan Review",
defaultOn: true,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review replanned spec" } }],
edges: [],
},
},
},
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "plan-review" },
{ from: "plan-review", to: "execute", condition: "success" },
{ from: "execute", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return { outcome: "success" };
},
},
runLoopNowForTests: () => Date.parse("2026-08-04T02:00:01.000Z"),
logTaskEntry: (summary) => { logs.push(summary); },
});
const result = await executor.run({
...taskWith(["plan-review"]),
id: "FN-plan-review-superseded",
workflowStepResults: [{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
phase: "pre-merge",
status: priorState === "passed" ? "passed" : "pending",
...(priorState === "live pending lease"
? { startedAt: "2026-08-04T02:00:00.000Z", leaseOwner: "planner:old-episode" }
: { completedAt: "2026-08-04T01:00:00.000Z" }),
supersededAt: "2026-08-04T02:00:00.000Z",
supersededReason: "dependency-change",
}],
log: [{
timestamp: "2026-08-04T01:00:00.000Z",
action: "[pre-merge] Workflow step completed: Plan Review",
}],
} as TaskDetail, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(calls).toEqual(["plan-review-step", "execute"]);
expect(logs).not.toContain(
"[pre-merge] Plan Review already in progress (lease held) — not dispatching a second reviewer",
);
},
);
it("repairs missing Plan Review result from the latest completed log before execution", async () => {
const records: Array<{ workflowStepId: string; status: string; notes?: string }> = [];
const calls: string[] = [];
const logs: string[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "plan-review-log-repair",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "plan-review",
kind: "optional-group",
config: {
name: "Plan Review",
defaultOn: true,
template: {
nodes: [{ id: "plan-review-step", kind: "prompt", config: { prompt: "review plan" } }],
edges: [],
},
},
},
{ id: "execute", kind: "prompt", config: { prompt: "execute" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "plan-review" },
{ from: "plan-review", to: "execute", condition: "success" },
{ from: "execute", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return { outcome: "success" };
},
},
logTaskEntry: (summary) => { logs.push(summary); },
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
});
const result = await executor.run({
...taskWith(["plan-review"]),
id: "FN-7228",
workflowStepResults: [],
log: [
{ timestamp: "2026-06-29T10:31:20.000Z", action: "[pre-merge] Workflow step failed: Plan Review" },
{
timestamp: "2026-06-29T10:34:21.000Z",
action: "[pre-merge] Workflow step completed: Plan Review",
outcome: "approved after replan",
},
],
} as TaskDetail, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(calls).toEqual(["execute"]);
expect(records).toEqual([
expect.objectContaining({
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "passed",
notes: "approved after replan",
}),
]);
expect(logs).toContain("[pre-merge] Workflow step already passed: Plan Review");
});
it("cycles REVISE findings across graph runs until APPROVE, and falls through only after the budget seam declines", async () => {
const verdicts = ["REVISE", "REVISE", "APPROVE"];
const requestFix = vi.fn(async () => true);
for (let cycle = 0; cycle < verdicts.length; cycle += 1) {
const calls: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
if (node.id === "review") {
const verdict = verdicts[cycle];
return { outcome: "success", value: verdict, contextPatch: { output: `cycle ${cycle + 1} ${verdict}` } };
}
return { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(taskWith(["group"]), settingsOn(), reviseGroupIr());
if (cycle < 2) {
expect(requestFix).toHaveBeenCalledTimes(cycle + 1);
expect(calls).not.toContain("after");
expect(result.context["node:group:fixScheduled"]).toBe(true);
} else {
expect(requestFix).toHaveBeenCalledTimes(2);
expect(calls).toContain("after");
expect(result.context["node:group:fixScheduled"]).toBeUndefined();
}
}
let exhaustedFixesScheduled = 0;
const exhaustedRequestFix = vi.fn(async () => {
if (exhaustedFixesScheduled >= 3) return false;
exhaustedFixesScheduled += 1;
return true;
});
for (let cycle = 0; cycle < 4; cycle += 1) {
const calls: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return node.id === "review"
? { outcome: "success", value: "REVISE", contextPatch: { output: `persistent finding ${cycle + 1}` } }
: { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: exhaustedRequestFix,
});
const result = await executor.run(taskWith(["group"]), settingsOn(), reviseGroupIr());
if (cycle < 3) {
expect(calls).not.toContain("after");
expect(result.context["node:group:fixScheduled"]).toBe(true);
} else {
expect(calls).toContain("after");
expect(result.context["node:group:fixScheduled"]).toBeUndefined();
}
}
expect(exhaustedRequestFix).toHaveBeenCalledTimes(4);
});
it("builtin coding optional Code Review and Browser Verification REVISE abort before review, and stepwise carries the same pre-merge path", async () => {
for (const groupId of ["code-review", "browser-verification"] as const) {
const requestFix = vi.fn(async () => true);
const calls: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
if ((groupId === "code-review" && node.id === "code-review-step")
|| (groupId === "browser-verification" && node.id === "browser-verification-step")) {
return {
outcome: groupId === "code-review" ? "failure" : "success",
value: "REVISE",
contextPatch: { output: `${groupId} finding` },
};
}
return { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run(
{ ...taskWith(groupId === "code-review" ? ["code-review"] : ["browser-verification", "code-review"]), id: `FN-${groupId}` } as TaskDetail,
settingsOn(),
BUILTIN_CODING_WORKFLOW_IR,
);
expect(requestFix).toHaveBeenCalledWith(`FN-${groupId}`, expect.objectContaining({
stepName: groupId === "code-review" ? "Code Review" : "Browser Verification",
feedback: `${groupId} finding`,
nodeId: groupId,
maxRevisions: groupId === "code-review" ? "unbounded" : 3,
}));
expect(calls).not.toContain("review");
expect(result.context[`node:${groupId}:fixScheduled`]).toBe(true);
}
for (const ir of [BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR]) {
for (const groupId of ["browser-verification", "code-review"] as const) {
const node = ir.nodes.find((candidate) => candidate.id === groupId);
expect(node).toMatchObject({ kind: "optional-group" });
expect(node?.config?.phase).toBeUndefined();
expect(ir.edges).toEqual(expect.arrayContaining([
expect.objectContaining({ from: groupId, to: groupId === "browser-verification" ? "code-review" : "completion-summary", condition: "success" }),
expect.objectContaining({
from: groupId,
to: groupId === "browser-verification" ? "browser-verification-remediation" : "code-review-remediation",
condition: "failure",
}),
]));
}
}
for (const groupId of ["browser-verification", "code-review"] as const) {
const stepwiseRequestFix = vi.fn(async () => true);
const stepwiseExecutor = new WorkflowGraphExecutor({
handlers: {
"parse-steps": async () => ({ outcome: "success", value: "no-steps" }),
prompt: async (node) => {
if ((groupId === "code-review" && node.id === "code-review-step")
|| (groupId === "browser-verification" && node.id === "browser-verification-step")) {
return {
outcome: groupId === "code-review" ? "failure" : "success",
value: "REVISE",
contextPatch: { output: `stepwise ${groupId} finding` },
};
}
return { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: stepwiseRequestFix,
});
const stepwiseResult = await stepwiseExecutor.run(
{ ...taskWith(groupId === "code-review" ? ["code-review"] : ["browser-verification", "code-review"]), id: `FN-stepwise-${groupId}`, steps: [] } as TaskDetail,
settingsOn(),
BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
);
expect(stepwiseRequestFix).toHaveBeenCalledWith(`FN-stepwise-${groupId}`, expect.objectContaining({
stepName: groupId === "code-review" ? "Code Review" : "Browser Verification",
feedback: `stepwise ${groupId} finding`,
nodeId: groupId,
maxRevisions: groupId === "code-review" ? "unbounded" : 3,
}));
expect(stepwiseResult.context[`node:${groupId}:fixScheduled`]).toBe(true);
}
});
/*
* FNXC:WorkflowReviewKind 2026-08-05-03:08:
* Marked top-level nodes are direct-review producers even without skillName.
* Exercise each writer through normal, failure, and retry paths so the marker
* is a declaration snapshot rather than a success-only presentation hint.
*/
it.each([
["prompt", "plan"],
["gate", "code"],
["script", "plan"],
] as const)("snapshots marked custom top-level %s results without skillName across terminal outcomes", async (kind, reviewKind) => {
const records: Array<{ status: string; reviewKind?: string; source?: string }> = [];
const ir: WorkflowIr = {
version: "v2",
name: "marked-top-level-review",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "review", kind, config: { reviewKind } },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "review" }, { from: "review", to: "end", condition: "success" }, { from: "review", to: "end", condition: "failure" }],
};
const executor = new WorkflowGraphExecutor({
handlers: { [kind]: async () => ({ outcome: "failure", contextPatch: { notes: "declared review failure" } }) },
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
});
await executor.run(taskWith([]), settingsOn(), ir);
expect(records).toEqual([
expect.objectContaining({ workflowStepId: "review", source: "node", status: "pending", reviewKind }),
expect.objectContaining({ workflowStepId: "review", source: "node", status: "failed", reviewKind, notes: "declared review failure" }),
]);
});
it.each([
["prompt", "plan"],
["gate", "code"],
["script", "plan"],
] as const)("snapshots marked custom top-level %s results on normal completion without skillName", async (kind, reviewKind) => {
const records: Array<{ status: string; reviewKind?: string; source?: string }> = [];
const ir: WorkflowIr = {
version: "v2",
name: "marked-top-level-review-success",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "review", kind, config: { reviewKind } },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "review" }, { from: "review", to: "end", condition: "success" }],
};
const executor = new WorkflowGraphExecutor({
handlers: { [kind]: async () => ({ outcome: "success", contextPatch: { output: "declared review passed" } }) },
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
});
await executor.run(taskWith([]), settingsOn(), ir);
expect(records).toEqual([
expect.objectContaining({ workflowStepId: "review", source: "node", status: "pending", reviewKind }),
expect.objectContaining({ workflowStepId: "review", source: "node", status: "passed", reviewKind, output: "declared review passed" }),
]);
});
it.each([
["prompt", "plan"],
["gate", "code"],
["script", "plan"],
] as const)("preserves %s reviewKind on current and prior failed retry records", async (kind, reviewKind) => {
let stored: WorkflowStepResult[] = [];
const ir: WorkflowIr = {
version: "v2",
name: "marked-top-level-review-retry",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "review", kind, config: { reviewKind } },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "review" }, { from: "review", to: "end", condition: "failure" }],
};
const executor = new WorkflowGraphExecutor({
handlers: { [kind]: async () => ({ outcome: "failure", contextPatch: { notes: "retryable finding" } }) },
recordWorkflowStepResult: async (_taskId, result) => { stored = upsertWorkflowStepResult(stored, result); },
});
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-08-05T03:21:00.000Z"));
await executor.run(taskWith([]), settingsOn(), ir);
vi.setSystemTime(new Date("2026-08-05T03:22:00.000Z"));
await executor.run(taskWith([]), settingsOn(), ir);
} finally {
vi.useRealTimers();
}
expect(stored).toEqual([
expect.objectContaining({ status: "failed", reviewKind, priorAttempts: [expect.objectContaining({ status: "failed", reviewKind })] }),
]);
});
it("snapshots a marked custom optional-group review kind on pending and terminal results", async () => {
const records: Array<{ status: string; reviewKind?: string; source?: string }> = [];
const ir = optionalGroupIr();
const group = ir.nodes.find((node) => node.id === "group");
if (!group) throw new Error("test workflow group missing");
group.config = { ...group.config, reviewKind: "code" };
const executor = new WorkflowGraphExecutor({
handlers: { prompt: async () => ({ outcome: "success", contextPatch: { output: "review feedback" } }) },
recordWorkflowStepResult: async (_taskId, result) => { records.push(result); },
});
await executor.run(taskWith(["group"]), settingsOn(), ir);
expect(records.filter((result) => result.source === "optional-group")).toEqual([
expect.objectContaining({ status: "pending", reviewKind: "code" }),
expect.objectContaining({ status: "passed", reviewKind: "code" }),
]);
});
/*
* FNXC:WorkflowReviewKind 2026-08-05-03:32:
* Optional groups replace their pending result after every execution. The
* declared marker must survive failed retries on both the current result and
* its bounded audit snapshot; it is not inferred from the template outcome.
*/
it("preserves optional-group reviewKind on current and prior failed retries", async () => {
let stored: WorkflowStepResult[] = [];
const ir = optionalGroupIr();
const group = ir.nodes.find((node) => node.id === "group");
if (!group) throw new Error("test workflow group missing");
group.config = { ...group.config, reviewKind: "code" };
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => node.id === "optstep"
? { outcome: "failure", contextPatch: { notes: "retryable group finding" } }
: { outcome: "success" },
},
recordWorkflowStepResult: async (_taskId, result) => { stored = upsertWorkflowStepResult(stored, result); },
});
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-08-05T03:32:00.000Z"));
await executor.run(taskWith(["group"]), settingsOn(), ir);
vi.setSystemTime(new Date("2026-08-05T03:33:00.000Z"));
await executor.run(taskWith(["group"]), settingsOn(), ir);
} finally {
vi.useRealTimers();
}
expect(stored).toEqual([
expect.objectContaining({
workflowStepId: "group",
source: "optional-group",
status: "failed",
reviewKind: "code",
priorAttempts: [expect.objectContaining({ status: "failed", reviewKind: "code" })],
}),
]);
});
it("blocks builtin coding review and merge when Code Review requests revision and no remediation is scheduled", async () => {
const requestFix = vi.fn(async () => false);
const calls: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
"parse-steps": async () => ({ outcome: "success", value: "no-steps" }),
prompt: async (node) => {
calls.push(node.id);
if (node.id === "code-review-step") {
return {
outcome: "failure",
value: "REVISE",
contextPatch: { output: "blocking code review finding" },
};
}
return { outcome: "success" };
},
},
requestPreMergeOptionalStepFix: requestFix,
});
const result = await executor.run({
...taskWith(["plan-review", "code-review"]),
id: "FN-7228-regression",
steps: [],
workflowStepResults: [
{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
phase: "pre-merge",
status: "passed",
startedAt: "2026-06-29T17:00:00.000Z",
completedAt: "2026-06-29T17:00:01.000Z",
},
],
} as TaskDetail, settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
expect(requestFix).toHaveBeenCalledWith("FN-7228-regression", expect.objectContaining({
stepName: "Code Review",
feedback: "blocking code review finding",
nodeId: "code-review",
status: "failed",
verdict: "REVISE",
}));
expect(result.outcome).toBe("failure");
expect(result.visitedNodeIds).toContain("code-review::code-review-step");
expect(result.visitedNodeIds).not.toContain("review");
expect(result.visitedNodeIds).not.toContain("merge-gate");
expect(result.visitedNodeIds).not.toContain("merge-attempt");
expect(calls).not.toContain("review");
});
});