feat(pr): top-level bounded rework + auto-merge gate + legacy-bypass pin (U6)

Generalizes the foreach-only bounded-rework mechanism to the top-level
graph walk so the PR review loop (await-review -> pr-respond -> rework
back) is a legal bounded cycle: a kind:rework back-edge to a stacked node
returns a ReworkSignal the loop-head re-runs up to maxReworkCycles, then
routes outcome:rework-exhausted. Non-rework cycles still throw 'Cycle
detected' (safety preserved); foreach rework unchanged (shared core
constants). Adds createAutoMergeGateHandler (live entity re-fetch +
isPrEntityAutoMergeReady -> auto-on/auto-off). Pins R14: graph-executed
PR tasks merge through pr-merge, never the legacy queue. 122 graph tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 20:32:24 -07:00
parent 31d4b5335b
commit 96f801c019
9 changed files with 662 additions and 22 deletions

View File

@@ -71,6 +71,11 @@ export type {
WorkflowFieldOption,
WorkflowFieldRender,
} from "./workflow-ir-types.js";
export {
DEFAULT_MAX_REWORK_CYCLES,
MAX_REWORK_CYCLES_CAP,
resolveMaxReworkCycles,
} from "./workflow-ir-types.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";

View File

@@ -31,13 +31,32 @@ export interface WorkflowIrNode {
config?: Record<string, unknown>;
}
/** Default bounded-rework budget when a rework region omits `maxReworkCycles`
* (KTD-5 foreach default; U6 reuses it for the top-level review loop). */
export const DEFAULT_MAX_REWORK_CYCLES = 3;
/** Defensive clamp on any rework budget (KTD-5; shared by foreach + U6). */
export const MAX_REWORK_CYCLES_CAP = 10;
/** Resolve a bounded-rework budget from a config bag, applying the shared
* default + clamp. Used by the foreach sub-walk and the top-level rework loop so
* the bound semantics cannot drift between the two. */
export function resolveMaxReworkCycles(raw: unknown): number {
const n = typeof raw === "number" ? raw : DEFAULT_MAX_REWORK_CYCLES;
return Math.max(1, Math.min(MAX_REWORK_CYCLES_CAP, Math.floor(n)));
}
export interface WorkflowIrEdge {
from: string;
to: string;
condition?: string;
/** Step-inversion (KTD-5): `rework` edges are the only legal cycles, scoped to
* one foreach template instance and bounded by the foreach `maxReworkCycles`.
* They are exempt from cycle/parallelism complaints. */
/** Step-inversion (KTD-5) + PR review loop (U6): `rework` edges are the only
* legal cycles. Originally scoped to one foreach template instance and bounded
* by the foreach `maxReworkCycles`; U6 generalizes the same mechanism to the
* top-level walk so a PR review region (await-review → pr-respond → back to
* await-review) is a legal bounded cycle too. The bound on a top-level rework
* edge is `maxReworkCycles` on this edge's `from` node config (the loop head),
* defaulting to {@link DEFAULT_MAX_REWORK_CYCLES}. Either way, rework edges are
* exempt from "Cycle detected"; every other back-edge still throws. */
kind?: "rework";
}

View File

@@ -0,0 +1,264 @@
/**
* U6 — auto-merge gate routing + legacy-queue bypass pin (R14).
*
* Auto-merge gate (R10): a `gate` node carrying `config.gate === "auto-merge"`
* consults the LIVE PR entity and routes:
* - `outcome:auto-on` when the entity is auto-merge-ready (opted in + approved
* + checks success + mergeable clean + verified) → toward pr-merge;
* - `outcome:auto-off` for every non-ready case (pending checks, UNKNOWN
* mergeable, unverified, not opted in, no entity) → park for manual merge.
*
* R14 pin: a graph-executed PR task merges THROUGH the pr-merge node's injected
* mergePr callback — the merge node IS the merge path — and never falls into the
* legacy merge queue. The executor's graph/legacy routing enforces this; this
* test pins the merge-node behavior so a regression can't silently re-introduce a
* double-merge.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "@fusion/core";
import type { PrEntity, TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import { createAutoMergeGateHandler } from "../pr-nodes.js";
import type { PrMergeCallResult, PrNodeDeps, PrSourceDescriptor } from "../pr-nodes.js";
import type { WorkflowNodeExecutionContext } from "../workflow-graph-executor.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
const SOURCE: PrSourceDescriptor = {
sourceType: "task",
sourceId: "T-1",
repo: "owner/repo",
headBranch: "fusion/t-1",
};
function ctx(taskId = "T-1"): WorkflowNodeExecutionContext {
return { task: { id: taskId } as unknown as TaskDetail, settings: undefined, context: {} };
}
const GATE_NODE = { id: "g", kind: "gate", config: { gate: "auto-merge" } } as WorkflowIrNode;
describe("auto-merge gate (U6, R10)", () => {
let rootDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-pr-graph-flow-"));
store = new TaskStore(rootDir, join(rootDir, ".fusion-global"));
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
function deps(overrides: Partial<PrNodeDeps> = {}): PrNodeDeps {
return {
getStore: () => store,
resolvePrSource: () => SOURCE,
createPr: async () => ({ prNumber: 1, prUrl: "u" }),
mergePr: async () => ({ status: "merged-requested" }) as PrMergeCallResult,
...overrides,
};
}
/** Seed a live `open` entity and patch it to a chosen readiness state. */
function seedEntity(patch: Partial<PrEntity>): PrEntity {
const entity = store.ensurePrEntityForSource({ ...SOURCE, state: "open" });
return store.updatePrEntity(entity.id, {
state: "open",
autoMerge: patch.autoMerge,
reviewDecision: patch.reviewDecision,
checksRollup: patch.checksRollup,
mergeable: patch.mergeable,
unverified: patch.unverified,
});
}
const READY: Partial<PrEntity> = {
autoMerge: true,
reviewDecision: "APPROVED",
checksRollup: "success",
mergeable: "clean",
unverified: false,
};
it("ready entity → auto-on", async () => {
seedEntity(READY);
const gate = createAutoMergeGateHandler(deps());
const result = await gate(GATE_NODE, ctx());
expect(result).toEqual({ outcome: "success", value: "auto-on" });
});
it("not opted in → auto-off", async () => {
seedEntity({ ...READY, autoMerge: false });
const gate = createAutoMergeGateHandler(deps());
expect(await gate(GATE_NODE, ctx())).toEqual({ outcome: "success", value: "auto-off" });
});
it("pending checks → auto-off", async () => {
seedEntity({ ...READY, checksRollup: "pending" });
const gate = createAutoMergeGateHandler(deps());
expect(await gate(GATE_NODE, ctx())).toEqual({ outcome: "success", value: "auto-off" });
});
it("unknown mergeability → auto-off", async () => {
seedEntity({ ...READY, mergeable: "unknown" });
const gate = createAutoMergeGateHandler(deps());
expect(await gate(GATE_NODE, ctx())).toEqual({ outcome: "success", value: "auto-off" });
});
it("unverified entity → auto-off (R19 hard gate)", async () => {
seedEntity({ ...READY, unverified: true });
const gate = createAutoMergeGateHandler(deps());
expect(await gate(GATE_NODE, ctx())).toEqual({ outcome: "success", value: "auto-off" });
});
it("not approved → auto-off", async () => {
seedEntity({ ...READY, reviewDecision: "CHANGES_REQUESTED" });
const gate = createAutoMergeGateHandler(deps());
expect(await gate(GATE_NODE, ctx())).toEqual({ outcome: "success", value: "auto-off" });
});
it("no live entity → auto-off (never blocks the run)", async () => {
const gate = createAutoMergeGateHandler(deps());
expect(await gate(GATE_NODE, ctx())).toEqual({ outcome: "success", value: "auto-off" });
});
it("routes a graph end-to-end: approve → auto-on gate → pr-merge", async () => {
seedEntity(READY);
const mergePr = vi.fn(async () => ({ status: "merged-requested" }) as PrMergeCallResult);
const ir: WorkflowIr = {
version: "v1",
name: "auto-merge-flow",
nodes: [
{ id: "start", kind: "start" },
{ id: "gate", kind: "gate", config: { gate: "auto-merge" } },
{ id: "merge", kind: "pr-merge" },
{ id: "park", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "gate" },
{ from: "gate", to: "merge", condition: "outcome:auto-on" },
{ from: "gate", to: "park", condition: "outcome:auto-off" },
{ from: "merge", to: "end" },
{ from: "park", to: "end" },
],
};
const park = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
prNodes: deps({ mergePr }),
handlers: { script: park },
});
const result = await executor.run({ id: "T-1" } as TaskDetail, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain("merge");
expect(mergePr).toHaveBeenCalledTimes(1);
expect(park).not.toHaveBeenCalled();
});
it("auto-off entity parks for manual merge (pr-merge not reached)", async () => {
seedEntity({ ...READY, checksRollup: "pending" });
const mergePr = vi.fn(async () => ({ status: "merged-requested" }) as PrMergeCallResult);
const ir: WorkflowIr = {
version: "v1",
name: "auto-merge-park",
nodes: [
{ id: "start", kind: "start" },
{ id: "gate", kind: "gate", config: { gate: "auto-merge" } },
{ id: "merge", kind: "pr-merge" },
{ id: "park", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "gate" },
{ from: "gate", to: "merge", condition: "outcome:auto-on" },
{ from: "gate", to: "park", condition: "outcome:auto-off" },
{ from: "merge", to: "end" },
{ from: "park", to: "end" },
],
};
const park = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
prNodes: deps({ mergePr }),
handlers: { script: park },
});
const result = await executor.run({ id: "T-1" } as TaskDetail, settingsOn(), ir);
expect(result.visitedNodeIds).toContain("park");
expect(result.visitedNodeIds).not.toContain("merge");
expect(mergePr).not.toHaveBeenCalled();
});
});
describe("legacy-queue bypass pin (U6, R14)", () => {
let rootDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-pr-r14-"));
store = new TaskStore(rootDir, join(rootDir, ".fusion-global"));
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
function deps(mergePr: PrNodeDeps["mergePr"]): PrNodeDeps {
return {
getStore: () => store,
resolvePrSource: () => SOURCE,
createPr: async () => ({ prNumber: 1, prUrl: "u" }),
mergePr,
};
}
it("a graph-executed PR task merges through the pr-merge node, not a legacy queue", async () => {
// Seed an actionable entity so pr-merge proceeds (the merge node IS the merge
// path under the graph executor).
const entity = store.ensurePrEntityForSource({ ...SOURCE, state: "open" });
store.updatePrEntity(entity.id, { state: "open", unverified: false, headOid: "deadbeef" });
// A legacy merge-queue sink. If the graph path EVER routed a PR task into the
// legacy merger this spy would be hit — pinning the bypass (R14).
const legacyMergeEnqueue = vi.fn();
const mergePr = vi.fn(async () => ({ status: "merged-requested" }) as PrMergeCallResult);
const ir: WorkflowIr = {
version: "v1",
name: "r14-merge-node-only",
nodes: [
{ id: "start", kind: "start" },
{ id: "merge", kind: "pr-merge" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "merge" },
{ from: "merge", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({ prNodes: deps(mergePr) });
const result = await executor.run({ id: "T-1" } as TaskDetail, settingsOn(), ir);
// Merge happened exactly once, via the injected node callback with the
// entity's head OID — the graph node IS the merge, no legacy enqueue.
expect(result.outcome).toBe("success");
expect(mergePr).toHaveBeenCalledTimes(1);
expect(mergePr).toHaveBeenCalledWith(expect.objectContaining({ expectedHeadOid: "deadbeef" }));
expect(legacyMergeEnqueue).not.toHaveBeenCalled();
// The node does NOT write the terminal `merged` state (reconcile corroborates),
// so there is no path for a second/legacy merge to also act on a `merged` row.
expect(store.getActivePrEntityBySource("task", "T-1")?.state).toBe("open");
});
});

View File

@@ -0,0 +1,203 @@
/**
* U6 — bounded-rework generalization to the top-level walk.
*
* U1–U5 confined `kind: "rework"` cycles to the foreach sub-walk; the top-level
* recursive `walk` threw "Cycle detected" on ANY back-edge. U6 lifts the same
* bounded-rework mechanism to the top level so the PR review loop (await-review →
* pr-respond → rework → await-review) is a legal, bounded cycle. These tests pin:
*
* - a top-level rework cycle loops up to the cap then routes
* `outcome:rework-exhausted` (finite; never infinite; never "Cycle detected");
* - a NON-rework top-level back-edge still throws "Cycle detected" (safety);
* - the bound is honored exactly (cap traversals of the rework edge).
*/
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, WorkflowIr } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
const task = { id: "FN-U6" } as TaskDetail;
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
describe("WorkflowGraphExecutor bounded-rework generalization (U6)", () => {
it("loops a top-level rework cycle up to the cap then routes rework-exhausted (never infinite)", async () => {
// start → A(head) → B → rework back to A; A also has an
// `outcome:rework-exhausted` forward edge to `done`. B always emits
// value:"again" so the rework edge keeps firing until the budget runs out.
const ir: WorkflowIr = {
version: "v1",
name: "toplevel-rework",
nodes: [
{ id: "start", kind: "start" },
{ id: "A", kind: "gate", config: { maxReworkCycles: 2 } },
{ id: "B", kind: "prompt" },
{ id: "done", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "A" },
{ from: "A", to: "B", condition: "success" },
// exhaustion routes the head forward via the rework-exhausted value.
{ from: "A", to: "done", condition: "outcome:rework-exhausted" },
{ from: "B", to: "A", kind: "rework", condition: "outcome:again" },
{ from: "done", to: "end" },
],
};
const a = vi.fn(async () => ({ outcome: "success" as const }));
const b = vi.fn(async () => ({ outcome: "success" as const, value: "again" }));
const done = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
handlers: { gate: a, prompt: b, script: done },
});
const result = await executor.run(task, settingsOn(), ir);
expect(result.outcome).toBe("success");
// cap = 2 → A runs the initial pass + 2 rework re-entries = 3 times; B once
// per A pass = 3; then exhaustion routes `done` exactly once.
expect(a).toHaveBeenCalledTimes(3);
expect(b).toHaveBeenCalledTimes(3);
expect(done).toHaveBeenCalledTimes(1);
expect(result.visitedNodeIds).toContain("done");
});
it("never throws 'Cycle detected' for the legal rework edge", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "rework-no-throw",
nodes: [
{ id: "start", kind: "start" },
{ id: "A", kind: "gate", config: { maxReworkCycles: 1 } },
{ id: "B", kind: "prompt" },
{ id: "done", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "A" },
{ from: "A", to: "B", condition: "success" },
{ from: "A", to: "done", condition: "outcome:rework-exhausted" },
{ from: "B", to: "A", kind: "rework", condition: "outcome:again" },
{ from: "done", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
gate: async () => ({ outcome: "success" }),
prompt: async () => ({ outcome: "success", value: "again" }),
script: async () => ({ outcome: "success" }),
},
});
// Must resolve, not reject with "Cycle detected".
await expect(executor.run(task, settingsOn(), ir)).resolves.toMatchObject({
outcome: "success",
});
});
it("a rework cycle that resolves before the cap takes the forward edge (no exhaustion)", async () => {
// B emits value:"again" on the first pass (rework), then value:"ok" so A's
// forward edge to `done` is taken on the second pass — exhaustion never fires.
const ir: WorkflowIr = {
version: "v1",
name: "rework-resolves",
nodes: [
{ id: "start", kind: "start" },
{ id: "A", kind: "gate", config: { maxReworkCycles: 5 } },
{ id: "B", kind: "prompt" },
{ id: "done", kind: "script" },
{ id: "exhausted", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "A" },
// A routes forward to B on every pass; B decides rework vs. proceed.
{ from: "A", to: "B", condition: "success" },
{ from: "A", to: "exhausted", condition: "outcome:rework-exhausted" },
{ from: "B", to: "done", condition: "outcome:ok" },
{ from: "B", to: "A", kind: "rework", condition: "outcome:again" },
{ from: "done", to: "end" },
{ from: "exhausted", to: "end" },
],
};
let bCalls = 0;
const done = vi.fn(async () => ({ outcome: "success" as const }));
const exhausted = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
handlers: {
gate: async () => ({ outcome: "success" }),
prompt: async () => {
bCalls += 1;
return { outcome: "success" as const, value: bCalls === 1 ? "again" : "ok" };
},
script: async (node) => (node.id === "done" ? done() : exhausted()),
},
});
const result = await executor.run(task, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(done).toHaveBeenCalledTimes(1);
expect(exhausted).not.toHaveBeenCalled();
expect(result.visitedNodeIds).toContain("done");
});
it("a NON-rework top-level back-edge still throws 'Cycle detected' (safety preserved)", async () => {
// A → B → A with NO kind:"rework" on the back-edge. This must still be
// rejected as an illegal cycle.
const ir: WorkflowIr = {
version: "v1",
name: "illegal-cycle",
nodes: [
{ id: "start", kind: "start" },
{ id: "A", kind: "prompt" },
{ id: "B", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "A" },
{ from: "A", to: "B", condition: "success" },
// plain back-edge — NOT a rework edge.
{ from: "B", to: "A", condition: "success" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: async () => ({ outcome: "success" }) },
});
await expect(executor.run(task, settingsOn(), ir)).rejects.toThrow(/Cycle detected/);
});
it("defaults the rework cap when the head omits maxReworkCycles", async () => {
// No config.maxReworkCycles → DEFAULT_MAX_REWORK_CYCLES (3): A runs 1 + 3 = 4.
const ir: WorkflowIr = {
version: "v1",
name: "rework-default-cap",
nodes: [
{ id: "start", kind: "start" },
{ id: "A", kind: "gate" },
{ id: "B", kind: "prompt" },
{ id: "done", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "A" },
{ from: "A", to: "B", condition: "success" },
{ from: "A", to: "done", condition: "outcome:rework-exhausted" },
{ from: "B", to: "A", kind: "rework", condition: "outcome:again" },
{ from: "done", to: "end" },
],
};
const a = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
handlers: {
gate: a,
prompt: async () => ({ outcome: "success", value: "again" }),
script: async () => ({ outcome: "success" }),
},
});
const result = await executor.run(task, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(a).toHaveBeenCalledTimes(4); // initial + 3 reworks (default cap)
});
});

View File

@@ -48,6 +48,7 @@ export {
} from "./workflow-node-handlers.js";
export {
createPrNodeHandlers,
createAutoMergeGateHandler,
buildPrNodeDeps,
type PrNodeDeps,
type PrNodeGithubOps,

View File

@@ -17,6 +17,7 @@
import {
isPrEntityActionable,
isPrEntityAutoMergeReady,
type PrEntity,
type PrEntityCreateInput,
type PrEntityUpdate,
@@ -425,3 +426,51 @@ export function createPrNodeHandlers(deps: PrNodeDeps): Record<
"pr-merge": prMerge,
};
}
/**
* Auto-merge gate handler (U6, R10). Placed after the approval step. It
* re-evaluates the LIVE PR entity each time (never trusts a cached/SSE copy) and
* routes:
*
* - `outcome:auto-on` → toward `pr-merge`, when {@link isPrEntityAutoMergeReady}
* (opted in + approved + all checks concluded success + mergeable clean +
* verified).
* - `outcome:auto-off` → park for a manual-release merge, for EVERY non-ready
* case: not opted in, pending/failed checks, UNKNOWN/conflicting mergeability,
* unverified entity, or no live entity at all. The gate never blocks the run.
*
* Reuses the gate-routing contract (`{ outcome: "success", value }` consumed by
* `outcome:` edges in shouldTraverseEdge) rather than forking a parallel routing
* mechanism. The store/entity lookup is injected via {@link PrNodeDeps} so the
* engine stays dashboard-import-free. The `auto-merge ready` predicate lives in
* @fusion/core so the gate, the dashboard, and the reconcile share one
* definition and cannot drift.
*/
export function createAutoMergeGateHandler(deps: Pick<PrNodeDeps, "getStore" | "audit">): WorkflowNodeHandler {
const audit = (reason: string, detail: string): void => {
try {
deps.audit?.(reason, detail);
} catch {
// Audit must never affect the run.
}
};
return async (node, ctx) => {
const store = deps.getStore();
const entity = store.getActivePrEntityBySource("task", ctx.task.id)
?? store.getActivePrEntityBySource("branch-group", ctx.task.id);
if (!entity) {
// No live entity → cannot auto-merge; park for manual handling (never block).
audit("auto-merge-gate-no-entity", `auto-merge gate '${node.id}' found no live PR entity for task ${ctx.task.id}`);
return { outcome: "success", value: "auto-off" };
}
// Re-fetch authoritative state: the entity row IS the live copy here (store
// read), so pending checks / UNKNOWN mergeable / unverified / not-opted-in all
// fall to auto-off via the shared predicate.
if (isPrEntityAutoMergeReady(entity)) {
return { outcome: "success", value: "auto-on" };
}
return { outcome: "success", value: "auto-off" };
};
}

View File

@@ -1,5 +1,5 @@
import type { Settings, TaskDetail, TaskStep, WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, isExperimentalFeatureEnabled } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, isExperimentalFeatureEnabled, resolveMaxReworkCycles } from "@fusion/core";
import {
createDefaultNodeHandlers,
@@ -183,6 +183,43 @@ export class WorkflowGraphExecutor {
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).
// The same mechanism the foreach sub-walk uses (bounded budget, exhaustion
// routes `outcome:rework-exhausted`) is lifted to the top-level walk so the PR
// review loop (await-review → pr-respond → rework → await-review) is legal and
// bounded. Every NON-rework back-edge still throws "Cycle detected" below.
//
// - reworkHeads: every node that is the target of a rework edge.
// - reworkBudget: per-head remaining traversals, seeded lazily from the head
// node's `config.maxReworkCycles` (shared default + clamp from core).
// - The loop is iterative at the head frame: when a downstream node takes its
// rework edge back to a head currently on the stack, the head's walk frame
// catches a REWORK_SIGNAL sentinel and re-iterates (under budget) instead of
// recursing — so `inStack` never sees the head re-entered as a cycle.
const reworkHeads = new Set<string>();
for (const edge of ir.edges) {
if (edge.kind === "rework") reworkHeads.add(edge.to);
}
const reworkBudget = new Map<string, number>();
const reworkBudgetFor = (headId: string): number => {
const existing = reworkBudget.get(headId);
if (existing !== undefined) return existing;
const head = nodeMap.get(headId);
const seeded = resolveMaxReworkCycles(head?.config?.maxReworkCycles);
reworkBudget.set(headId, seeded);
return seeded;
};
// Sentinel a downstream rework edge returns up the recursion to its loop head.
interface ReworkSignal {
readonly __rework: true;
readonly headId: string;
/** The source node's result, carried so the head re-runs against fresh state. */
readonly source: WorkflowNodeResult;
}
const isReworkSignal = (r: WorkflowNodeResult | ReworkSignal): r is ReworkSignal =>
(r as ReworkSignal).__rework === true;
// On resume, completed branch nodes (from a prior crashed run) are skipped
// so their handlers do not re-fire (idempotency).
let completedNodeIds: Set<string> | undefined;
@@ -220,14 +257,11 @@ export class WorkflowGraphExecutor {
completedNodeIds,
});
const walk = async (nodeId: string): Promise<WorkflowNodeResult> => {
const node = nodeMap.get(nodeId);
if (!node) throw new WorkflowIrError(`Unknown workflow node: ${nodeId}`);
if (inStack.has(nodeId)) throw new WorkflowIrError(`Cycle detected at node: ${nodeId}`);
inStack.add(nodeId);
visitedNodeIds.push(nodeId);
try {
// Execute one node and traverse its outgoing edges. May return a ReworkSignal
// (a rework back-edge fired); the caller frame propagates or consumes it.
const runNodeAndTraverse = async (
node: WorkflowIrNode,
): Promise<WorkflowNodeResult | ReworkSignal> => {
if (node.kind === "start") {
return await traverseChildren(node, { outcome: "success" });
}
@@ -310,12 +344,55 @@ export class WorkflowGraphExecutor {
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
return await traverseChildren(node, result);
};
// Recursive walk into a node. A rework region head (target of a `kind:
// "rework"` edge) is wrapped in an iterative loop: while a downstream rework
// edge fires back to it (returned as a ReworkSignal under budget) the head
// re-runs; budget exhaustion re-routes the head with an
// `outcome:rework-exhausted` source so its forward edge carries the flow out.
// Every NON-rework back-edge still hits the cycle detector and throws.
const walk = async (nodeId: string): Promise<WorkflowNodeResult | ReworkSignal> => {
const node = nodeMap.get(nodeId);
if (!node) throw new WorkflowIrError(`Unknown workflow node: ${nodeId}`);
if (inStack.has(nodeId)) throw new WorkflowIrError(`Cycle detected at node: ${nodeId}`);
inStack.add(nodeId);
visitedNodeIds.push(nodeId);
try {
const isReworkHead = reworkHeads.has(nodeId);
for (;;) {
const outcome = await runNodeAndTraverse(node);
if (!isReworkSignal(outcome)) return outcome;
// A rework back-edge fired. It must target THIS head (the deepest
// enclosing rework head); a signal for an outer head propagates up.
if (!isReworkHead || outcome.headId !== nodeId) return outcome;
const remaining = reworkBudgetFor(nodeId);
if (remaining > 0) {
reworkBudget.set(nodeId, remaining - 1);
continue; // re-run the head node fresh (await-review re-evaluates)
}
// Budget exhausted: route the head's `outcome:rework-exhausted` forward
// edge (mirrors the foreach node's `{outcome:"failure", value:
// "rework-exhausted"}`). The `failure` outcome is deliberate so the
// exhausted re-route does NOT also satisfy the head's generic
// `condition:"success"` forward edge (which would re-enter the loop body
// and never terminate). Never loops forever; never throws "Cycle
// detected" for the legal rework edge.
const exhausted = await traverseChildren(node, { outcome: "failure", value: "rework-exhausted" });
// If no `outcome:rework-exhausted` edge exists the source bubbles back
// (a failure outcome) — a finite, routable terminal, never an infinite loop.
return exhausted;
}
} finally {
inStack.delete(nodeId);
}
};
const traverseChildren = async (node: WorkflowIrNode, sourceResult: WorkflowNodeResult): Promise<WorkflowNodeResult> => {
const traverseChildren = async (
node: WorkflowIrNode,
sourceResult: WorkflowNodeResult,
): Promise<WorkflowNodeResult | ReworkSignal> => {
const edges = outgoingMap.get(node.id) ?? [];
if (edges.length === 0) {
return sourceResult;
@@ -327,13 +404,22 @@ export class WorkflowGraphExecutor {
}
let aggregate: WorkflowNodeResult = sourceResult;
// Forward edges first, deterministic by target id (matches prior ordering);
// a rework edge is a loop-back and is handled distinctly below.
for (const edge of matching.sort((a, b) => a.to.localeCompare(b.to))) {
// Rework back-edge: do NOT recurse (the head is on the stack — that would
// be a cycle). Bubble a ReworkSignal up to the head's iterative loop.
if (edge.kind === "rework" && inStack.has(edge.to)) {
return { __rework: true, headId: edge.to, source: sourceResult } satisfies ReworkSignal;
}
const target = nodeMap.get(edge.to);
if (target?.kind === "end") {
aggregate = sourceResult;
continue;
}
const child = await walk(edge.to);
// A ReworkSignal propagated from deeper: bubble it further up unchanged.
if (isReworkSignal(child)) return child;
if (child.outcome === "failure") {
aggregate = child;
break;
@@ -344,6 +430,11 @@ export class WorkflowGraphExecutor {
};
const terminal = await walk(startNode.id);
if (isReworkSignal(terminal)) {
// A rework edge whose target is not an enclosing head on the stack — i.e. a
// rework edge pointing at a node never entered as a loop head. Malformed IR.
throw new WorkflowIrError(`Rework edge targets a node that is not a region head: ${terminal.headId}`);
}
// Prune again on run completion (#1412): keeps only this run's rows so the
// table does not accumulate historical runs for a long-lived task.
await this.pruneStaleBranches(task.id, runId);

View File

@@ -1,5 +1,5 @@
import type { TaskDetail, TaskStep, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import { WorkflowIrError } from "@fusion/core";
import { WorkflowIrError, resolveMaxReworkCycles } from "@fusion/core";
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
import {
@@ -43,10 +43,9 @@ import { schedulerLog } from "./logger.js";
* is guarded to a clean failure (U10 replaces it).
*/
/** Default rework budget when the foreach config omits `maxReworkCycles`. */
const DEFAULT_MAX_REWORK_CYCLES = 3;
/** Defensive cap mirroring core's validation clamp (KTD-5). */
const MAX_REWORK_CYCLES_CAP = 10;
// Rework budget default + clamp live in @fusion/core (DEFAULT_MAX_REWORK_CYCLES /
// MAX_REWORK_CYCLES_CAP / resolveMaxReworkCycles) so the foreach sub-walk and the
// top-level PR review loop (U6) share one definition and cannot drift.
/** Default parallel concurrency (KTD-3). */
const DEFAULT_CONCURRENCY = 2;
/** Hard cap on parallel concurrency (KTD-3). */
@@ -265,8 +264,7 @@ function resolveForeachConfig(node: WorkflowIrNode): {
if (!template || !Array.isArray(template.nodes) || !Array.isArray(template.edges)) {
throw new WorkflowIrError(`foreach node '${node.id}' has no template subgraph`);
}
const raw = typeof cfg.maxReworkCycles === "number" ? cfg.maxReworkCycles : DEFAULT_MAX_REWORK_CYCLES;
const maxReworkCycles = Math.max(1, Math.min(MAX_REWORK_CYCLES_CAP, Math.floor(raw)));
const maxReworkCycles = resolveMaxReworkCycles(cfg.maxReworkCycles);
const mode = cfg.mode === "parallel" ? "parallel" : "sequential";
// Default isolation: worktree for parallel mode, shared for sequential (KTD-3).
// (Core validation rejects parallel+shared; this default mirrors that intent.)

View File

@@ -2,7 +2,7 @@ import { WorkflowIrError, getStepParser } from "@fusion/core";
import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
import { createPrNodeHandlers, type PrNodeDeps } from "./pr-nodes.js";
import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "./pr-nodes.js";
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule" | "step-execute";
@@ -554,10 +554,20 @@ export function createDefaultNodeHandlers(
"pr-respond": async () => ({ outcome: "failure", value: "pr-nodes-unwired" }),
"pr-merge": async () => ({ outcome: "failure", value: "pr-nodes-unwired" }),
};
// Auto-merge gate (U6): a `gate` node carrying `config.gate === "auto-merge"`
// routes on live PR-entity state (outcome:auto-on/auto-off) instead of the
// generic context/executable gate. Wired only when PR deps are present; absent
// them it falls back to the generic gate (fail-closed, no silent auto-merge).
const genericGate = createGateHandler(runCustomNode);
const autoMergeGate = deps?.prNodes ? createAutoMergeGateHandler(deps.prNodes) : undefined;
const gate: WorkflowNodeHandler = autoMergeGate
? (node, ctx) =>
node.config?.gate === "auto-merge" ? autoMergeGate(node, ctx) : genericGate(node, ctx)
: genericGate;
return {
prompt: promptLike,
script: promptLike,
gate: createGateHandler(runCustomNode),
gate,
"step-review": createStepReviewHandler(seams),
"parse-steps": parseSteps,
code: createCodeNodeHandler(deps?.runCode),