feat(engine): WorkflowGraphTaskRunner — interpreter drives a task's lifecycle (CU-U2)

Loads a task's selected workflow, runs the graph with injected legacy seams
(execute/review/merge) and a custom-node runner, and maps the terminal outcome
to completed/failed/fell-back. Any interpreter-level error falls back so the
caller can run the legacy pipeline — a task is never stranded. Covered with
fake seams: lifecycle ordering, failure routing, gate blocking, fallback
reasons, diagnostics isolation. Includes the interpreter-cutover plan doc.
This commit is contained in:
gsxdsm
2026-06-03 10:29:13 -07:00
parent ba27e499b4
commit 83451b165a
4 changed files with 418 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
---
title: "feat: Workflow interpreter cutover — graph owns the full lifecycle"
type: feat
status: active
date: 2026-06-03
depth: deep
origin: docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md (Deferred Track)
---
# feat: Workflow interpreter cutover — graph owns the full lifecycle
## Summary
Promote `WorkflowGraphExecutor` from a flag-off no-op to the authoritative driver of a task's lifecycle, so a custom workflow graph can **replace** planning → execute → review → merge, not just inject steps around it. The user has explicitly waived the FN-4359 reliability freeze for this track; the parity invariants below remain the correctness bar regardless.
The existing scaffold already provides: graph walking with cycle detection, `success`/`failure`/`outcome:*` edge conditions, per-node retries, a `WorkflowLegacySeams` interface (`execute/review/merge/schedule`), the `workflowGraphExecutor` experimental flag, and the dual-observe parity machinery. The MVP (plan 001) provides: persisted workflow definitions, the node editor, and per-task selection. What's missing is **real seam implementations**, **custom-node handlers** (the default handlers throw for non-seam prompt/script nodes), and the **entry point** that routes a graph-selected task through the interpreter.
---
## Scope Boundaries
### In scope
- M-A: Interpreter foundation — custom-node handlers + a `WorkflowGraphTaskRunner` with injected seams, fully tested with fakes.
- M-B: Real seam wiring — delegate execute/review/merge to the legacy engine implementations via narrow injected callbacks from `TaskExecutor`/`ProjectEngine`.
- M-C: Flag-gated entry point — graph-selected tasks route through the interpreter when `experimentalFeatures.workflowGraphExecutor` is on; legacy fallback on any interpreter error.
- M-D: Parity + graduation — dual-observe on real runs, drive drift to zero, then default the flag on for graph-selected tasks.
### Deferred to Follow-Up Work
- Removing the legacy hardcoded pipeline (FN-5719 Phase 4) — only after M-D proves parity in the field.
- Planning/triage as a replaceable seam (the `schedule` seam exists but triage replacement needs the planning subsystem mapped first).
- Parallel branches (fan-out) executing concurrently — the walker is sequential; concurrency within a graph is a later extension.
---
## Key Technical Decisions
- **KTD-1 — Seams delegate, never reimplement.** The `execute`/`review`/`merge` seam implementations call the same engine functions the legacy path uses (agent session machinery, `reviewStep`, the auto-merge queue). The interpreter owns *sequencing*; the engine keeps owning *mechanics* (worktrees, leases, file-scope guard, squash contract, self-healing). This is the enqueue-only posture from DAG ADR-0001 applied to seams.
- **KTD-2 — Custom nodes run on the WorkflowStep machinery.** Non-seam prompt/script/gate nodes execute via the same prompt-session/script/verdict machinery as workflow steps (proven, readonly-tool-policy aware), invoked through an injected `runCustomNode` callback — the interpreter stays engine-agnostic and unit-testable with fakes.
- **KTD-3 — Legacy fallback on interpreter error (M-C).** Any thrown error from the interpreter path (not a graph-routed `failure` edge) falls back to the legacy pipeline for that task and emits an audit event. No task is ever stranded by interpreter bugs.
- **KTD-4 — Column transitions are seam side-effects.** `execute` seam entry → `in-progress`, review handoff → `in-review`, merge success → `done` — performed by the delegated engine code itself (KTD-1), so board invariants (FN-5147 terminal-until-merged, hard-cancel) are preserved by construction.
- **KTD-5 — Invariant bar (from FN-5719 / workflow-steps.md):** `FileScopeViolationError` guard, squash/merge contract, `autoMerge:false` terminal-until-merged, `moveTask(in-progress→todo)` hard-cancel, resume-limbo non-oscillation. Parity is machine-checked via the existing `compareWorkflowRunObservations` / `compareWorkflowRunAudits`.
---
## Implementation Units
### U1. Custom-node handlers (unblock non-seam nodes)
**Goal:** Default handlers route seam-configured prompt/script nodes to seams and **custom** prompt/script nodes to an injected runner instead of throwing.
**Files:** `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/__tests__/workflow-node-handlers.test.ts`.
**Approach:** `createDefaultNodeHandlers(seams, runCustomNode)` — `resolveSeam` returns undefined (not throw) for non-seam nodes; gate nodes keep the context-gate behavior but also support prompt/script-backed gates via `runCustomNode` with `gateMode` semantics.
**Test scenarios:** seam node → seam called; custom prompt node → runner called with node config; custom script node → runner; gate node with context expectation → pass/fail; gate node with prompt config → runner verdict drives outcome; unknown seam string → error.
### U2. WorkflowGraphTaskRunner (engine-agnostic orchestration)
**Goal:** A runner that loads a task's selected workflow IR, runs `WorkflowGraphExecutor` with injected seams + custom-node runner, and maps the terminal outcome to a lifecycle disposition (`completed` / `failed` / `fell-back`).
**Files:** `packages/engine/src/workflow-graph-task-runner.ts` (new), `packages/engine/src/__tests__/workflow-graph-task-runner.test.ts` (new).
**Approach:** Pure DI: `{ store, seams, runCustomNode, settings }`. Reads selection via `store.getTaskWorkflowSelection` + `getWorkflowDefinition`. Falls back (disposition `fell-back`) when flag off, no selection, or IR load fails. Audit events for start/terminal/fallback.
**Test scenarios:** full graph run order with fake seams (execute→review→merge sequencing via the builtin IR); custom pre-merge node runs between start and execute when authored that way; failure edge routes to end with `failed`; thrown seam error → `fell-back`; flag off → `fell-back`; gate failure blocks merge seam.
### U3. Real seam implementations (M-B)
**Goal:** `createEngineSeams(executor, projectEngine)` delegating to real engine entry points.
**Files:** `packages/engine/src/workflow-engine-seams.ts` (new), executor/project-engine narrow accessor methods as needed, `packages/engine/src/__tests__/workflow-engine-seams.test.ts`.
**Approach:** execute → the executor's implementation-phase entry for an already-claimed task; review → `reviewStep` path with verdict mapped to `outcome:*`; merge → enqueue on the auto-merge queue and await terminal merge outcome. Each seam returns `WorkflowNodeResult` with `value` carrying verdict/outcome tokens for edge conditions.
**Execution note:** characterization-first — capture the legacy call sequence for one task end-to-end before extracting accessors.
### U4. Flag-gated entry point + fallback (M-C)
**Goal:** Graph-selected tasks route through the runner from `TaskExecutor.execute`; interpreter errors fall back to legacy mid-flight where safe, else fail the task through existing recovery.
**Files:** `packages/engine/src/executor.ts` (top-of-execute branch only), `packages/engine/src/__tests__/workflow-graph-entry.test.ts`.
### U5. Dual-observe parity on real runs + graduation (M-D)
**Goal:** Enable `workflowInterpreterDualObserve` for graph-selected tasks, record drift, fix until `{agree:true}` is sustained, then flip `workflowGraphExecutor` default for selected-workflow tasks.
**Files:** `packages/engine/src/workflow-parity-observer.ts` call-site wiring, settings default change, docs update.
---
## Risks
- **TaskExecutor.execute is ~3k lines of intertwined state.** Mitigation: U3 extracts *accessors*, never moves logic; U4 touches only a top-of-function branch; characterization tests first.
- **Self-healing/recovery assume the legacy shape.** Mitigation: KTD-3 fallback + parity observation before authority; recovery paths treat interpreter tasks as legacy until M-D.
- **Merge queue awaiting from inside a graph walk** could deadlock with the executor's own lifecycle. Mitigation: merge seam enqueues and resolves on the queue's completion callback (same contract the legacy handoff uses), never polls.
## Sources
Plan 001 (Deferred Track), `workflow-graph-executor.ts`, `workflow-node-handlers.ts`, `workflow-parity-observer.ts`, `docs/rfcs/FN-5719-decouple-executor-merger.md`, `docs/dag/adr-0001-dag-orchestration.md`, `docs/workflow-steps.md`.

View File

@@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr } from "@fusion/core";
import { WorkflowGraphTaskRunner, type WorkflowGraphRunnerStore } from "../workflow-graph-task-runner.js";
import type { WorkflowNodeResult } from "../workflow-graph-executor.js";
const task = { id: "FN-9001" } as TaskDetail;
const flagOn = { experimentalFeatures: { workflowGraphExecutor: true } } as unknown as Pick<
Settings,
"experimentalFeatures"
>;
const flagOff = { experimentalFeatures: {} } as unknown as Pick<Settings, "experimentalFeatures">;
/** start → lint(custom) → execute → review → merge → notify(custom) → end, with seam failure edges to end. */
function fullLifecycleIr(): WorkflowIr {
return {
version: "v1",
name: "full",
nodes: [
{ id: "start", kind: "start" },
{ id: "lint", kind: "prompt", config: { prompt: "lint it" } },
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
{ id: "notify", kind: "script", config: { scriptName: "notify" } },
{ id: "zend", kind: "end" },
],
edges: [
{ from: "start", to: "lint" },
{ from: "lint", to: "execute", condition: "success" },
{ from: "execute", to: "review", condition: "success" },
{ from: "review", to: "merge", condition: "success" },
{ from: "merge", to: "notify", condition: "success" },
{ from: "notify", to: "zend", condition: "success" },
{ from: "execute", to: "zend", condition: "failure" },
{ from: "review", to: "zend", condition: "failure" },
{ from: "merge", to: "zend", condition: "failure" },
],
};
}
function definition(ir: WorkflowIr): WorkflowDefinition {
return {
id: "WF-001",
name: "Full lifecycle",
description: "",
ir,
layout: {},
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
function storeWith(def: WorkflowDefinition | undefined, workflowId = "WF-001"): WorkflowGraphRunnerStore {
return {
getTaskWorkflowSelection: () => (def ? { workflowId, stepIds: [] } : undefined),
getWorkflowDefinition: async () => def,
};
}
function recordingSeams(calls: string[], overrides: Partial<Record<string, WorkflowNodeResult>> = {}) {
const seam = (name: string) => async (): Promise<WorkflowNodeResult> => {
calls.push(name);
return overrides[name] ?? { outcome: "success" };
};
return {
execute: seam("execute"),
review: seam("review"),
merge: seam("merge"),
schedule: seam("schedule"),
};
}
describe("WorkflowGraphTaskRunner (CU-U2)", () => {
it("runs the full lifecycle in graph order: custom → execute → review → merge → custom", async () => {
const calls: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fullLifecycleIr())),
seams: recordingSeams(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["custom:lint", "execute", "review", "merge", "custom:notify"]);
expect(result.visitedNodeIds).toEqual(["start", "lint", "execute", "review", "merge", "notify"]);
});
it("a failing seam terminates the run as failed without running later nodes", async () => {
const calls: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fullLifecycleIr())),
seams: recordingSeams(calls, { review: { outcome: "failure", value: "REVISE" } }),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("failed");
expect(calls).toEqual(["custom:lint", "execute", "review"]);
expect(calls).not.toContain("merge");
});
it("a failing custom gate before execute blocks the whole pipeline", async () => {
const calls: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fullLifecycleIr())),
seams: recordingSeams(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return node.id === "lint" ? { outcome: "failure", value: "lint-failed" } : { outcome: "success" };
},
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("failed");
expect(calls).toEqual(["custom:lint"]);
});
it("falls back when the flag is off", async () => {
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fullLifecycleIr())),
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success" }),
});
const result = await runner.run(task, flagOff);
expect(result.disposition).toBe("fell-back");
expect(result.reason).toBe("flag-off");
});
it("falls back when the task has no workflow selection", async () => {
const runner = new WorkflowGraphTaskRunner({
store: storeWith(undefined),
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success" }),
});
const result = await runner.run(task, flagOn);
expect(result).toMatchObject({ disposition: "fell-back", reason: "no-selection" });
});
it("falls back when the selected workflow no longer exists", async () => {
const store: WorkflowGraphRunnerStore = {
getTaskWorkflowSelection: () => ({ workflowId: "WF-404", stepIds: [] }),
getWorkflowDefinition: async () => undefined,
};
const runner = new WorkflowGraphTaskRunner({
store,
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success" }),
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("fell-back");
expect(result.reason).toMatch(/workflow-missing/);
});
it("falls back (never strands the task) when the interpreter throws", async () => {
// Malformed graph: edge references unknown node → WorkflowIrError inside run().
const badIr: WorkflowIr = {
version: "v1",
name: "bad",
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "ghost" }],
};
const events: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(badIr)),
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success" }),
onEvent: (e) => events.push(e.type),
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("fell-back");
expect(result.reason).toMatch(/interpreter-error/);
expect(events).toContain("fallback");
});
it("exposes node outcomes in the shared context for downstream consumers", async () => {
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fullLifecycleIr())),
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success", value: "APPROVE" }),
});
const result = await runner.run(task, flagOn);
expect(result.context?.["node:lint:outcome"]).toBe("success");
expect(result.context?.["node:lint:value"]).toBe("APPROVE");
});
it("onEvent diagnostics failures never affect the run", async () => {
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fullLifecycleIr())),
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success" }),
onEvent: () => {
throw new Error("diagnostics boom");
},
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
});
});

View File

@@ -24,9 +24,17 @@ export {
export {
createDefaultNodeHandlers,
createNoopLegacySeams,
type WorkflowCustomNodeRunner,
type WorkflowLegacySeams,
type WorkflowSeamName,
} from "./workflow-node-handlers.js";
export {
WorkflowGraphTaskRunner,
type WorkflowGraphRunDisposition,
type WorkflowGraphRunnerStore,
type WorkflowGraphTaskRunResult,
type WorkflowGraphTaskRunnerDeps,
} from "./workflow-graph-task-runner.js";
export { collectTaskEvaluationEvidence } from "./evaluator-evidence.js";
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";

View File

@@ -0,0 +1,117 @@
import type { Settings, TaskDetail, WorkflowDefinition } from "@fusion/core";
import { isExperimentalFeatureEnabled } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js";
import type { WorkflowCustomNodeRunner, WorkflowLegacySeams } from "./workflow-node-handlers.js";
/**
* Terminal disposition of an interpreter-driven task run.
* - "completed" — the graph ran to its end node successfully.
* - "failed" — the graph ran and terminated on a failure outcome.
* - "fell-back" — the interpreter did not (or could not) own this task;
* the caller must run the legacy pipeline instead.
*/
export type WorkflowGraphRunDisposition = "completed" | "failed" | "fell-back";
export interface WorkflowGraphTaskRunResult {
disposition: WorkflowGraphRunDisposition;
outcome?: WorkflowNodeOutcome;
visitedNodeIds: string[];
/** Why the runner fell back (flag-off, no-selection, workflow-missing, interpreter-error). */
reason?: string;
/** Shared graph context after the run (node outcomes/values). */
context?: Record<string, unknown>;
}
/** The minimal store surface the runner needs — keeps tests fake-friendly. */
export interface WorkflowGraphRunnerStore {
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined;
getWorkflowDefinition(id: string): Promise<WorkflowDefinition | undefined>;
}
export interface WorkflowGraphTaskRunnerDeps {
store: WorkflowGraphRunnerStore;
seams: WorkflowLegacySeams;
runCustomNode: WorkflowCustomNodeRunner;
maxRetriesPerNode?: number;
/** Optional diagnostics hook (audit/log emission). Never throws into the run. */
onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void;
}
/**
* Drives a task's lifecycle from its selected workflow graph. The runner owns
* SEQUENCING only — seam nodes delegate to the legacy engine implementations
* (execute/review/merge), custom nodes run via the injected runner. Any
* interpreter-level error yields a "fell-back" disposition so the caller can
* run the legacy pipeline; a task is never stranded by interpreter bugs.
*/
export class WorkflowGraphTaskRunner {
public constructor(private readonly deps: WorkflowGraphTaskRunnerDeps) {}
private emit(type: "start" | "terminal" | "fallback", taskId: string, detail: string): void {
try {
this.deps.onEvent?.({ type, taskId, detail });
} catch {
// Diagnostics must never affect the run.
}
}
private fallBack(taskId: string, reason: string): WorkflowGraphTaskRunResult {
this.emit("fallback", taskId, reason);
return { disposition: "fell-back", reason, visitedNodeIds: [] };
}
public async run(
task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined,
): Promise<WorkflowGraphTaskRunResult> {
if (!isExperimentalFeatureEnabled(settings, "workflowGraphExecutor")) {
return this.fallBack(task.id, "flag-off");
}
let selection: { workflowId: string; stepIds: string[] } | undefined;
try {
selection = this.deps.store.getTaskWorkflowSelection(task.id);
} catch (err) {
return this.fallBack(task.id, `selection-error: ${err instanceof Error ? err.message : String(err)}`);
}
if (!selection) {
return this.fallBack(task.id, "no-selection");
}
let definition: WorkflowDefinition | undefined;
try {
definition = await this.deps.store.getWorkflowDefinition(selection.workflowId);
} catch (err) {
return this.fallBack(task.id, `workflow-load-error: ${err instanceof Error ? err.message : String(err)}`);
}
if (!definition) {
return this.fallBack(task.id, `workflow-missing: ${selection.workflowId}`);
}
this.emit("start", task.id, definition.id);
try {
const executor = new WorkflowGraphExecutor({
seams: this.deps.seams,
runCustomNode: this.deps.runCustomNode,
maxRetriesPerNode: this.deps.maxRetriesPerNode,
});
const result = await executor.run(task, settings, definition.ir);
if (!result.executed) {
return this.fallBack(task.id, "not-executed");
}
const disposition: WorkflowGraphRunDisposition = result.outcome === "success" ? "completed" : "failed";
this.emit("terminal", task.id, `${definition.id}:${disposition}`);
return {
disposition,
outcome: result.outcome,
visitedNodeIds: result.visitedNodeIds,
context: result.context,
};
} catch (err) {
// Interpreter-level error (bad IR, handler wiring, etc.) — never strand
// the task; the caller runs the legacy pipeline.
return this.fallBack(task.id, `interpreter-error: ${err instanceof Error ? err.message : String(err)}`);
}
}
}