feat(core,engine): U7 — builtin stepwise coding workflow + trajectory parity & invariant suite; agent-tool surfaces for custom fields and IR authoring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 13:15:49 -07:00
parent af7c141976
commit 60e9938f5a
9 changed files with 916 additions and 9 deletions

View File

@@ -7,15 +7,38 @@ import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr } from "../workflow-ir.js"
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("built-in workflows", () => {
it("every built-in has a valid IR and compiles without error", () => {
// Graph-only built-ins (step inversion, KTD-9) model branching/foreach/rework
// structure the linear compiler cannot lower to a step list — they run only
// under the workflow graph executor. They still must parse as valid IR.
const GRAPH_ONLY_BUILTIN_IDS = new Set(["builtin:stepwise-coding"]);
it("every built-in has a valid IR; linear built-ins compile without error", () => {
expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4);
for (const wf of BUILTIN_WORKFLOWS) {
expect(isBuiltinWorkflowId(wf.id)).toBe(true);
expect(() => parseWorkflowIr(wf.ir)).not.toThrow();
expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow();
if (!GRAPH_ONLY_BUILTIN_IDS.has(wf.id)) {
expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow();
}
}
});
it("includes the stepwise coding built-in modeling step inversion (KTD-9)", () => {
const stepwise = getBuiltinWorkflow("builtin:stepwise-coding");
expect(stepwise).toBeDefined();
const ir = parseWorkflowIr(stepwise!.ir);
if (ir.version !== "v2") throw new Error("expected v2");
// The chain: a parse-steps node dominating a foreach with a step-review template.
expect(ir.nodes.some((n) => n.kind === "parse-steps")).toBe(true);
const foreach = ir.nodes.find((n) => n.kind === "foreach");
expect(foreach).toBeDefined();
const template = (
foreach!.config as { template: { nodes: Array<{ kind: string; config?: { seam?: string } }> } }
).template;
expect(template.nodes.some((n) => n.kind === "step-review")).toBe(true);
expect(template.nodes.some((n) => n.config?.seam === "step-execute")).toBe(true);
});
it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => {
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");

View File

@@ -0,0 +1,151 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
/**
* The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step
* inversion and the parity-comparison subject for the engine's
* `stepwise-workflow-parity.test.ts`.
*
* Unlike the default `builtin-coding-workflow-ir` (which keeps a single monolithic
* `execute` seam and is the byte-identity parity oracle, KTD-1), this workflow
* models per-step policy explicitly as graph structure:
*
* plan seam
* → parse-steps(PROMPT.md, step-headings) (KTD-12: graph-native parse)
* → foreach(task-steps, sequential, shared) { (KTD-3: runtime expansion)
* step-execute (KTD-2: run one step)
* → step-review(code): (KTD-4: verdicts as edges)
* approve → step-done (template exit) (APPROVE auto-completes)
* revise → rework back to step-execute (revise in place, no reset)
* rethink → rework back to step-execute (reset semantics handler-side)
* unavailable → (advisory) routes onward
* }
* rework-exhausted → hold(manual) (KTD-5: bounded escalation)
* → review seam
* → merge seam
*
* The columns/traits are identical to the default builtin so the full lifecycle
* (merge-blocker, capacity, hold, complete, archived) behaves exactly as it does
* for the default workflow — only the in-progress step modeling differs.
*
* It declares its step-source artifact (KTD-12): PROMPT.md produced by the
* planning seam. The IR is v2-only (foreach/step-review/parse-steps are v2 node
* kinds), so `downgradeIrToV1IfPure` refuses it and the flag-OFF rollback contract
* (KTD-8) is preserved automatically.
*/
const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v2",
name: "builtin-stepwise-coding",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{
id: "todo",
name: "Todo",
traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }],
},
{
id: "in-progress",
name: "In progress",
traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }],
},
{
id: "in-review",
name: "In review",
traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }, { trait: "merge" }],
},
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
{ id: "archived", name: "Archived", traits: [{ trait: "archived" }] },
],
// KTD-12: PROMPT.md is the planning-produced step-source artifact this workflow
// parses into task steps.
artifacts: [{ key: "PROMPT.md", title: "Plan", producedBy: "planning", role: "step-source" }],
nodes: [
{ id: "start", kind: "start", column: "triage" },
// Planning seam: produces PROMPT.md (the declared step-source artifact).
{ id: "plan", kind: "prompt", column: "in-progress", config: { seam: "planning" } },
// KTD-12: parse the planned PROMPT.md into the task step list. This node must
// dominate the foreach (validator-enforced).
{
id: "parse",
kind: "parse-steps",
column: "in-progress",
config: { artifact: "PROMPT.md", parser: "step-headings" },
},
// KTD-3: runtime-expanding per-step region. Sequential + shared isolation is
// the default baseline physics (one step at a time in the task's worktree).
{
id: "steps",
kind: "foreach",
column: "in-progress",
config: {
source: "task-steps",
mode: "sequential",
isolation: "shared",
maxReworkCycles: 3,
template: {
nodes: [
// KTD-2: run exactly this step inside the task's session/worktree.
{ id: "step-execute", kind: "prompt", config: { seam: "step-execute" } },
// KTD-4: per-step code review; verdicts become outcome edges.
{ id: "step-review", kind: "step-review", config: { type: "code" } },
// Template exit (the single sink the validator requires): a config-less
// gate is a pure pass-through (createGateHandler → success), so APPROVE
// routes here and the instance exits. The step is already marked done by
// the step-review APPROVE verdict (projection authority, KTD-4/KTD-7).
{ id: "step-done", kind: "gate", config: {} },
],
edges: [
{ from: "step-execute", to: "step-review", condition: "success" },
// APPROVE → template exit (step-done). The step-review verdict already
// marked the step done through the projection.
{ from: "step-review", to: "step-done", condition: "outcome:approve" },
// REVISE → rework back to step-execute, revise in place (no reset).
{
from: "step-review",
to: "step-execute",
condition: "outcome:revise",
kind: "rework",
},
// RETHINK → rework back to step-execute; the traversal triggers
// resetStepToBaseline (reset semantics are handler-side, KTD-4/U5).
{
from: "step-review",
to: "step-execute",
condition: "outcome:rethink",
kind: "rework",
},
],
},
},
},
// KTD-5: rework exhaustion escalates to a manual hold (a human releases it).
{ id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } },
{ id: "review", kind: "prompt", column: "in-review", config: { seam: "review" } },
{ id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "plan" },
{ from: "plan", to: "parse", condition: "success" },
{ from: "plan", to: "end", condition: "failure" },
{ from: "parse", to: "steps", condition: "success" },
// parse-steps no-steps defaults to success; route it explicitly to the foreach
// (zero steps → foreach no-ops through its success edge, KTD-8/R8).
{ from: "parse", to: "steps", condition: "outcome:no-steps" },
{ from: "parse", to: "end", condition: "failure" },
{ from: "parse", to: "end", condition: "outcome:parse-error" },
{ from: "steps", to: "review", condition: "success" },
// KTD-5: bounded rework exhaustion → manual hold; release re-enters review.
{ from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" },
{ from: "rework-hold", to: "review", condition: "success" },
{ from: "steps", to: "end", condition: "failure" },
{ from: "review", to: "merge", condition: "success" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "success" },
{ from: "merge", to: "end", condition: "failure" },
],
};
export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr(
RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
);

View File

@@ -1,3 +1,4 @@
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
import type { WorkflowDefinition } from "./workflow-definition-types.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
@@ -139,6 +140,32 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
},
],
}),
// The stepwise coding workflow (KTD-9) — step inversion as authored graph
// structure (parse-steps → foreach{ step-execute → step-review } → review →
// merge). Authored directly as a v2 IR (the `linear` helper only builds simple
// pipelines); it is read-only like every built-in. Requires the
// `workflowGraphExecutor` flag at run time (foreach/step-review/parse-steps are
// interpreter-only node kinds, KTD-8); under the flag-off compile path its
// step-inversion nodes are skipped, the same posture as the other seam nodes.
{
id: "builtin:stepwise-coding",
name: "Stepwise coding (built-in)",
description:
"Per-step plan, execute, and review modeled as graph structure: each planned step runs and is reviewed (approve / revise / rethink) before the next, with bounded rework. Requires the workflow graph executor.",
ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
layout: {
start: { x: 60, y: 160 },
plan: { x: 230, y: 160 },
parse: { x: 400, y: 160 },
steps: { x: 570, y: 160 },
"rework-hold": { x: 570, y: 320 },
review: { x: 740, y: 160 },
merge: { x: 910, y: 160 },
end: { x: 1080, y: 160 },
},
createdAt: BUILTIN_TS,
updatedAt: BUILTIN_TS,
},
];
const BUILTIN_BY_ID = new Map(BUILTIN_WORKFLOWS.map((wf) => [wf.id, wf]));

View File

@@ -63,8 +63,16 @@ export type {
WorkflowHoldRelease,
WorkflowJoinMode,
WorkflowJoinBranchFailure,
// Step-inversion (KTD-3/12/13): foreach / artifacts / custom-field IR types.
WorkflowForeachConfig,
WorkflowIrArtifact,
WorkflowFieldDefinition,
WorkflowFieldType,
WorkflowFieldOption,
WorkflowFieldRender,
} from "./workflow-ir-types.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
// ── Trait model (U2) ─────────────────────────────────────────────────
export type {

View File

@@ -0,0 +1,578 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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");
});
});

View File

@@ -1,3 +1,14 @@
// ─────────────────────────────────────────────────────────────────────────────
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
// executor reproduces the legacy monolithic execute → review → merge seam
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
// cover per-step / updateStep-trajectory parity.
//
// The stepwise per-step trajectory + merge-blocker-window parity (legacy
// step-session path vs the stepwise foreach graph) is owned by the sibling
// suite `stepwise-workflow-parity.test.ts`. Keep the two concerns separate.
// ─────────────────────────────────────────────────────────────────────────────
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";

View File

@@ -1137,7 +1137,22 @@ export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
label: "Create Workflow",
description:
"Create a new custom workflow definition from a name and a workflow graph (IR). " +
"The IR is validated server-side. Returns the new workflow ID.",
"The IR is validated server-side; a malformed graph rejects. Returns the new workflow ID.\n" +
"v2 IR supports step-inversion constructs (all additive, opt-in): " +
"`parse-steps` node {artifact, parser} writes the task step list from a declared artifact " +
"(built-in parsers: `step-headings`, `json-steps`; routable `no-steps`/`parse-error` outcomes) — " +
"it must precede any `foreach`; " +
"`foreach` node {source:'task-steps', template:{nodes,edges}, mode:'sequential'|'parallel', " +
"isolation:'shared'|'worktree', concurrency (parallel only, 1-8), maxReworkCycles (1-10)} " +
"instantiates its single-entry/exit template subgraph once per planned step " +
"(parallel+shared is rejected); a `step-execute` node is legal only inside a foreach template; " +
"`step-review` node {type:'plan'|'code', model?} surfaces verdicts as outcome edges " +
"(`outcome:approve|revise|rethink|unavailable`); edges may set `kind:'rework'` (the only legal cycles, " +
"back to step-execute within an instance; rethink edges trigger a reset-to-baseline); " +
"`code` node {source, timeoutMs?} runs sandboxed TypeScript returning {outcome?, contextPatch?, customFields?}. " +
"Declare task documents via `artifacts: [{key, title?, producedBy?, role?}]` and custom task fields via " +
"`fields: [{id, name, type, required?, default?, options?, render?}]` (types: string/text/number/boolean/" +
"enum/multi-enum/date/url; render.placement card|detail|detail-section, render.badge for card chips).",
parameters: workflowCreateParams,
execute: async (_id: string, params: Static<typeof workflowCreateParams>) => {
try {
@@ -1178,7 +1193,10 @@ export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition {
description:
"Update a custom workflow definition (name/description/ir/layout). Built-ins cannot be edited. " +
"If an IR change removes a column that still holds cards, the update is blocked and returns the " +
"occupied columns — retry with rehome_to set to a column id that survives in the new IR.",
"occupied columns — retry with rehome_to set to a column id that survives in the new IR. " +
"The IR accepts the same step-inversion constructs as fn_workflow_create (foreach with mode/isolation/" +
"concurrency, step-execute, step-review, parse-steps, code nodes, rework edges, artifacts, fields). " +
"Editing `fields` orphans (never destroys) existing task values for removed/incompatible fields.",
parameters: workflowUpdateParams,
execute: async (_id: string, params: Static<typeof workflowUpdateParams>) => {
try {

View File

@@ -717,14 +717,21 @@ export async function __runConfiguredCommandForTests(
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
const taskUpdateParams = Type.Object({
step: Type.Number({ description: "Step number (1-indexed)" }),
status: Type.Union(
step: Type.Optional(Type.Number({ description: "Step number (1-indexed). Omit when updating only custom_fields/dependencies." })),
status: Type.Optional(Type.Union(
STEP_STATUSES.map((s) => Type.Literal(s)),
{ description: "New status: pending, in-progress, done, or skipped" },
),
{ description: "New status: pending, in-progress, done, or skipped. Required when step is set." },
)),
dependencies: Type.Optional(Type.Array(Type.String(), {
description: "Optional task dependency array. Replaces existing dependencies. Pass ['FN-001', 'FN-002'] to set dependencies. Pass [] to clear all dependencies. Omit parameter to preserve existing dependencies.",
})),
custom_fields: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
description:
"Optional patch of workflow-defined custom field values, keyed by field id. " +
"Values are validated against the task's workflow field schema (type/enum membership); " +
"pass null for a field to clear it. Rejected writes return the offending field id and reason. " +
"Only fields declared by the task's workflow may be written.",
})),
});
// taskLogParams and taskCreateParams are imported from agent-tools.ts
@@ -7088,10 +7095,44 @@ export class TaskExecutor {
"Update a step's status. Call before starting a step (in-progress), " +
"after completing it (done), or to skip it (skipped). " +
"Optionally update task dependencies by passing a dependencies array. " +
"Optionally set workflow-defined custom field values by passing a custom_fields patch " +
"(keyed by field id; validated against the workflow's field schema; pass null to clear a field). " +
"step/status may be omitted to update only custom_fields or dependencies. " +
"The board updates in real-time.",
parameters: taskUpdateParams,
execute: async (_id: string, params: Static<typeof taskUpdateParams>) => {
const { step, status, dependencies } = params;
const { step, status, dependencies, custom_fields } = params;
// Custom-field patch (KTD-13): routed through the store's single write
// authority, which validates each value against the task's workflow field
// schema. A typed rejection surfaces the offending field id + reason as a
// tool error so the agent can correct it. Applied first so a field-only
// call (step omitted) returns here.
if (custom_fields !== undefined) {
const res = await store.updateTaskCustomFields(taskId, custom_fields);
if (!res.ok) {
const r = res.rejection;
return {
content: [{
type: "text" as const,
text: `ERROR: custom field '${r.fieldId}' rejected (${r.code}): ${r.detail}`,
}],
details: { fieldId: r.fieldId, code: r.code, detail: r.detail },
isError: true,
};
}
// A custom-fields-only update (no step) succeeds here.
if (step === undefined && status === undefined && dependencies === undefined) {
const updatedKeys = Object.keys(custom_fields);
return {
content: [{
type: "text" as const,
text: `Updated custom field(s): ${updatedKeys.join(", ")}.`,
}],
details: { updatedFields: updatedKeys },
};
}
}
// Record step progress for stuck task detection.
// Step transitions (in-progress, done, skipped) indicate real progress
@@ -7101,6 +7142,44 @@ export class TaskExecutor {
stuckDetector?.recordProgress(taskId);
}
// Dependencies-only update (no step) is permitted; handle deps then return.
if (step === undefined) {
if (dependencies !== undefined) {
if (dependencies.includes(taskId)) {
return {
content: [{ type: "text" as const, text: `Cannot add self-dependency: ${taskId} cannot depend on itself.` }],
details: {},
};
}
const invalidIds: string[] = [];
for (const depId of dependencies) {
try { await store.getTask(depId); } catch { invalidIds.push(depId); }
}
if (invalidIds.length > 0) {
return {
content: [{ type: "text" as const, text: `Cannot set dependencies — the following task(s) do not exist: ${invalidIds.join(", ")}` }],
details: {},
};
}
await store.updateTask(taskId, { dependencies });
return {
content: [{ type: "text" as const, text: `Dependencies updated.` }],
details: {},
};
}
return {
content: [{ type: "text" as const, text: `No-op: provide a step+status, dependencies, or custom_fields to update.` }],
details: {},
};
}
if (status === undefined) {
return {
content: [{ type: "text" as const, text: `Step ${step} provided without a status. Pass status (pending/in-progress/done/skipped).` }],
details: {},
};
}
if (!Number.isInteger(step) || step < 1) {
return {
content: [{

View File

@@ -139,6 +139,12 @@ Follow this structure exactly:
## Steps
> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed
> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps
> that are genuinely independent of their immediate predecessor; an unannotated step is
> assumed to depend on the one before it (fully sequential). Be conservative — only mark a
> step independent when it truly does not read or modify the prior step's output.
### Step 0: Preflight
- [ ] Required files and paths exist
@@ -437,6 +443,12 @@ Follow this structure exactly:
## Steps
> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed
> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps
> that are genuinely independent of their immediate predecessor; an unannotated step is
> assumed to depend on the one before it (fully sequential). Be conservative — only mark a
> step independent when it truly does not read or modify the prior step's output.
### Step 0: Preflight
- [ ] Required files and paths exist