fix(FN-6035): model pre-merge workflow steps in builtin coding

This commit is contained in:
gsxdsm
2026-06-08 22:02:26 -07:00
parent b6800e8622
commit 8d3b869ec1
17 changed files with 283 additions and 46 deletions

View File

@@ -108,13 +108,13 @@ Before the cutover, `builtin:coding` started at execute:
start -> execute -> review -> merge -> end
```
After the cutover, planning is explicit:
After the cutover, planning and pre-merge workflow steps are explicit:
```text
start -> planning -> execute -> review -> merge -> end
start -> planning -> execute -> workflow-step -> review -> merge -> end
```
The workflow compiler also treats `planning` as a seam anchor before `execute`, so built-in graph shape and compile-time compatibility stay aligned.
The workflow compiler also treats `planning` and `workflow-step` as seam anchors in the canonical lifecycle order, so built-in graph shape and compile-time compatibility stay aligned.
### Graph routing owns workflow-aware tasks first
@@ -128,6 +128,7 @@ Primitive handlers map old seam names to explicit operations:
- `planning` -> `runPlanningSession`
- `execute` -> `prepareWorktree` then `runCodingSession`
- `workflow-step` -> `runWorkflowStep`
- `review` -> `runReview`
- `merge` -> `requestMerge`
- `step-execute` -> `runTaskStep`

View File

@@ -34,9 +34,9 @@ The workflow runtime is the authoritative execution path for task lifecycle work
The engine remains the substrate for scheduler dispatch, routing claims, persistence, concurrency limits, process supervision, storage, and audit plumbing. Lifecycle policy belongs in built-in or custom workflows.
The default built-in catalog entry `builtin:coding` is backed by the canonical `BUILTIN_CODING_WORKFLOW_IR`, which is also the resolver/runtime fallback for tasks with no workflow selection, an explicit default selection, or a missing/corrupt custom selection. That IR currently encodes the legacy lifecycle path as graph stages:
The default built-in catalog entry `builtin:coding` is backed by the canonical `BUILTIN_CODING_WORKFLOW_IR`, which is also the resolver/runtime fallback for tasks with no workflow selection or an explicit default selection. Missing/corrupt explicit custom selections fail closed as workflow-resolution failures instead of silently running the default. The built-in IR encodes the legacy lifecycle path as graph stages:
- `triage/planning` → `execute` → `review` → `merge` → `end`
- `triage/planning` → `execute` → `workflow-step` → `review` → `merge` → `end`
`builtin:stepwise-coding` is a separate graph variant backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review/rework as authored graph structure.
@@ -488,7 +488,7 @@ Prompt-mode workflow agents should emit a trailing JSON object:
Workflow graph execution is the task lifecycle runtime. `TaskExecutor` pins `workflowGraphExecutor` for the run and unselected tasks resolve to `builtin:coding`.
Default node dispatch:
- `prompt` / `script` nodes with `config.seam` dispatch through workflow runtime primitives (`planning`, `execute`, `review`, `merge`, `schedule`, `step-execute`)
- `prompt` / `script` nodes with `config.seam` dispatch through workflow runtime primitives (`planning`, `execute`, `workflow-step`, `review`, `merge`, `schedule`, `step-execute`)
- `step-review`, `parse-steps`, `code`, and PR nodes use their dedicated primitive/dependency adapters
- `gate` nodes evaluate context-key expectations or run configured executable checks
@@ -503,9 +503,9 @@ Coverage includes lifecycle ordering, primitive invocation, merge/file-scope fai
### Workflow-native Cutover
`TaskExecutor.execute()` gives graph routing first claim. The graph runtime resolves a workflow selection, degrading missing/unselected workflows to `builtin:coding`, and parks interpreter failures as workflow failures instead of re-running the old imperative lifecycle.
`TaskExecutor.execute()` gives graph routing first claim. The graph runtime resolves a workflow selection, using `builtin:coding` for unselected/default tasks, failing closed for missing explicit custom workflows, and parking interpreter failures as workflow failures instead of re-running the old imperative lifecycle.
The legacy seam adapter remains as a compatibility layer for older tests and callers, but authoritative node execution uses `WorkflowRuntimePrimitives`. The built-in coding workflow now includes explicit planning before execute/review/merge.
The legacy seam adapter remains as a compatibility layer for older tests and callers, but authoritative node execution uses `WorkflowRuntimePrimitives`. The built-in coding workflow now includes explicit planning and pre-merge workflow-step gates before review/merge.
Reliability invariants preserved under authoritative mode:
- file-scope enforcement including `FileScopeViolationError`

View File

@@ -36,7 +36,7 @@ describe("builtin coding workflow ir", () => {
const seams = BUILTIN_CODING_WORKFLOW_IR.nodes
.map((node) => String(node.config?.seam ?? ""))
.filter((seam) => seam.length > 0);
expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"]));
expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review", "merge"]));
expect(seams).not.toContain("triage");
});
@@ -66,13 +66,15 @@ describe("builtin coding workflow ir", () => {
it("places seam nodes in their columns", () => {
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
expect(byId.get("execute")?.column).toBe("in-progress");
expect(byId.get("workflow-step")?.column).toBe("in-progress");
expect(byId.get("review")?.column).toBe("in-review");
expect(byId.get("merge")?.column).toBe("in-review");
});
it("assigns descriptive names to execute/review/merge seam nodes", () => {
it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => {
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
expect(byId.get("execute")?.config?.name).toBe("Execute");
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
expect(byId.get("review")?.config?.name).toBe("Review");
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
});
@@ -85,8 +87,10 @@ describe("builtin coding workflow ir", () => {
expect(config.maxRetries).toBeLessThanOrEqual(10);
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
expect(byId.get("review")?.config?.name).toBe("Review");
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
expect(byId.get("merge")?.config?.maxRetries).toBeUndefined();
});

View File

@@ -133,6 +133,7 @@ describe("built-in workflows", () => {
const byId = new Map(ir.nodes.map((node) => [node.id, node]));
expect(byId.get("execute")?.column).toBe("in-progress");
expect(byId.get("workflow-step")?.column).toBe("in-progress");
expect(byId.get("review")?.column).toBe("in-review");
expect(byId.get("merge")?.column).toBe("in-review");
expect(ir.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS);
@@ -169,8 +170,10 @@ describe("built-in workflows", () => {
expect(executeConfig?.maxRetries).toBeLessThanOrEqual(10);
const byId = new Map(candidate.nodes.map((node) => [node.id, node]));
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
expect(byId.get("review")?.config?.name).toBe("Review");
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
expect(byId.get("merge")?.config?.maxRetries).toBeUndefined();
}

View File

@@ -145,7 +145,7 @@ describe("compileWorkflowToSteps (U2)", () => {
expect(err).toBeInstanceOf(WorkflowCompileError);
});
it("rejects seams that are out of the execute -> review -> merge order", () => {
it("rejects seams that are out of the planning -> execute -> workflow-step -> review -> merge order", () => {
const ir: WorkflowIr = {
version: "v1",
name: "misordered-seams",
@@ -163,7 +163,7 @@ describe("compileWorkflowToSteps (U2)", () => {
};
const err = validateLinearity(ir);
expect(err).toBeInstanceOf(WorkflowCompileError);
expect(err?.message).toMatch(/execute -> review -> merge order/);
expect(err?.message).toMatch(/planning -> execute -> workflow-step -> review -> merge order/);
});
it("rejects a graph with a duplicated seam role", () => {

View File

@@ -20,8 +20,9 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
*
* The lifecycle seam nodes are placed in their columns. Planning is explicit so
* the built-in workflow owns the specification phase rather than relying on
* triage code that runs outside the graph; execute/review/merge keep the same
* observable pipeline and failure routing.
* triage code that runs outside the graph; workflow-step keeps the legacy
* pre-merge quality gate between implementation and review; execute/review/
* merge keep the same observable pipeline and failure routing.
*/
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v2",
@@ -60,6 +61,12 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
column: "in-progress",
config: { ...builtinPromptConfig("execute", "Execute"), maxRetries: 2 },
},
{
id: "workflow-step",
kind: "prompt",
column: "in-progress",
config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"),
},
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
{ id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") },
{ id: "end", kind: "end", column: "done" },
@@ -67,11 +74,15 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
edges: [
{ from: "start", to: "planning" },
{ from: "planning", to: "execute", condition: "success" },
{ from: "execute", to: "review", condition: "success" },
{ from: "execute", to: "workflow-step", condition: "success" },
{ from: "workflow-step", to: "review", condition: "success" },
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
{ from: "review", to: "merge", condition: "success" },
{ from: "merge", to: "end", condition: "success" },
{ from: "planning", to: "end", condition: "failure" },
{ from: "execute", to: "end", condition: "failure" },
{ from: "workflow-step", to: "end", condition: "failure" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "failure" },
],

View File

@@ -9,6 +9,7 @@ const BUILTIN_SEAM_PROMPTS: Record<string, string> = {
execute: DEFAULT_EXECUTOR_PROMPT,
planning: DEFAULT_TRIAGE_PROMPT,
"step-execute": DEFAULT_EXECUTOR_PROMPT,
"workflow-step": DEFAULT_REVIEWER_PROMPT,
review: DEFAULT_REVIEWER_PROMPT,
merge: DEFAULT_MERGER_PROMPT,
};

View File

@@ -16,8 +16,9 @@ export class WorkflowCompileError extends Error {
}
/** Seam anchor kinds, encoded on IR nodes as `config.seam`. These map to the
* fixed execute → review → merge pipeline and are not emitted as steps. */
const SEAM_NAMES = new Set(["planning", "execute", "review", "merge"]);
* fixed planning → execute → workflow-step → review → merge pipeline and are
* not emitted as steps. */
const SEAM_NAMES = new Set(["planning", "execute", "workflow-step", "review", "merge"]);
function seamOf(node: WorkflowIrNode): string | undefined {
const seam = node.config?.seam;
@@ -77,7 +78,8 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
const seam = seamOf(node);
if (seam) {
const failureEdges = outs.filter((edge) => edge.condition === "failure");
const mainEdges = outs.filter((edge) => edge.condition !== "failure");
const mainEdges = outs.filter((edge) => !edge.condition || edge.condition === "success");
const outcomeEdges = outs.filter((edge) => edge.condition?.startsWith("outcome:"));
if (mainEdges.length !== 1) {
return new WorkflowCompileError(`seam '${node.id}' must have exactly one success path`);
}
@@ -87,6 +89,10 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
if (failureEdges[0] && failureEdges[0].to !== endNode.id) {
return new WorkflowCompileError(`seam '${node.id}' failure edge must target the end node`);
}
const nonTerminalOutcomeEdge = outcomeEdges.find((edge) => edge.to !== endNode.id);
if (nonTerminalOutcomeEdge) {
return new WorkflowCompileError(`seam '${node.id}' outcome edge must target the end node`);
}
continue;
}
@@ -106,12 +112,12 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
}
// Reachability: the single main path must reach end and cover every node.
// While walking, enforce the canonical seam pipeline: each of execute/review/
// merge may appear at most once and only in that order. The compiler treats
// seams as a fixed execute → review → merge boundary (merge flips pre- to
// post-merge), so out-of-order or duplicate seams would compile inconsistently
// with the runtime contract.
const expectedSeamOrder = ["planning", "execute", "review", "merge"] as const;
// While walking, enforce the canonical seam pipeline: each of planning/
// execute/workflow-step/review/merge may appear at most once and only in that
// order. The compiler treats seams as a fixed lifecycle boundary (merge flips
// pre- to post-merge), so out-of-order or duplicate seams would compile
// inconsistently with the runtime contract.
const expectedSeamOrder = ["planning", "execute", "workflow-step", "review", "merge"] as const;
const seenSeams = new Set<string>();
let nextExpectedSeamIndex = 0;
const visited = new Set<string>();
@@ -131,7 +137,9 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
nextExpectedSeamIndex += 1;
}
if (expectedSeamOrder[nextExpectedSeamIndex] !== seam) {
return new WorkflowCompileError("seams must follow the planning -> execute -> review -> merge order");
return new WorkflowCompileError(
"seams must follow the planning -> execute -> workflow-step -> review -> merge order",
);
}
seenSeams.add(seam);
nextExpectedSeamIndex += 1;

View File

@@ -138,6 +138,7 @@ export const DEFAULT_WORKFLOW_COLUMN_IDS = [
function defaultColumnForNode(node: WorkflowIrNode): string {
const seam = node.config?.seam;
if (seam === "execute") return "in-progress";
if (seam === "workflow-step") return "in-progress";
if (seam === "review") return "in-review";
if (seam === "merge") return "in-review";
return "todo";

View File

@@ -1,7 +1,8 @@
// ─────────────────────────────────────────────────────────────────────────────
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
// executor reproduces the workflow-native planning → execute → review → merge seam
// executor reproduces the workflow-native planning → execute → workflow-step
// → review → merge seam
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
// cover per-step / updateStep-trajectory parity.
//
@@ -22,6 +23,14 @@ import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import type { WorkflowLegacySeams } from "../workflow-node-handlers.js";
const task = { id: "FN-5767" } as TaskDetail;
type BaseSeam = "planning" | "execute" | "workflow-step" | "review" | "merge" | "schedule";
function runBaseSeam(seams: WorkflowLegacySeams, seam: BaseSeam, task: TaskDetail, context: Record<string, unknown>) {
if (seam === "workflow-step") {
return seams.workflowStep?.(task, context) ?? Promise.resolve({ outcome: "success" as const });
}
return seams[seam](task, context);
}
function runLegacy(seams: WorkflowLegacySeams) {
return async () => {
@@ -32,6 +41,9 @@ function runLegacy(seams: WorkflowLegacySeams) {
const execute = await seams.execute(task, {});
events.push(`execute:${execute.outcome}`);
if (execute.outcome !== "success") return events;
const workflowStep = await seams.workflowStep?.(task, {}) ?? { outcome: "success" as const };
events.push(`workflow-step:${workflowStep.outcome}`);
if (workflowStep.outcome !== "success") return events;
const review = await seams.review(task, {});
events.push(`review:${review.outcome}`);
if (review.outcome !== "success") return events;
@@ -55,15 +67,15 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
workflowStep: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
};
const legacyEvents = await runLegacy(seams)();
type BaseSeam = "planning" | "execute" | "review" | "merge" | "schedule";
const executor = new WorkflowGraphExecutor({ seams, handlers: { prompt: async (node, ctx) => {
const seam = String(node.config?.seam);
const result = await seams[seam as BaseSeam](ctx.task, ctx.context);
const seam = String(node.config?.seam) as BaseSeam;
const result = await runBaseSeam(seams, seam, ctx.task, ctx.context);
events.push(`${seam}:${result.outcome}`);
return result;
} } });
@@ -77,6 +89,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
workflowStep: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "failure", value: "FileScopeViolationError" }),
schedule: async () => ({ outcome: "success" }),
@@ -85,7 +98,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.outcome).toBe("failure");
expect(legacyEvents).toEqual(["planning:success", "execute:success", "review:success", "merge:failure"]);
expect(legacyEvents).toEqual(["planning:success", "execute:success", "workflow-step:success", "review:success", "merge:failure"]);
});
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
@@ -162,18 +175,18 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
workflowStep: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
};
type BaseSeam = "planning" | "execute" | "review" | "merge" | "schedule";
const executor = new WorkflowGraphExecutor({
seams,
handlers: {
prompt: async (node, ctx) => {
const seam = String(node.config?.seam) as BaseSeam;
stages.push(seam);
return seams[seam](ctx.task, ctx.context);
return runBaseSeam(seams, seam, ctx.task, ctx.context);
},
},
});
@@ -185,11 +198,11 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
// Bind the invariant to actual executor behavior (PR #1432 review): the
// observation below derives from the run-captured seam sequence, so seam
// drift fails here instead of being masked by a hard-coded literal.
expect(stages).toEqual(["planning", "execute", "review", "merge"]);
expect(stages).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
// Legacy authoritative observation: a clean run that lands in `done`/merged.
const legacyObs = buildWorkflowObservation({
stageTransitions: ["triage", "planning", "execute", "review", "merge"],
stageTransitions: ["triage", "planning", "execute", "workflow-step", "review", "merge"],
terminalColumn: "done",
terminalStatus: "done",
reviewVerdict: "approve",

View File

@@ -69,6 +69,7 @@ function recordingSeams(calls: string[], overrides: Partial<Record<string, Workf
return {
planning: seam("planning"),
execute: seam("execute"),
workflowStep: seam("workflow-step"),
review: seam("review"),
merge: seam("merge"),
schedule: seam("schedule"),
@@ -236,7 +237,7 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["execute", "review", "merge"]);
expect(calls).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
expect(result.reason).toBeUndefined();
expect(getWorkflowDefinition).not.toHaveBeenCalled();
});

View File

@@ -29,7 +29,7 @@ function selectedIr(): WorkflowIr {
function recordingPrimitives(
calls: string[],
overrides: Partial<Record<"prepare" | "execute", WorkflowNodeResult>> = {},
overrides: Partial<Record<"prepare" | "execute" | "workflowStep", WorkflowNodeResult>> = {},
observed: { prepared?: PreparedWorktree } = {},
): WorkflowRuntimePrimitives {
const prepared: PreparedWorktree = { worktreePath: "/tmp/fusion-worktree" };
@@ -71,7 +71,16 @@ function recordingPrimitives(
};
},
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
runWorkflowStep: async () => ({ outcome: "success", data: { allPassed: true } }),
runWorkflowStep: async () => {
calls.push("workflow-step");
const override = overrides.workflowStep;
return {
outcome: override?.outcome ?? "success",
value: override?.value ?? "workflow-steps-passed",
contextPatch: override?.contextPatch,
data: { allPassed: override?.value !== "remediation-scheduled" },
};
},
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }),
transitionTask: async () => {
calls.push("schedule");
@@ -153,8 +162,31 @@ describe("WorkflowTaskRuntime", () => {
const result = await runtime.run(task, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "review", "merge"]);
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
});
it("stops the built-in workflow before review when workflow-step remediation is scheduled", async () => {
const calls: string[] = [];
const runtime = new WorkflowTaskRuntime({
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
},
primitives: recordingPrimitives(calls, {
workflowStep: { outcome: "success", value: "remediation-scheduled" },
}),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
});
const result = await runtime.run(task, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step"]);
});
it("fails selected workflow lookup misses instead of running the built-in workflow", async () => {

View File

@@ -4775,9 +4775,69 @@ export class TaskExecutor {
runVerification: async () => ({ outcome: "success", value: "verification-skipped", data: {
verdict: "skipped",
} }),
runWorkflowStep: async () => ({ outcome: "success", value: "workflow-step-skipped", data: {
allPassed: true,
} }),
runWorkflowStep: async (_ctx, task, input) => {
if (input.phase !== "pre-merge") {
return { outcome: "success", value: "workflow-step-skipped", data: { allPassed: true } };
}
const live = await this.store.getTask(task.id);
if (live.executionMode === "fast") {
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id));
return { outcome: "success", value: "workflow-step-skipped", data: { allPassed: true } };
}
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps after task completion")) {
return { outcome: "success", value: "deferred-paused", data: { allPassed: false } };
}
const worktreePath = input.worktreePath || live.worktree || this.rootDir;
const workflowResult = await this.runWorkflowSteps(live, worktreePath, settings, undefined);
if (workflowResult === "deferred-paused") {
if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
this.pausedAborted.delete(task.id);
} else if (this.pausedAborted.has(task.id)) {
this.pausedAborted.delete(task.id);
}
return { outcome: "success", value: "deferred-paused", data: { allPassed: false } };
}
if (!workflowResult.allPassed) {
const feedback = workflowResult.feedback || "Workflow step failed";
const stepName = workflowResult.stepName || "Unknown";
if (workflowResult.revisionRequested) {
const rerunScheduled = await this.handleWorkflowRevisionRequest(
live,
worktreePath,
feedback,
stepName,
settings,
);
if (!rerunScheduled) {
return {
outcome: "failure",
value: "workflow-step-revision-unhandled",
data: workflowResult,
};
}
} else {
const retried = await this.handleWorkflowStepFailure(
live,
worktreePath,
feedback,
stepName,
);
if (!retried) {
await this.sendTaskBackForFix(
live,
worktreePath,
feedback,
stepName,
"Workflow step failed",
);
}
}
return { outcome: "success", value: "remediation-scheduled", data: workflowResult };
}
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
return { outcome: "success", value: "workflow-steps-passed", data: workflowResult };
},
updateSteps: async (_ctx, task, steps) => {
await this.store.updateTask(task.id, { steps });
return { outcome: "success", value: "steps-updated", data: { count: steps.length } };
@@ -4887,6 +4947,58 @@ export class TaskExecutor {
value: paused ? "implementation-paused" : "implementation-incomplete",
};
},
workflowStep: async (seamTask) => {
const live = await this.store.getTask(seamTask.id);
if (live.executionMode === "fast") {
executorLog.log(`${seamTask.id}: fast mode — skipping pre-merge workflow steps`);
await this.store.logEntry(seamTask.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(seamTask.id));
return { outcome: "success", value: "workflow-step-skipped" };
}
const worktreePath = live.worktree || this.rootDir;
const settings = await this.store.getSettings();
const workflowResult = await this.runWorkflowSteps(live, worktreePath, settings, undefined);
if (workflowResult === "deferred-paused") {
if (await this.parkTaskAfterWorkflowStepPause(seamTask.id)) {
this.pausedAborted.delete(seamTask.id);
} else if (this.pausedAborted.has(seamTask.id)) {
this.pausedAborted.delete(seamTask.id);
}
return { outcome: "success", value: "deferred-paused" };
}
if (!workflowResult.allPassed) {
const feedback = workflowResult.feedback || "Workflow step failed";
const stepName = workflowResult.stepName || "Unknown";
if (workflowResult.revisionRequested) {
const rerunScheduled = await this.handleWorkflowRevisionRequest(
live,
worktreePath,
feedback,
stepName,
settings,
);
if (!rerunScheduled) return { outcome: "failure", value: "workflow-step-revision-unhandled" };
} else {
const retried = await this.handleWorkflowStepFailure(
live,
worktreePath,
feedback,
stepName,
);
if (!retried) {
await this.sendTaskBackForFix(
live,
worktreePath,
feedback,
stepName,
"Workflow step failed",
);
}
}
return { outcome: "success", value: "remediation-scheduled" };
}
await this.store.updateTask(seamTask.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
return { outcome: "success", value: "workflow-steps-passed" };
},
review: async (seamTask) => {
// The legacy "review" stage is the in-review handoff: per-step AI review
// already ran during implementation (fn_review_step), and the in-review

View File

@@ -101,7 +101,15 @@ function primitivesFromLegacySeams(seams: WorkflowLegacySeams): WorkflowRuntimeP
return { ...result, data: { verdict: result.outcome === "success" ? "APPROVE" : "REVISE" } };
},
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
runWorkflowStep: async () => ({ outcome: "success", data: { allPassed: true } }),
runWorkflowStep: async (ctx, task) => {
const result = await seams.workflowStep?.(task, ctx.node.context ?? {});
return {
outcome: result?.outcome ?? "success",
value: result?.value ?? "workflow-step-skipped",
contextPatch: result?.contextPatch,
data: { allPassed: result?.outcome !== "failure" },
};
},
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }),
transitionTask: async (ctx, task) => seams.schedule(task, ctx.node.context ?? {}),
requestMerge: async (ctx, task) => {

View File

@@ -430,7 +430,12 @@ export class WorkflowGraphExecutor {
return sourceResult;
}
const matching = edges.filter((edge) => this.shouldTraverseEdge(edge, sourceResult));
const outcomeMatching = edges.filter((edge) =>
edge.condition?.startsWith("outcome:") && this.shouldTraverseEdge(edge, sourceResult)
);
const matching = outcomeMatching.length > 0
? outcomeMatching
: edges.filter((edge) => this.shouldTraverseEdge(edge, sourceResult));
if (matching.length === 0) {
return sourceResult;
}

View File

@@ -171,6 +171,11 @@ export class WorkflowGraphTaskRunner {
const wrappedSeams: WorkflowLegacySeams = {
planning: (t, c) => ((sideEffectsRan = true), invoked.push("planning"), seams.planning(t, c)),
execute: (t, c) => ((sideEffectsRan = true), invoked.push("execute"), seams.execute(t, c)),
workflowStep: (t, c) => {
sideEffectsRan = true;
invoked.push("workflow-step");
return seams.workflowStep?.(t, c) ?? Promise.resolve({ outcome: "success", value: "workflow-step-skipped" });
},
review: (t, c) => ((sideEffectsRan = true), invoked.push("review"), seams.review(t, c)),
merge: (t, c) => ((sideEffectsRan = true), invoked.push("merge"), seams.merge(t, c)),
schedule: (t, c) => ((sideEffectsRan = true), invoked.push("schedule"), seams.schedule(t, c)),

View File

@@ -9,7 +9,14 @@ import {
type WorkflowRuntimePrimitives,
} from "./runtime-primitives.js";
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule" | "step-execute";
export type WorkflowSeamName =
| "planning"
| "execute"
| "workflow-step"
| "review"
| "merge"
| "schedule"
| "step-execute";
export interface WorkflowLegacySeams {
/** Planning/spec stage. Built-in triage runs upstream of the interpreter
@@ -17,6 +24,7 @@ export interface WorkflowLegacySeams {
* custom planning behavior is expressed as a custom prompt node. */
planning: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
execute: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
workflowStep?: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
review: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
merge: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
schedule: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
@@ -195,6 +203,7 @@ export function resolveSeamName(node: { config?: Record<string, unknown> }): Wor
if (
seam === "planning" ||
seam === "execute" ||
seam === "workflow-step" ||
seam === "review" ||
seam === "merge" ||
seam === "schedule" ||
@@ -250,6 +259,11 @@ export function createPromptLikeHandler(
// IS the seam node, so its declared column drives the binding. (Other seams
// — planning/review/merge/schedule — stamp it too; only execute reads it.)
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id;
if (seam === "workflow-step") {
return seams.workflowStep
? seams.workflowStep(context.task, context.context)
: { outcome: "success", value: "workflow-step-skipped" };
}
return seams[seam]!(context.task, context.context);
}
if (!runCustomNode) {
@@ -317,7 +331,24 @@ export function createPrimitivePromptLikeHandler(
...(result.contextPatch ?? {}),
}
: undefined;
return { outcome: result.outcome, value: result.value, contextPatch };
return {
outcome: result.outcome,
value: result.value,
contextPatch: {
...(contextPatch ?? {}),
"workflow:worktree-path": prepared.data.worktreePath,
},
};
}
if (seam === "workflow-step") {
const worktreePath = typeof context.context["workflow:worktree-path"] === "string"
? context.context["workflow:worktree-path"]
: undefined;
const result = await primitives.runWorkflowStep(primitiveCtx, context.task, {
phase: "pre-merge",
worktreePath,
});
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
}
if (seam === "review") {
const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" });
@@ -798,6 +829,7 @@ export function createNoopLegacySeams(): WorkflowLegacySeams {
return {
planning: success,
execute: success,
workflowStep: success,
review: success,
merge: success,
schedule: success,