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

@@ -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,