feat(FN-6035): route execution through workflow primitives

Fusion-Task-Id: FN-6035
This commit is contained in:
gsxdsm
2026-06-08 19:13:14 -07:00
parent 8bc3d7b0a5
commit 8f42098cc3
17 changed files with 1383 additions and 96 deletions

View File

@@ -18,9 +18,10 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
* done = complete
* archived = archived
*
* The seam nodes (execute/review/merge) are placed in their columns; the graph
* walk (edges) is byte-identical to the prior v1 coding pipeline, so the graph
* executor continues to drive execute → review → merge unchanged.
* 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.
*/
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v2",
@@ -47,6 +48,12 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{
id: "planning",
kind: "prompt",
column: "triage",
config: builtinPromptConfig("planning", "Plan / specify"),
},
{
id: "execute",
kind: "prompt",
@@ -58,10 +65,12 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute" },
{ from: "start", to: "planning" },
{ from: "planning", to: "execute", condition: "success" },
{ from: "execute", to: "review", condition: "success" },
{ 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: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "failure" },

View File

@@ -17,7 +17,7 @@ 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(["execute", "review", "merge"]);
const SEAM_NAMES = new Set(["planning", "execute", "review", "merge"]);
function seamOf(node: WorkflowIrNode): string | undefined {
const seam = node.config?.seam;
@@ -111,7 +111,7 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
// 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 = ["execute", "review", "merge"] as const;
const expectedSeamOrder = ["planning", "execute", "review", "merge"] as const;
const seenSeams = new Set<string>();
let nextExpectedSeamIndex = 0;
const visited = new Set<string>();

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { markSideEffectsStarted, primitiveNodeContext } from "../runtime-primitives.js";
describe("runtime primitives", () => {
it("creates a workflow primitive context from a run and node", () => {
const run = {
runId: "run-1",
taskId: "FN-1",
workflowId: "coding",
};
const node = {
id: "execute",
kind: "prompt" as const,
column: "in-progress",
config: { prompt: "implement" },
};
const ctx = primitiveNodeContext(run, node, {
effectivePrincipalId: "agent:builder",
attempt: 2,
context: { priorOutcome: "revise" },
});
expect(ctx).toEqual({
run,
node: {
node,
effectivePrincipalId: "agent:builder",
attempt: 2,
context: { priorOutcome: "revise" },
},
});
});
it("marks side effects on an immutable context copy", () => {
const ctx = primitiveNodeContext(
{
runId: "run-1",
taskId: "FN-1",
workflowId: "coding",
},
{ id: "execute", kind: "prompt" as const },
);
const marked = markSideEffectsStarted(ctx);
expect(marked).toEqual({
...ctx,
run: {
...ctx.run,
sideEffectsStarted: true,
},
});
expect(ctx.run.sideEffectsStarted).toBeUndefined();
});
});

View File

@@ -1,7 +1,7 @@
// ─────────────────────────────────────────────────────────────────────────────
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
// executor reproduces the legacy monolithic execute → review → merge seam
// executor reproduces the workflow-native planning → execute → review → merge seam
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
// cover per-step / updateStep-trajectory parity.
//
@@ -26,6 +26,9 @@ const task = { id: "FN-5767" } as TaskDetail;
function runLegacy(seams: WorkflowLegacySeams) {
return async () => {
const events: string[] = [];
const planning = await seams.planning(task, {});
events.push(`planning:${planning.outcome}`);
if (planning.outcome !== "success") return events;
const execute = await seams.execute(task, {});
events.push(`execute:${execute.outcome}`);
if (execute.outcome !== "success") return events;
@@ -47,7 +50,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
expect(prompt).not.toHaveBeenCalled();
});
it("matches legacy execute-review-merge success path", async () => {
it("matches default planning-execute-review-merge success path", async () => {
const events: string[] = [];
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
@@ -82,7 +85,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(["execute:success", "review:success", "merge:failure"]);
expect(legacyEvents).toEqual(["planning:success", "execute:success", "review:success", "merge:failure"]);
});
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
@@ -182,13 +185,16 @@ 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(["execute", "review", "merge"]);
expect(stages).toEqual(["planning", "execute", "review", "merge"]);
// Legacy authoritative observation: a clean run that lands in `done`/merged.
const legacyObs = buildWorkflowObservationFromTask(
{ column: "done", status: "done", review: { verdict: "approve" } },
{ columnSequence: ["todo", "in-progress", "in-review", "done"] },
);
const legacyObs = buildWorkflowObservation({
stageTransitions: ["triage", "planning", "execute", "review", "merge"],
terminalColumn: "done",
terminalStatus: "done",
reviewVerdict: "approve",
mergeOutcome: "merged",
});
// Interpreter (binding-free) observation assembled from the same run.
const interpreterObs = buildWorkflowObservation({
stageTransitions: ["triage", ...stages] as WorkflowStage[],

View File

@@ -3,7 +3,7 @@ import type { Settings, TaskDetail, WorkflowIr } from "@fusion/core";
import { WorkflowTaskRuntime, type WorkflowTaskRuntimeDeps } from "../workflow-task-runtime.js";
import type { WorkflowNodeResult } from "../workflow-graph-executor.js";
import type { WorkflowLegacySeams } from "../workflow-node-handlers.js";
import type { PreparedWorktree, WorkflowRuntimePrimitives } from "../runtime-primitives.js";
const task = { id: "FN-9002" } as TaskDetail;
const flagOff = { experimentalFeatures: {} } as unknown as Pick<Settings, "experimentalFeatures">;
@@ -27,31 +27,69 @@ function selectedIr(): WorkflowIr {
};
}
function recordingSeams(calls: string[], overrides: Partial<Record<string, WorkflowNodeResult>> = {}): WorkflowLegacySeams {
const seam = (name: keyof WorkflowLegacySeams) => async (): Promise<WorkflowNodeResult> => {
calls.push(name);
return overrides[name] ?? { outcome: "success" };
};
function recordingPrimitives(
calls: string[],
overrides: Partial<Record<"execute", WorkflowNodeResult>> = {},
): WorkflowRuntimePrimitives {
const prepared: PreparedWorktree = { worktreePath: "/tmp/fusion-worktree" };
return {
planning: seam("planning"),
execute: seam("execute"),
review: seam("review"),
merge: seam("merge"),
schedule: seam("schedule"),
prepareWorktree: async () => {
calls.push("prepare-worktree");
return { outcome: "success", data: prepared };
},
readArtifact: async () => undefined,
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }),
runPlanningSession: async () => {
calls.push("planning");
return { outcome: "success", data: { approved: true, artifactKeys: [] } };
},
runCodingSession: async () => {
calls.push("execute");
const override = overrides.execute;
return {
outcome: override?.outcome ?? "success",
value: override?.value ?? "implemented",
contextPatch: override?.contextPatch,
data: { taskDone: override?.outcome !== "failure", modifiedFiles: [] },
};
},
runTaskStep: async () => ({ outcome: "success" }),
resetTaskStep: async () => ({ ok: true }),
runReview: async (_ctx, _task, input) => {
calls.push(input.stepIndex === undefined ? "review" : "step-review");
return {
outcome: "success",
value: input.stepIndex === undefined ? "in-review" : "approve",
data: { verdict: "APPROVE" },
};
},
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
runWorkflowStep: async () => ({ outcome: "success", data: { allPassed: true } }),
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }),
transitionTask: async () => {
calls.push("schedule");
return { outcome: "success" };
},
requestMerge: async () => {
calls.push("merge");
return { outcome: "success", value: "merged", data: { status: "merged" } };
},
abortRun: async () => ({ outcome: "success" }),
audit: () => undefined,
};
}
describe("WorkflowTaskRuntime", () => {
it("requires execution wiring at the type boundary", () => {
// @ts-expect-error WorkflowTaskRuntime is an execution entry point, so seams are required.
const missingSeams: WorkflowTaskRuntimeDeps = {
// @ts-expect-error WorkflowTaskRuntime is an execution entry point, so primitives are required.
const missingPrimitives: WorkflowTaskRuntimeDeps = {
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
},
runCustomNode: async () => ({ outcome: "success" }),
};
expect(missingSeams).toBeDefined();
expect(missingPrimitives).toBeDefined();
});
it("runs a selected workflow through the graph engine", async () => {
@@ -65,7 +103,7 @@ describe("WorkflowTaskRuntime", () => {
},
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
},
seams: recordingSeams(calls),
primitives: recordingPrimitives(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
@@ -75,7 +113,7 @@ describe("WorkflowTaskRuntime", () => {
const result = await runtime.run(task, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["custom:prepare", "execute"]);
expect(calls).toEqual(["custom:prepare", "prepare-worktree", "execute"]);
expect(result.visitedNodeIds).toEqual(["start", "prepare", "execute"]);
expect(workflowSelectionReads).toBe(1);
});
@@ -87,7 +125,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
},
seams: recordingSeams(calls),
primitives: recordingPrimitives(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
@@ -97,8 +135,8 @@ describe("WorkflowTaskRuntime", () => {
const result = await runtime.run(task, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["execute", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "execute", "review", "merge"]);
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "review", "merge"]);
});
it("turns selected workflow lookup failures into the built-in workflow target", async () => {
@@ -109,7 +147,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-MISSING", stepIds: [] }),
getWorkflowDefinition: async () => undefined,
},
seams: recordingSeams(calls),
primitives: recordingPrimitives(calls),
runCustomNode: async () => ({ outcome: "success" }),
branchPersistence: {
loadBranchStates: (_taskId, runId) => {
@@ -122,7 +160,7 @@ describe("WorkflowTaskRuntime", () => {
const result = await runtime.run(task, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["execute", "review", "merge"]);
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
expect(observedRunIds).toContain("FN-9002:builtin:coding");
expect(observedRunIds).not.toContain("FN-9002:WF-MISSING");
});
@@ -135,7 +173,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-CORRUPT", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: "not a workflow ir" }),
},
seams: recordingSeams(calls),
primitives: recordingPrimitives(calls),
runCustomNode: async () => ({ outcome: "success" }),
branchPersistence: {
loadBranchStates: (_taskId, runId) => {
@@ -148,7 +186,7 @@ describe("WorkflowTaskRuntime", () => {
const result = await runtime.run(task, flagOff);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["execute", "review", "merge"]);
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
expect(observedRunIds).toContain("FN-9002:builtin:coding");
expect(observedRunIds).not.toContain("FN-9002:WF-CORRUPT");
});
@@ -160,7 +198,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
},
seams: recordingSeams([]),
primitives: recordingPrimitives([]),
runCustomNode: async () => ({ outcome: "success" }),
handlers: {
prompt: async (_node, context) => {
@@ -189,7 +227,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
},
seams: recordingSeams([]),
primitives: recordingPrimitives([]),
runCustomNode: async () => ({ outcome: "success" }),
branchPersistence: {
loadBranchStates: (_taskId, runId) => {
@@ -211,7 +249,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
},
seams: recordingSeams([]),
primitives: recordingPrimitives([]),
runCustomNode: async () => ({ outcome: "success" }),
branchPersistence: {
loadBranchStates: (_taskId, runId) => {
@@ -233,7 +271,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
},
seams: recordingSeams(calls, { execute: { outcome: "failure", value: "implementation-incomplete" } }),
primitives: recordingPrimitives(calls, { execute: { outcome: "failure", value: "implementation-incomplete" } }),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
@@ -244,7 +282,7 @@ describe("WorkflowTaskRuntime", () => {
expect(result.disposition).toBe("failed");
expect(result.outcome).toBe("failure");
expect(calls).toEqual(["custom:prepare", "execute"]);
expect(calls).toEqual(["custom:prepare", "prepare-worktree", "execute"]);
});
it("converts interpreter throws into workflow-engine failures", async () => {
@@ -262,7 +300,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: badIr }),
},
seams: recordingSeams([]),
primitives: recordingPrimitives([]),
runCustomNode: async () => ({ outcome: "success" }),
});
@@ -292,7 +330,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: cyclicIr }),
},
seams: recordingSeams([]),
primitives: recordingPrimitives([]),
runCustomNode: async () => ({ outcome: "success" }),
});
@@ -309,7 +347,7 @@ describe("WorkflowTaskRuntime", () => {
getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }),
getWorkflowDefinition: async () => ({ ir: selectedIr() }),
},
seams: recordingSeams([]),
primitives: recordingPrimitives([]),
runCustomNode: async () => ({ outcome: "success" }),
onEvent: () => {
throw new Error("diagnostics failed");

View File

@@ -9,7 +9,7 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n
import { existsSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow } from "@fusion/core";
import { mergeEffectiveSettings } from "./effective-settings.js";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
import {
@@ -30,10 +30,17 @@ import { observeWorkflowParity, WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from ".
import {
FOREACH_ACTIVE_CONTEXT_KEY,
SEAM_GOVERNING_NODE_CONTEXT_KEY,
SPLIT_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type WorkflowLegacySeams,
} from "./workflow-node-handlers.js";
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
import type {
AuditPrimitiveInput,
PreparedWorktree,
WorkflowPrimitiveContext,
WorkflowRuntimePrimitives,
} from "./runtime-primitives.js";
import {
ApprovalRequestStore,
buildExecutionMemoryInstructions,
@@ -3649,19 +3656,32 @@ export class TaskExecutor {
let settings: Settings;
try {
settings = await this.store.getSettings();
} catch {
return false;
} catch (err) {
await this.handleGraphFailure(task, {
disposition: "failed",
outcome: "failure",
reason: `settings-load-failed: ${err instanceof Error ? err.message : String(err)}`,
visitedNodeIds: [],
});
return true;
}
if (!isExperimentalFeatureEnabled(settings, "workflowGraphExecutor")) return false;
if (typeof this.store.getTaskWorkflowSelection !== "function") return false;
const hasWorkflowResolver = typeof this.store.getTaskWorkflowSelection === "function";
const explicitlyEnabled = isExperimentalFeatureEnabled(settings, "workflowGraphExecutor");
if (!hasWorkflowResolver && !explicitlyEnabled) return false;
settings = {
...settings,
experimentalFeatures: {
...(settings.experimentalFeatures ?? {}),
workflowGraphExecutor: true,
},
};
let selection: { workflowId: string; stepIds: string[] } | undefined;
try {
selection = this.store.getTaskWorkflowSelection(task.id);
selection = this.store.getTaskWorkflowSelection?.(task.id);
} catch {
return false;
selection = undefined;
}
if (!selection) return false;
selection ??= { workflowId: "builtin:coding", stepIds: [] };
// Resolve the production run id ONCE, here, so it is the single source of
// truth shared by the runner AND the executor-side persistence deps
@@ -3673,7 +3693,9 @@ export class TaskExecutor {
// prior behavior — so this never strands a task.
let resolvedRunId: string | undefined;
try {
const definition = await this.store.getWorkflowDefinition?.(selection.workflowId);
const definition = selection.workflowId === "builtin:coding"
? { id: "builtin:coding" }
: await this.store.getWorkflowDefinition?.(selection.workflowId);
if (definition) resolvedRunId = `${task.id}:${definition.id}`;
} catch {
// Definition load failure — leave undefined; deps/runner use fallbacks.
@@ -3710,8 +3732,15 @@ export class TaskExecutor {
}
const runner = new WorkflowGraphTaskRunner({
store: this.store,
store: {
...this.store,
getTaskWorkflowSelection: (taskId: string) =>
this.store.getTaskWorkflowSelection?.(taskId) ?? { workflowId: "builtin:coding", stepIds: [] },
getWorkflowDefinition: async (id: string) =>
(await this.store.getWorkflowDefinition?.(id)) ?? getBuiltinWorkflow("builtin:coding"),
},
runId: resolvedRunId,
primitives: this.createAuthoritativeWorkflowPrimitives(settings),
seams: this.createAuthoritativeWorkflowSeams(settings),
runCustomNode: (node, nodeTask) =>
this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)),
@@ -3758,16 +3787,26 @@ export class TaskExecutor {
const detail = await this.store.getTask(task.id);
result = await runner.run(detail, settings);
} catch (err) {
// A thrown interpreter error must not strand the task in-progress: fall
// back to the legacy pipeline so the normal executor lock + flow runs.
executorLog.error(
`[workflow-graph] ${task.id} interpreter threw — falling back to legacy pipeline: ${err instanceof Error ? err.message : String(err)}`,
`[workflow-graph] ${task.id} interpreter threw — parking task as workflow failure: ${err instanceof Error ? err.message : String(err)}`,
);
return false;
await this.handleGraphFailure(task, {
disposition: "failed",
outcome: "failure",
reason: `interpreter-error: ${err instanceof Error ? err.message : String(err)}`,
visitedNodeIds: [],
});
return true;
}
if (result.disposition === "fell-back") {
executorLog.log(`[workflow-graph] ${task.id} fell back to legacy pipeline: ${result.reason}`);
return false;
executorLog.warn(`[workflow-graph] ${task.id} could not resolve workflow — parking task instead of legacy fallback: ${result.reason}`);
await this.handleGraphFailure(task, {
...result,
disposition: "failed",
outcome: "failure",
reason: result.reason ?? "workflow-resolution-failed",
});
return true;
}
if (result.disposition === "failed") {
await this.handleGraphFailure(task, result);
@@ -4553,6 +4592,240 @@ export class TaskExecutor {
/** Public authoritative-driver seam factory: exposes the same real lifecycle
* seams the internal graph runner uses, without changing legacy behavior. */
public createAuthoritativeWorkflowPrimitives(settings: Settings): WorkflowRuntimePrimitives {
const logAudit = async (taskId: string | undefined, input: AuditPrimitiveInput): Promise<void> => {
if (!taskId) return;
try {
await this.store.logEntry(taskId, input.message, input.metadata ? JSON.stringify(input.metadata) : undefined);
} catch {
// Audit is diagnostic-only and must not affect workflow execution.
}
};
return {
prepareWorktree: async (_ctx, task) => {
const live = await this.store.getTask(task.id);
const prepared: PreparedWorktree = {
worktreePath: live.worktree || this.rootDir,
branchName: live.branch,
};
return { outcome: "success", value: "worktree-ready", data: prepared };
},
readArtifact: async (_ctx, task, key) => {
const deps = this.buildParseStepsDeps(`${task.id}:artifact-read`);
return deps.readArtifact(task, key);
},
writeArtifact: async (ctx, task, key, content) => {
const writer = (this.store as unknown as {
writeTaskDocument?: (taskId: string, key: string, content: string) => Promise<void>;
}).writeTaskDocument;
if (!writer) {
await logAudit(task.id, {
type: "artifact-write-unavailable",
message: `Workflow node ${ctx.node.node.id} could not write artifact ${key}: store writer unavailable`,
});
return { outcome: "failure", value: "artifact-write-unavailable" };
}
await writer.call(this.store, task.id, key, content);
return { outcome: "success", value: "artifact-written", data: { key } };
},
runPlanningSession: async () => ({ outcome: "success", value: "pre-specified", data: {
approved: true,
artifactKeys: [],
} }),
runCodingSession: async (ctx, task) => {
const governingNodeId = ctx.node.context?.[SEAM_GOVERNING_NODE_CONTEXT_KEY];
if (typeof governingNodeId === "string") {
this.graphSeamGoverningNodeId.set(task.id, governingNodeId);
}
let result: { taskDone: boolean; modifiedFiles: string[] };
try {
result = await this.runImplementationPhase(task);
} finally {
this.graphSeamGoverningNodeId.delete(task.id);
}
if (result.taskDone) {
return { outcome: "success", value: "implemented", data: result };
}
let paused = this.pausedAborted.has(task.id);
if (!paused) {
try {
paused = Boolean((await this.store.getTask(task.id)).paused);
} catch {
// Best-effort pause probe; fall through to the failure value.
}
}
return {
outcome: "failure",
value: paused ? "implementation-paused" : "implementation-incomplete",
data: result,
};
},
runTaskStep: async (ctx, task, stepIndex) => {
const context = ctx.node.context ?? {};
const active = context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
if (!active || typeof active.stepIndex !== "number") {
return { outcome: "failure" };
}
const live = await this.store.getTask(task.id);
const worktreePath = active.worktreePath || live.worktree || this.rootDir;
this.graphStepActiveContext.set(this.graphActiveContextKey(task.id, active.instanceId), active);
const stepGoverningNodeId = context[SEAM_GOVERNING_NODE_CONTEXT_KEY];
return await runTaskStep(
{
store: this.store,
worktreePath,
runStep: (idx) =>
this.runGraphTaskStep(
task,
idx,
active.instanceId,
typeof stepGoverningNodeId === "string" ? stepGoverningNodeId : undefined,
),
},
{ id: task.id, steps: live.steps },
stepIndex,
{ markDoneOnSuccess: active.deferDoneToReview !== true },
);
},
resetTaskStep: async (ctx, task, stepIndex, baselineSha, checkpointId) => {
const active = ctx.node.context?.[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
const branchScoped = typeof active?.worktreePath === "string" && active.worktreePath.length > 0;
let worktreePath = active?.worktreePath ?? this.rootDir;
if (!branchScoped) {
try {
worktreePath = (await this.store.getTask(task.id)).worktree || this.rootDir;
} catch {
// Best-effort worktree resolution; fall back to rootDir.
}
}
const liveSteps = await this.store.getTask(task.id).then((t) => t.steps).catch(() => []);
return await resetStepToBaseline(
{
store: this.store,
worktreePath,
sessionRef: { current: null },
reviewType: "code",
blastRadiusGuard: branchScoped
? undefined
: makeAncestryBlastRadiusGuard({
worktreePath,
task: { id: task.id, steps: liveSteps },
stepIndex,
}),
},
{ id: task.id, steps: liveSteps },
stepIndex,
baselineSha,
checkpointId,
);
},
runReview: async (ctx, task, input) => {
if (typeof input.stepIndex === "number") {
const context = ctx.node.context ?? {};
const active = context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
if (!active || typeof active.stepIndex !== "number") {
return {
outcome: "success",
value: "unavailable",
data: { verdict: "UNAVAILABLE", review: "no active step instance" },
};
}
const config = {
type: input.type,
advisory: context[SPLIT_ACTIVE_CONTEXT_KEY] === true,
} as const;
const seamResult = await this.createAuthoritativeWorkflowSeams(settings).stepReview?.(
task,
context,
config,
);
return {
outcome: "success",
value: seamResult?.verdict === "APPROVE" ? "approve" : seamResult?.verdict === "REVISE" ? "revise" : seamResult?.verdict === "RETHINK" ? "rethink" : "unavailable",
data: seamResult ?? { verdict: "UNAVAILABLE", review: "step review unavailable" },
};
}
const live = await this.store.getTask(task.id);
await this.persistTokenUsage(task.id);
await this.handoffTaskToReview(live, "workflow-graph-review");
return {
outcome: "success",
value: "in-review",
data: { verdict: "APPROVE", summary: "Task handed off for merge review" },
};
},
runVerification: async () => ({ outcome: "success", value: "verification-skipped", data: {
verdict: "skipped",
} }),
runWorkflowStep: async () => ({ outcome: "success", value: "workflow-step-skipped", data: {
allPassed: true,
} }),
updateSteps: async (_ctx, task, steps) => {
await this.store.updateTask(task.id, { steps });
return { outcome: "success", value: "steps-updated", data: { count: steps.length } };
},
transitionTask: async (_ctx, task, input) => {
const patch: Partial<TaskDetail> = {};
if (input.column !== undefined) patch.column = input.column;
if (input.status !== undefined && input.status !== null) patch.status = input.status;
if (Object.keys(patch).length > 0) {
await this.store.updateTask(task.id, patch);
}
return { outcome: "success", value: input.reason };
},
requestMerge: async (ctx, task) => {
if (!this.mergeRequester) {
return { outcome: "failure", value: "merge-unavailable", data: { status: "failed", reason: "merge-unavailable" } };
}
const GRAPH_MERGE_TIMEOUT_MS = 30 * 60 * 1000;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<"timeout">((resolve) => {
timeoutHandle = setTimeout(() => resolve("timeout"), GRAPH_MERGE_TIMEOUT_MS);
timeoutHandle.unref?.();
});
try {
const result = await Promise.race([this.mergeRequester(task.id), timeout]);
if (result === "timeout") {
executorLog.warn(`${task.id}: workflow merge primitive timed out after ${GRAPH_MERGE_TIMEOUT_MS}ms`);
return { outcome: "failure", value: "merge-timeout", data: { status: "timeout" } };
}
if (result.merged || result.noOp) {
return {
outcome: "success",
value: result.noOp ? "merge-noop" : "merged",
data: { status: "merged", noOp: result.noOp },
};
}
return {
outcome: "failure",
value: result.reason ?? result.error ?? "merge-failed",
data: { status: "failed", reason: result.reason ?? result.error ?? "merge-failed" },
};
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
await logAudit(task.id, {
type: "merge-requested",
message: `Workflow node ${ctx.node.node.id} requested merge`,
});
}
},
abortRun: async (_ctx, task, input) => {
if (input.hardCancel) {
this.pausedAborted.add(task.id);
}
await this.store.updateTask(task.id, {
paused: true,
pausedReason: input.reason,
} as Partial<TaskDetail>);
return { outcome: "success", value: "aborted" };
},
audit: async (ctx: WorkflowPrimitiveContext, input) => {
await logAudit(ctx.run.taskId, input);
},
};
}
public createAuthoritativeWorkflowSeams(_settings: Settings): WorkflowLegacySeams {
return {
// Built-in triage/spec generation runs upstream of the interpreter today,
@@ -5700,10 +5973,10 @@ export class TaskExecutor {
executorLog.log(`execute() called for ${task.id} while graph routing is active — skipping duplicate`);
return;
}
const authoritativeOwned = await this.options.workflowAuthoritativeDispatch?.(task);
if (authoritativeOwned) return;
const graphOwned = await this.maybeExecuteWorkflowGraph(task);
if (graphOwned) return;
const authoritativeOwned = await this.options.workflowAuthoritativeDispatch?.(task);
if (authoritativeOwned) return;
}
// FN-4811 follow-up (FN-4814/FN-4809/FN-4811 production failure): claim a

View File

@@ -40,10 +40,14 @@ export {
} from "./workflow-graph-branches.js";
export {
createDefaultNodeHandlers,
createPrimitivePromptLikeHandler,
createPrimitiveStepReviewHandler,
createNoopLegacySeams,
createParseStepsHandler,
createCodeNodeHandler,
PARSE_STEPS_DEFAULT_ARTIFACT,
WORKFLOW_ID_CONTEXT_KEY,
WORKFLOW_RUN_ID_CONTEXT_KEY,
type WorkflowCustomNodeRunner,
type WorkflowLegacySeams,
type WorkflowSeamName,
@@ -51,6 +55,28 @@ export {
type CodeNodeRunner,
type DefaultNodeHandlerDeps,
} from "./workflow-node-handlers.js";
export {
markSideEffectsStarted,
primitiveNodeContext,
type RuntimePrimitiveName,
type WorkflowRuntimeRunContext,
type WorkflowRuntimeNodeContext,
type WorkflowPrimitiveContext,
type RuntimePrimitiveResult,
type PreparedWorktree,
type PlanningSessionResult,
type CodingSessionResult,
type ReviewPrimitiveResult,
type VerificationPrimitiveResult,
type WorkflowStepPrimitiveInput,
type WorkflowStepPrimitiveResult,
type TransitionPrimitiveInput,
type MergePrimitiveInput,
type MergePrimitiveResult,
type AbortPrimitiveInput,
type AuditPrimitiveInput,
type WorkflowRuntimePrimitives,
} from "./runtime-primitives.js";
export {
createPrNodeHandlers,
createAutoMergeGateHandler,

View File

@@ -0,0 +1,238 @@
import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
import type { PrMergeCallResult } from "./pr-nodes.js";
import type { RunTaskStepResult, ResetStepResult } from "./step-runner.js";
import type { WorkflowNodeOutcome } from "./workflow-graph-executor.js";
export type RuntimePrimitiveName =
| "prepare-worktree"
| "read-artifact"
| "write-artifact"
| "planning-session"
| "coding-session"
| "step-session"
| "reset-step"
| "review"
| "verification"
| "workflow-step"
| "transition"
| "merge"
| "abort"
| "audit";
export interface WorkflowRuntimeRunContext {
runId: string;
taskId: string;
workflowId: string;
/** True after any primitive with task/git/session side effects starts. */
sideEffectsStarted?: boolean;
recoveryEventId?: string;
}
export interface WorkflowRuntimeNodeContext {
node: Pick<WorkflowIrNode, "id" | "kind" | "column" | "config">;
effectivePrincipalId?: string;
attempt?: number;
context?: Record<string, unknown>;
}
export interface WorkflowPrimitiveContext {
run: WorkflowRuntimeRunContext;
node: WorkflowRuntimeNodeContext;
}
export interface RuntimePrimitiveResult<TValue = unknown> {
outcome: WorkflowNodeOutcome;
value?: string;
data?: TValue;
contextPatch?: Record<string, unknown>;
}
export interface PreparedWorktree {
worktreePath: string;
branchName?: string;
baseCommitSha?: string;
modifiedFiles?: string[];
}
export interface PlanningSessionResult {
approved: boolean;
artifactKeys: string[];
createdTaskIds?: string[];
feedback?: string;
}
export interface CodingSessionResult {
taskDone: boolean;
modifiedFiles: string[];
summary?: string;
}
export interface ReviewPrimitiveResult {
verdict: "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
review?: string;
summary?: string;
}
export interface VerificationPrimitiveResult {
verdict: "approve" | "revise" | "failed" | "advisory-failed" | "skipped";
feedback?: string;
stepName?: string;
}
export interface WorkflowStepPrimitiveInput {
phase: "pre-merge" | "post-merge";
stepId?: string;
worktreePath?: string;
}
export interface WorkflowStepPrimitiveResult {
allPassed: boolean;
revisionRequested?: boolean;
feedback?: string;
stepName?: string;
}
export interface TransitionPrimitiveInput {
column?: string;
status?: string | null;
reason: string;
preserveProgress?: boolean;
}
export interface MergePrimitiveInput {
expectedHeadOid?: string;
manualAllowed?: boolean;
}
export type MergePrimitiveResult =
| { status: "merged"; noOp?: boolean }
| { status: "manual-required"; reason?: string }
| { status: "failed"; reason: string }
| { status: "timeout" }
| PrMergeCallResult;
export interface AbortPrimitiveInput {
reason: string;
hardCancel?: boolean;
}
export interface AuditPrimitiveInput {
type: string;
message: string;
metadata?: Record<string, unknown>;
}
export interface WorkflowRuntimePrimitives {
prepareWorktree(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
): Promise<RuntimePrimitiveResult<PreparedWorktree>>;
readArtifact(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
key: string,
): Promise<string | undefined>;
writeArtifact(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
key: string,
content: string,
): Promise<RuntimePrimitiveResult<{ key: string }>>;
runPlanningSession(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
): Promise<RuntimePrimitiveResult<PlanningSessionResult>>;
runCodingSession(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
prepared: PreparedWorktree,
): Promise<RuntimePrimitiveResult<CodingSessionResult>>;
runTaskStep(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
stepIndex: number,
): Promise<RunTaskStepResult>;
resetTaskStep(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
stepIndex: number,
baselineSha?: string,
checkpointId?: string,
): Promise<ResetStepResult>;
runReview(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input: { type: "plan" | "code"; stepIndex?: number; baselineSha?: string },
): Promise<RuntimePrimitiveResult<ReviewPrimitiveResult>>;
runVerification(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
prepared: PreparedWorktree,
): Promise<RuntimePrimitiveResult<VerificationPrimitiveResult>>;
runWorkflowStep(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input: WorkflowStepPrimitiveInput,
): Promise<RuntimePrimitiveResult<WorkflowStepPrimitiveResult>>;
updateSteps(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
steps: TaskStep[],
): Promise<RuntimePrimitiveResult<{ count: number }>>;
transitionTask(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input: TransitionPrimitiveInput,
): Promise<RuntimePrimitiveResult>;
requestMerge(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input?: MergePrimitiveInput,
): Promise<RuntimePrimitiveResult<MergePrimitiveResult>>;
abortRun(
ctx: WorkflowPrimitiveContext,
task: TaskDetail,
input: AbortPrimitiveInput,
): Promise<RuntimePrimitiveResult>;
audit(ctx: WorkflowPrimitiveContext, input: AuditPrimitiveInput): Promise<void> | void;
}
export function markSideEffectsStarted(ctx: WorkflowPrimitiveContext): WorkflowPrimitiveContext {
return {
...ctx,
run: {
...ctx.run,
sideEffectsStarted: true,
},
};
}
export function primitiveNodeContext(
run: WorkflowRuntimeRunContext,
node: WorkflowRuntimeNodeContext["node"],
extras: Omit<WorkflowRuntimeNodeContext, "node"> = {},
): WorkflowPrimitiveContext {
return {
run,
node: {
...extras,
node,
},
};
}

View File

@@ -14,6 +14,8 @@ import type { TaskExecutor } from "./executor.js";
import { executorLog } from "./logger.js";
import { WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from "./workflow-parity-observer.js";
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js";
import type { WorkflowLegacySeams } from "./workflow-node-handlers.js";
import type { PreparedWorktree, WorkflowRuntimePrimitives } from "./runtime-primitives.js";
const AUTHORITATIVE_WORKFLOW_ID = "workflow-interpreter-authoritative";
@@ -26,7 +28,7 @@ export interface WorkflowAuthoritativeDriverStore {
export interface WorkflowAuthoritativeDriverDeps {
store: WorkflowAuthoritativeDriverStore;
executor: Pick<TaskExecutor, "createAuthoritativeWorkflowSeams">;
executor: Pick<TaskExecutor, "createAuthoritativeWorkflowSeams"> & Partial<Pick<TaskExecutor, "createAuthoritativeWorkflowPrimitives">>;
minimumObservedRuns?: number;
}
@@ -49,6 +51,58 @@ function buildAuthoritativeSettings(settings: Settings): Settings {
};
}
function primitivesFromLegacySeams(seams: WorkflowLegacySeams): WorkflowRuntimePrimitives {
const prepared: PreparedWorktree = { worktreePath: "" };
return {
prepareWorktree: async () => ({ outcome: "success", data: prepared }),
readArtifact: async () => undefined,
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }),
runPlanningSession: async (ctx, task) => {
const result = await seams.planning(task, ctx.node.context ?? {});
return { ...result, data: { approved: result.outcome === "success", artifactKeys: [] } };
},
runCodingSession: async (ctx, task) => {
const result = await seams.execute(task, ctx.node.context ?? {});
return {
...result,
data: { taskDone: result.outcome === "success", modifiedFiles: [] },
};
},
runTaskStep: async (ctx, task) => {
const result = await seams.stepExecute?.(task, ctx.node.context ?? {});
return { outcome: result?.outcome ?? "failure" };
},
resetTaskStep: async () => ({ ok: true }),
runReview: async (ctx, task, input) => {
if (typeof input.stepIndex === "number") {
const result = await seams.stepReview?.(task, ctx.node.context ?? {}, { type: input.type });
return {
outcome: "success",
value: result?.verdict === "APPROVE" ? "approve" : result?.verdict === "REVISE" ? "revise" : result?.verdict === "RETHINK" ? "rethink" : "unavailable",
data: result ?? { verdict: "UNAVAILABLE" },
};
}
const result = await seams.review(task, ctx.node.context ?? {});
return { ...result, data: { verdict: result.outcome === "success" ? "APPROVE" : "REVISE" } };
},
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
runWorkflowStep: async () => ({ outcome: "success", data: { allPassed: true } }),
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) => {
const result = await seams.merge(task, ctx.node.context ?? {});
return {
...result,
data: result.outcome === "success"
? { status: "merged" as const }
: { status: "failed" as const, reason: result.value ?? "merge-failed" },
};
},
abortRun: async () => ({ outcome: "success" }),
audit: () => undefined,
};
}
export class WorkflowAuthoritativeDriver {
public constructor(private readonly deps: WorkflowAuthoritativeDriverDeps) {}
@@ -114,6 +168,7 @@ export class WorkflowAuthoritativeDriver {
readinessReasons: [],
};
}
const seams = this.deps.executor.createAuthoritativeWorkflowSeams(settings);
const runner = new WorkflowGraphTaskRunner({
store: {
getTaskWorkflowSelection: () => ({ workflowId: AUTHORITATIVE_WORKFLOW_ID, stepIds: [] }),
@@ -123,7 +178,8 @@ export class WorkflowAuthoritativeDriver {
ir: BUILTIN_CODING_WORKFLOW_IR,
} satisfies Pick<WorkflowDefinition, "id" | "name" | "ir"> as WorkflowDefinition),
},
seams: this.deps.executor.createAuthoritativeWorkflowSeams(settings),
primitives: this.deps.executor.createAuthoritativeWorkflowPrimitives?.(settings) ?? primitivesFromLegacySeams(seams),
seams,
runCustomNode: async (node) => {
throw new Error(`unexpected custom node in builtin authoritative workflow: ${node.id}`);
},

View File

@@ -5,12 +5,15 @@ import {
createDefaultNodeHandlers,
createNoopLegacySeams,
SPLIT_ACTIVE_CONTEXT_KEY,
WORKFLOW_ID_CONTEXT_KEY,
WORKFLOW_RUN_ID_CONTEXT_KEY,
type CodeNodeRunner,
type ForeachActiveContext,
type ParseStepsHandlerDeps,
type WorkflowCustomNodeRunner,
type WorkflowLegacySeams,
} from "./workflow-node-handlers.js";
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
import type { PrNodeDeps } from "./pr-nodes.js";
import {
runSplitJoin,
@@ -48,6 +51,9 @@ export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeEx
export interface WorkflowGraphExecutorDeps {
handlers?: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
/** Workflow-native runtime primitives. When present, default nodes call these
* directly instead of legacy executor/reviewer/merge seams. */
primitives?: WorkflowRuntimePrimitives;
seams?: WorkflowLegacySeams;
/** Executes custom (non-seam) prompt/script/gate nodes. */
runCustomNode?: WorkflowCustomNodeRunner;
@@ -150,6 +156,7 @@ export class WorkflowGraphExecutor {
this.maxRetriesPerNode = Math.max(1, Math.floor(deps.maxRetriesPerNode ?? 2));
this.handlers = {
...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams(), deps.runCustomNode, {
primitives: deps.primitives,
parseSteps: deps.parseStepsDeps,
runCode: deps.runCode,
prNodes: deps.prNodes,
@@ -181,10 +188,13 @@ export class WorkflowGraphExecutor {
outgoingMap.set(edge.from, list);
}
const context: Record<string, unknown> = {};
const runId = this.deps.runId ?? `${task.id}:run`;
const context: Record<string, unknown> = {
[WORKFLOW_RUN_ID_CONTEXT_KEY]: runId,
[WORKFLOW_ID_CONTEXT_KEY]: ir.name || "unknown",
};
const visitedNodeIds: string[] = [];
const inStack = new Set<string>();
const runId = this.deps.runId ?? `${task.id}:run`;
// Bounded-rework generalization (U6). A `kind: "rework"` edge is the only
// legal cycle: it loops back to a "rework region head" (the edge's `to` node).

View File

@@ -16,6 +16,7 @@ import type {
} from "./workflow-graph-branches.js";
import type { ForeachEnvironment, WorkflowStepInstancePersistence } from "./workflow-graph-foreach.js";
import type { PrNodeDeps } from "./pr-nodes.js";
import type { WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "./runtime-primitives.js";
// (Both types are also used as values in the side-effect tracking wrappers below.)
/**
@@ -46,6 +47,7 @@ export interface WorkflowGraphRunnerStore {
export interface WorkflowGraphTaskRunnerDeps {
store: WorkflowGraphRunnerStore;
seams: WorkflowLegacySeams;
primitives?: WorkflowRuntimePrimitives;
runCustomNode: WorkflowCustomNodeRunner;
maxRetriesPerNode?: number;
/** Optional diagnostics hook (audit/log emission). Never throws into the run. */
@@ -186,10 +188,25 @@ export class WorkflowGraphTaskRunner {
invoked.push(node.id);
return this.deps.runCustomNode(node, t, c);
};
const wrappedPrimitives = this.deps.primitives
? new Proxy(this.deps.primitives, {
get: (target, prop, receiver) => {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") return value;
return (...args: unknown[]) => {
sideEffectsRan = true;
const ctx = args[0] as WorkflowPrimitiveContext | undefined;
invoked.push(ctx?.node?.node?.id ?? String(prop));
return value.apply(target, args);
};
},
}) as WorkflowRuntimePrimitives
: undefined;
try {
const executor = new WorkflowGraphExecutor({
seams: wrappedSeams,
primitives: wrappedPrimitives,
runCustomNode: wrappedRunCustomNode,
maxRetriesPerNode: this.deps.maxRetriesPerNode,
branchPersistence: this.deps.branchPersistence,

View File

@@ -3,6 +3,11 @@ import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "./pr-nodes.js";
import {
primitiveNodeContext,
type WorkflowPrimitiveContext,
type WorkflowRuntimePrimitives,
} from "./runtime-primitives.js";
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule" | "step-execute";
@@ -104,6 +109,12 @@ export const SPLIT_ACTIVE_CONTEXT_KEY = "split:active";
*/
export const INTEGRATION_CONFLICT_CONTEXT_KEY = "integration:conflict";
/** Reserved graph context key for the current workflow run id. */
export const WORKFLOW_RUN_ID_CONTEXT_KEY = "workflow:run-id";
/** Reserved graph context key for the current workflow id. */
export const WORKFLOW_ID_CONTEXT_KEY = "workflow:id";
/** Shape of the value stored under {@link FOREACH_ACTIVE_CONTEXT_KEY}. */
export interface ForeachActiveContext {
foreachNodeId: string;
@@ -149,6 +160,34 @@ export type WorkflowCustomNodeRunner = (
context: Record<string, unknown>,
) => Promise<WorkflowNodeResult>;
function primitiveContextForNode(
node: WorkflowIrNode,
task: TaskDetail,
context: Record<string, unknown>,
attempt?: number,
): WorkflowPrimitiveContext {
return primitiveNodeContext(
{
runId: typeof context[WORKFLOW_RUN_ID_CONTEXT_KEY] === "string"
? context[WORKFLOW_RUN_ID_CONTEXT_KEY]
: `${task.id}:workflow`,
taskId: task.id,
workflowId: typeof context[WORKFLOW_ID_CONTEXT_KEY] === "string"
? context[WORKFLOW_ID_CONTEXT_KEY]
: "unknown",
},
node,
{
attempt,
context,
effectivePrincipalId:
typeof context["workflow:effective-principal-id"] === "string"
? context["workflow:effective-principal-id"]
: undefined,
},
);
}
/** Resolve a node's seam name, or undefined for custom (non-seam) nodes. */
export function resolveSeamName(node: { config?: Record<string, unknown> }): WorkflowSeamName | undefined {
const seam = node.config?.seam;
@@ -220,6 +259,83 @@ export function createPromptLikeHandler(
};
}
export function createPrimitivePromptLikeHandler(
primitives: WorkflowRuntimePrimitives,
runCustomNode?: WorkflowCustomNodeRunner,
): WorkflowNodeHandler {
return async (node, context) => {
const seam = resolveSeamName(node);
if (seam === "step-execute") {
const active = context.context[FOREACH_ACTIVE_CONTEXT_KEY] as
| ForeachActiveContext
| undefined;
if (!active || typeof active.stepIndex !== "number") {
throw new WorkflowIrError(
`step-execute node '${node.id}' reached without an active foreach instance context`,
);
}
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = instanceNodeId(
active.foreachNodeId,
active.stepIndex,
node.id,
);
const result = await primitives.runTaskStep(
primitiveContextForNode(node, context.task, context.context),
context.task,
active.stepIndex,
);
active.baselineSha = result.baselineSha;
active.checkpointId = result.checkpointId;
return {
outcome: result.outcome,
value: result.outcome === "success" ? "step-done" : "step-failed",
contextPatch: {
[FOREACH_ACTIVE_CONTEXT_KEY]: active,
},
};
}
if (seam) {
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id;
const primitiveCtx = primitiveContextForNode(node, context.task, context.context);
if (seam === "planning") {
const result = await primitives.runPlanningSession(primitiveCtx, context.task);
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
}
if (seam === "execute") {
const prepared = await primitives.prepareWorktree(primitiveCtx, context.task);
if (prepared.outcome !== "success" || !prepared.data) {
return {
outcome: prepared.outcome,
value: prepared.value ?? "prepare-worktree-failed",
contextPatch: prepared.contextPatch,
};
}
const result = await primitives.runCodingSession(primitiveCtx, context.task, prepared.data);
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
}
if (seam === "review") {
const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" });
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
}
if (seam === "merge") {
const result = await primitives.requestMerge(primitiveCtx, context.task);
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
}
if (seam === "schedule") {
const result = await primitives.transitionTask(primitiveCtx, context.task, {
reason: "workflow-schedule",
preserveProgress: true,
});
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
}
}
if (!runCustomNode) {
throw new WorkflowIrError(`No custom-node runner registered for node: ${node.id}`);
}
return runCustomNode(node, context.task, context.context);
};
}
/**
* Gate handler. Two forms:
* - Context gate (original scaffold contract): `config.expect` compared against
@@ -337,6 +453,55 @@ export function createStepReviewHandler(seams: WorkflowLegacySeams): WorkflowNod
};
}
export function createPrimitiveStepReviewHandler(primitives: WorkflowRuntimePrimitives): WorkflowNodeHandler {
return async (node, ctx) => {
const active = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
if (!active || typeof active.stepIndex !== "number") {
throw new WorkflowIrError(
`step-review node '${node.id}' reached without an active foreach instance context`,
);
}
const advisory = ctx.context[SPLIT_ACTIVE_CONTEXT_KEY] === true;
const config = resolveStepReviewConfig(node, advisory);
let result: StepReviewSeamResult = {
verdict: "UNAVAILABLE",
};
for (let attempt = 0; attempt <= STEP_REVIEW_UNAVAILABLE_RETRY_CAP; attempt++) {
const primitiveResult = await primitives.runReview(
primitiveContextForNode(node, ctx.task, ctx.context, attempt + 1),
ctx.task,
{
type: config.type,
stepIndex: active.stepIndex,
baselineSha: config.type === "code" ? active.baselineSha : undefined,
},
);
result = primitiveResult.data ?? { verdict: "UNAVAILABLE" as const };
if (result.verdict !== "UNAVAILABLE") break;
}
if (!advisory) {
active.verdict = result.verdict;
}
const patch: Record<string, unknown> = {
[FOREACH_ACTIVE_CONTEXT_KEY]: active,
[`node:${node.id}:verdict`]: result.verdict,
};
const value =
result.verdict === "APPROVE"
? "approve"
: result.verdict === "REVISE"
? "revise"
: result.verdict === "RETHINK"
? "rethink"
: "unavailable";
return { outcome: "success", value, contextPatch: patch };
};
}
// ── parse-steps node (U12, KTD-12) ──────────────────────────────────────────
/** The implicit default step-source artifact when a workflow declares no
@@ -542,6 +707,8 @@ export function createCodeNodeHandler(runCode?: CodeNodeRunner): WorkflowNodeHan
}
export interface DefaultNodeHandlerDeps {
/** Workflow-native runtime primitives. When present they replace legacy seams. */
primitives?: WorkflowRuntimePrimitives;
/** parse-steps node deps (U12). When absent, a parse-steps node fails cleanly. */
parseSteps?: ParseStepsHandlerDeps;
/** code node runner (U14). When absent, a code node fails cleanly. */
@@ -566,7 +733,9 @@ export function createDefaultNodeHandlers(
| "pr-merge",
WorkflowNodeHandler
> {
const promptLike = createPromptLikeHandler(seams, runCustomNode);
const promptLike = deps?.primitives
? createPrimitivePromptLikeHandler(deps.primitives, runCustomNode)
: createPromptLikeHandler(seams, runCustomNode);
// parse-steps without deps fails closed (would otherwise have no handler at
// all and throw "No handler registered"); a clean failure is the safe posture.
const parseSteps: WorkflowNodeHandler = deps?.parseSteps
@@ -596,7 +765,9 @@ export function createDefaultNodeHandlers(
prompt: promptLike,
script: promptLike,
gate,
"step-review": createStepReviewHandler(seams),
"step-review": deps?.primitives
? createPrimitiveStepReviewHandler(deps.primitives)
: createStepReviewHandler(seams),
"parse-steps": parseSteps,
code: createCodeNodeHandler(deps?.runCode),
...prNodes,

View File

@@ -15,9 +15,10 @@ import {
} from "./workflow-graph-executor.js";
import {
createDefaultNodeHandlers,
createNoopLegacySeams,
type WorkflowCustomNodeRunner,
type WorkflowLegacySeams,
} from "./workflow-node-handlers.js";
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
export type WorkflowTaskRuntimeDisposition = "completed" | "failed";
@@ -31,7 +32,7 @@ export interface WorkflowTaskRuntimeResult {
export interface WorkflowTaskRuntimeDeps extends Omit<WorkflowGraphExecutorDeps, "seams" | "runCustomNode"> {
store: WorkflowIrResolverStore;
seams: WorkflowLegacySeams;
primitives: WorkflowRuntimePrimitives;
runCustomNode: WorkflowCustomNodeRunner;
onEvent?: (event: { type: "start" | "terminal"; taskId: string; detail: string }) => void;
}
@@ -80,6 +81,7 @@ export class WorkflowTaskRuntime {
const invoked: string[] = [];
const executor = new WorkflowGraphExecutor({
...this.deps,
primitives: this.deps.primitives,
handlers: this.recordingHandlers(invoked),
// WorkflowTaskRuntime is the execution engine, so internally the graph
// executor is authoritative even before the old feature flag plumbing is
@@ -140,7 +142,8 @@ export class WorkflowTaskRuntime {
}
private recordingHandlers(invoked: string[]): Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>> {
const defaultHandlers = createDefaultNodeHandlers(this.deps.seams, this.deps.runCustomNode, {
const defaultHandlers = createDefaultNodeHandlers(createNoopLegacySeams(), this.deps.runCustomNode, {
primitives: this.deps.primitives,
parseSteps: this.deps.parseStepsDeps,
runCode: this.deps.runCode,
prNodes: this.deps.prNodes,