Files
fusion/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts

579 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─────────────────────────────────────────────────────────────────────────────
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
// This suite owns the STEPWISE PER-STEP parity + invariant coverage: it compares
// the `updateStep` TRAJECTORY and the MERGE-BLOCKER WINDOWS of the legacy
// step-session path against the inverted stepwise foreach graph driven by the
// built-in `builtin:stepwise-coding` IR.
//
// The legacy step-session path (runStepsInNewSessions ON) is the deterministic
// per-step ORACLE here — the agent-paced monolithic path is NOT deterministically
// comparable (see plan U7) and stays covered by the default-workflow byte-identity
// suite `workflow-graph-executor-parity.test.ts`. Both paths in this file are
// driven by the SAME scripted reviewer/seams so the only variable is the path.
//
// The graph side wires the REAL substrate seams (`runTaskStep`,
// `resetStepToBaseline`, `makeAncestryBlastRadiusGuard`) exactly as the executor
// does (executor.ts createGraphSeams / applyGraphRethinkReset), against a fake
// store that records the projection trajectory — so the comparison exercises the
// production reset/blast-radius/projection code, not a re-implementation.
//
// It also exercises the non-configurable lifecycle invariants (FN-5147
// terminal-until-merged, hard-cancel, file-scope guard) and the flag posture
// (pinned-at-dispatch, OFF-rollback recovery) on the stepwise path.
// ─────────────────────────────────────────────────────────────────────────────
import { describe, expect, it } from "vitest";
import {
BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
type StepStatus,
type TaskDetail,
type TaskStep,
type WorkflowIr,
} from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type StepReviewSeamResult,
type WorkflowLegacySeams,
} from "../workflow-node-handlers.js";
import {
makeAncestryBlastRadiusGuard,
resetStepToBaseline,
runTaskStep,
type StepRunnerTask,
} from "../step-runner.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
const settingsOff = () => ({ experimentalFeatures: { workflowGraphExecutor: false } });
type Verdict = StepReviewSeamResult["verdict"];
/** One recorded projection write — the unit of trajectory comparison (KTD-7). */
interface TrajectoryEntry {
step: number;
status: StepStatus;
source?: "graph";
}
/**
* A minimal fake store recording the `updateStep` projection trajectory and
* applying each write to an in-memory `steps[]` so the blast-radius guard's
* "later step already done" probe and the merge-blocker reads see real state.
* Implements only the surface `runTaskStep` / `resetStepToBaseline` touch.
*/
function makeFakeStore(steps: TaskStep[]) {
const trajectory: TrajectoryEntry[] = [];
return {
trajectory,
steps,
updateStep: async (
_id: string,
stepIndex: number,
status: StepStatus,
options?: { source?: "graph" },
) => {
trajectory.push({ step: stepIndex, status, ...(options?.source ? { source: options.source } : {}) });
if (steps[stepIndex]) steps[stepIndex] = { ...steps[stepIndex], status };
return {} as never;
},
logEntry: async () => {},
};
}
/** Build a TaskDetail with N pending steps. */
function taskWithSteps(n: number): 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;
}
/**
* The legacy step-session ORACLE (KTD-9). Deterministic per-step loop modeling the
* in-session `fn_review_step` policy: for each step, mark in-progress, run, review;
* APPROVE → done, REVISE → re-run in place (no reset), RETHINK → reset to pending +
* re-run. Bounded by maxReworkCycles. Records the same TrajectoryEntry shape the
* graph side records — the legacy side never uses `source:"graph"`.
*/
async function runLegacyStepSession(
stepCount: number,
scripts: Verdict[][],
maxReworkCycles = 3,
): Promise<TrajectoryEntry[]> {
const trajectory: TrajectoryEntry[] = [];
const steps: TaskStep[] = Array.from({ length: stepCount }, (_, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
}));
for (let i = 0; i < stepCount; i++) {
const verdicts = scripts[i] ?? ["APPROVE"];
let cursor = 0;
let rework = 0;
for (;;) {
// run step i (mark in-progress)
trajectory.push({ step: i, status: "in-progress" });
steps[i] = { ...steps[i], status: "in-progress" };
const verdict = verdicts[Math.min(cursor, verdicts.length - 1)];
cursor++;
if (verdict === "APPROVE") {
trajectory.push({ step: i, status: "done" });
steps[i] = { ...steps[i], status: "done" };
break;
}
if (verdict === "RETHINK") {
// reset to baseline: step → pending, then re-run.
trajectory.push({ step: i, status: "pending" });
steps[i] = { ...steps[i], status: "pending" };
}
// REVISE: re-run in place (no extra projection write — step stays in-progress
// on the next loop's in-progress write).
rework++;
if (rework > maxReworkCycles) {
// rework exhausted — step stays non-done (escalates). Mirror the graph's
// exhaustion: leave the last in-progress write as the terminal state.
break;
}
}
}
return trajectory;
}
/**
* Drive the stepwise foreach graph (the REAL builtin IR) and capture the projection
* trajectory. Wires the substrate seams exactly as the executor does:
* - stepExecute → runTaskStep (markDoneOnSuccess driven by deferDoneToReview);
* - stepReview → scripted verdict; APPROVE marks the step done via updateStep
* (the projection authority, like createGraphSeams);
* - onReworkReset → resetStepToBaseline with the shared-isolation blast guard.
*/
async function runStepwiseGraph(
stepCount: number,
scripts: Verdict[][],
opts: {
maxReworkCycles?: number;
signal?: AbortSignal;
onReset?: (active: ForeachActiveContext) => void;
captureResetResult?: (ok: boolean, reason?: string) => void;
} = {},
): Promise<{ trajectory: TrajectoryEntry[]; outcome: string; result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>> }> {
const task = taskWithSteps(stepCount);
const fake = makeFakeStore(task.steps as TaskStep[]);
const reviewCursor = new Map<number, number>();
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
const result = await runTaskStep(
{
store: fake as never,
worktreePath: "/fake/worktree",
runStep: async () => ({ success: true }),
// Deterministic per-step baseline (substrate-captured, KTD-2). HEAD at
// instance start postdates steps 0..i-1's commits.
gitRevParse: async () => `sha-baseline-${active.stepIndex}`,
captureCheckpointId: () => `ckpt-${active.stepIndex}`,
},
{ id: task.id, steps: task.steps } as StepRunnerTask,
active.stepIndex,
{ markDoneOnSuccess: active.deferDoneToReview !== true },
);
active.baselineSha = result.baselineSha;
active.checkpointId = result.checkpointId;
return {
outcome: result.outcome,
value: "step-done",
contextPatch: { [FOREACH_ACTIVE_CONTEXT_KEY]: active },
};
},
stepReview: async (_t, ctx, _config) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
const verdicts = scripts[active.stepIndex] ?? ["APPROVE"];
const cursor = reviewCursor.get(active.stepIndex) ?? 0;
reviewCursor.set(active.stepIndex, cursor + 1);
const verdict = verdicts[Math.min(cursor, verdicts.length - 1)];
// APPROVE: the seam is the projection authority for done (createGraphSeams).
if (verdict === "APPROVE") {
await fake.updateStep(task.id, active.stepIndex, "done", { source: "graph" });
}
return { verdict };
},
};
const executor = new WorkflowGraphExecutor({
seams,
signal: opts.signal,
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).
parseStepsDeps: {
readArtifact: async () =>
Array.from({ length: stepCount }, (_, i) => `### Step ${i + 1}: do ${i + 1}\n`).join("\n"),
writeSteps: async (_t, parsed) => {
// Mirror production: project the parsed list (all pending). Keep the
// in-memory steps array length authoritative for the run.
task.steps = parsed.length > 0 ? parsed : (task.steps as TaskStep[]);
},
},
onReworkReset: async (active) => {
opts.onReset?.(active);
const res = await resetStepToBaseline(
{
store: fake as never,
worktreePath: "/fake/worktree",
sessionRef: { current: null },
reviewType: "code",
blastRadiusGuard: makeAncestryBlastRadiusGuard({
worktreePath: "/fake/worktree",
task: { id: task.id, steps: task.steps } as StepRunnerTask,
stepIndex: active.stepIndex,
// Deterministic ancestry: the captured baseline is always an ancestor
// of HEAD on a clean scripted run.
isAncestor: async () => true,
}),
},
{ id: task.id, steps: task.steps } as StepRunnerTask,
active.stepIndex,
active.baselineSha,
active.checkpointId,
);
opts.captureResetResult?.(res.ok, res.reason);
},
});
const ir: WorkflowIr = BUILTIN_STEPWISE_CODING_WORKFLOW_IR;
// Override maxReworkCycles when the scenario needs a tighter budget.
const runIr =
opts.maxReworkCycles !== undefined ? withForeachMaxRework(ir, opts.maxReworkCycles) : ir;
const result = await executor.run(task, settingsOn(), runIr);
return { trajectory: fake.trajectory, outcome: result.outcome, result };
}
/** Clone the IR with the foreach node's maxReworkCycles overridden (test only). */
function withForeachMaxRework(ir: WorkflowIr, max: number): WorkflowIr {
const cloned = JSON.parse(JSON.stringify(ir)) as WorkflowIr;
for (const node of cloned.nodes) {
if (node.kind === "foreach" && node.config) {
(node.config as { maxReworkCycles?: number }).maxReworkCycles = max;
}
}
return cloned;
}
/** Strip the `source` marker so the legacy (no-source) and graph trajectories are
* compared on (step, status) only — the projection content the merge-blocker and
* dashboard read (KTD-7). The graph side additionally carries `source:"graph"`. */
function normalize(t: TrajectoryEntry[]): Array<{ step: number; status: StepStatus }> {
return t.map(({ step, status }) => ({ step, status }));
}
describe("stepwise workflow parity (U7 / KTD-9)", () => {
// ── Trajectory parity vs the legacy step-session oracle ────────────────────
it("identical updateStep trajectory: 3-step approve-all (legacy step-session vs stepwise graph)", async () => {
const scripts: Verdict[][] = [["APPROVE"], ["APPROVE"], ["APPROVE"]];
const legacy = await runLegacyStepSession(3, scripts);
const { trajectory, outcome } = await runStepwiseGraph(3, scripts);
expect(outcome).toBe("success");
expect(normalize(trajectory)).toEqual(normalize(legacy));
// Concretely: each step in-progress then done, in order.
expect(normalize(trajectory)).toEqual([
{ step: 0, status: "in-progress" },
{ step: 0, status: "done" },
{ step: 1, status: "in-progress" },
{ step: 1, status: "done" },
{ step: 2, status: "in-progress" },
{ step: 2, status: "done" },
]);
});
it("revise-then-approve trajectory parity (revise re-runs in place, no reset)", async () => {
// Step 0: REVISE once then APPROVE. Step 1: APPROVE.
const scripts: Verdict[][] = [["REVISE", "APPROVE"], ["APPROVE"]];
const legacy = await runLegacyStepSession(2, scripts);
const { trajectory, outcome } = await runStepwiseGraph(2, scripts);
expect(outcome).toBe("success");
expect(normalize(trajectory)).toEqual(normalize(legacy));
// No `pending` write for step 0 (revise never resets).
expect(trajectory.some((e) => e.step === 0 && e.status === "pending")).toBe(false);
// Step 0 ran twice (in-progress ×2) then done once.
expect(trajectory.filter((e) => e.step === 0 && e.status === "in-progress").length).toBe(2);
});
it("RETHINK trajectory parity incl. reset to pending and baseline == agent-equivalent baseline (KTD-2)", async () => {
// Step 0: RETHINK once (resets) then APPROVE.
const scripts: Verdict[][] = [["RETHINK", "APPROVE"]];
const legacy = await runLegacyStepSession(1, scripts);
const resetSeen: ForeachActiveContext[] = [];
const { trajectory, outcome } = await runStepwiseGraph(1, scripts, {
onReset: (active) => resetSeen.push({ ...active }),
});
expect(outcome).toBe("success");
expect(normalize(trajectory)).toEqual(normalize(legacy));
// A RETHINK resets to pending before re-execute.
expect(trajectory.some((e) => e.step === 0 && e.status === "pending")).toBe(true);
// The reset fired exactly once, with the substrate-captured baseline. KTD-2:
// HEAD-at-instance-start (`sha-baseline-0`) is exactly the agent-equivalent
// baseline (the boundary after steps 0..-1 = the start). Asserted here.
expect(resetSeen.length).toBe(1);
expect(resetSeen[0].baselineSha).toBe("sha-baseline-0");
expect(resetSeen[0].checkpointId).toBe("ckpt-0");
});
// ── RETHINK blast-radius guard (KTD-2) ─────────────────────────────────────
it("RETHINK blast-radius guard REFUSES when a later step is already done", async () => {
// Directly exercise the production guard the graph wires: a reset for step 0
// when step 1 is already `done` must be refused (would destroy approved work).
const steps: TaskStep[] = [
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "done" }, // a LATER step already completed
];
const fake = makeFakeStore(steps);
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/fake/worktree",
task: { id: "FN-STEPWISE", steps } as StepRunnerTask,
stepIndex: 0,
isAncestor: async () => true,
});
const res = await resetStepToBaseline(
{
store: fake as never,
worktreePath: "/fake/worktree",
sessionRef: { current: null },
reviewType: "code",
blastRadiusGuard: guard,
},
{ id: "FN-STEPWISE", steps } as StepRunnerTask,
0,
"sha-baseline-0",
"ckpt-0",
);
expect(res.ok).toBe(false);
expect(res.reason).toMatch(/later step/i);
// Refusal mutates NOTHING (no projection write at all).
expect(fake.trajectory.length).toBe(0);
});
// ── Lifecycle invariants on the stepwise path (R14) ────────────────────────
it("FN-5147 terminal-until-merged: stepwise run with merge failure stays out of done", async () => {
// autoMerge:false → the merge seam fails (manual-merge-required); the task
// never routes to merge success, so it stays terminal-in-review until merged.
const task = taskWithSteps(1);
const fake = makeFakeStore(task.steps as TaskStep[]);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
// FN-5147: autoMerge:false surfaces as a merge-blocking failure value.
merge: async () => ({ outcome: "failure", value: "manual-merge-required" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
await runTaskStep(
{
store: fake as never,
worktreePath: "/fake/worktree",
runStep: async () => ({ success: true }),
gitRevParse: async () => "sha",
captureCheckpointId: () => "ckpt",
},
{ id: task.id, steps: task.steps } as StepRunnerTask,
active.stepIndex,
{ markDoneOnSuccess: active.deferDoneToReview !== true },
);
return { outcome: "success", value: "step-done" };
},
stepReview: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
await fake.updateStep(task.id, active.stepIndex, "done", { source: "graph" });
return { verdict: "APPROVE" } as StepReviewSeamResult;
},
};
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => task.steps as TaskStep[],
parseStepsDeps: {
readArtifact: async () => "### Step 1: do it\n",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("failure");
// The walk never reached `end` through merge — terminal-until-merged preserved.
expect(result.visitedNodeIds).not.toContain("end");
// All step work completed (the step is done) — the blocker is the merge, not steps.
expect((task.steps as TaskStep[])[0].status).toBe("done");
});
it("hard-cancel mid-instance: abort signal halts the foreach cleanly (no further step work)", async () => {
const controller = new AbortController();
const ran: number[] = [];
const task = taskWithSteps(3);
const fake = makeFakeStore(task.steps as TaskStep[]);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
ran.push(active.stepIndex);
await fake.updateStep(task.id, active.stepIndex, "in-progress", { source: "graph" });
// Simulate a hard-cancel (moveTask in-progress→todo) mid-first-instance.
if (active.stepIndex === 0) controller.abort();
return { outcome: "success", value: "step-done" };
},
stepReview: async () => ({ verdict: "APPROVE" }) as StepReviewSeamResult,
};
const executor = new WorkflowGraphExecutor({
seams,
signal: controller.signal,
getTaskSteps: () => task.steps as TaskStep[],
parseStepsDeps: {
readArtifact: async () => "### Step 1: a\n### Step 2: b\n### Step 3: c\n",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("failure");
// Only the first instance started; later instances never ran (clean cancel).
expect(ran).toEqual([0]);
});
it("file-scope guard fires inside step-execute: a step-execute failure value propagates (no merge)", async () => {
// The file-scope guard surfaces as a step-execute failure (the session commit
// is rejected). The foreach must route failure — NOT silently approve/merge.
const task = taskWithSteps(2);
const fake = makeFakeStore(task.steps as TaskStep[]);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
await fake.updateStep(task.id, active.stepIndex, "in-progress", { source: "graph" });
// Step 0 violates file scope.
if (active.stepIndex === 0) {
return { outcome: "failure", value: "FileScopeViolationError" };
}
return { outcome: "success", value: "step-done" };
},
stepReview: async () => ({ verdict: "APPROVE" }) as StepReviewSeamResult,
};
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => task.steps as TaskStep[],
parseStepsDeps: {
readArtifact: async () => "### Step 1: a\n### Step 2: b\n",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("failure");
// Step 0 never reached `done` (the guard blocked it); step 1 never ran.
expect((task.steps as TaskStep[])[0].status).toBe("in-progress");
expect((task.steps as TaskStep[])[1].status).toBe("pending");
expect(result.visitedNodeIds).not.toContain("merge");
});
// ── Flag posture (R10) ─────────────────────────────────────────────────────
it("flag pinned-at-dispatch: flag OFF → graph executor is a strict no-op (legacy path owns the run)", async () => {
// With the flag OFF at dispatch, the graph executor does not run at all — the
// legacy step-session path owns the task. Toggling the flag mid-run cannot
// switch paths because the run never entered the graph.
const task = taskWithSteps(2);
let stepExecuteCalls = 0;
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async () => {
stepExecuteCalls++;
return { outcome: "success", value: "step-done" };
},
};
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, settingsOff(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.executed).toBe(false);
expect(result.outcome).toBe("failure");
expect(stepExecuteCalls).toBe(0);
});
it("OFF-rollback: a stepwise run with the flag OFF leaves steps[] (git-reconcilable) as surviving truth", async () => {
// KTD-8 OFF-rollback: instance rows are swept and steps[] — always
// git-reconcilable — is the surviving truth that legacy resume reconciles
// from. With the flag OFF the graph never writes, so the pre-existing steps[]
// projection (legacy's truth) is untouched; legacy resume then completes.
const task = taskWithSteps(2);
// Simulate a partially-progressed legacy projection (step 0 done by legacy).
(task.steps as TaskStep[])[0] = { name: "Step 1", status: "done" };
const executor = new WorkflowGraphExecutor({ seams: undefined });
const result = await executor.run(task, settingsOff(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.executed).toBe(false);
// steps[] is untouched by the (no-op) graph — legacy's projection survives.
expect((task.steps as TaskStep[])[0].status).toBe("done");
expect((task.steps as TaskStep[])[1].status).toBe("pending");
});
// ── Zero-step task (R8) ────────────────────────────────────────────────────
it("zero-step task on stepwise merges without step work (no-steps outcome path)", async () => {
let stepExecuteCalls = 0;
const task = taskWithSteps(0);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async () => {
stepExecuteCalls++;
return { outcome: "success", value: "step-done" };
},
};
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => [],
parseStepsDeps: {
// No headings → zero steps → parse-steps routes outcome:no-steps → foreach
// no-ops through its success edge (R8).
readArtifact: async () => "no steps here, just prose",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("success");
expect(stepExecuteCalls).toBe(0);
// The foreach was reached but expanded zero instances.
expect(result.visitedNodeIds).toContain("steps");
expect(result.visitedNodeIds.some((id) => id.startsWith("steps#"))).toBe(false);
// Merge ran (the task merges with no step work).
expect(result.visitedNodeIds).toContain("merge");
});
});