fix(FN-6035): model pre-merge workflow steps in builtin coding
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user