feat(engine): U3 — foreach expansion, iterative instance sub-walk, bounded rework cycles, step-execute seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 12:03:29 -07:00
parent ad5cd579be
commit a782e5c04c
6 changed files with 1134 additions and 8 deletions

View File

@@ -3,7 +3,7 @@ import type { TaskDetail, WorkflowIrNode } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule";
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule" | "step-execute";
export interface WorkflowLegacySeams {
/** Planning/spec stage. Built-in triage runs upstream of the interpreter
@@ -14,6 +14,32 @@ export interface WorkflowLegacySeams {
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>;
/**
* Step-inversion (KTD-2/KTD-4, U3): run exactly the foreach-active step inside
* the task's session/worktree. Only invoked for `step-execute` prompt nodes
* inside a foreach template, where `context["foreach:active"]` carries the
* active instance's `stepIndex`. Optional — a workflow that never uses a
* foreach/step-execute node needs no implementation (the noop seams omit it,
* and a step-execute node reached without this wired fails cleanly rather than
* silently no-opping). The engine wires this to `runTaskStep` (executor.ts
* createGraphSeams); it returns the per-step `baselineSha`/`checkpointId` in
* its `contextPatch` so a later RETHINK (U5) can reset the step.
*/
stepExecute?: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
}
/** The reserved context key carrying the active foreach instance (KTD-3, U3).
* Template node handlers (step-execute now; step-review in U5) read it to learn
* which step they operate on and the per-instance baseline/checkpoint state. */
export const FOREACH_ACTIVE_CONTEXT_KEY = "foreach:active";
/** Shape of the value stored under {@link FOREACH_ACTIVE_CONTEXT_KEY}. */
export interface ForeachActiveContext {
foreachNodeId: string;
stepIndex: number;
instanceId: string;
baselineSha?: string;
checkpointId?: string;
}
/**
@@ -31,7 +57,14 @@ export type WorkflowCustomNodeRunner = (
export function resolveSeamName(node: { config?: Record<string, unknown> }): WorkflowSeamName | undefined {
const seam = node.config?.seam;
if (seam === undefined) return undefined;
if (seam === "planning" || seam === "execute" || seam === "review" || seam === "merge" || seam === "schedule") {
if (
seam === "planning" ||
seam === "execute" ||
seam === "review" ||
seam === "merge" ||
seam === "schedule" ||
seam === "step-execute"
) {
return seam;
}
throw new WorkflowIrError(`Unsupported workflow seam: ${String(seam)}`);
@@ -47,8 +80,27 @@ export function createPromptLikeHandler(
): WorkflowNodeHandler {
return async (node, context) => {
const seam = resolveSeamName(node);
if (seam === "step-execute") {
// Step-inversion (U3): step-execute resolves the active foreach instance
// from the reserved context key and runs exactly that step. The active
// context is set by the executor's foreach sub-walk on instance entry.
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`,
);
}
if (!seams.stepExecute) {
// Fail closed: a step-execute node with no seam wired must NOT silently
// succeed — that would merge a task with no step work done.
return { outcome: "failure", value: "step-execute-unwired" };
}
return seams.stepExecute(context.task, context.context);
}
if (seam) {
return seams[seam](context.task, context.context);
return seams[seam]!(context.task, context.context);
}
if (!runCustomNode) {
throw new WorkflowIrError(`No custom-node runner registered for node: ${node.id}`);
@@ -91,15 +143,33 @@ export function createGateHandler(runCustomNode?: WorkflowCustomNodeRunner): Wor
};
}
/**
* Placeholder handler for the `step-review` node kind (KTD-4). The real verdict
* logic (delegating to `reviewStep`, mapping APPROVE/REVISE/RETHINK/UNAVAILABLE
* to outcome edges, and triggering RETHINK reset on rework traversal) is U5, NOT
* U3. Until U5 wires it, a step-review node reached during a foreach instance
* fails cleanly with a documented not-implemented value rather than throwing an
* unhandled-node-kind error — keeping a foreach with a step-review node from
* crashing the walk while making the gap explicit and routable.
*/
export const stepReviewNotImplementedHandler: WorkflowNodeHandler = async (node) => ({
outcome: "failure",
value: "step-review-not-implemented",
contextPatch: {
[`node:${node.id}:error`]: "step-review handler is not implemented until U5",
},
});
export function createDefaultNodeHandlers(
seams: WorkflowLegacySeams,
runCustomNode?: WorkflowCustomNodeRunner,
): Record<"prompt" | "script" | "gate", WorkflowNodeHandler> {
): Record<"prompt" | "script" | "gate" | "step-review", WorkflowNodeHandler> {
const promptLike = createPromptLikeHandler(seams, runCustomNode);
return {
prompt: promptLike,
script: promptLike,
gate: createGateHandler(runCustomNode),
"step-review": stepReviewNotImplementedHandler,
};
}