Merge main into fast-tests: keep both CONCEPTS additions (workflow columns/plugins + merge-gate testing vocabulary)

This commit is contained in:
gsxdsm
2026-06-05 09:37:22 -07:00
261 changed files with 49409 additions and 5866 deletions

View File

@@ -43,6 +43,7 @@
"@earendil-works/pi-ai": "^0.78.0",
"@earendil-works/pi-coding-agent": "^0.78.0",
"cron-parser": "^5.5.0",
"esbuild": "^0.25.12",
"proper-lockfile": "^4.1.2",
"typebox": "^1.0.0"
},

View File

@@ -16,7 +16,13 @@ import {
createPostRoomMessageTool,
createResearchTools,
createWorkflowListTool,
createWorkflowGetTool,
createWorkflowSelectTool,
createTaskPromoteTool,
createWorkflowCreateTool,
createWorkflowUpdateTool,
createWorkflowDeleteTool,
createTraitListTool,
qmdAgentMemoryCollectionName,
readAgentMemoryWorkspaceLongTerm,
sendMessageParams,
@@ -26,6 +32,12 @@ import * as core from "@fusion/core";
import { ChatStore, Database } from "@fusion/core";
import type { MessageStore, Message } from "@fusion/core";
import { getEnabledPluginTools, getResearchToolSurfaceStatus } from "../tool-availability.js";
import { promoteHeldTask } from "../hold-release.js";
vi.mock("../hold-release.js", () => ({
promoteHeldTask: vi.fn(),
}));
const mockPromoteHeldTask = vi.mocked(promoteHeldTask);
const loggerSpies = vi.hoisted(() => ({
log: vi.fn(),
@@ -382,26 +394,125 @@ describe("createWorkflowListTool", () => {
});
});
describe("createWorkflowGetTool", () => {
it("returns the definition with builtin flag and full IR as JSON", async () => {
const ir = {
version: "v2",
name: "QA",
columns: [{ id: "intake", name: "Intake", traits: [] }],
nodes: [{ id: "n1", kind: "step-execute" }],
edges: [],
fields: [{ id: "severity", name: "Severity", type: "enum", options: [{ value: "low", label: "Low" }] }],
};
const store = {
getWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-003", name: "QA", description: "QA flow", ir }),
};
const tool = createWorkflowGetTool(store as any);
const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any);
expect(store.getWorkflowDefinition).toHaveBeenCalledWith("WF-003");
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
const parsed = JSON.parse(text);
expect(parsed).toMatchObject({ id: "WF-003", name: "QA", description: "QA flow", builtin: false });
expect(parsed.ir.fields[0].id).toBe("severity");
expect(result.details).toMatchObject({ workflowId: "WF-003", builtin: false });
});
it("marks a builtin id as builtin", async () => {
const store = {
getWorkflowDefinition: vi.fn().mockResolvedValue({
id: "builtin:coding",
name: "Coding",
description: "Standard",
ir: { version: "v2", name: "Coding", columns: [], nodes: [], edges: [] },
}),
};
const tool = createWorkflowGetTool(store as any);
const result = await tool.execute("call-1", { workflow_id: "builtin:coding" } as any, undefined, undefined, {} as any);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(JSON.parse(text).builtin).toBe(true);
expect(result.details).toMatchObject({ builtin: true });
});
it("includes layout when the definition carries editor positions", async () => {
const layout = { n1: { x: 10, y: 20 } };
const store = {
getWorkflowDefinition: vi.fn().mockResolvedValue({
id: "WF-005",
name: "Laid out",
description: "",
ir: { version: "v2", name: "Laid out", columns: [], nodes: [{ id: "n1", kind: "step-execute" }], edges: [] },
layout,
}),
};
const tool = createWorkflowGetTool(store as any);
const result = await tool.execute("call-1", { workflow_id: "WF-005" } as any, undefined, undefined, {} as any);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(JSON.parse(text).layout).toEqual(layout);
expect(result.details).toMatchObject({ workflowId: "WF-005", layout });
});
it("omits layout when the definition has none", async () => {
const store = {
getWorkflowDefinition: vi.fn().mockResolvedValue({
id: "WF-006",
name: "No layout",
description: "",
ir: { version: "v2", name: "No layout", columns: [], nodes: [], edges: [] },
}),
};
const tool = createWorkflowGetTool(store as any);
const result = await tool.execute("call-1", { workflow_id: "WF-006" } as any, undefined, undefined, {} as any);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(JSON.parse(text)).not.toHaveProperty("layout");
expect(result.details).not.toHaveProperty("layout");
});
it("returns an error result for an unknown id", async () => {
const store = { getWorkflowDefinition: vi.fn().mockResolvedValue(undefined) };
const tool = createWorkflowGetTool(store as any);
const result = await tool.execute("call-1", { workflow_id: "WF-404" } as any, undefined, undefined, {} as any);
expect((result as { isError?: boolean }).isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/Unknown workflow id 'WF-404'/);
});
});
describe("createWorkflowSelectTool", () => {
it("selects for the current task by default and reports enabled step count", async () => {
const store = { selectTaskWorkflow: vi.fn().mockResolvedValue(["workflow:WF-003:lint"]) };
const store = {
selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({ enabledWorkflowSteps: ["workflow:WF-003:lint"] }),
};
const tool = createWorkflowSelectTool(store as any, "FN-200");
const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any);
expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-200", "WF-003");
expect(store.selectTaskWorkflowAndReconcile).toHaveBeenCalledWith("FN-200", "WF-003");
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Selected workflow WF-003 for FN-200 (1 step enabled)");
expect(result.details).toMatchObject({ taskId: "FN-200", workflowId: "WF-003" });
});
it("honors an explicit task_id override", async () => {
const store = { selectTaskWorkflow: vi.fn().mockResolvedValue([]) };
const store = { selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({ enabledWorkflowSteps: [] }) };
const tool = createWorkflowSelectTool(store as any, "FN-200");
await tool.execute("call-1", { workflow_id: "builtin:coding", task_id: "FN-999" } as any, undefined, undefined, {} as any);
expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-999", "builtin:coding");
expect(store.selectTaskWorkflowAndReconcile).toHaveBeenCalledWith("FN-999", "builtin:coding");
});
it("surfaces the reconciliation re-home outcome", async () => {
const store = {
selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({
enabledWorkflowSteps: [],
reconciliation: { preserved: false, fromColumn: "review", toColumn: "intake" },
}),
};
const tool = createWorkflowSelectTool(store as any, "FN-200");
const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Re-homed from 'review' to 'intake'");
expect(result.details).toMatchObject({ reconciliation: { preserved: false, fromColumn: "review", toColumn: "intake" } });
});
it("returns an error result when selection fails", async () => {
const store = { selectTaskWorkflow: vi.fn().mockRejectedValue(new Error("Workflow not found: WF-404")) };
const store = { selectTaskWorkflowAndReconcile: vi.fn().mockRejectedValue(new Error("Workflow not found: WF-404")) };
const tool = createWorkflowSelectTool(store as any, "FN-200");
const result = await tool.execute("call-1", { workflow_id: "WF-404" } as any, undefined, undefined, {} as any);
expect((result as { isError?: boolean }).isError).toBe(true);
@@ -410,6 +521,124 @@ describe("createWorkflowSelectTool", () => {
});
});
describe("createTaskPromoteTool", () => {
beforeEach(() => mockPromoteHeldTask.mockReset());
it("promotes the current task by default and reports the destination column", async () => {
const store = {} as any;
mockPromoteHeldTask.mockResolvedValue({ released: true, toColumn: "ready" });
const tool = createTaskPromoteTool(store, "FN-200");
const result = await tool.execute("c", {} as any, undefined, undefined, {} as any);
expect(mockPromoteHeldTask).toHaveBeenCalledWith(store, "FN-200");
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Promoted FN-200 to column 'ready'");
expect(result.details).toMatchObject({ taskId: "FN-200", released: true, toColumn: "ready" });
});
it("honors an explicit task_id and surfaces a rejection as an error", async () => {
const store = {} as any;
mockPromoteHeldTask.mockResolvedValue({ released: false, rejection: "not-held" });
const tool = createTaskPromoteTool(store, "FN-200");
const result = await tool.execute("c", { task_id: "FN-999" } as any, undefined, undefined, {} as any);
expect(mockPromoteHeldTask).toHaveBeenCalledWith(store, "FN-999");
expect((result as { isError?: boolean }).isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/not-held/);
});
});
describe("createWorkflowCreateTool", () => {
it("creates a workflow and returns the new id", async () => {
const store = { createWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA" }) };
const tool = createWorkflowCreateTool(store as any);
const result = await tool.execute("c", { name: "QA", ir: { columns: [] } } as any, undefined, undefined, {} as any);
expect(store.createWorkflowDefinition).toHaveBeenCalledWith(
expect.objectContaining({ name: "QA", ir: { columns: [] } }),
);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Created workflow WF-010 (QA)");
});
it("returns an error result when creation fails", async () => {
const store = { createWorkflowDefinition: vi.fn().mockRejectedValue(new Error("Workflow name is required")) };
const tool = createWorkflowCreateTool(store as any);
const result = await tool.execute("c", { name: "", ir: {} } as any, undefined, undefined, {} as any);
expect((result as { isError?: boolean }).isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/name is required/);
});
});
describe("createWorkflowUpdateTool", () => {
it("updates a workflow and reports the name", async () => {
const store = { updateWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA v2" }) };
const tool = createWorkflowUpdateTool(store as any);
const result = await tool.execute("c", { workflow_id: "WF-010", name: "QA v2" } as any, undefined, undefined, {} as any);
expect(store.updateWorkflowDefinition).toHaveBeenCalledWith("WF-010", expect.objectContaining({ name: "QA v2" }));
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Updated workflow WF-010 (QA v2)");
});
it("forwards rehome_to as rehomeTo", async () => {
const store = { updateWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA" }) };
const tool = createWorkflowUpdateTool(store as any);
await tool.execute("c", { workflow_id: "WF-010", ir: { columns: [] }, rehome_to: "intake" } as any, undefined, undefined, {} as any);
expect(store.updateWorkflowDefinition).toHaveBeenCalledWith("WF-010", expect.objectContaining({ rehomeTo: "intake" }));
});
it("surfaces an OccupiedColumnsError as a structured retryable response", async () => {
const err = new core.OccupiedColumnsError("WF-010", [{ columnId: "review", count: 2 }]);
const store = { updateWorkflowDefinition: vi.fn().mockRejectedValue(err) };
const tool = createWorkflowUpdateTool(store as any);
const result = await tool.execute("c", { workflow_id: "WF-010", ir: { columns: [] } } as any, undefined, undefined, {} as any);
expect((result as { isError?: boolean }).isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("review (2)");
expect(text).toMatch(/rehome_to/);
expect(result.details).toMatchObject({
occupiedColumns: [{ columnId: "review", count: 2 }],
workflowId: "WF-010",
retryWith: "rehome_to",
});
});
});
describe("createWorkflowDeleteTool", () => {
it("deletes a workflow", async () => {
const store = { deleteWorkflowDefinition: vi.fn().mockResolvedValue(undefined) };
const tool = createWorkflowDeleteTool(store as any);
const result = await tool.execute("c", { workflow_id: "WF-010" } as any, undefined, undefined, {} as any);
expect(store.deleteWorkflowDefinition).toHaveBeenCalledWith("WF-010");
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Deleted workflow WF-010");
});
it("surfaces a built-in protection error", async () => {
const store = {
deleteWorkflowDefinition: vi.fn().mockRejectedValue(new Error("Built-in workflows cannot be deleted")),
};
const tool = createWorkflowDeleteTool(store as any);
const result = await tool.execute("c", { workflow_id: "builtin:coding" } as any, undefined, undefined, {} as any);
expect((result as { isError?: boolean }).isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/cannot be deleted/);
});
});
describe("createTraitListTool", () => {
it("lists the trait catalog with ids, names, and flags in details", async () => {
const tool = createTraitListTool();
const result = await tool.execute("c", {} as any, undefined, undefined, {} as any);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toMatch(/Available traits:/);
const traits = (result.details as { traits?: Array<{ id: string; name: string; flags: unknown }> }).traits ?? [];
expect(traits.length).toBeGreaterThan(0);
expect(traits[0]).toHaveProperty("id");
expect(traits[0]).toHaveProperty("name");
expect(traits[0]).toHaveProperty("flags");
});
});
describe("createTaskLogToolWithContext", () => {
it("returns a graceful archived read-only message instead of throwing", async () => {
const store = {

View File

@@ -0,0 +1,274 @@
/**
* U14 (KTD-15) — code node: esbuild compile, child-process execution, the
* harness contract, result→graph mapping, and failure modes.
*
* The child-process spawning tests use tiny inline sources and the real node
* binary; they are kept to a small focused set (happy/throw/timeout) so the
* suite stays fast. The result-mapping and customFields/contextPatch/instance
* scenarios use the injected `spawnRunner` seam (no spawn) for speed + hermetic
* determinism.
*/
import { describe, expect, it, vi } from "vitest";
import type { CustomFieldRejection, TaskDetail, WorkflowIrNode } from "@fusion/core";
import {
runCodeNode,
createCodeNodeRunner,
compileCodeNodeSource,
validateCodeNodeSources,
resolveCodeNodeTimeout,
CodeNodeError,
CODE_NODE_MAX_SOURCE_BYTES,
CODE_NODE_OUTPUT_CAP_BYTES,
type CodeNodeResult,
} from "../code-node-runner.js";
import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflow-node-handlers.js";
const RESULT_BEGIN = "__FUSION_CODE_NODE_RESULT_BEGIN__";
const RESULT_END = "__FUSION_CODE_NODE_RESULT_END__";
function task(over: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-CODE",
title: "T",
description: "d",
column: "work",
steps: [],
customFields: {},
...over,
} as unknown as TaskDetail;
}
function codeNode(source: string, timeoutMs?: number): WorkflowIrNode {
return { id: "code1", kind: "code", config: { source, ...(timeoutMs ? { timeoutMs } : {}) } };
}
/** A spawnRunner that frames a fixed result, so mapping logic is testable
* without spawning a child. */
function fakeSpawn(result: unknown, stderr = "") {
return async () => ({
stdout: `${RESULT_BEGIN}${JSON.stringify(result)}${RESULT_END}`,
stderr,
});
}
function runnerDeps(over: Partial<Parameters<typeof createCodeNodeRunner>[0]> = {}) {
const writes: Array<Record<string, unknown>> = [];
const audits: Array<{ reason: string; detail: string }> = [];
const deps = {
resolveCwd: () => process.cwd(),
readArtifacts: () => ({ "PROMPT.md": "hello" }),
writeCustomFields: async (_t: TaskDetail, patch: Record<string, unknown>) => {
writes.push(patch);
return { ok: true as const };
},
audit: (reason: string, detail: string) => audits.push({ reason, detail }),
...over,
};
return { deps, writes, audits };
}
describe("compileCodeNodeSource (U14)", () => {
it("compiles valid TS", async () => {
const out = await compileCodeNodeSource("export default async (ctx: any) => ({ value: ctx.task.id });");
expect(out).toContain("default");
});
it("throws compile-error on a syntax error", async () => {
await expect(compileCodeNodeSource("export default async (ctx => {")).rejects.toMatchObject({
reason: "compile-error",
});
});
it("rejects an over-size source defensively", async () => {
const huge = `export default async () => ({});//${"x".repeat(CODE_NODE_MAX_SOURCE_BYTES)}`;
await expect(compileCodeNodeSource(huge)).rejects.toMatchObject({ reason: "source-too-large" });
});
});
describe("resolveCodeNodeTimeout (U14)", () => {
it("defaults and clamps", () => {
expect(resolveCodeNodeTimeout(undefined)).toBe(30_000);
expect(resolveCodeNodeTimeout(500)).toBe(1000);
expect(resolveCodeNodeTimeout(999_999)).toBe(300_000);
expect(resolveCodeNodeTimeout(45_000)).toBe(45_000);
});
});
describe("validateCodeNodeSources (U14, save-time helper)", () => {
it("returns failures for uncompilable code nodes incl. inside foreach templates", async () => {
const innerBad: WorkflowIrNode = { id: "inner-bad", kind: "code", config: { source: "syntax ( error" } };
const ir = {
nodes: [
{ id: "ok", kind: "code", config: { source: "export default async () => ({});" } } as WorkflowIrNode,
{ id: "fe", kind: "foreach", config: { template: { nodes: [innerBad], edges: [] } } } as WorkflowIrNode,
],
};
const failures = await validateCodeNodeSources(ir);
expect(failures).toHaveLength(1);
expect(failures[0].nodeId).toBe("inner-bad");
});
it("returns empty for all-valid code", async () => {
const ir = { nodes: [codeNode("export default async () => ({ outcome: 'ok' });")] };
expect(await validateCodeNodeSources(ir)).toEqual([]);
});
it("does not crash on a malformed foreach template.nodes (non-array)", async () => {
const ir = {
nodes: [
// `nodes` is an object, not an array — a truthy-but-malformed config.
{ id: "fe", kind: "foreach", config: { template: { nodes: { bogus: true } } } } as unknown as WorkflowIrNode,
],
};
const failures = await validateCodeNodeSources(ir);
expect(failures).toEqual([{ nodeId: "fe", error: "foreach template.nodes must be an array" }]);
});
it("ignores a foreach with no template nodes", async () => {
const ir = {
nodes: [{ id: "fe", kind: "foreach", config: { template: {} } } as unknown as WorkflowIrNode],
};
expect(await validateCodeNodeSources(ir)).toEqual([]);
});
});
describe("createCodeNodeRunner result mapping (U14, seam-injected)", () => {
it("happy path: returns value + routes success", async () => {
const { deps } = runnerDeps({ spawnRunner: fakeSpawn({ value: "computed" }) });
const runner = createCodeNodeRunner(deps);
const result = await runner(codeNode("x"), task(), {});
expect(result.outcome).toBe("success");
expect(result.value).toBe("computed");
});
it("outcome string routes outcome:<value>", async () => {
const { deps } = runnerDeps({ spawnRunner: fakeSpawn({ outcome: "needs-review" }) });
const runner = createCodeNodeRunner(deps);
const result = await runner(codeNode("x"), task(), {});
expect(result.outcome).toBe("success");
expect(result.value).toBe("needs-review");
});
it("contextPatch is merged into the result", async () => {
const { deps } = runnerDeps({ spawnRunner: fakeSpawn({ contextPatch: { foo: 1, bar: "b" } }) });
const runner = createCodeNodeRunner(deps);
const result = await runner(codeNode("x"), task(), {});
expect(result.contextPatch).toMatchObject({ foo: 1, bar: "b" });
});
it("customFields patch goes through the authority", async () => {
const { deps, writes } = runnerDeps({ spawnRunner: fakeSpawn({ customFields: { priority: "high" } }) });
const runner = createCodeNodeRunner(deps);
const result = await runner(codeNode("x"), task(), {});
expect(result.outcome).toBe("success");
expect(writes).toEqual([{ priority: "high" }]);
});
it("customFields typed rejection → node failure surfacing the rejection", async () => {
const rejection: CustomFieldRejection = {
code: "type-mismatch",
fieldId: "priority",
detail: "expected number",
};
const { deps, audits } = runnerDeps({
spawnRunner: fakeSpawn({ customFields: { priority: "nope" } }),
writeCustomFields: async () => ({ ok: false as const, rejection }),
});
const runner = createCodeNodeRunner(deps);
const result = await runner(codeNode("x"), task(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("custom-field-rejected");
expect(result.contextPatch?.["node:code1:rejection"]).toContain("type-mismatch");
expect(audits.some((a) => a.reason === "custom-field-rejected")).toBe(true);
});
it("instance (foreach:active) is surfaced to the ctx assembly", async () => {
let receivedCtx: unknown;
const { deps } = runnerDeps({
spawnRunner: async ({ stdin }) => {
receivedCtx = JSON.parse(stdin);
return { stdout: `${RESULT_BEGIN}{}${RESULT_END}`, stderr: "" };
},
});
const runner = createCodeNodeRunner(deps);
const active = { foreachNodeId: "fe", stepIndex: 2, instanceId: "fe#2" };
await runner(codeNode("x"), task(), { [FOREACH_ACTIVE_CONTEXT_KEY]: active, other: "ctx" });
expect((receivedCtx as { instance?: { stepIndex?: number } }).instance?.stepIndex).toBe(2);
// The reserved key is stripped from the generic context snapshot.
expect((receivedCtx as { context?: Record<string, unknown> }).context).toEqual({ other: "ctx" });
});
it("bad result (no sentinels) → failure", async () => {
const { deps, audits } = runnerDeps({
spawnRunner: async () => ({ stdout: "garbage", stderr: "" }),
});
const runner = createCodeNodeRunner(deps);
const result = await runner(codeNode("x"), task(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("bad-result");
expect(audits.some((a) => a.reason === "bad-result")).toBe(true);
});
it("captures + caps stderr from a thrown child into the node result", async () => {
const big = "E".repeat(CODE_NODE_OUTPUT_CAP_BYTES * 2);
const { deps } = runnerDeps({
spawnRunner: async () => {
const err = Object.assign(new Error("child died"), { code: 7, stderr: big });
throw err;
},
});
const runner = createCodeNodeRunner(deps);
const result = await runner(codeNode("x"), task(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("runtime-throw");
const captured = String(result.contextPatch?.["node:code1:stderr"]);
expect(captured.length).toBeLessThan(big.length);
expect(captured).toContain("[truncated]");
});
});
describe("runCodeNode real child process (U14, hermetic)", () => {
it("happy path executes the harness and returns the parsed result", async () => {
const result: CodeNodeResult = await runCodeNode({
source: "export default async (ctx) => ({ value: ctx.task.id, outcome: undefined });",
cwd: process.cwd(),
ctx: { task: { id: "FN-CODE", title: "T", steps: [], customFields: {} }, context: {}, artifacts: {} },
});
expect(result.value).toBe("FN-CODE");
});
it("artifacts.read(key) returns pre-read content", async () => {
const result = await runCodeNode({
source: "export default async (ctx) => ({ value: ctx.artifacts.read('PROMPT.md') });",
cwd: process.cwd(),
ctx: {
task: { id: "x", title: "T", steps: [], customFields: {} },
context: {},
artifacts: { "PROMPT.md": "the-prompt" },
},
});
expect(result.value).toBe("the-prompt");
});
it("a runtime throw fails with stderr captured", async () => {
await expect(
runCodeNode({
source: "export default async () => { throw new Error('boom-runtime'); };",
cwd: process.cwd(),
ctx: { task: { id: "x", title: "T", steps: [], customFields: {} }, context: {}, artifacts: {} },
}),
).rejects.toMatchObject({ reason: "runtime-throw" });
});
it("timeout kills the child and fails with reason timeout", async () => {
await expect(
runCodeNode({
source: "export default async () => { while (true) {} };",
timeoutMs: 1000,
cwd: process.cwd(),
ctx: { task: { id: "x", title: "T", steps: [], customFields: {} }, context: {}, artifacts: {} },
}),
).rejects.toMatchObject({ reason: "timeout" });
}, 10_000);
});

View File

@@ -145,6 +145,34 @@ describe("buildExecutionPrompt", () => {
expect(result).not.toContain("## Attachments");
});
it("includes Custom fields section listing id/name/type, enum options, required, and current value", () => {
const task = createMockTaskDetail({ customFields: { severity: "high" } });
const result = buildExecutionPrompt(task, "/home/user/project", undefined, undefined, undefined, [
{ id: "severity", name: "Severity", type: "enum", required: true, options: [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
] },
{ id: "notes", name: "Notes", type: "text" },
] as any);
expect(result).toContain("## Custom fields");
expect(result).toContain("`severity` (Severity) — type: enum");
expect(result).toContain("options: [low (Low), high (High)]");
expect(result).toContain("required");
expect(result).toContain('current: "high"');
// The unset field reports "unset".
expect(result).toContain("`notes` (Notes) — type: text; current: unset");
});
it("omits Custom fields section when no field defs are provided", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).not.toContain("## Custom fields");
const resultEmpty = buildExecutionPrompt(task, "/home/user/project", undefined, undefined, undefined, []);
expect(resultEmpty).not.toContain("## Custom fields");
});
it("includes Project Commands section with test command when settings.testCommand is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
@@ -2514,3 +2542,33 @@ describe("TaskExecutor global pause behavior", () => {
});
});
describe("fn_task_update bare-call guard (P1 api-contract)", () => {
// createTaskUpdateTool is a private executor method; the bare-call guard runs
// before any store access, so we reach it via the lowest-cost seam: construct
// a TaskExecutor over a mock store and invoke the private method with `as any`.
function makeTool() {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
return (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }, new Map());
}
it("returns isError with a self-describing message when no fields are supplied", async () => {
const tool = makeTool();
const result = await tool.execute("call-1", {});
expect(result.isError).toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("fn_task_update requires at least one of");
// The legacy no-op text is preserved as the detail.
expect(text).toContain("No-op: provide a step+status, dependencies, or custom_fields to update.");
});
it("does not trigger the guard when a dependencies-only patch is supplied", async () => {
const tool = makeTool();
const result = await tool.execute("call-1", { dependencies: [] });
// Reaches the dependencies path, not the bare-call guard.
expect(result.isError).not.toBe(true);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).not.toContain("fn_task_update requires at least one of");
});
});

View File

@@ -3583,3 +3583,133 @@ describe("TaskExecutor loop recovery", () => {
// ── Context limit error recovery tests ────────────────────────────────
// ── U2 RETHINK delegation characterization (plan 2026-06-04-001, KTD-2) ──
//
// The legacy in-session fn_review_step RETHINK case now DELEGATES to
// step-runner.ts's resetStepToBaseline. These tests pin that the observable
// side effects are byte-identical to the pre-extraction block: git reset to
// the agent-supplied baseline, session rewind via navigateTree, step→pending,
// and the RETHINK log entry — all reached through the real executor session.
describe("U2: fn_review_step RETHINK delegates to resetStepToBaseline (characterization)", () => {
beforeEach(() => {
resetExecutorMocks();
});
function runRethinkScenario(reviewType: "code" | "plan", navigateTree: any) {
const store = createMockStore();
const baseTask = {
id: "FN-RT-1",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Implement", status: "in-progress" }],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 1: Implement\n- [ ] implement",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
store.getTask.mockResolvedValue(baseTask as any);
// updateStep returns the task with the step persisted in-progress so the
// executor's checkpoint-capture path (executor.ts ~6517) populates the
// stepCheckpoints map that RETHINK rewinds to.
store.updateStep.mockResolvedValue({
...baseTask,
steps: [{ name: "Implement", status: "in-progress" }],
} as any);
mockedReviewStep.mockResolvedValue({
verdict: "RETHINK",
review: "wrong approach",
summary: "rejected approach",
} as any);
let reviewToolError: unknown;
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
const tools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// First, flip the step to in-progress via fn_task_update so the
// checkpoint map is populated (mirrors the real session lifecycle).
const updateTool = tools.find((t: any) => t.name === "fn_task_update");
if (updateTool) {
try {
await updateTool.execute("tool-update", { step: 1, status: "in-progress" });
} catch { /* tool param shape varies; ignore */ }
}
const reviewTool = tools.find((t: any) => t.name === "fn_review_step");
if (reviewTool) {
try {
await reviewTool.execute("tool-review", {
step: 1,
type: reviewType,
step_name: "Implement",
baseline: reviewType === "code" ? "agentBaselineSHA" : undefined,
});
} catch (e) {
reviewToolError = e;
}
}
}),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
navigateTree,
sessionManager: {
getLeafId: vi.fn().mockReturnValue("leaf-pre-step"),
branchWithSummary: vi.fn(),
},
state: {},
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
return { store, baseTask, executor, getReviewToolError: () => reviewToolError };
}
it("code RETHINK: git reset to baseline, navigateTree rewind, step→pending, RETHINK log", async () => {
const navigateTree = vi.fn().mockResolvedValue(undefined);
const { store, baseTask, executor } = runRethinkScenario("code", navigateTree);
await executor.execute(baseTask as any);
// git reset --hard <baseline> issued in the worktree (via the mocked exec).
const resetIssued = mockedExecSync.mock.calls.some(
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard agentBaselineSHA"),
);
expect(resetIssued).toBe(true);
// Session rewound to the captured pre-step checkpoint.
expect(navigateTree).toHaveBeenCalledWith("leaf-pre-step", { summarize: false });
// Step reset to pending through the projection sink.
expect(store.updateStep).toHaveBeenCalledWith("FN-RT-1", 0, "pending");
// RETHINK log entry (code-review variant references the git reset).
expect(store.logEntry).toHaveBeenCalledWith(
"FN-RT-1",
expect.stringContaining("git reset to agentBaselineSHA"),
"rejected approach",
);
});
it("plan RETHINK: no git reset, navigateTree rewind, step→pending, plan-rewound log", async () => {
const navigateTree = vi.fn().mockResolvedValue(undefined);
const { store, baseTask, executor } = runRethinkScenario("plan", navigateTree);
await executor.execute(baseTask as any);
const resetIssued = mockedExecSync.mock.calls.some(
(c) => typeof c[0] === "string" && (c[0] as string).includes("git reset --hard"),
);
expect(resetIssued).toBe(false);
expect(navigateTree).toHaveBeenCalledWith("leaf-pre-step", { summarize: false });
expect(store.updateStep).toHaveBeenCalledWith("FN-RT-1", 0, "pending");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-RT-1",
expect.stringContaining("Step 1 plan rewound"),
"rejected approach",
);
});
});

View File

@@ -0,0 +1,457 @@
// @vitest-environment node
//
// HOLD/RELEASE SWEEP SUITE (U6).
//
// Exercises the generalized scheduler sweep (`hold-release.ts`) against a REAL
// TaskStore so the in-txn capacity check (KTD-10) actually arbitrates races:
// - two holds, one slot → exactly one releases; other retries next sweep
// - timer release fires at its deadline under fake timers (no real sleeps)
// - manual release only on the explicit promote call
// - capacity release respects mid-transitionPending cards (in-txn authority)
// - cross-workflow dependency complete-flag unblocks + dual-accept diff logged
// - sweep release into a full column rejected by the in-txn check despite
// moveSource:"scheduler" bypassing trait guards (capacity is not a guard)
// - reservation-first: semaphore exhausted → no commit, card stays held
// - paused / recovery-backoff tasks skipped exactly as the legacy scheduler
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { TaskStore, type WorkflowIr } from "@fusion/core";
import {
runHoldReleaseSweep,
promoteHeldTask,
releaseHeldTaskByEvent,
type HoldReleaseDeps,
type SlotReservation,
} from "../hold-release.js";
function git(cwd: string, args: string): void {
execSync(`git ${args}`, { cwd, stdio: "ignore" });
}
/** Directly set a task's stored column (test setup helper — bypasses adjacency
* validation so a card can be placed at an arbitrary workflow column). */
function setColumn(store: TaskStore, taskId: string, column: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare('UPDATE tasks SET "column" = ?, "columnMovedAt" = ? WHERE id = ?').run(
column,
new Date().toISOString(),
taskId,
);
}
/** Directly set a task's workflow selection row (bypasses step compilation). */
function setSelection(store: TaskStore, taskId: string, workflowId: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, '[]', ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, updatedAt = excluded.updatedAt`,
).run(taskId, workflowId, new Date().toISOString());
}
/** Write a transitionPending marker directly (simulating a crash mid-transition). */
function setTransitionPending(store: TaskStore, taskId: string, toColumn: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?").run(
JSON.stringify({ toColumn, hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }),
taskId,
);
}
const noReserveDeps: HoldReleaseDeps = { now: () => Date.now() };
describe("hold-release sweep (U6)", () => {
let rootDir = "";
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "u6-hold-release-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
vi.useRealTimers();
vi.clearAllMocks();
});
// A held card in the DEFAULT workflow: a task resting in `todo`
// (hold release: capacity), which releases into `in-progress` (wip).
async function seedTodoCard(): Promise<string> {
const task = await store.createTask({ description: "card" });
setColumn(store, task.id, "todo");
return task.id;
}
it("flag OFF: sweep is a no-op (legacy scheduler path untouched)", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
const id = await seedTodoCard();
const result = await runHoldReleaseSweep(store, noReserveDeps);
expect(result.released).toEqual([]);
expect((await store.getTask(id))?.column).toBe("todo");
});
it("two holds, one slot: exactly one releases; the other releases next sweep after the slot frees", async () => {
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const a = await seedTodoCard();
const b = await seedTodoCard();
const r1 = await runHoldReleaseSweep(store, noReserveDeps);
expect(r1.released.length).toBe(1);
const released = r1.released[0];
const stillHeld = released === a ? b : a;
expect((await store.getTask(released))?.column).toBe("in-progress");
expect((await store.getTask(stillHeld))?.column).toBe("todo");
// Free the slot by moving the released card out of in-progress.
await store.moveTask(released, "in-review", { moveSource: "engine", allowDirectInReviewMove: true });
const r2 = await runHoldReleaseSweep(store, noReserveDeps);
expect(r2.released).toContain(stillHeld);
expect((await store.getTask(stillHeld))?.column).toBe("in-progress");
});
it("FN-1415: two concurrent sweeps, one held card + one slot → exactly one release commits; loser's reservation is released", async () => {
// The scheduler can tick again before a slow sweep finishes. The in-txn
// capacity check (KTD-10) serializes the COMMIT, but we must also prove the
// reservation side effects across racing sweeps don't double-release or leak:
// the winning sweep moves the card, the loser's reservation is released, and
// the held card lands in exactly one downstream slot.
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const held = await seedTodoCard();
// Fake reservations: each reserveSlot hands out a distinct reservation whose
// release() we observe. Both racing sweeps see a free slot in the snapshot
// pre-check and reserve; only one move can commit (maxConcurrent: 1), so the
// loser must release its reservation.
let reserveCount = 0;
let releaseCount = 0;
const deps: HoldReleaseDeps = {
now: () => Date.now(),
reserveSlot: (): SlotReservation | null => {
reserveCount += 1;
return { release: () => { releaseCount += 1; } };
},
};
const [r1, r2] = await Promise.all([
runHoldReleaseSweep(store, deps),
runHoldReleaseSweep(store, deps),
]);
// The single held card was released into the single slot. Both sweeps may
// report it as released (the second sweep re-moves the already-released card
// to the SAME target — an idempotent same-column move the in-txn capacity
// check permits, since the card is itself the lone occupant). What must hold:
expect(r1.released.concat(r2.released)).toContain(held);
// (a) Single occupancy: the card lands in exactly one downstream slot, and is
// the only occupant of in-progress (no double-occupancy / slot leak).
expect((await store.getTask(held))?.column).toBe("in-progress");
const inProgress = (await store.listTasks({ includeArchived: false })).filter((t) => t.column === "in-progress");
expect(inProgress.map((t) => t.id)).toEqual([held]);
// (b) Reservation accounting across the racing sweeps.
//
// Both sweeps read the same snapshot, both pass the pre-check, and both
// reserve a slot (reserveCount === 2). The winning sweep commits the move;
// the losing sweep, after acquiring its reservation, re-reads the card's
// current column inside `issueRelease`, sees it already at the target (the
// winner moved it), and releases its reservation without issuing a redundant
// same-column move. The safety invariant therefore holds: at most one live
// reservation backs the single occupant.
expect(reserveCount).toBe(2);
// The loser releases its reservation, so the net live reservations is exactly
// one (the winner's), backing the single in-progress occupant — no leak.
expect(releaseCount).toBe(1);
expect(reserveCount - releaseCount).toBe(1);
});
it("sweep release into a full column is rejected by the in-txn check (capacity is not a guard, scheduler bypasses guards)", async () => {
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const occupant = await store.createTask({ description: "occupant" });
setColumn(store, occupant.id, "in-progress");
const held = await seedTodoCard();
const result = await runHoldReleaseSweep(store, noReserveDeps);
expect(result.released).not.toContain(held);
expect((await store.getTask(held))?.column).toBe("todo");
});
it("capacity release respects cards mid-transitionPending (they hold the slot from commit time)", async () => {
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
// Occupant has committed into in-progress AND is mid-transitionPending — it
// holds the slot; the in-txn count must include it.
const occupant = await store.createTask({ description: "occupant" });
setColumn(store, occupant.id, "in-progress");
setTransitionPending(store, occupant.id, "in-progress");
const held = await seedTodoCard();
const result = await runHoldReleaseSweep(store, noReserveDeps);
expect((await store.getTask(held))?.column).toBe("todo");
expect(result.released).not.toContain(held);
});
it("paused and recovery-backoff tasks are skipped exactly as the legacy scheduler", async () => {
await store.updateSettings({ maxConcurrent: 5 } as Parameters<typeof store.updateSettings>[0]);
const paused = await seedTodoCard();
await store.updateTask(paused, { paused: true });
const backoff = await seedTodoCard();
await store.updateTask(backoff, { nextRecoveryAt: new Date(Date.now() + 60_000).toISOString() });
const result = await runHoldReleaseSweep(store, { now: () => Date.now() });
expect(result.released).not.toContain(paused);
expect(result.released).not.toContain(backoff);
expect((await store.getTask(paused))?.column).toBe("todo");
expect((await store.getTask(backoff))?.column).toBe("todo");
});
it("reservation-first: semaphore exhausted → no commit, card stays held", async () => {
await store.updateSettings({ maxConcurrent: 5 } as Parameters<typeof store.updateSettings>[0]);
const held = await seedTodoCard();
// reserveSlot returns null (semaphore exhausted) for a processing-column
// release — the move must never be issued.
const deps: HoldReleaseDeps = {
now: () => Date.now(),
reserveSlot: (): SlotReservation | null => null,
};
const result = await runHoldReleaseSweep(store, deps);
expect(result.released).not.toContain(held);
expect((await store.getTask(held))?.column).toBe("todo");
});
it("reservation is RELEASED when the move rejects on capacity", async () => {
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const occupant = await store.createTask({ description: "occupant" });
setColumn(store, occupant.id, "in-progress");
const held = await seedTodoCard();
const releases: number[] = [];
let reserveCount = 0;
const deps: HoldReleaseDeps = {
now: () => Date.now(),
reserveSlot: (): SlotReservation | null => {
reserveCount += 1;
return { release: () => releases.push(1) };
},
};
const result = await runHoldReleaseSweep(store, deps);
expect(result.released).not.toContain(held);
// A reservation was taken (downstream pre-check passed since maxConcurrent
// read-through is evaluated against the snapshot) then released on the
// in-txn capacity rejection. If the pre-check already gated, reserveCount
// may be 0; if it reserved, it must have released exactly once.
if (reserveCount > 0) expect(releases.length).toBe(reserveCount);
});
});
// ── Timer / manual / external-event holds (custom workflows) ──────────────────
/** A custom workflow whose middle column is a hold with the given release kind.
* Columns: c-intake (intake) → c-hold (hold) → c-run (wip) → c-done (complete). */
function customHoldWorkflowIr(release: string, holdConfig: Record<string, unknown> = {}): WorkflowIr {
return {
version: "v2",
name: "custom-hold",
columns: [
{ id: "c-intake", name: "Intake", traits: [{ trait: "intake" }] },
{ id: "c-hold", name: "Hold", traits: [{ trait: "hold", config: { release, ...holdConfig } }] },
{ id: "c-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] },
{ id: "c-done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "c-intake" },
{ id: "end", kind: "end", column: "c-done" },
],
edges: [{ from: "start", to: "end" }],
} as WorkflowIr;
}
describe("hold-release sweep — timer / manual / external-event (U6)", () => {
let rootDir = "";
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "u6-hold-kinds-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
vi.useRealTimers();
});
async function seedCustomHold(release: string, holdConfig: Record<string, unknown> = {}): Promise<string> {
const def = await store.createWorkflowDefinition({ name: `wf-${release}`, ir: customHoldWorkflowIr(release, holdConfig) });
const task = await store.createTask({ description: `hold-${release}` });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "c-hold");
return task.id;
}
it("timer release fires at the deadline under fake timers (no real sleeps)", async () => {
vi.useFakeTimers();
const base = Date.now();
const id = await seedCustomHold("timer", { durationMs: 10_000 });
// Re-stamp columnMovedAt to the fake-clock base so the deadline is base+10s.
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare('UPDATE tasks SET "columnMovedAt" = ? WHERE id = ?').run(new Date(base).toISOString(), id);
// Before the deadline: not released.
const before = await runHoldReleaseSweep(store, { now: () => base + 5_000 });
expect(before.released).not.toContain(id);
expect((await store.getTask(id))?.column).toBe("c-hold");
// At/after the deadline: released into the downstream run column.
const after = await runHoldReleaseSweep(store, { now: () => base + 10_000 });
expect(after.released).toContain(id);
expect((await store.getTask(id))?.column).toBe("c-run");
});
it("manual hold: the sweep never auto-releases; an explicit promote does", async () => {
const id = await seedCustomHold("manual");
const swept = await runHoldReleaseSweep(store, { now: () => Date.now() });
expect(swept.released).not.toContain(id);
expect((await store.getTask(id))?.column).toBe("c-hold");
const promoted = await promoteHeldTask(store, id);
expect(promoted.released).toBe(true);
expect(promoted.toColumn).toBe("c-run");
expect((await store.getTask(id))?.column).toBe("c-run");
});
it("external-event hold: the sweep never auto-releases; an event release does; a stray event on a manual hold is a no-op", async () => {
const eventId = await seedCustomHold("external-event");
const swept = await runHoldReleaseSweep(store, { now: () => Date.now() });
expect(swept.released).not.toContain(eventId);
const released = await releaseHeldTaskByEvent(store, eventId, "webhook:approved");
expect(released.released).toBe(true);
expect((await store.getTask(eventId))?.column).toBe("c-run");
// A manual hold is NOT releasable by an external event.
const manualId = await seedCustomHold("manual");
const stray = await releaseHeldTaskByEvent(store, manualId, "webhook:approved");
expect(stray.released).toBe(false);
expect((await store.getTask(manualId))?.column).toBe("c-hold");
});
});
// ── Dependency gating (KTD-5 + FN-5719 dual-accept) ───────────────────────────
/** A custom workflow with a hold(dependency) column. */
function dependencyHoldWorkflowIr(): WorkflowIr {
return {
version: "v2",
name: "dep-hold",
columns: [
{ id: "d-intake", name: "Intake", traits: [{ trait: "intake" }] },
{ id: "d-hold", name: "Hold", traits: [{ trait: "hold", config: { release: "dependency" } }] },
{ id: "d-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] },
{ id: "d-done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "d-intake" },
{ id: "end", kind: "end", column: "d-done" },
],
edges: [{ from: "start", to: "end" }],
} as WorkflowIr;
}
/** A custom "producer" workflow whose terminal column carries the complete flag
* under a NON-legacy column id (so the complete-flag path differs from the
* legacy done/in-review/archived signal — used for the dual-accept diff). */
function completeFlagWorkflowIr(): WorkflowIr {
return {
version: "v2",
name: "producer",
columns: [
{ id: "p-intake", name: "Intake", traits: [{ trait: "intake" }] },
{ id: "p-finished", name: "Finished", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "p-intake" },
{ id: "end", kind: "end", column: "p-finished" },
],
edges: [{ from: "start", to: "end" }],
} as WorkflowIr;
}
describe("hold-release sweep — dependency gating (KTD-5)", () => {
let rootDir = "";
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "u6-dep-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("a dependency in another workflow's complete-flagged column unblocks the dependent; dual-accept logs a diff on disagreement", async () => {
const auditSpy = vi.spyOn(store, "recordRunAuditEvent");
const producerDef = await store.createWorkflowDefinition({ name: "producer", ir: completeFlagWorkflowIr() });
const dep = await store.createTask({ description: "producer task" });
setSelection(store, dep.id, producerDef.id);
// Producer NOT yet complete → dependent stays held.
setColumn(store, dep.id, "p-intake");
const depHoldDef = await store.createWorkflowDefinition({ name: "dep-hold", ir: dependencyHoldWorkflowIr() });
const dependent = await store.createTask({ description: "dependent", dependencies: [dep.id] });
setSelection(store, dependent.id, depHoldDef.id);
setColumn(store, dependent.id, "d-hold");
const r1 = await runHoldReleaseSweep(store, { now: () => Date.now() });
expect(r1.released).not.toContain(dependent.id);
expect((await store.getTask(dependent.id))?.column).toBe("d-hold");
// Move the producer into its complete-flagged column (NON-legacy id).
setColumn(store, dep.id, "p-finished");
auditSpy.mockClear();
const r2 = await runHoldReleaseSweep(store, { now: () => Date.now() });
expect(r2.released).toContain(dependent.id);
expect((await store.getTask(dependent.id))?.column).toBe("d-run");
// Dual-accept disagreement: the complete-flag says satisfied, but the legacy
// signal (column p-finished is NOT done/in-review/archived, no marker) says
// NOT satisfied → an audit-diff event was logged.
const diffLogged = auditSpy.mock.calls.some(
(call) => (call[0] as { mutationType?: string })?.mutationType === "merge:dependency-parity-diff",
);
expect(diffLogged).toBe(true);
});
});

View File

@@ -0,0 +1,630 @@
/**
* U7 — Merge trait behavior (R10).
*
* Covers every U7 plan scenario:
* - each `strategy` value routes to the merger behavior it names, incl.
* `pr-only` (enqueue-with-prState marker, documented below);
* - `fileScope` off / warn / strict / custom behaviors incl. audit payloads;
* - lost-work guard trio regression: config CANNOT reach the three guards;
* - merge completion drives the next column via the queue callback, not
* inline;
* - a queued merge surviving restart resumes from SQLite state (fixture).
*
* Fast: mock stores + a single in-memory `TaskStore` (no real git, no real
* merges). No process spawns; no fake-timer-dependent waits.
*
* PR-ONLY DESIGN DECISION: there is no PR-creation path inside the merge queue
* in this codebase — the merge-queue worker loop runs `aiMergeTask` (a direct
* merge). So `pr-only` is implemented as a *routing flag* on the resolved
* policy (`pullRequestOnly: true`), consistent with the existing
* `settings.mergeStrategy === "pull-request"` posture: `merger.ts` skips
* direct-merge commit routing exactly as it does for the pull-request setting.
* The card still enqueues onto the same persisted merge-request queue; the
* pr-state marker is the existing pr-monitor machinery's concern. This is the
* narrowest change that makes `pr-only` behave like the pull-request route
* without reimplementing merge mechanics.
*/
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
TaskStore,
getTraitRegistry,
type Settings,
type Task,
type WorkflowIr,
} from "@fusion/core";
import {
resolveMergePolicy,
registerMergeTraitHooks,
__resetMergeTraitRegistrationForTests,
} from "../merge-trait.js";
import {
assertSquashOverlapsFileScope,
enforceSquashFileScopeInvariant,
FileScopeViolationError,
} from "../merger.js";
// NOTE: this file deliberately does NOT import `./merger-test-helpers.js` — that
// module installs a module-scope `vi.mock("node:child_process")` that would
// break the real `git` operations the real-`TaskStore` fixtures here rely on.
// The file-scope tests use a real git repo and stage real files instead, so the
// merger's real `git diff --cached --name-only` returns the staged set.
// ── helpers ──────────────────────────────────────────────────────────────────
function settingsWith(overrides: Partial<Settings>): Settings {
return { ...DEFAULT_SETTINGS, ...overrides } as Settings;
}
/** Initialize a temp dir as a git repo with a `.fusion` dir so `createTask`
* (which writes `task.json`) works against a real `TaskStore`. */
async function initRepo(rootDir: string): Promise<void> {
const run = (cmd: string) => execSync(cmd, { cwd: rootDir, stdio: "pipe" });
run("git init -b main");
run('git config user.email "test@example.com"');
run('git config user.name "Test User"');
await writeFile(join(rootDir, "README.md"), "# fixture\n", "utf-8");
run("git add README.md");
run('git commit -m "chore: init"');
await mkdir(join(rootDir, ".fusion"), { recursive: true });
}
/** A linear custom workflow whose `in-review` column carries a merge trait with
* the given config. Linear so `selectTaskWorkflow` compiles it. */
function customMergeWorkflowIr(mergeConfig: Record<string, unknown>): WorkflowIr {
return {
version: "v2",
name: "custom-merge-wf",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] },
{
id: "in-review",
name: "In review",
traits: [{ trait: "merge", config: mergeConfig }],
},
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } },
{ id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute" },
{ from: "execute", to: "merge", condition: "success" },
{ from: "merge", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "failure" },
],
};
}
// ── 1. strategy routing (resolveMergePolicy) ─────────────────────────────────
describe("resolveMergePolicy — strategy routing", () => {
beforeEach(() => vi.clearAllMocks());
// Flag-OFF resolution returns before touching the store (settings passed in).
const noStore = {} as never;
it("flag OFF: falls back to settings (directMergeCommitStrategy + mergeStrategy)", async () => {
const settings = settingsWith({
mergeStrategy: "direct",
directMergeCommitStrategy: "always-rebase",
});
const policy = await resolveMergePolicy(noStore, { id: "FN-1", column: "in-review" }, settings);
expect(policy.source).toBe("settings");
expect(policy.commitStrategy).toBe("always-rebase");
expect(policy.pullRequestOnly).toBe(false);
});
it("flag OFF + pull-request setting: pullRequestOnly true via settings", async () => {
const settings = settingsWith({ mergeStrategy: "pull-request" });
const policy = await resolveMergePolicy(noStore, { id: "FN-1", column: "in-review" }, settings);
expect(policy.pullRequestOnly).toBe(true);
expect(policy.source).toBe("settings");
});
it.each([
["always-squash", "always-squash", false],
["auto", "auto", false],
["always-rebase", "always-rebase", false],
] as const)(
"flag ON: merge trait strategy '%s' resolves to commitStrategy '%s'",
async (strategy, expectedStrategy, expectedPrOnly) => {
const fx = await makeStoreFixture();
try {
await fx.selectCustomMergeWorkflow({ strategy });
const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" });
expect(policy.source).toBe("workflow");
expect(policy.commitStrategy).toBe(expectedStrategy);
expect(policy.pullRequestOnly).toBe(expectedPrOnly);
} finally {
await fx.cleanup();
}
},
);
it("flag ON: merge trait strategy 'pr-only' sets pullRequestOnly (PR-route, no direct merge)", async () => {
const fx = await makeStoreFixture();
try {
await fx.selectCustomMergeWorkflow({ strategy: "pr-only" });
const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" });
expect(policy.source).toBe("workflow");
expect(policy.pullRequestOnly).toBe(true);
} finally {
await fx.cleanup();
}
});
it("flag ON but default workflow (no merge config): resolves entirely from settings", async () => {
const fx = await makeStoreFixture();
try {
// No custom workflow selected → default workflow → merge trait has no
// config → settings read-through (verbatim back-compat). The flag is
// already ON from the fixture; set the project-level strategy.
await fx.store.updateSettings(settingsWith({ directMergeCommitStrategy: "auto" }));
const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" });
expect(policy.commitStrategy).toBe("auto");
// Default workflow's merge column has no config, so source is settings.
expect(policy.source).toBe("settings");
} finally {
await fx.cleanup();
}
});
});
// ── 2. fileScope modes (off / warn / strict / custom) ────────────────────────
//
// These use a REAL git repo with a staged out-of-scope file so the merger's
// real `git diff --cached --name-only` returns it. The store is a lightweight
// inline fake (NOT the merger-test-helpers mock, which would shadow git). The
// resolved fileScope mode is driven by the fake's settings + workflow stubs.
interface ScopeRepo {
rootDir: string;
/** Stage a file at the given repo-relative path (creates it). */
stage: (relPath: string) => Promise<void>;
cleanup: () => Promise<void>;
}
async function makeScopeRepo(): Promise<ScopeRepo> {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-scope-"));
await initRepo(rootDir);
const run = (cmd: string) => execSync(cmd, { cwd: rootDir, stdio: "pipe" });
return {
rootDir,
async stage(relPath) {
const abs = join(rootDir, relPath);
await mkdir(join(abs, ".."), { recursive: true });
await writeFile(abs, "// staged\n", "utf-8");
run(`git add -- "${relPath}"`);
},
cleanup: async () => {
await rm(rootDir, { recursive: true, force: true });
},
};
}
/** Inline fake store for the file-scope assertions: just the methods the
* resolver + enforcement read. `workflow` drives the resolved fileScope mode;
* omitting it (with a flag-off settings) yields the legacy `warn` mode. */
function fakeScopeStore(opts: {
declaredScope: string[];
settings: Settings;
scopeOverride?: boolean;
workflow?: { id: string; mergeConfig: Record<string, unknown> };
}) {
const task: Task = {
id: "FN-4073",
title: "scope task",
description: "x",
column: "in-review",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
scopeOverride: opts.scopeOverride,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
const parseFileScopeFromPrompt = vi.fn().mockResolvedValue(opts.declaredScope);
return {
task,
parseFileScopeFromPrompt,
store: {
getTask: vi.fn().mockResolvedValue(task),
getSettings: vi.fn().mockResolvedValue(opts.settings),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseFileScopeFromPrompt,
getTaskWorkflowSelection: vi.fn().mockReturnValue(
opts.workflow ? { workflowId: opts.workflow.id, stepIds: [] } : undefined,
),
getWorkflowDefinition: vi.fn().mockResolvedValue(
opts.workflow
? { id: opts.workflow.id, ir: customMergeWorkflowIr(opts.workflow.mergeConfig) }
: undefined,
),
} as never,
};
}
describe("fileScope modes — enforceSquashFileScopeInvariant", () => {
let repo: ScopeRepo;
beforeEach(async () => {
vi.clearAllMocks();
repo = await makeScopeRepo();
});
afterEach(async () => {
await repo.cleanup();
});
const declared = ["packages/engine/src/merger.ts"];
it("'warn' (legacy/flag-OFF default): logs + proceeds, audit carries the file list", async () => {
const { store } = fakeScopeStore({ declaredScope: declared, settings: settingsWith({}) });
await repo.stage("packages/core/src/store.ts"); // out of scope
const auditor = { git: vi.fn().mockResolvedValue(undefined) };
await expect(
enforceSquashFileScopeInvariant({
store,
taskId: "FN-4073",
rootDir: repo.rootDir,
task: await (store as never as { getTask: (id: string) => Promise<Task> }).getTask("FN-4073"),
resetLabel: "file-scope invariant violation",
auditor: auditor as never,
}),
).resolves.toBeUndefined();
expect(auditor.git).toHaveBeenCalledTimes(1);
const call = auditor.git.mock.calls[0][0];
expect(call.type).toBe("merge:file-scope-violation");
expect(call.metadata.warningOnly).toBe(true);
expect(call.metadata.stagedFiles).toEqual(["packages/core/src/store.ts"]);
expect(call.metadata.declaredScope).toEqual(declared);
});
it("'strict': re-throws FileScopeViolationError and audits with warningOnly=false", async () => {
const { store } = fakeScopeStore({
declaredScope: declared,
settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }),
workflow: { id: "wf-strict", mergeConfig: { fileScope: "strict" } },
});
await repo.stage("packages/core/src/store.ts");
const auditor = { git: vi.fn().mockResolvedValue(undefined) };
await expect(
enforceSquashFileScopeInvariant({
store,
taskId: "FN-4073",
rootDir: repo.rootDir,
task: await (store as never as { getTask: (id: string) => Promise<Task> }).getTask("FN-4073"),
resetLabel: "file-scope invariant violation",
auditor: auditor as never,
}),
).rejects.toBeInstanceOf(FileScopeViolationError);
const call = auditor.git.mock.calls[0][0];
expect(call.metadata.mode).toBe("strict");
expect(call.metadata.warningOnly).toBe(false);
});
it("'off': skips the throw and emits one scope-enforcement-disabled audit", async () => {
const { store } = fakeScopeStore({
declaredScope: declared,
settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }),
scopeOverride: true,
workflow: { id: "wf-off", mergeConfig: { fileScope: "off" } },
});
await repo.stage("packages/core/src/store.ts");
const auditor = { git: vi.fn().mockResolvedValue(undefined) };
await expect(
enforceSquashFileScopeInvariant({
store,
taskId: "FN-4073",
rootDir: repo.rootDir,
task: await (store as never as { getTask: (id: string) => Promise<Task> }).getTask("FN-4073"),
resetLabel: "file-scope invariant violation",
auditor: auditor as never,
}),
).resolves.toBeUndefined();
expect(auditor.git).toHaveBeenCalledTimes(1);
const call = auditor.git.mock.calls[0][0];
expect(call.type).toBe("merge:file-scope-enforcement-disabled");
expect(call.metadata.disabledByWorkflowConfig).toBe(true);
// per-task scopeOverride is a documented no-op in this mode
expect(call.metadata.scopeOverrideIsNoOp).toBe(true);
});
it("'custom': evaluates supplied rules in place of the prompt's File Scope", async () => {
// Prompt scope would be `declared` (no overlap), but custom rules DO overlap
// the staged file → no violation.
const { store, parseFileScopeFromPrompt } = fakeScopeStore({
declaredScope: declared,
settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }),
workflow: { id: "wf-custom", mergeConfig: { fileScope: "custom", rules: ["packages/core/src/**"] } },
});
await repo.stage("packages/core/src/store.ts"); // overlaps custom rules
const auditor = { git: vi.fn().mockResolvedValue(undefined) };
await expect(
enforceSquashFileScopeInvariant({
store,
taskId: "FN-4073",
rootDir: repo.rootDir,
task: await (store as never as { getTask: (id: string) => Promise<Task> }).getTask("FN-4073"),
resetLabel: "file-scope invariant violation",
auditor: auditor as never,
}),
).resolves.toBeUndefined();
expect(auditor.git).not.toHaveBeenCalled();
// The prompt's File Scope is bypassed when custom rules are present.
expect(parseFileScopeFromPrompt).not.toHaveBeenCalled();
});
it("'custom' with violating rules: rules replace prompt scope and a violation is detected", async () => {
const { store } = fakeScopeStore({
declaredScope: declared,
settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }),
workflow: { id: "wf-custom", mergeConfig: { fileScope: "custom", rules: ["docs/**"] } },
});
await repo.stage("packages/core/src/store.ts"); // does NOT overlap docs/**
const auditor = { git: vi.fn().mockResolvedValue(undefined) };
await expect(
enforceSquashFileScopeInvariant({
store,
taskId: "FN-4073",
rootDir: repo.rootDir,
task: await (store as never as { getTask: (id: string) => Promise<Task> }).getTask("FN-4073"),
resetLabel: "file-scope invariant violation",
auditor: auditor as never,
}),
).resolves.toBeUndefined();
expect(auditor.git).toHaveBeenCalledTimes(1);
expect(auditor.git.mock.calls[0][0].metadata.declaredScope).toEqual(["docs/**"]);
});
});
describe("assertSquashOverlapsFileScope — custom rules + scopeOverride interaction", () => {
let repo: ScopeRepo;
beforeEach(async () => {
vi.clearAllMocks();
repo = await makeScopeRepo();
});
afterEach(async () => {
await repo.cleanup();
});
it("custom rules override the per-task scopeOverride (rules take precedence)", async () => {
const { store } = fakeScopeStore({
declaredScope: ["packages/engine/**"],
settings: settingsWith({}),
scopeOverride: true,
});
await repo.stage("packages/core/src/store.ts"); // does not overlap custom rules
await expect(
assertSquashOverlapsFileScope({
store,
taskId: "FN-4073",
rootDir: repo.rootDir,
task: await (store as never as { getTask: (id: string) => Promise<Task> }).getTask("FN-4073"),
customScopeRules: ["packages/engine/**"],
}),
).rejects.toBeInstanceOf(FileScopeViolationError);
});
it("without custom rules, scopeOverride bypasses the check (legacy behavior intact)", async () => {
const { store } = fakeScopeStore({
declaredScope: ["packages/engine/**"],
settings: settingsWith({}),
scopeOverride: true,
});
await repo.stage("packages/core/src/store.ts");
await expect(
assertSquashOverlapsFileScope({
store,
taskId: "FN-4073",
rootDir: repo.rootDir,
task: await (store as never as { getTask: (id: string) => Promise<Task> }).getTask("FN-4073"),
}),
).resolves.toBeUndefined();
});
});
// ── 3. lost-work guard trio: config CANNOT reach them ────────────────────────
describe("lost-work guard trio is non-configurable (KTD-6 regression)", () => {
it("the merge trait config schema exposes NO field that names a lost-work guard", () => {
const def = getTraitRegistry().getTrait("merge");
expect(def).toBeDefined();
const keys = (def?.configSchema?.fields ?? []).map((f) => f.key);
// Only policy knobs — nothing that could disable sibling-branch rejection,
// line-anchored attribution, or no-op-finalize modifiedFiles preservation.
expect(keys.sort()).toEqual(["conflictStrategy", "fileScope", "rules", "squash", "strategy"]);
for (const forbidden of [
"allowSiblingMergeTarget",
"siblingBranch",
"attribution",
"clearModifiedFiles",
"noOpFinalize",
"lostWork",
]) {
expect(keys).not.toContain(forbidden);
}
});
it("resolved policy never carries a lost-work toggle, regardless of fileScope/strategy", async () => {
const fx = await makeStoreFixture();
try {
for (const cfg of [
{ fileScope: "off", strategy: "always-squash" },
{ fileScope: "warn", strategy: "auto" },
{ fileScope: "custom", rules: ["**/*"], strategy: "pr-only" },
] as const) {
await fx.selectCustomMergeWorkflow(cfg);
const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" });
// The resolved policy object's keys are a closed set — no guard knob.
expect(Object.keys(policy).sort()).toEqual(
["commitStrategy", "fileScope", "fileScopeRules", "pullRequestOnly", "source"].sort(),
);
}
} finally {
await fx.cleanup();
}
});
});
// ── 4. merge trait hooks: enqueue (onEnter) drives queue, never inline ───────
describe("merge trait hooks — enqueue-only, queue-driven", () => {
beforeEach(() => {
__resetMergeTraitRegistrationForTests();
registerMergeTraitHooks();
});
it("registers real onEnter/onExit impls in the registry (not degraded no-ops)", () => {
const onEnter = getTraitRegistry().resolveTraitHook("merge", "onEnter");
const onExit = getTraitRegistry().resolveTraitHook("merge", "onExit");
expect(onEnter.impl).toBeDefined();
expect(onEnter.warning).toBeUndefined(); // a real impl is registered
expect(onExit.impl).toBeDefined();
expect(onExit.warning).toBeUndefined();
});
it("onEnter enqueues onto the persisted merge queue and never awaits a merge", async () => {
const fx = await makeStoreFixture();
try {
const onEnter = getTraitRegistry().resolveTraitHook("merge", "onEnter").impl as (
s: TaskStore,
t: { id: string; priority?: string },
) => Promise<void>;
const task = await fx.store.getTask(fx.taskId);
await onEnter(fx.store, { id: task.id, priority: task.priority });
// Exactly one queue entry; the merge itself is NOT performed by the hook.
expect(fx.peekQueue(fx.taskId)).toBeTruthy();
const after = await fx.store.getTask(fx.taskId);
expect(after.column).toBe("in-review"); // hook did not move the card
} finally {
await fx.cleanup();
}
});
it("onEnter is idempotent: re-running (crash-replay) holds exactly one entry", async () => {
const fx = await makeStoreFixture();
try {
const onEnter = getTraitRegistry().resolveTraitHook("merge", "onEnter").impl as (
s: TaskStore,
t: { id: string; priority?: string },
) => Promise<void>;
const task = await fx.store.getTask(fx.taskId);
await onEnter(fx.store, { id: task.id, priority: task.priority });
await onEnter(fx.store, { id: task.id, priority: task.priority });
expect(fx.queueCount()).toBe(1);
} finally {
await fx.cleanup();
}
});
});
// ── 5. queued merge survives restart (resumes from SQLite) ───────────────────
describe("queued merge survives restart (SQLite-authoritative)", () => {
it("a queued entry persists across a fresh TaskStore over the same DB file", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-merge-trait-"));
try {
await initRepo(rootDir);
// First store: create task in-review and enqueue.
const store1 = new TaskStore(rootDir, undefined, {});
await store1.init();
await store1.updateSettings(settingsWith({ mergeStrategy: "direct" }));
const created = await store1.createTask({
title: "resume",
description: "x",
column: "in-review",
branch: "fusion/fn-resume",
baseBranch: "main",
steps: [],
} as never);
const resumeId = created.id;
store1.enqueueMergeQueue(resumeId, {});
expect(store1.peekMergeQueue().some((e) => e.taskId === resumeId)).toBe(true);
store1.close();
// Second store over the same on-disk DB: the queued entry is still there.
const store2 = new TaskStore(rootDir, undefined, {});
await store2.init();
expect(store2.peekMergeQueue().some((e) => e.taskId === resumeId)).toBe(true);
store2.close();
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});
});
// ── shared in-memory store fixture ───────────────────────────────────────────
interface StoreFixture {
store: TaskStore;
taskId: string;
selectCustomMergeWorkflow: (mergeConfig: Record<string, unknown>) => Promise<void>;
peekQueue: (taskId: string) => unknown;
queueCount: () => number;
cleanup: () => Promise<void>;
}
async function makeStoreFixture(): Promise<StoreFixture> {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-merge-trait-"));
await initRepo(rootDir);
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await store.init();
await store.updateSettings(settingsWith({ mergeStrategy: "direct" }));
// `experimentalFeatures` is a GLOBAL setting (mirrors the characterization
// suite), so it must be set via updateGlobalSettings to flip the flag.
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } } as never);
const created = await store.createTask({
title: "merge-trait fixture",
description: "merge-trait fixture",
column: "in-review",
branch: "fusion/fn-mt",
baseBranch: "main",
steps: [],
} as never);
const taskId = created.id;
return {
store,
taskId,
async selectCustomMergeWorkflow(mergeConfig) {
const def = await store.createWorkflowDefinition({
name: `wf-${Math.random().toString(36).slice(2)}`,
ir: customMergeWorkflowIr(mergeConfig),
} as never);
await store.selectTaskWorkflow(taskId, def.id);
},
peekQueue(id) {
return store.peekMergeQueue().find((e) => e.taskId === id);
},
queueCount() {
return store.peekMergeQueue().length;
},
cleanup: async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
},
};
}

View File

@@ -0,0 +1,648 @@
// @vitest-environment node
//
// PLUGIN-CONTRIBUTED TRAITS SUITE (U8, R6/R15/R22, KTD-2/KTD-7).
//
// Asserts against REAL engine wiring per the branch-group dead-wiring lesson:
// - real TaskStore (in-memory sqlite) with the workflowColumns flag ON,
// - real core TraitRegistry (fresh per test) + built-ins,
// - real PluginLoader/PluginStore loading a JSON plugin module that declares
// `traits`,
// - real plugin-trait adapter (registration / gate eval / degrade / dependents).
//
// No engine methods are mocked. The only injected fake is the custom-node
// RUNNER (the prompt-session/script machinery), which is the documented seam the
// executor wires — we substitute a deterministic verdict producer so the test
// stays fast and offline.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import {
TaskStore,
PluginStore,
PluginLoader,
getTraitRegistry,
__resetTraitRegistryForTests,
registerBuiltinTraits,
registerDefaultWorkflowHooks,
__resetDefaultWorkflowHooksForTests,
validatePluginTraitContribution,
type WorkflowIr,
type PluginTraitContribution,
} from "@fusion/core";
import {
registerPluginTraits,
degradePluginTraits,
findLivePluginTraitDependents,
evaluatePluginGate,
pluginTraitRegistryId,
PluginTraitHasDependentsError,
} from "../plugin-trait-adapter.js";
import type { WorkflowCustomNodeRunner } from "../workflow-node-handlers.js";
import type { WorkflowNodeResult } from "../workflow-graph-executor.js";
function git(cwd: string, args: string): void {
execSync(`git ${args}`, { cwd, stdio: "ignore" });
}
/** Fresh registry with built-ins + default-workflow hooks re-wired (so the
* default-workflow move-effect hooks aren't degraded to no-ops mid-suite). */
function freshRegistry(): void {
__resetTraitRegistryForTests();
__resetDefaultWorkflowHooksForTests();
registerBuiltinTraits();
registerDefaultWorkflowHooks();
}
/** Raw column placement (bypasses adjacency validation for setup). */
function setColumn(store: TaskStore, taskId: string, column: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare('UPDATE tasks SET "column" = ?, "columnMovedAt" = ? WHERE id = ?').run(
column,
new Date().toISOString(),
taskId,
);
}
function setSelection(store: TaskStore, taskId: string, workflowId: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, '[]', ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, updatedAt = excluded.updatedAt`,
).run(taskId, workflowId, new Date().toISOString());
}
function readTransitionPending(store: TaskStore, taskId: string): string | null {
const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db;
const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = ?").get(taskId) as
| { transitionPending: string | null }
| undefined;
return row?.transitionPending ?? null;
}
/**
* A custom v2 workflow with three ordered columns. `gate-col` carries the given
* plugin trait id; order-derived adjacency lets a card move
* `intake-col → gate-col`.
*/
function customWorkflowIr(pluginTraitId: string, opts?: { traitConfig?: Record<string, unknown> }): WorkflowIr {
return {
version: "v2",
name: "Custom",
columns: [
{ id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] },
{
id: "gate-col",
name: "Gate",
traits: [{ trait: pluginTraitId, config: opts?.traitConfig }],
},
{ id: "done-col", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "intake-col" },
{ id: "end", kind: "end", column: "done-col" },
],
edges: [{ from: "start", to: "end" }],
} as WorkflowIr;
}
const PASS_RUNNER: WorkflowCustomNodeRunner = async (): Promise<WorkflowNodeResult> => ({
outcome: "success",
value: "passed",
});
const FAIL_RUNNER: WorkflowCustomNodeRunner = async (): Promise<WorkflowNodeResult> => ({
outcome: "failure",
value: "blocked",
});
describe("U8 plugin trait contribution validation (R22, schemaVersion)", () => {
it("rejects a malformed trait manifest (missing schemaVersion / name)", () => {
const errors = validatePluginTraitContribution({ traitId: "x" });
expect(errors.some((e) => e.includes("schemaVersion is required"))).toBe(true);
expect(errors.some((e) => e.includes("name is required"))).toBe(true);
});
it("rejects a sync `guard` hook key (built-in-only, R22)", () => {
const errors = validatePluginTraitContribution({
traitId: "g",
name: "G",
schemaVersion: 1,
hooks: { guard: true },
});
expect(errors.some((e) => e.includes("hooks.guard"))).toBe(true);
});
it("rejects a restricted flag (complete / archived, R22)", () => {
const completeErr = validatePluginTraitContribution({
traitId: "c",
name: "C",
schemaVersion: 1,
flags: { complete: true },
});
expect(completeErr.some((e) => e.includes("flags.complete"))).toBe(true);
const archivedErr = validatePluginTraitContribution({
traitId: "a",
name: "A",
schemaVersion: 1,
flags: { archived: true },
});
expect(archivedErr.some((e) => e.includes("flags.archived"))).toBe(true);
});
it("rejects a wrong schemaVersion (versioned extension contract)", () => {
const errors = validatePluginTraitContribution({ traitId: "v", name: "V", schemaVersion: 2 as unknown as 1 });
expect(errors.some((e) => e.includes("schemaVersion must be 1"))).toBe(true);
});
it("accepts a valid async-only gate contribution", () => {
const errors = validatePluginTraitContribution({
traitId: "approval",
name: "Approval gate",
schemaVersion: 1,
flags: { gate: true },
hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } },
});
expect(errors).toEqual([]);
});
});
describe("U8 registry resolution (valid trait resolves like a built-in)", () => {
beforeEach(() => {
freshRegistry();
});
afterEach(() => {
__resetTraitRegistryForTests();
});
it("registers a plugin trait under a plugin-namespaced id and resolves through the same lookup", () => {
const registry = getTraitRegistry();
const contribution: PluginTraitContribution = {
traitId: "approval",
name: "Approval gate",
schemaVersion: 1,
flags: { gate: true },
hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } },
};
const ids = registerPluginTraits({ registry, pluginId: "gate-plugin", contributions: [contribution], runCustomNode: PASS_RUNNER });
const id = pluginTraitRegistryId("gate-plugin", "approval");
expect(ids).toEqual([id]);
// Same lookup path as a built-in.
const def = registry.getTrait(id);
expect(def?.flags.gate).toBe(true);
expect(def?.builtin).toBeFalsy();
// Built-in still resolvable through the same registry.
expect(registry.getTrait("complete")?.flags.complete).toBe(true);
// The gate hook impl is registered (not a missing-impl degrade).
const resolved = registry.resolveTraitHook(id, "gate");
expect(resolved.impl).toBeTypeOf("function");
expect(resolved.warning).toBeUndefined();
});
it("registry rejects a restricted-flag plugin trait as a backstop (R22)", () => {
const registry = getTraitRegistry();
// The adapter builds a non-builtin definition; the registry enforces R22.
const bad: PluginTraitContribution = {
traitId: "sneaky",
name: "Sneaky",
schemaVersion: 1,
// @ts-expect-error — restricted flag deliberately set to prove the backstop.
flags: { complete: true },
};
expect(() =>
registerPluginTraits({ registry, pluginId: "p", contributions: [bad], runCustomNode: PASS_RUNNER }),
).toThrow(/restricted flag/i);
});
});
describe("U8 gate evaluation (blocking fails closed; advisory allows)", () => {
it("blocking gate: a failure verdict does not allow", async () => {
const result = await evaluatePluginGate({
traitRegistryId: "plugin:gate-plugin:approval",
descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" },
task: { id: "T1" } as never,
runCustomNode: FAIL_RUNNER,
});
expect(result.outcome).toBe("failure");
});
it("blocking gate: a pass verdict allows", async () => {
const result = await evaluatePluginGate({
traitRegistryId: "plugin:gate-plugin:approval",
descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" },
task: { id: "T1" } as never,
runCustomNode: PASS_RUNNER,
});
expect(result.outcome).toBe("success");
});
it("advisory gate: the handler reports the raw verdict (store layer record-and-allows)", async () => {
// evaluatePluginGate returns the raw runner outcome; the advisory
// "record-and-allow" decision is made at the store guard (see the store
// re-check suite below, which proves an advisory column move commits).
const result = await evaluatePluginGate({
traitRegistryId: "plugin:gate-plugin:approval",
descriptor: { mode: "prompt", prompt: "FYI", gateMode: "advisory" },
task: { id: "T1" } as never,
runCustomNode: FAIL_RUNNER,
});
expect(result.outcome).toBe("failure");
});
});
describe("U8 store gate re-check (pre-evaluated verdict, KTD-2)", () => {
let rootDir = "";
let store: TaskStore;
const gateTraitId = pluginTraitRegistryId("gate-plugin", "approval");
beforeEach(async () => {
freshRegistry();
const registry = getTraitRegistry();
registry.register({
id: gateTraitId,
name: "Approval gate",
flags: { gate: true },
hooks: { gate: true },
builtin: false,
});
// A LIVE gate hook impl (so the store enforces the recorded verdict rather
// than treating the gate as a degraded/passive no-op).
registry.registerTraitHookImpl(gateTraitId, "gate", () => undefined);
rootDir = mkdtempSync(join(tmpdir(), "u8-plugin-traits-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
__resetTraitRegistryForTests();
vi.clearAllMocks();
});
async function seedCardInGateWorkflow(config?: Record<string, unknown>): Promise<string> {
const def = await store.createWorkflowDefinition({
name: "Gate WF",
ir: customWorkflowIr(gateTraitId, { traitConfig: config }),
});
const task = await store.createTask({ description: "card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "intake-col");
return task.id;
}
it("blocking gate with NO recorded verdict rejects the move (fail closed)", async () => {
const id = await seedCardInGateWorkflow({ gateMode: "blocking" });
await expect(
store.moveTask(id, "gate-col", { moveSource: "user" }),
).rejects.toThrow(/has not been evaluated|did not pass/);
expect((await store.getTask(id)).column).toBe("intake-col");
});
it("blocking gate with a recorded ALLOW verdict permits the move", async () => {
const id = await seedCardInGateWorkflow({ gateMode: "blocking" });
store.recordPluginGateVerdict(id, "gate-col", {
traitId: gateTraitId,
allow: true,
gateMode: "blocking",
});
const moved = await store.moveTask(id, "gate-col", { moveSource: "user" });
expect(moved.column).toBe("gate-col");
});
it("blocking gate with a recorded DENY verdict rejects the move (typed rejection)", async () => {
const id = await seedCardInGateWorkflow({ gateMode: "blocking" });
store.recordPluginGateVerdict(id, "gate-col", {
traitId: gateTraitId,
allow: false,
gateMode: "blocking",
detail: "reviewer rejected",
});
await expect(
store.moveTask(id, "gate-col", { moveSource: "user" }),
).rejects.toThrow(/reviewer rejected/);
expect((await store.getTask(id)).column).toBe("intake-col");
});
it("advisory gate allows the move even without a verdict (record-and-allow)", async () => {
const id = await seedCardInGateWorkflow({ gateMode: "advisory" });
const moved = await store.moveTask(id, "gate-col", { moveSource: "user" });
expect(moved.column).toBe("gate-col");
});
it("engine-sourced move bypasses the plugin gate (KTD-9)", async () => {
const id = await seedCardInGateWorkflow({ gateMode: "blocking" });
// No verdict recorded; an engine move bypasses guards entirely.
const moved = await store.moveTask(id, "gate-col", { moveSource: "engine" });
expect(moved.column).toBe("gate-col");
});
});
describe("U8 onEnter hook degradation (card stays, marker cleared, no wedge)", () => {
let rootDir = "";
let store: TaskStore;
const traitId = pluginTraitRegistryId("notify-plugin", "boom");
beforeEach(async () => {
freshRegistry();
// A plugin trait with an onEnter hook whose impl THROWS.
const registry = getTraitRegistry();
registry.register({
id: traitId,
name: "Boom",
flags: { notify: true },
hooks: { onEnter: true },
builtin: false,
});
registry.registerTraitHookImpl(traitId, "onEnter", () => {
throw new Error("plugin onEnter blew up");
});
rootDir = mkdtempSync(join(tmpdir(), "u8-onenter-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
__resetTraitRegistryForTests();
});
it("a throwing plugin onEnter does NOT strand the card or wedge the lock", async () => {
// gate-col carries the throwing onEnter trait; move there, then verify a
// subsequent move still succeeds (the lock was not wedged) and the
// transitionPending marker did not stick.
const def = await store.createWorkflowDefinition({
name: "Boom WF",
ir: customWorkflowIr(traitId),
});
const task = await store.createTask({ description: "card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "intake-col");
// Degraded-not-stranded (KTD-2/R15): the move commits the column change in
// its transaction; plugin post-commit hooks are isolated from the move's
// success path (a throwing onEnter cannot fail the move, strand the card, or
// wedge the lock). The card lands in gate-col regardless of the plugin hook.
const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" });
expect(moved.column).toBe("gate-col");
// The marker was cleared post-commit — not left dangling.
expect(readTransitionPending(store, task.id)).toBeNull();
// The lock is not wedged: a follow-up move proceeds.
const back = await store.moveTask(task.id, "intake-col", { moveSource: "user" });
expect(back.column).toBe("intake-col");
});
});
describe("Residual C: plugin onEnter/onExit are INVOKED on the post-commit path", () => {
let rootDir = "";
let store: TaskStore;
const enterTrait = pluginTraitRegistryId("notify-plugin", "enter");
const exitTrait = pluginTraitRegistryId("notify-plugin", "exit");
let enterCalls = 0;
let exitCalls = 0;
beforeEach(async () => {
freshRegistry();
enterCalls = 0;
exitCalls = 0;
const registry = getTraitRegistry();
registry.register({ id: enterTrait, name: "Enter", flags: { notify: true }, hooks: { onEnter: true }, builtin: false });
registry.register({ id: exitTrait, name: "Exit", flags: { notify: true }, hooks: { onExit: true }, builtin: false });
registry.registerTraitHookImpl(enterTrait, "onEnter", () => { enterCalls += 1; });
registry.registerTraitHookImpl(exitTrait, "onExit", () => { exitCalls += 1; });
rootDir = mkdtempSync(join(tmpdir(), "u8-cohooks-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
__resetTraitRegistryForTests();
});
it("onEnter fires for the to-column's plugin trait; onExit fires for the from-column's", async () => {
// Workflow: intake-col(exit trait) → gate-col(enter trait) → done-col.
const ir = {
version: "v2",
name: "CoHooks",
columns: [
{ id: "intake-col", name: "Intake", traits: [{ trait: "intake" }, { trait: exitTrait }] },
{ id: "gate-col", name: "Gate", traits: [{ trait: enterTrait }] },
{ id: "done-col", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "intake-col" },
{ id: "end", kind: "end", column: "done-col" },
],
edges: [{ from: "start", to: "end" }],
} as WorkflowIr;
const def = await store.createWorkflowDefinition({ name: "CoHooks", ir });
const task = await store.createTask({ description: "card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "intake-col");
const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" });
expect(moved.column).toBe("gate-col");
expect(enterCalls).toBe(1); // gate-col onEnter
expect(exitCalls).toBe(1); // intake-col onExit
// Marker cleared (no strand).
expect(readTransitionPending(store, task.id)).toBeNull();
});
it("engine-sourced (bypassGuards) moves skip plugin hooks (KTD-9)", async () => {
const ir = {
version: "v2",
name: "CoHooks2",
columns: [
{ id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] },
{ id: "gate-col", name: "Gate", traits: [{ trait: enterTrait }] },
{ id: "done-col", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "intake-col" },
{ id: "end", kind: "end", column: "done-col" },
],
edges: [{ from: "start", to: "end" }],
} as WorkflowIr;
const def = await store.createWorkflowDefinition({ name: "CoHooks2", ir });
const task = await store.createTask({ description: "card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "intake-col");
await store.moveTask(task.id, "gate-col", { moveSource: "engine", bypassGuards: true });
expect(enterCalls).toBe(0); // engine move bypasses trait effects
});
});
describe("U8 plugin loader aggregation + disable/force-disable (KTD-7)", () => {
let rootDir = "";
let pluginStore: PluginStore;
let loader: PluginLoader;
let taskRoot = "";
let store: TaskStore;
const traitContribution: PluginTraitContribution = {
traitId: "approval",
name: "Approval gate",
schemaVersion: 1,
flags: { gate: true },
hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } },
};
const traitRegistryId = pluginTraitRegistryId("gate-plugin", "approval");
beforeEach(async () => {
freshRegistry();
rootDir = mkdtempSync(join(tmpdir(), "u8-loader-"));
pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
loader = new PluginLoader({ pluginStore, taskStore: { logActivity: vi.fn() } as never });
await pluginStore.init();
taskRoot = mkdtempSync(join(tmpdir(), "u8-loader-tasks-"));
git(taskRoot, "init -b main");
git(taskRoot, "config user.name 'Fusion'");
git(taskRoot, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(taskRoot, "README.md"), "root\n");
git(taskRoot, "add README.md");
git(taskRoot, "commit -m init");
store = new TaskStore(taskRoot, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
try { store?.close(); } catch { /* ignore */ }
if (taskRoot) rmSync(taskRoot, { recursive: true, force: true });
const { rm } = await import("node:fs/promises");
await rm(rootDir, { recursive: true, force: true });
__resetTraitRegistryForTests();
});
async function loadGatePlugin(): Promise<void> {
const pluginDir = join(rootDir, "plugins");
await mkdir(pluginDir, { recursive: true });
const plugin = {
manifest: { id: "gate-plugin", name: "Gate Plugin", version: "1.0.0" },
state: "installed",
hooks: {},
traits: [traitContribution],
};
const path = join(pluginDir, "gate-plugin.mjs");
await writeFile(path, `const plugin = ${JSON.stringify(plugin, null, 2)}; export default plugin;`);
await pluginStore.registerPlugin({ manifest: plugin.manifest, path });
await loader.loadAllPlugins();
}
it("loader aggregates plugin trait contributions with ownership", async () => {
await loadGatePlugin();
const traits = loader.getPluginTraits();
expect(traits).toHaveLength(1);
expect(traits[0].pluginId).toBe("gate-plugin");
expect(traits[0].trait.traitId).toBe("approval");
});
it("disable with cards in a plugin-trait column is BLOCKED with a typed dependents error", async () => {
await loadGatePlugin();
const registry = getTraitRegistry();
registerPluginTraits({
registry,
pluginId: "gate-plugin",
contributions: loader.getPluginTraits().map((t) => t.trait),
runCustomNode: PASS_RUNNER,
});
// Seed a live card in a column using the plugin trait.
const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId) });
const task = await store.createTask({ description: "card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "gate-col");
const resolveIr = (taskId: string): WorkflowIr | undefined =>
store.getTaskWorkflowSelection(taskId)?.workflowId === def.id ? def.ir : undefined;
const dependents = await findLivePluginTraitDependents({
store,
resolveTaskWorkflowIr: resolveIr,
pluginTraitIds: [traitRegistryId],
});
expect(dependents).toHaveLength(1);
expect(dependents[0].taskId).toBe(task.id);
expect(dependents[0].column).toBe("gate-col");
// The typed error is the disable block (mirrors the built-in-workflow block).
const err = new PluginTraitHasDependentsError("gate-plugin", dependents);
expect(err.dependents).toHaveLength(1);
expect(err.message).toContain("gate-plugin");
});
it("force-disable degrades the column to passive: hooks become no-ops, cards still movable", async () => {
await loadGatePlugin();
const registry = getTraitRegistry();
registerPluginTraits({
registry,
pluginId: "gate-plugin",
contributions: loader.getPluginTraits().map((t) => t.trait),
runCustomNode: FAIL_RUNNER, // would block if still live
});
const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId, { traitConfig: { gateMode: "blocking" } }) });
const task = await store.createTask({ description: "card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "intake-col");
// Before degrade: the gate hook impl is registered (not a missing-impl no-op).
expect(registry.resolveTraitHook(traitRegistryId, "gate").warning).toBeUndefined();
// Force-disable: degrade the trait's hooks to no-ops.
const degraded = degradePluginTraits(registry, [traitRegistryId]);
expect(degraded).toContain(traitRegistryId);
// The trait definition still resolves (column not bricked) but the hook is
// now the degraded no-op + audit warning path.
expect(registry.getTrait(traitRegistryId)).toBeDefined();
const resolved = registry.resolveTraitHook(traitRegistryId, "gate");
expect(resolved.warning?.kind).toBe("missing-hook-impl");
// Card is still movable into the degraded column with NO recorded verdict:
// the store guard sees the degraded (warning) gate and treats it as passive
// (KTD-7 — cards remain movable). A live (non-degraded) blocking gate would
// have rejected this move for lack of a verdict.
const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" });
expect(moved.column).toBe("gate-col");
});
});

View File

@@ -10,6 +10,12 @@ vi.mock("@earendil-works/pi-ai", () => ({
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }),
Union: (schemas: unknown[], opts?: unknown) => ({ anyOf: schemas, ...((opts as object) ?? {}) }),
Literal: (value: unknown) => ({ const: value }),
Unknown: (opts?: unknown) => ({ ...((opts as object) ?? {}) }),
Record: (_key: unknown, value: unknown, opts?: unknown) => ({
type: "object",
additionalProperties: value,
...((opts as object) ?? {}),
}),
},
}));

View File

@@ -101,7 +101,7 @@ describe("reliability interactions: executor no-fn_task_done vs worktree reclaim
branch: null,
worktreeSessionRetryCount: 1,
}));
expect(store.moveTask).toHaveBeenCalledWith("FN-4601", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-4601", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
// FN-4806: session-start missing-worktree is engine self-heal, must not burn retry budget
// and must not mark the task failed.
expect(store.updateTask).not.toHaveBeenCalledWith(

View File

@@ -82,7 +82,7 @@ describe("FN-5219 reliability interactions: in-progress limbo recovery", () => {
expect(first).toBe(1);
expect(second).toBe(0);
expect(mockStore.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true });
expect(mockStore.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
expect(mockStore.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:auto-recover-in-progress-limbo",
target: "FN-5149",

View File

@@ -87,8 +87,7 @@ describe("reliability interactions: FN-4917 worktree incomplete session-start",
}),
}));
expect(store.moveTask.mock.calls).toContainEqual(["FN-4917-T", "todo"]);
expect(store.moveTask.mock.calls.some((call: any[]) => call.length > 2)).toBe(false);
expect(store.moveTask.mock.calls).toContainEqual(["FN-4917-T", "todo", { moveSource: "engine", recoveryRehome: true }]);
for (const call of store.logEntry.mock.calls) {
const leaked = call.some((arg: unknown) => typeof arg === "string" && /Refusing to start coding agent/.test(arg));
expect(leaked).toBe(false);
@@ -113,7 +112,7 @@ describe("reliability interactions: FN-4917 worktree incomplete session-start",
await runRecovery(store, task, "Refusing to start coding agent in incomplete worktree: /tmp/wt", events);
expect(store.moveTask).toHaveBeenCalledWith("FN-4917-T", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-4917-T", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
expect(store.moveTask.mock.calls).not.toContainEqual(["FN-4917-T", "todo"]);
for (const call of store.logEntry.mock.calls) {
const leaked = call.some((arg: unknown) => typeof arg === "string" && /Refusing to start coding agent/.test(arg));

View File

@@ -235,6 +235,12 @@ vi.mock("@earendil-works/pi-ai", () => ({
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }),
Union: (schemas: unknown[], opts?: unknown) => ({ anyOf: schemas, ...((opts as object) ?? {}) }),
Literal: (value: unknown) => ({ const: value }),
Unknown: (opts?: unknown) => ({ ...((opts as object) ?? {}) }),
Record: (_key: unknown, value: unknown, opts?: unknown) => ({
type: "object",
additionalProperties: value,
...((opts as object) ?? {}),
}),
},
}));
vi.mock("@earendil-works/pi-coding-agent", () => {
@@ -256,7 +262,7 @@ import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "../wo
import { createFnAgent } from "../pi.js";
import { execSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import type { Task, TaskDetail, TaskStep, Column, Settings, StepStatus } from "@fusion/core";
import type { Task, TaskDetail, TaskStep, Column, ColumnId, Settings, StepStatus } from "@fusion/core";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedExecSync = vi.mocked(execSync);
@@ -320,7 +326,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
return store;
}
function makeTask(id: string, column: Column, overrides: Partial<Task> = {}): Task {
function makeTask(id: string, column: ColumnId, overrides: Partial<Task> = {}): Task {
return {
id,
title: `Task ${id}`,
@@ -336,7 +342,7 @@ function makeTask(id: string, column: Column, overrides: Partial<Task> = {}): Ta
};
}
function makeTaskDetail(id: string, column: Column, overrides: Partial<TaskDetail> = {}): TaskDetail {
function makeTaskDetail(id: string, column: ColumnId, overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
...makeTask(id, column, overrides),
prompt: overrides.prompt ?? "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n## Review Level: 0",

View File

@@ -92,6 +92,12 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
// U6: the hold/release sweep consults workflow selection + completion markers
// when the workflowColumns flag is ON; default mocks keep flag-OFF behavior
// (sweep early-returns before touching these).
getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null),
on: vi.fn(),
off: vi.fn(),
...overrides,
@@ -530,6 +536,50 @@ describe("Scheduler", () => {
});
});
describe("U6 hold/release sweep integration (flag-gated)", () => {
function setupTodoStore(workflowColumns: boolean) {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const todo = createMockTask({ id: "FN-1", column: "todo", dependencies: [] });
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([todo]),
getTask: vi.fn().mockResolvedValue(todo),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
experimentalFeatures: { workflowColumns },
}),
});
const scheduler = new Scheduler(store);
(scheduler as unknown as { running: boolean }).running = true;
return { store, scheduler };
}
it("flag-ON default-workflow pickup matches flag-OFF: same todo→in-progress dispatch", async () => {
// Flag-OFF baseline: the legacy pull-from-todo loop dispatches the card.
const off = setupTodoStore(false);
await off.scheduler.schedule();
const offMoves = vi.mocked(off.store.moveTask).mock.calls.map((c) => [c[0], c[1]]);
expect(offMoves).toContainEqual(["FN-1", "in-progress"]);
// Flag-ON: the sweep runs first (default-workflow todo is a capacity hold),
// then the legacy loop; the net dispatch is the SAME todo→in-progress move.
const on = setupTodoStore(true);
await on.scheduler.schedule();
const onMoves = vi.mocked(on.store.moveTask).mock.calls.map((c) => [c[0], c[1]]);
expect(onMoves).toContainEqual(["FN-1", "in-progress"]);
});
it("flag-OFF: the sweep never issues a scheduler-sourced move (legacy path byte-identical)", async () => {
const off = setupTodoStore(false);
await off.scheduler.schedule();
const schedulerSourcedMoves = vi
.mocked(off.store.moveTask)
.mock.calls.filter((c) => (c[2] as { moveSource?: string } | undefined)?.moveSource === "scheduler");
expect(schedulerSourcedMoves.length).toBe(0);
});
});
describe("backlog pressure reporter integration", () => {
it("invokes reporter from schedule when enabled", async () => {
vi.useFakeTimers();

View File

@@ -0,0 +1,124 @@
// @vitest-environment node
//
// #1411: self-healing recovery/backward moves on CUSTOM workflows must pass
// `recoveryRehome: true` (not rely on `bypassGuards`, which skips trait guards
// but NOT order-derived column-graph adjacency). A custom workflow whose
// order-derived adjacency lacks the custom-column → todo edge would otherwise
// reject the recovery move and strand the card.
//
// This exercises a REAL TaskStore (flag-ON) so the in-lock adjacency check
// (resolveAllowedColumns) actually runs:
// - a backward recovery move WITHOUT recoveryRehome (engine source +
// bypassGuards) is rejected by adjacency, proving bypassGuards alone is
// insufficient (the bug),
// - the SAME move WITH recoveryRehome: true succeeds (the fix self-healing
// now applies at its moveTask call sites).
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { TaskStore, type WorkflowIr } from "@fusion/core";
function git(cwd: string, args: string): void {
execSync(`git ${args}`, { cwd, stdio: "ignore" });
}
function setColumn(store: TaskStore, taskId: string, column: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
db.prepare('UPDATE tasks SET "column" = ?, "columnMovedAt" = ? WHERE id = ?').run(
column,
new Date().toISOString(),
taskId,
);
}
/**
* A custom workflow whose linear order is intake → build → done. Its
* order-derived adjacency has NO edge build → todo (todo is not even a column),
* so a recovery move build → todo is only reachable via recoveryRehome.
*/
function customIr(): WorkflowIr {
return {
version: "v2",
name: "linear-custom",
columns: [
{ id: "intake", name: "intake", traits: [{ trait: "intake" }] },
{ id: "build", name: "build", traits: [] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "intake" },
{ id: "work", kind: "prompt", column: "build", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "work", condition: "success" },
{ from: "work", to: "end", condition: "success" },
],
} as WorkflowIr;
}
describe("#1411 self-healing recovery move on custom workflows", () => {
let rootDir = "";
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn-1411-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
vi.clearAllMocks();
});
async function seedCardInBuild(): Promise<string> {
const wf = await store.createWorkflowDefinition({ name: "linear-custom", ir: customIr() });
const task = await store.createTask({ description: "stuck-in-build" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
setColumn(store, task.id, "build");
expect((await store.getTask(task.id)).column).toBe("build");
return task.id;
}
it("bypassGuards alone is rejected by order-derived adjacency (build → todo)", async () => {
const id = await seedCardInBuild();
let caught: unknown;
try {
// Mirrors a self-healing backward move BEFORE the fix: engine source +
// bypassGuards, but no recoveryRehome. Adjacency (build → todo) has no edge.
await store.moveTask(id, "todo", { moveSource: "engine", bypassGuards: true, preserveProgress: true });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect((await store.getTask(id)).column).toBe("build");
});
it("recoveryRehome: true lets the recovery move reach todo (the fix)", async () => {
const id = await seedCardInBuild();
await store.moveTask(id, "todo", {
moveSource: "engine",
recoveryRehome: true,
preserveProgress: true,
});
expect((await store.getTask(id)).column).toBe("todo");
});
it("recoveryRehome: true also reaches a terminal recovery target (archived)", async () => {
const id = await seedCardInBuild();
await store.moveTask(id, "archived", { moveSource: "engine", recoveryRehome: true });
expect((await store.getTask(id)).column).toBe("archived");
});
});

View File

@@ -118,7 +118,7 @@ describe("recoverInProgressLimbo", () => {
taskDoneRetryCount: null,
sessionFile: null,
}));
expect(store.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
domain: "database",
mutationType: "task:auto-recover-in-progress-limbo",

View File

@@ -458,6 +458,8 @@ describe("SelfHealingManager", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", {
preserveProgress: true,
preserveStatus: true,
moveSource: "engine",
recoveryRehome: true,
});
expect(store.updateTask).toHaveBeenLastCalledWith("FN-001", expect.objectContaining({
stuckKillCount: 7,
@@ -494,6 +496,8 @@ describe("SelfHealingManager", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", {
preserveProgress: true,
preserveStatus: true,
moveSource: "engine",
recoveryRehome: true,
});
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({
paused: false,
@@ -530,6 +534,8 @@ describe("SelfHealingManager", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", {
preserveProgress: true,
preserveStatus: true,
moveSource: "engine",
recoveryRehome: true,
});
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
@@ -1364,7 +1370,7 @@ describe("SelfHealingManager", () => {
"FN-1473",
expect.stringContaining("no-progress no-task_done failure"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-1473", "todo");
expect(store.moveTask).toHaveBeenCalledWith("FN-1473", "todo", { moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -2233,7 +2239,7 @@ describe("SelfHealingManager", () => {
"FN-3900",
expect.stringContaining("session-start unusable-worktree assertion"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-3900", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-3900", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -2277,7 +2283,7 @@ describe("SelfHealingManager", () => {
"FN-4559",
expect.stringContaining("session-start unusable-worktree assertion"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-4559", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-4559", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -2309,7 +2315,7 @@ describe("SelfHealingManager", () => {
"FN-4560",
expect.stringContaining("session-start unusable-worktree assertion"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-4560", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-4560", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -2349,7 +2355,7 @@ describe("SelfHealingManager", () => {
"FN-4651",
expect.stringContaining("Auto-recovered (no-progress): session-start refused unusable worktree"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-4651", "todo");
expect(store.moveTask).toHaveBeenCalledWith("FN-4651", "todo", { moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -2574,7 +2580,7 @@ describe("SelfHealingManager", () => {
"FN-2164",
expect.stringContaining("Auto-retry 1/3"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-2164", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-2164", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -3912,7 +3918,7 @@ describe("SelfHealingManager", () => {
"FN-1572",
expect.stringContaining("in-review task still had incomplete steps"),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-1572", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-1572", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -3971,7 +3977,7 @@ describe("SelfHealingManager", () => {
const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks();
expect(result).toBe(1);
expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-1", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-1", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -3999,7 +4005,7 @@ describe("SelfHealingManager", () => {
const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks();
expect(result).toBe(1);
expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-2", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-2", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});
@@ -5808,7 +5814,7 @@ describe("SelfHealingManager", () => {
expect(result).toBe(1);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith("FN-9003", "todo", { preserveProgress: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-9003", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
managerWithRecovery.stop();
});

View File

@@ -0,0 +1,397 @@
/**
* Unit tests for the U2 substrate seams (plan 2026-06-04-001, KTD-2):
* - runTaskStep — per-step driver over step-session physics.
* - resetStepToBaseline — verbatim RETHINK mechanics + blast-radius guard.
*
* Fast tests: real git / sessions / StepSessionExecutor are never touched —
* every external is injected via the explicit `deps` object (FN-5048 fake-timer
* convention is moot here since the seams take no clock). The executor's
* delegation of the legacy RETHINK block is characterized separately in
* executor-step-session.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
runTaskStep,
resetStepToBaseline,
makeAncestryBlastRadiusGuard,
type StepRunnerTask,
type SessionRef,
} from "../step-runner.js";
function makeStore() {
return {
updateStep: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
};
}
function makeTask(steps: Array<{ name?: string; status?: string }>): StepRunnerTask {
return { id: "FN-001", steps };
}
function makeSessionRef(opts?: {
navigateTree?: ReturnType<typeof vi.fn>;
branchWithSummary?: ReturnType<typeof vi.fn>;
leafId?: string;
}): SessionRef {
const navigateTree = opts?.navigateTree ?? vi.fn().mockResolvedValue(undefined);
const branchWithSummary = opts?.branchWithSummary ?? vi.fn();
return {
current: {
navigateTree,
sessionManager: {
branchWithSummary,
getLeafId: vi.fn().mockReturnValue(opts?.leafId ?? "leaf-pre-step"),
},
} as unknown as SessionRef["current"],
};
}
describe("runTaskStep", () => {
beforeEach(() => vi.clearAllMocks());
it("marks the step in-progress then done on success, capturing baseline + checkpoint", async () => {
const store = makeStore();
const task = makeTask([{ name: "Implement", status: "pending" }]);
const gitRevParse = vi.fn().mockResolvedValue("baseSHA123");
const captureCheckpointId = vi.fn().mockReturnValue("leaf-pre-step");
const runStep = vi.fn().mockResolvedValue({ success: true });
const result = await runTaskStep(
{ store, worktreePath: "/wt", runStep, gitRevParse, captureCheckpointId },
task,
0,
);
expect(result).toEqual({ outcome: "success", baselineSha: "baseSHA123", checkpointId: "leaf-pre-step" });
// Baseline is captured BEFORE the step runs.
expect(gitRevParse).toHaveBeenCalledWith("/wt");
expect(runStep).toHaveBeenCalledWith(0);
// Projection ordering: in-progress before done.
expect(store.updateStep.mock.calls).toEqual([
["FN-001", 0, "in-progress"],
["FN-001", 0, "done"],
]);
});
it("captures the baseline before running the step (order check)", async () => {
const store = makeStore();
const order: string[] = [];
const gitRevParse = vi.fn().mockImplementation(async () => {
order.push("baseline");
return "sha";
});
const runStep = vi.fn().mockImplementation(async () => {
order.push("run");
return { success: true };
});
await runTaskStep(
{ store, worktreePath: "/wt", runStep, gitRevParse, captureCheckpointId: () => "leaf" },
makeTask([{ status: "pending" }]),
0,
);
expect(order).toEqual(["baseline", "run"]);
});
it("leaves the step non-done on failure (no 'done'/'skipped' write)", async () => {
const store = makeStore();
const runStep = vi.fn().mockResolvedValue({ success: false, error: "boom" });
const result = await runTaskStep(
{
store,
worktreePath: "/wt",
runStep,
gitRevParse: async () => "baseSHA",
captureCheckpointId: () => "leaf",
},
makeTask([{ status: "pending" }]),
0,
);
expect(result).toEqual({ outcome: "failure", baselineSha: "baseSHA", checkpointId: "leaf" });
// Only the in-progress write happened — the failed step is left non-done.
expect(store.updateStep.mock.calls).toEqual([["FN-001", 0, "in-progress"]]);
expect(store.updateStep).not.toHaveBeenCalledWith("FN-001", 0, "done");
expect(store.updateStep).not.toHaveBeenCalledWith("FN-001", 0, "skipped");
});
it("still returns a result when baseline capture fails (best-effort)", async () => {
const store = makeStore();
const runStep = vi.fn().mockResolvedValue({ success: true });
const gitRevParse = vi.fn().mockRejectedValue(new Error("not a git repo"));
const result = await runTaskStep(
{ store, worktreePath: "/wt", runStep, gitRevParse, captureCheckpointId: () => "leaf" },
makeTask([{ status: "pending" }]),
0,
);
expect(result.outcome).toBe("success");
expect(result.baselineSha).toBeUndefined();
expect(result.checkpointId).toBe("leaf");
});
it("uses the default checkpoint capture from the session ref when none injected", async () => {
const store = makeStore();
const sessionRef = makeSessionRef({ leafId: "leaf-xyz" });
const result = await runTaskStep(
{
store,
worktreePath: "/wt",
runStep: async () => ({ success: true }),
gitRevParse: async () => "sha",
},
makeTask([{ status: "pending" }]),
0,
{ sessionRef },
);
expect(result.checkpointId).toBe("leaf-xyz");
});
});
describe("resetStepToBaseline", () => {
beforeEach(() => vi.clearAllMocks());
it("does git reset + session rewind + step→pending with baseline and checkpoint (code review)", async () => {
const store = makeStore();
const navigateTree = vi.fn().mockResolvedValue(undefined);
const sessionRef = makeSessionRef({ navigateTree });
// We can't observe the real git command without mocking child_process; verify
// the session rewind + projection happen. (The git path is exercised through
// the executor characterization test.)
const result = await resetStepToBaseline(
{ store, worktreePath: "/wt", sessionRef, reviewType: "code", summary: "rejected" },
makeTask([{ status: "in-progress" }]),
0,
"baseSHA",
"leaf-checkpoint",
);
expect(result).toEqual({ ok: true });
expect(navigateTree).toHaveBeenCalledWith("leaf-checkpoint", { summarize: false });
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
expect.stringContaining("git reset to baseSHA"),
"rejected",
);
});
it("skips the session rewind when no checkpoint is provided (partial path)", async () => {
const store = makeStore();
const navigateTree = vi.fn();
const branchWithSummary = vi.fn();
const sessionRef = makeSessionRef({ navigateTree, branchWithSummary });
const result = await resetStepToBaseline(
{ store, worktreePath: "/wt", sessionRef, reviewType: "code" },
makeTask([{ status: "in-progress" }]),
0,
"baseSHA",
undefined,
);
expect(result.ok).toBe(true);
expect(navigateTree).not.toHaveBeenCalled();
expect(branchWithSummary).not.toHaveBeenCalled();
// Step still flips to pending.
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
});
it("plan review skips git reset, logs the plan-rewound line, still flips pending", async () => {
const store = makeStore();
const navigateTree = vi.fn().mockResolvedValue(undefined);
const sessionRef = makeSessionRef({ navigateTree });
const result = await resetStepToBaseline(
{ store, worktreePath: "/wt", sessionRef, reviewType: "plan", summary: "plan rejected" },
makeTask([{ status: "in-progress" }]),
2,
undefined,
"leaf-checkpoint",
);
expect(result.ok).toBe(true);
expect(navigateTree).toHaveBeenCalledWith("leaf-checkpoint", { summarize: false });
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 2, "pending");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
// 0-indexed step 2 → 1-indexed "Step 3"
expect.stringContaining("Step 3 plan rewound"),
"plan rejected",
);
});
it("falls back to branchWithSummary when navigateTree throws", async () => {
const store = makeStore();
const navigateTree = vi.fn().mockRejectedValue(new Error("navigate failed"));
const branchWithSummary = vi.fn();
const sessionRef = makeSessionRef({ navigateTree, branchWithSummary });
const result = await resetStepToBaseline(
{ store, worktreePath: "/wt", sessionRef, reviewType: "code", summary: "why" },
makeTask([{ status: "in-progress" }]),
0,
"baseSHA",
"leaf-checkpoint",
);
expect(result.ok).toBe(true);
expect(branchWithSummary).toHaveBeenCalledWith("leaf-checkpoint", "RETHINK: why");
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
});
// ── KTD-2 blast-radius guard refusal cases ──────────────────────────────
it("REFUSES and mutates nothing when the guard reports a violation", async () => {
const store = makeStore();
const navigateTree = vi.fn();
const sessionRef = makeSessionRef({ navigateTree });
const audit = { database: vi.fn().mockResolvedValue(undefined) };
const blastRadiusGuard = vi.fn().mockResolvedValue("baseSHA is not an ancestor of HEAD");
const result = await resetStepToBaseline(
{ store, worktreePath: "/wt", sessionRef, reviewType: "code", audit, blastRadiusGuard },
makeTask([{ status: "in-progress" }]),
0,
"baseSHA",
"leaf-checkpoint",
);
expect(result).toEqual({ ok: false, reason: "baseSHA is not an ancestor of HEAD" });
// No mutation: no rewind, no updateStep, no RETHINK logEntry.
expect(navigateTree).not.toHaveBeenCalled();
expect(store.updateStep).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
// Audit warning emitted (task:integrity-warning, database domain).
expect(audit.database).toHaveBeenCalledWith(
expect.objectContaining({
type: "task:integrity-warning",
target: "FN-001",
metadata: expect.objectContaining({
guard: "step-reset-blast-radius",
reason: "baseSHA is not an ancestor of HEAD",
}),
}),
);
});
it("fails closed (refuses) when the guard itself throws", async () => {
const store = makeStore();
const sessionRef = makeSessionRef();
const blastRadiusGuard = vi.fn().mockRejectedValue(new Error("git exploded"));
const result = await resetStepToBaseline(
{ store, worktreePath: "/wt", sessionRef, reviewType: "code", blastRadiusGuard },
makeTask([{ status: "in-progress" }]),
0,
"baseSHA",
"leaf",
);
expect(result.ok).toBe(false);
expect(result.reason).toContain("git exploded");
expect(store.updateStep).not.toHaveBeenCalled();
});
it("proceeds with the reset when the guard returns null (safe)", async () => {
const store = makeStore();
const navigateTree = vi.fn().mockResolvedValue(undefined);
const sessionRef = makeSessionRef({ navigateTree });
const blastRadiusGuard = vi.fn().mockResolvedValue(null);
const result = await resetStepToBaseline(
{ store, worktreePath: "/wt", sessionRef, reviewType: "code", blastRadiusGuard },
makeTask([{ status: "in-progress" }]),
0,
"baseSHA",
"leaf-checkpoint",
);
expect(result.ok).toBe(true);
expect(navigateTree).toHaveBeenCalledWith("leaf-checkpoint", { summarize: false });
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
});
});
describe("makeAncestryBlastRadiusGuard", () => {
beforeEach(() => vi.clearAllMocks());
it("refuses when a LATER step is already done", async () => {
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/wt",
task: makeTask([{ status: "in-progress" }, { status: "done" }]),
stepIndex: 0,
isAncestor: async () => true,
});
const reason = await guard("baseSHA");
expect(reason).toContain("later step 1 is done");
});
it("refuses when a LATER step is already skipped", async () => {
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/wt",
task: makeTask([{ status: "in-progress" }, { status: "skipped" }]),
stepIndex: 0,
isAncestor: async () => true,
});
const reason = await guard("baseSHA");
expect(reason).toContain("later step 1 is skipped");
});
it("refuses when the baseline is NOT an ancestor of HEAD", async () => {
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/wt",
task: makeTask([{ status: "in-progress" }]),
stepIndex: 0,
isAncestor: async () => false,
});
const reason = await guard("baseSHA");
expect(reason).toContain("not an ancestor of HEAD");
});
it("allows when baseline is an ancestor and no later step is terminal", async () => {
const isAncestor = vi.fn().mockResolvedValue(true);
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/wt",
task: makeTask([
{ status: "pending" },
{ status: "in-progress" },
{ status: "pending" },
]),
stepIndex: 1,
isAncestor,
});
const reason = await guard("baseSHA");
expect(reason).toBeNull();
expect(isAncestor).toHaveBeenCalledWith("baseSHA", "/wt");
});
it("allows (skipping ancestry) when no baseline is supplied", async () => {
const isAncestor = vi.fn();
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/wt",
task: makeTask([{ status: "in-progress" }]),
stepIndex: 0,
isAncestor,
});
const reason = await guard(undefined);
expect(reason).toBeNull();
expect(isAncestor).not.toHaveBeenCalled();
});
it("treats an earlier done step as harmless (only LATER steps matter)", async () => {
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/wt",
task: makeTask([{ status: "done" }, { status: "in-progress" }]),
stepIndex: 1,
isAncestor: async () => true,
});
const reason = await guard("baseSHA");
expect(reason).toBeNull();
});
});

View File

@@ -0,0 +1,578 @@
// ─────────────────────────────────────────────────────────────────────────────
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
// This suite owns the STEPWISE PER-STEP parity + invariant coverage: it compares
// the `updateStep` TRAJECTORY and the MERGE-BLOCKER WINDOWS of the legacy
// step-session path against the inverted stepwise foreach graph driven by the
// built-in `builtin:stepwise-coding` IR.
//
// The legacy step-session path (runStepsInNewSessions ON) is the deterministic
// per-step ORACLE here — the agent-paced monolithic path is NOT deterministically
// comparable (see plan U7) and stays covered by the default-workflow byte-identity
// suite `workflow-graph-executor-parity.test.ts`. Both paths in this file are
// driven by the SAME scripted reviewer/seams so the only variable is the path.
//
// The graph side wires the REAL substrate seams (`runTaskStep`,
// `resetStepToBaseline`, `makeAncestryBlastRadiusGuard`) exactly as the executor
// does (executor.ts createGraphSeams / applyGraphRethinkReset), against a fake
// store that records the projection trajectory — so the comparison exercises the
// production reset/blast-radius/projection code, not a re-implementation.
//
// It also exercises the non-configurable lifecycle invariants (FN-5147
// terminal-until-merged, hard-cancel, file-scope guard) and the flag posture
// (pinned-at-dispatch, OFF-rollback recovery) on the stepwise path.
// ─────────────────────────────────────────────────────────────────────────────
import { describe, expect, it } from "vitest";
import {
BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
type StepStatus,
type TaskDetail,
type TaskStep,
type WorkflowIr,
} from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type StepReviewSeamResult,
type WorkflowLegacySeams,
} from "../workflow-node-handlers.js";
import {
makeAncestryBlastRadiusGuard,
resetStepToBaseline,
runTaskStep,
type StepRunnerTask,
} from "../step-runner.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
const settingsOff = () => ({ experimentalFeatures: { workflowGraphExecutor: false } });
type Verdict = StepReviewSeamResult["verdict"];
/** One recorded projection write — the unit of trajectory comparison (KTD-7). */
interface TrajectoryEntry {
step: number;
status: StepStatus;
source?: "graph";
}
/**
* A minimal fake store recording the `updateStep` projection trajectory and
* applying each write to an in-memory `steps[]` so the blast-radius guard's
* "later step already done" probe and the merge-blocker reads see real state.
* Implements only the surface `runTaskStep` / `resetStepToBaseline` touch.
*/
function makeFakeStore(steps: TaskStep[]) {
const trajectory: TrajectoryEntry[] = [];
return {
trajectory,
steps,
updateStep: async (
_id: string,
stepIndex: number,
status: StepStatus,
options?: { source?: "graph" },
) => {
trajectory.push({ step: stepIndex, status, ...(options?.source ? { source: options.source } : {}) });
if (steps[stepIndex]) steps[stepIndex] = { ...steps[stepIndex], status };
return {} as never;
},
logEntry: async () => {},
};
}
/** Build a TaskDetail with N pending steps. */
function taskWithSteps(n: number): TaskDetail {
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
}));
return { id: "FN-STEPWISE", steps } as unknown as TaskDetail;
}
/**
* The legacy step-session ORACLE (KTD-9). Deterministic per-step loop modeling the
* in-session `fn_review_step` policy: for each step, mark in-progress, run, review;
* APPROVE → done, REVISE → re-run in place (no reset), RETHINK → reset to pending +
* re-run. Bounded by maxReworkCycles. Records the same TrajectoryEntry shape the
* graph side records — the legacy side never uses `source:"graph"`.
*/
async function runLegacyStepSession(
stepCount: number,
scripts: Verdict[][],
maxReworkCycles = 3,
): Promise<TrajectoryEntry[]> {
const trajectory: TrajectoryEntry[] = [];
const steps: TaskStep[] = Array.from({ length: stepCount }, (_, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
}));
for (let i = 0; i < stepCount; i++) {
const verdicts = scripts[i] ?? ["APPROVE"];
let cursor = 0;
let rework = 0;
for (;;) {
// run step i (mark in-progress)
trajectory.push({ step: i, status: "in-progress" });
steps[i] = { ...steps[i], status: "in-progress" };
const verdict = verdicts[Math.min(cursor, verdicts.length - 1)];
cursor++;
if (verdict === "APPROVE") {
trajectory.push({ step: i, status: "done" });
steps[i] = { ...steps[i], status: "done" };
break;
}
if (verdict === "RETHINK") {
// reset to baseline: step → pending, then re-run.
trajectory.push({ step: i, status: "pending" });
steps[i] = { ...steps[i], status: "pending" };
}
// REVISE: re-run in place (no extra projection write — step stays in-progress
// on the next loop's in-progress write).
rework++;
if (rework > maxReworkCycles) {
// rework exhausted — step stays non-done (escalates). Mirror the graph's
// exhaustion: leave the last in-progress write as the terminal state.
break;
}
}
}
return trajectory;
}
/**
* Drive the stepwise foreach graph (the REAL builtin IR) and capture the projection
* trajectory. Wires the substrate seams exactly as the executor does:
* - stepExecute → runTaskStep (markDoneOnSuccess driven by deferDoneToReview);
* - stepReview → scripted verdict; APPROVE marks the step done via updateStep
* (the projection authority, like createGraphSeams);
* - onReworkReset → resetStepToBaseline with the shared-isolation blast guard.
*/
async function runStepwiseGraph(
stepCount: number,
scripts: Verdict[][],
opts: {
maxReworkCycles?: number;
signal?: AbortSignal;
onReset?: (active: ForeachActiveContext) => void;
captureResetResult?: (ok: boolean, reason?: string) => void;
} = {},
): Promise<{ trajectory: TrajectoryEntry[]; outcome: string; result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>> }> {
const task = taskWithSteps(stepCount);
const fake = makeFakeStore(task.steps as TaskStep[]);
const reviewCursor = new Map<number, number>();
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
const result = await runTaskStep(
{
store: fake as never,
worktreePath: "/fake/worktree",
runStep: async () => ({ success: true }),
// Deterministic per-step baseline (substrate-captured, KTD-2). HEAD at
// instance start postdates steps 0..i-1's commits.
gitRevParse: async () => `sha-baseline-${active.stepIndex}`,
captureCheckpointId: () => `ckpt-${active.stepIndex}`,
},
{ id: task.id, steps: task.steps } as StepRunnerTask,
active.stepIndex,
{ markDoneOnSuccess: active.deferDoneToReview !== true },
);
active.baselineSha = result.baselineSha;
active.checkpointId = result.checkpointId;
return {
outcome: result.outcome,
value: "step-done",
contextPatch: { [FOREACH_ACTIVE_CONTEXT_KEY]: active },
};
},
stepReview: async (_t, ctx, _config) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
const verdicts = scripts[active.stepIndex] ?? ["APPROVE"];
const cursor = reviewCursor.get(active.stepIndex) ?? 0;
reviewCursor.set(active.stepIndex, cursor + 1);
const verdict = verdicts[Math.min(cursor, verdicts.length - 1)];
// APPROVE: the seam is the projection authority for done (createGraphSeams).
if (verdict === "APPROVE") {
await fake.updateStep(task.id, active.stepIndex, "done", { source: "graph" });
}
return { verdict };
},
};
const executor = new WorkflowGraphExecutor({
seams,
signal: opts.signal,
getTaskSteps: () => task.steps as TaskStep[],
// parse-steps reads PROMPT.md; produce headings matching the step count so the
// real builtin chain runs end-to-end. writeSteps is a no-op (steps pre-set).
parseStepsDeps: {
readArtifact: async () =>
Array.from({ length: stepCount }, (_, i) => `### Step ${i + 1}: do ${i + 1}\n`).join("\n"),
writeSteps: async (_t, parsed) => {
// Mirror production: project the parsed list (all pending). Keep the
// in-memory steps array length authoritative for the run.
task.steps = parsed.length > 0 ? parsed : (task.steps as TaskStep[]);
},
},
onReworkReset: async (active) => {
opts.onReset?.(active);
const res = await resetStepToBaseline(
{
store: fake as never,
worktreePath: "/fake/worktree",
sessionRef: { current: null },
reviewType: "code",
blastRadiusGuard: makeAncestryBlastRadiusGuard({
worktreePath: "/fake/worktree",
task: { id: task.id, steps: task.steps } as StepRunnerTask,
stepIndex: active.stepIndex,
// Deterministic ancestry: the captured baseline is always an ancestor
// of HEAD on a clean scripted run.
isAncestor: async () => true,
}),
},
{ id: task.id, steps: task.steps } as StepRunnerTask,
active.stepIndex,
active.baselineSha,
active.checkpointId,
);
opts.captureResetResult?.(res.ok, res.reason);
},
});
const ir: WorkflowIr = BUILTIN_STEPWISE_CODING_WORKFLOW_IR;
// Override maxReworkCycles when the scenario needs a tighter budget.
const runIr =
opts.maxReworkCycles !== undefined ? withForeachMaxRework(ir, opts.maxReworkCycles) : ir;
const result = await executor.run(task, settingsOn(), runIr);
return { trajectory: fake.trajectory, outcome: result.outcome, result };
}
/** Clone the IR with the foreach node's maxReworkCycles overridden (test only). */
function withForeachMaxRework(ir: WorkflowIr, max: number): WorkflowIr {
const cloned = JSON.parse(JSON.stringify(ir)) as WorkflowIr;
for (const node of cloned.nodes) {
if (node.kind === "foreach" && node.config) {
(node.config as { maxReworkCycles?: number }).maxReworkCycles = max;
}
}
return cloned;
}
/** Strip the `source` marker so the legacy (no-source) and graph trajectories are
* compared on (step, status) only — the projection content the merge-blocker and
* dashboard read (KTD-7). The graph side additionally carries `source:"graph"`. */
function normalize(t: TrajectoryEntry[]): Array<{ step: number; status: StepStatus }> {
return t.map(({ step, status }) => ({ step, status }));
}
describe("stepwise workflow parity (U7 / KTD-9)", () => {
// ── Trajectory parity vs the legacy step-session oracle ────────────────────
it("identical updateStep trajectory: 3-step approve-all (legacy step-session vs stepwise graph)", async () => {
const scripts: Verdict[][] = [["APPROVE"], ["APPROVE"], ["APPROVE"]];
const legacy = await runLegacyStepSession(3, scripts);
const { trajectory, outcome } = await runStepwiseGraph(3, scripts);
expect(outcome).toBe("success");
expect(normalize(trajectory)).toEqual(normalize(legacy));
// Concretely: each step in-progress then done, in order.
expect(normalize(trajectory)).toEqual([
{ step: 0, status: "in-progress" },
{ step: 0, status: "done" },
{ step: 1, status: "in-progress" },
{ step: 1, status: "done" },
{ step: 2, status: "in-progress" },
{ step: 2, status: "done" },
]);
});
it("revise-then-approve trajectory parity (revise re-runs in place, no reset)", async () => {
// Step 0: REVISE once then APPROVE. Step 1: APPROVE.
const scripts: Verdict[][] = [["REVISE", "APPROVE"], ["APPROVE"]];
const legacy = await runLegacyStepSession(2, scripts);
const { trajectory, outcome } = await runStepwiseGraph(2, scripts);
expect(outcome).toBe("success");
expect(normalize(trajectory)).toEqual(normalize(legacy));
// No `pending` write for step 0 (revise never resets).
expect(trajectory.some((e) => e.step === 0 && e.status === "pending")).toBe(false);
// Step 0 ran twice (in-progress ×2) then done once.
expect(trajectory.filter((e) => e.step === 0 && e.status === "in-progress").length).toBe(2);
});
it("RETHINK trajectory parity incl. reset to pending and baseline == agent-equivalent baseline (KTD-2)", async () => {
// Step 0: RETHINK once (resets) then APPROVE.
const scripts: Verdict[][] = [["RETHINK", "APPROVE"]];
const legacy = await runLegacyStepSession(1, scripts);
const resetSeen: ForeachActiveContext[] = [];
const { trajectory, outcome } = await runStepwiseGraph(1, scripts, {
onReset: (active) => resetSeen.push({ ...active }),
});
expect(outcome).toBe("success");
expect(normalize(trajectory)).toEqual(normalize(legacy));
// A RETHINK resets to pending before re-execute.
expect(trajectory.some((e) => e.step === 0 && e.status === "pending")).toBe(true);
// The reset fired exactly once, with the substrate-captured baseline. KTD-2:
// HEAD-at-instance-start (`sha-baseline-0`) is exactly the agent-equivalent
// baseline (the boundary after steps 0..-1 = the start). Asserted here.
expect(resetSeen.length).toBe(1);
expect(resetSeen[0].baselineSha).toBe("sha-baseline-0");
expect(resetSeen[0].checkpointId).toBe("ckpt-0");
});
// ── RETHINK blast-radius guard (KTD-2) ─────────────────────────────────────
it("RETHINK blast-radius guard REFUSES when a later step is already done", async () => {
// Directly exercise the production guard the graph wires: a reset for step 0
// when step 1 is already `done` must be refused (would destroy approved work).
const steps: TaskStep[] = [
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "done" }, // a LATER step already completed
];
const fake = makeFakeStore(steps);
const guard = makeAncestryBlastRadiusGuard({
worktreePath: "/fake/worktree",
task: { id: "FN-STEPWISE", steps } as StepRunnerTask,
stepIndex: 0,
isAncestor: async () => true,
});
const res = await resetStepToBaseline(
{
store: fake as never,
worktreePath: "/fake/worktree",
sessionRef: { current: null },
reviewType: "code",
blastRadiusGuard: guard,
},
{ id: "FN-STEPWISE", steps } as StepRunnerTask,
0,
"sha-baseline-0",
"ckpt-0",
);
expect(res.ok).toBe(false);
expect(res.reason).toMatch(/later step/i);
// Refusal mutates NOTHING (no projection write at all).
expect(fake.trajectory.length).toBe(0);
});
// ── Lifecycle invariants on the stepwise path (R14) ────────────────────────
it("FN-5147 terminal-until-merged: stepwise run with merge failure stays out of done", async () => {
// autoMerge:false → the merge seam fails (manual-merge-required); the task
// never routes to merge success, so it stays terminal-in-review until merged.
const task = taskWithSteps(1);
const fake = makeFakeStore(task.steps as TaskStep[]);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
// FN-5147: autoMerge:false surfaces as a merge-blocking failure value.
merge: async () => ({ outcome: "failure", value: "manual-merge-required" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
await runTaskStep(
{
store: fake as never,
worktreePath: "/fake/worktree",
runStep: async () => ({ success: true }),
gitRevParse: async () => "sha",
captureCheckpointId: () => "ckpt",
},
{ id: task.id, steps: task.steps } as StepRunnerTask,
active.stepIndex,
{ markDoneOnSuccess: active.deferDoneToReview !== true },
);
return { outcome: "success", value: "step-done" };
},
stepReview: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
await fake.updateStep(task.id, active.stepIndex, "done", { source: "graph" });
return { verdict: "APPROVE" } as StepReviewSeamResult;
},
};
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => task.steps as TaskStep[],
parseStepsDeps: {
readArtifact: async () => "### Step 1: do it\n",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("failure");
// The walk never reached `end` through merge — terminal-until-merged preserved.
expect(result.visitedNodeIds).not.toContain("end");
// All step work completed (the step is done) — the blocker is the merge, not steps.
expect((task.steps as TaskStep[])[0].status).toBe("done");
});
it("hard-cancel mid-instance: abort signal halts the foreach cleanly (no further step work)", async () => {
const controller = new AbortController();
const ran: number[] = [];
const task = taskWithSteps(3);
const fake = makeFakeStore(task.steps as TaskStep[]);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
ran.push(active.stepIndex);
await fake.updateStep(task.id, active.stepIndex, "in-progress", { source: "graph" });
// Simulate a hard-cancel (moveTask in-progress→todo) mid-first-instance.
if (active.stepIndex === 0) controller.abort();
return { outcome: "success", value: "step-done" };
},
stepReview: async () => ({ verdict: "APPROVE" }) as StepReviewSeamResult,
};
const executor = new WorkflowGraphExecutor({
seams,
signal: controller.signal,
getTaskSteps: () => task.steps as TaskStep[],
parseStepsDeps: {
readArtifact: async () => "### Step 1: a\n### Step 2: b\n### Step 3: c\n",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("failure");
// Only the first instance started; later instances never ran (clean cancel).
expect(ran).toEqual([0]);
});
it("file-scope guard fires inside step-execute: a step-execute failure value propagates (no merge)", async () => {
// The file-scope guard surfaces as a step-execute failure (the session commit
// is rejected). The foreach must route failure — NOT silently approve/merge.
const task = taskWithSteps(2);
const fake = makeFakeStore(task.steps as TaskStep[]);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
await fake.updateStep(task.id, active.stepIndex, "in-progress", { source: "graph" });
// Step 0 violates file scope.
if (active.stepIndex === 0) {
return { outcome: "failure", value: "FileScopeViolationError" };
}
return { outcome: "success", value: "step-done" };
},
stepReview: async () => ({ verdict: "APPROVE" }) as StepReviewSeamResult,
};
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => task.steps as TaskStep[],
parseStepsDeps: {
readArtifact: async () => "### Step 1: a\n### Step 2: b\n",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("failure");
// Step 0 never reached `done` (the guard blocked it); step 1 never ran.
expect((task.steps as TaskStep[])[0].status).toBe("in-progress");
expect((task.steps as TaskStep[])[1].status).toBe("pending");
expect(result.visitedNodeIds).not.toContain("merge");
});
// ── Flag posture (R10) ─────────────────────────────────────────────────────
it("flag pinned-at-dispatch: flag OFF → graph executor is a strict no-op (legacy path owns the run)", async () => {
// With the flag OFF at dispatch, the graph executor does not run at all — the
// legacy step-session path owns the task. Toggling the flag mid-run cannot
// switch paths because the run never entered the graph.
const task = taskWithSteps(2);
let stepExecuteCalls = 0;
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async () => {
stepExecuteCalls++;
return { outcome: "success", value: "step-done" };
},
};
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, settingsOff(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.executed).toBe(false);
expect(result.outcome).toBe("failure");
expect(stepExecuteCalls).toBe(0);
});
it("OFF-rollback: a stepwise run with the flag OFF leaves steps[] (git-reconcilable) as surviving truth", async () => {
// KTD-8 OFF-rollback: instance rows are swept and steps[] — always
// git-reconcilable — is the surviving truth that legacy resume reconciles
// from. With the flag OFF the graph never writes, so the pre-existing steps[]
// projection (legacy's truth) is untouched; legacy resume then completes.
const task = taskWithSteps(2);
// Simulate a partially-progressed legacy projection (step 0 done by legacy).
(task.steps as TaskStep[])[0] = { name: "Step 1", status: "done" };
const executor = new WorkflowGraphExecutor({ seams: undefined });
const result = await executor.run(task, settingsOff(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.executed).toBe(false);
// steps[] is untouched by the (no-op) graph — legacy's projection survives.
expect((task.steps as TaskStep[])[0].status).toBe("done");
expect((task.steps as TaskStep[])[1].status).toBe("pending");
});
// ── Zero-step task (R8) ────────────────────────────────────────────────────
it("zero-step task on stepwise merges without step work (no-steps outcome path)", async () => {
let stepExecuteCalls = 0;
const task = taskWithSteps(0);
const seams: WorkflowLegacySeams = {
planning: async () => ({ outcome: "success" }),
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
stepExecute: async () => {
stepExecuteCalls++;
return { outcome: "success", value: "step-done" };
},
};
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => [],
parseStepsDeps: {
// No headings → zero steps → parse-steps routes outcome:no-steps → foreach
// no-ops through its success edge (R8).
readArtifact: async () => "no steps here, just prose",
writeSteps: async () => {},
},
});
const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(result.outcome).toBe("success");
expect(stepExecuteCalls).toBe(0);
// The foreach was reached but expanded zero instances.
expect(result.visitedNodeIds).toContain("steps");
expect(result.visitedNodeIds.some((id) => id.startsWith("steps#"))).toBe(false);
// Merge ran (the task merges with no step work).
expect(result.visitedNodeIds).toContain("merge");
});
});

View File

@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it } 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 { TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core";
/** The exact param type the store's save method expects (WorkflowRunStepInstance
* is not exported via the barrel; derive it from the method signature). */
type SaveInstanceArg = Parameters<TaskStore["saveWorkflowRunStepInstance"]>[0];
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import type {
IntegrationGitOps,
IntegrationProjection,
} from "../step-integration.js";
import type { WorkflowStepInstancePersistence, WorkflowStepInstanceState } from "../workflow-graph-foreach.js";
import { type WorkflowLegacySeams } from "../workflow-node-handlers.js";
/**
* runId/foreachNodeId wiring regression coverage (FIX 1). These tests wire the
* REAL store (an in-memory TaskStore) through store-backed persistence + projection
* adapters that MIRROR the executor's production adapters, then assert that:
* (i) after a foreach expands + persists, a pin-protection probe under the
* PRODUCTION runId sees the rows (would be empty under the old `:run` literal);
* (ii) markInstanceIntegrated flips the SAME row the sub-walk persisted (status
* completed, integratedAt set) with NO orphan row;
* (iii) a resume load under the production runId sees the persisted rows.
*/
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-foreach-wiring-"));
}
/** The production run id derivation: `${task.id}:${definition.id}`. */
const DEFINITION_ID = "wf-coding";
const runIdFor = (taskId: string) => `${taskId}:${DEFINITION_ID}`;
/** Store-backed step-instance persistence — MIRRORS executor.buildStepInstancePersistence. */
function storePersistence(store: TaskStore): WorkflowStepInstancePersistence {
return {
saveInstanceState: (state) =>
store.saveWorkflowRunStepInstance(state as unknown as SaveInstanceArg),
loadInstanceStates: (taskId, runId) =>
store.loadWorkflowRunStepInstances(taskId, runId) as unknown as WorkflowStepInstanceState[],
clearStaleInstanceStates: (taskId, keepRunId) => store.clearWorkflowRunStepInstances(taskId, keepRunId),
};
}
/** Store-backed projection — MIRRORS executor.buildForeachWorktreeDeps.integrationProjection.
* Critically, markInstanceIntegrated flips the EXISTING row by its REAL identity. */
function storeProjection(store: TaskStore): IntegrationProjection {
return {
markStepDone: async (stepIndex) => {
await store.updateStep(STORED_TASK_ID, stepIndex, "done", { source: "graph" });
},
markInstanceIntegrated: async (stepIndex, integratedAt, identity) => {
const rows = store.loadWorkflowRunStepInstances(STORED_TASK_ID, identity.runId);
const existing = rows.find(
(r) => r.foreachNodeId === identity.foreachNodeId && r.stepIndex === stepIndex,
);
store.saveWorkflowRunStepInstance({
...(existing ?? {}),
taskId: STORED_TASK_ID,
runId: identity.runId,
foreachNodeId: identity.foreachNodeId,
stepIndex,
pinnedStepCount: identity.pinnedStepCount,
currentNodeId: existing?.currentNodeId ?? "",
status: "completed",
reworkCount: existing?.reworkCount ?? 0,
branchName: identity.branchName,
integratedAt,
} as unknown as SaveInstanceArg);
},
};
}
let STORED_TASK_ID = "";
function fakeGitOps(): IntegrationGitOps {
return {
integrate: async () => ({ kind: "integrated" as const, integratedAt: "2026-01-01T00:00:00Z" }),
discardBranch: async () => {},
};
}
function singleExecuteTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIr["edges"] } {
return {
nodes: [{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } }],
edges: [],
};
}
function foreachIr(config: Record<string, unknown>): WorkflowIr {
return {
version: "v2",
name: "wiring-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template: singleExecuteTemplate(), ...config } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "fe" },
{ from: "fe", to: "end", condition: "success" },
],
} as WorkflowIr;
}
function baseSeams(overrides: Partial<WorkflowLegacySeams>): WorkflowLegacySeams {
const ok = async () => ({ outcome: "success" as const });
return { planning: ok, execute: ok, review: ok, merge: ok, schedule: ok, ...overrides };
}
describe("foreach runId/foreachNodeId wiring (FIX 1)", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
let taskId: string;
beforeEach(async () => {
rootDir = makeTmpDir();
globalDir = join(rootDir, ".fusion-global");
store = new TaskStore(rootDir, globalDir);
await store.init();
const task = await store.createTask({ description: "wiring task" });
taskId = task.id;
STORED_TASK_ID = taskId;
// Two steps so the foreach expands two instances.
await store.updateTask(taskId, {
steps: [
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "pending" },
],
});
});
afterEach(async () => {
store?.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
function makeExecutor() {
const stepExecuteCalls: number[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx["foreach:active"] as { stepIndex: number } | undefined;
if (active) stepExecuteCalls.push(active.stepIndex);
return { outcome: "success" as const, value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({
seams,
runCustomNode: async () => ({ outcome: "success" as const }),
stepInstancePersistence: storePersistence(store),
// Worktree isolation deps (parallel).
allocateInstanceWorktree: async (stepIndex) => ({
worktreePath: `/wt/step-${stepIndex}`,
branchName: `fusion/${taskId.toLowerCase()}-step-${stepIndex}`,
}),
resolveIntegrationBase: async () => "base",
integrationGitOps: fakeGitOps(),
integrationProjection: storeProjection(store),
semaphoreAvailability: () => 8,
// The PRODUCTION runId, threaded as the single source of truth.
runId: runIdFor(taskId),
});
return { executor, stepExecuteCalls };
}
it("(i)+(ii) expands+persists under the production runId and integration flips the SAME row (no orphans)", async () => {
const detail = (await store.getTask(taskId)) as unknown as TaskDetail;
const { executor } = makeExecutor();
const result = await executor.run(detail, settingsOn(), foreachIr({ mode: "parallel" }));
expect(result.outcome).toBe("success");
const prodRunId = runIdFor(taskId);
// (i) Pin-protection probe under the PRODUCTION runId sees rows; the old buggy
// `${taskId}:run` literal would see nothing.
const rowsProd = store.loadWorkflowRunStepInstances(taskId, prodRunId);
expect(rowsProd.length).toBe(2);
expect(store.loadWorkflowRunStepInstances(taskId, `${taskId}:run`)).toEqual([]);
// (ii) Each instance row was FLIPPED in place to completed/integratedAt — and
// there are NO orphan rows (no foreachNodeId:"" rows, exactly 2 rows total).
expect(rowsProd.every((r) => r.foreachNodeId === "fe")).toBe(true);
expect(rowsProd.every((r) => r.status === "completed")).toBe(true);
expect(rowsProd.every((r) => typeof r.integratedAt === "string" && r.integratedAt)).toBe(true);
expect(rowsProd.some((r) => r.foreachNodeId === "")).toBe(false);
// Both steps are done in the projection.
const after = await store.getTask(taskId);
expect(after.steps.map((s) => s.status)).toEqual(["done", "done"]);
});
it("(iii) a resume load under the production runId sees the persisted rows", async () => {
const detail = (await store.getTask(taskId)) as unknown as TaskDetail;
const { executor } = makeExecutor();
await executor.run(detail, settingsOn(), foreachIr({ mode: "parallel" }));
// Resume-equivalent probe: load under the production runId.
const rows = store.loadWorkflowRunStepInstances(taskId, runIdFor(taskId));
expect(rows.length).toBe(2);
expect(rows.map((r) => r.stepIndex).sort()).toEqual([0, 1]);
expect(rows.every((r) => r.branchName?.includes("step-"))).toBe(true);
});
it("prunes stale-run instance rows at run start, keeping the current run", async () => {
// Seed a stale row from a prior run.
store.saveWorkflowRunStepInstance({
taskId,
runId: `${taskId}:stale-run`,
foreachNodeId: "fe",
stepIndex: 0,
pinnedStepCount: 1,
currentNodeId: "exec",
status: "in-progress",
reworkCount: 0,
updatedAt: new Date().toISOString(),
});
expect(store.loadWorkflowRunStepInstances(taskId, `${taskId}:stale-run`).length).toBe(1);
const detail = (await store.getTask(taskId)) as unknown as TaskDetail;
const { executor } = makeExecutor();
await executor.run(detail, settingsOn(), foreachIr({ mode: "parallel" }));
// The stale run's rows were pruned at run start (keepRunId = production runId).
expect(store.loadWorkflowRunStepInstances(taskId, `${taskId}:stale-run`)).toEqual([]);
// The current run's rows survive.
expect(store.loadWorkflowRunStepInstances(taskId, runIdFor(taskId)).length).toBe(2);
});
});

View File

@@ -1,3 +1,14 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
// cover per-step / updateStep-trajectory parity.
//
// The stepwise per-step trajectory + merge-blocker-window parity (legacy
// step-session path vs the stepwise foreach graph) is owned by the sibling
// suite `stepwise-workflow-parity.test.ts`. Keep the two concerns separate.
// ─────────────────────────────────────────────────────────────────────────────
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
@@ -40,9 +51,10 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
schedule: async () => ({ outcome: "success" }),
};
const legacyEvents = await runLegacy(seams)();
type BaseSeam = "planning" | "execute" | "review" | "merge" | "schedule";
const executor = new WorkflowGraphExecutor({ seams, handlers: { prompt: async (node, ctx) => {
const seam = String(node.config?.seam);
const result = await seams[seam as keyof WorkflowLegacySeams](ctx.task, ctx.context);
const result = await seams[seam as BaseSeam](ctx.task, ctx.context);
events.push(`${seam}:${result.outcome}`);
return result;
} } });

View File

@@ -0,0 +1,360 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
import type {
WorkflowBranchPersistence,
WorkflowBranchProgress,
WorkflowBranchRunState,
} from "../workflow-graph-branches.js";
const task = { id: "FN-FANOUT" } as TaskDetail;
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
/** A controllable deferred so branches can complete in any order under test control. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
/** start → split → (branchA → branchB) → join → tail → end */
function twoBranchIr(joinConfig: Record<string, unknown>): WorkflowIr {
return {
version: "v2",
name: "two-branch",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "split", kind: "split", column: "work" },
{ id: "branchA", kind: "prompt", column: "work", config: { prompt: "a" } },
{ id: "branchB", kind: "prompt", column: "work", config: { prompt: "b" } },
{ id: "join", kind: "join", column: "work", config: joinConfig },
{ id: "tail", kind: "prompt", column: "work", config: { prompt: "tail" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "split" },
{ from: "split", to: "branchA" },
{ from: "split", to: "branchB" },
{ from: "branchA", to: "join", condition: "success" },
{ from: "branchB", to: "join", condition: "success" },
{ from: "join", to: "tail", condition: "success" },
{ from: "join", to: "end", condition: "failure" },
{ from: "tail", to: "end", condition: "success" },
],
};
}
describe("WorkflowGraphExecutor fan-out/join (U13)", () => {
it("mode:all — both branches complete in any order, join fires once, advances to tail", async () => {
const a = deferred<void>();
const b = deferred<void>();
const tail = vi.fn(async () => ({ outcome: "success" as const }));
const prompt: WorkflowNodeHandler = async (node) => {
if (node.id === "branchA") await a.promise;
if (node.id === "branchB") await b.promise;
if (node.id === "tail") return tail();
return { outcome: "success" as const };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const run = executor.run(task, settingsOn(), twoBranchIr({ mode: "all" }));
// Complete in reverse order to prove order-independence.
b.resolve();
await Promise.resolve();
expect(tail).not.toHaveBeenCalled();
a.resolve();
const result = await run;
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain("tail");
expect(tail).toHaveBeenCalledTimes(1);
});
it("mode:any with collect — first completion fires join; slower branch finishes without re-firing", async () => {
const slow = deferred<void>();
const tail = vi.fn(async () => ({ outcome: "success" as const }));
let slowFinished = false;
const prompt: WorkflowNodeHandler = async (node) => {
if (node.id === "branchB") {
await slow.promise;
slowFinished = true;
}
if (node.id === "tail") return tail();
return { outcome: "success" as const };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const run = executor.run(task, settingsOn(), twoBranchIr({ mode: "any", onBranchFailure: "collect" }));
// branchA resolves immediately → join fires. tail must run exactly once.
await Promise.resolve();
slow.resolve();
const result = await run;
expect(result.outcome).toBe("success");
expect(tail).toHaveBeenCalledTimes(1);
expect(slowFinished).toBe(true);
});
it("mode:any with fail-fast — slower branch is aborted via signal", async () => {
let aborted = false;
const slow = deferred<void>();
const prompt: WorkflowNodeHandler = async (node, ctx) => {
if (node.id === "branchB") {
ctx.signal?.addEventListener("abort", () => {
aborted = true;
slow.resolve();
});
await slow.promise;
if (ctx.signal?.aborted) return { outcome: "failure" as const, value: "aborted" };
}
return { outcome: "success" as const };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "any", onBranchFailure: "fail-fast" }));
expect(result.outcome).toBe("success");
expect(aborted).toBe(true);
});
it("quorum(2) of 3 — join fires on the second completion", async () => {
const ir: WorkflowIr = {
version: "v2",
name: "quorum",
columns: [{ id: "w", name: "W", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "split", kind: "split" },
{ id: "b1", kind: "prompt", config: {} },
{ id: "b2", kind: "prompt", config: {} },
{ id: "b3", kind: "prompt", config: {} },
{ id: "join", kind: "join", config: { mode: { quorum: 2 }, onBranchFailure: "collect" } },
{ id: "tail", kind: "prompt", config: {} },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "split" },
{ from: "split", to: "b1" },
{ from: "split", to: "b2" },
{ from: "split", to: "b3" },
{ from: "b1", to: "join", condition: "success" },
{ from: "b2", to: "join", condition: "success" },
{ from: "b3", to: "join", condition: "success" },
{ from: "join", to: "tail", condition: "success" },
{ from: "join", to: "end", condition: "failure" },
{ from: "tail", to: "end" },
],
};
const d3 = deferred<void>();
const tail = vi.fn(async () => ({ outcome: "success" as const }));
const prompt: WorkflowNodeHandler = async (node) => {
if (node.id === "b3") await d3.promise;
if (node.id === "tail") return tail();
return { outcome: "success" as const };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const run = executor.run(task, settingsOn(), ir);
// b1 + b2 resolve immediately → quorum(2) satisfied without b3.
await Promise.resolve();
d3.resolve();
const result = await run;
expect(result.outcome).toBe("success");
expect(tail).toHaveBeenCalledTimes(1);
});
it("branch failure fail-fast — siblings aborted, join routes the failure edge", async () => {
let siblingAborted = false;
const slow = deferred<void>();
const prompt: WorkflowNodeHandler = async (node, ctx) => {
if (node.id === "branchA") return { outcome: "failure" as const, value: "boom" };
if (node.id === "branchB") {
ctx.signal?.addEventListener("abort", () => {
siblingAborted = true;
slow.resolve();
});
await slow.promise;
return { outcome: "success" as const };
}
return { outcome: "success" as const };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "all", onBranchFailure: "fail-fast" }));
expect(result.outcome).toBe("failure");
expect(result.visitedNodeIds).not.toContain("tail");
expect(siblingAborted).toBe(true);
});
it("branch failure collect — all branches finish; join evaluates combined outcomes", async () => {
const calls: string[] = [];
const prompt: WorkflowNodeHandler = async (node) => {
calls.push(node.id);
if (node.id === "branchA") return { outcome: "failure" as const, value: "boom" };
return { outcome: "success" as const };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "all", onBranchFailure: "collect" }));
// mode:all unmet (one failed) → join outcome failure; both branches ran.
expect(result.outcome).toBe("failure");
expect(calls).toContain("branchA");
expect(calls).toContain("branchB");
const branchOutcomes = result.context["node:join:branchOutcomes"] as { outcome: string }[];
expect(branchOutcomes.some((b) => b.outcome === "failure")).toBe(true);
expect(branchOutcomes.some((b) => b.outcome === "success")).toBe(true);
});
it("crash mid-branch resume — completed branches' nodes are NOT re-run", async () => {
const calls: string[] = [];
const store: WorkflowBranchRunState[] = [];
const persistence: WorkflowBranchPersistence = {
saveBranchState: (s) => {
const idx = store.findIndex((e) => e.branchId === s.branchId);
if (idx >= 0) store[idx] = s;
else store.push({ ...s });
},
loadBranchStates: () => store.map((s) => ({ ...s })),
};
// First run: branchA completes, branchB hangs (simulated crash before join).
const hang = deferred<void>();
const aPersisted = deferred<void>();
const persistenceA: WorkflowBranchPersistence = {
saveBranchState: (s) => {
persistence.saveBranchState!(s);
if (s.branchId === "branchA" && s.status === "completed") aPersisted.resolve();
},
loadBranchStates: persistence.loadBranchStates,
};
const prompt1: WorkflowNodeHandler = async (node) => {
calls.push(`run1:${node.id}`);
if (node.id === "branchB") await hang.promise; // never resolves this run
return { outcome: "success" as const };
};
const exec1 = new WorkflowGraphExecutor({ handlers: { prompt: prompt1 }, branchPersistence: persistenceA });
const run1 = exec1.run(task, settingsOn(), twoBranchIr({ mode: "all" }));
await aPersisted.promise;
// Don't await run1 (branchB stuck) — simulate process death by starting fresh.
expect(store.find((s) => s.branchId === "branchA")?.status).toBe("completed");
// Resume: a brand-new executor reconstructed from persisted rows.
const prompt2: WorkflowNodeHandler = async (node) => {
calls.push(`run2:${node.id}`);
return { outcome: "success" as const };
};
const exec2 = new WorkflowGraphExecutor({ handlers: { prompt: prompt2 }, branchPersistence: persistence });
const result = await exec2.run(task, settingsOn(), twoBranchIr({ mode: "all" }));
expect(result.outcome).toBe("success");
// branchA already completed → not re-run on resume.
expect(calls).not.toContain("run2:branchA");
// branchB re-runs (it never completed).
expect(calls).toContain("run2:branchB");
hang.resolve();
await run1.catch(() => {});
});
it("card-position invariant — no column move occurs during the parallel window", async () => {
// The executor never touches task.column; assert the handler context exposes
// the split's column to all branch nodes and the task object is untouched.
const columnsSeen = new Set<string | undefined>();
const taskColumnBefore = (task as { column?: string }).column;
const prompt: WorkflowNodeHandler = async (node) => {
if (node.id.startsWith("branch")) columnsSeen.add(node.column);
return { outcome: "success" as const };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
await executor.run(task, settingsOn(), twoBranchIr({ mode: "all" }));
// Branch nodes live in the split's column; task position never forked.
expect(columnsSeen).toEqual(new Set(["work"]));
expect((task as { column?: string }).column).toBe(taskColumnBefore);
});
it("semaphore bound — branches queue, never exceeding the limit (fake semaphore)", async () => {
let active = 0;
let peak = 0;
const limit = 1;
const queue: (() => void)[] = [];
const fakeSemaphore = {
async run<T>(fn: () => Promise<T>): Promise<T> {
if (active >= limit) await new Promise<void>((res) => queue.push(res));
active += 1;
peak = Math.max(peak, active);
try {
return await fn();
} finally {
active -= 1;
queue.shift()?.();
}
},
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: async () => ({ outcome: "success" as const }) },
branchSemaphore: fakeSemaphore,
});
const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "all" }));
expect(result.outcome).toBe("success");
expect(peak).toBeLessThanOrEqual(limit);
});
it("nested split resolves recursively", async () => {
// start → split(outer) → [ branchX | split(inner) → [i1 | i2] → joinInner ] → joinOuter → end
const ir: WorkflowIr = {
version: "v2",
name: "nested",
columns: [{ id: "w", name: "W", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "outer", kind: "split" },
{ id: "branchX", kind: "prompt", config: {} },
{ id: "inner", kind: "split" },
{ id: "i1", kind: "prompt", config: {} },
{ id: "i2", kind: "prompt", config: {} },
{ id: "joinInner", kind: "join", config: { mode: "all" } },
{ id: "joinOuter", kind: "join", config: { mode: "all" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "outer" },
{ from: "outer", to: "branchX" },
{ from: "outer", to: "inner" },
{ from: "branchX", to: "joinOuter", condition: "success" },
{ from: "inner", to: "i1" },
{ from: "inner", to: "i2" },
{ from: "i1", to: "joinInner", condition: "success" },
{ from: "i2", to: "joinInner", condition: "success" },
{ from: "joinInner", to: "joinOuter", condition: "success" },
{ from: "joinOuter", to: "end", condition: "success" },
],
};
const calls: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async (node) => {
calls.push(node.id);
return { outcome: "success" as const };
},
},
});
const result = await executor.run(task, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(calls).toEqual(expect.arrayContaining(["branchX", "i1", "i2"]));
});
it("reports live per-branch progress for the dashboard", async () => {
const progress: WorkflowBranchProgress[] = [];
const executor = new WorkflowGraphExecutor({
handlers: { prompt: async () => ({ outcome: "success" as const }) },
onBranchProgress: (p) => progress.push(p),
});
await executor.run(task, settingsOn(), twoBranchIr({ mode: "all" }));
expect(progress.some((p) => p.branchId === "branchA" && p.status === "completed")).toBe(true);
expect(progress.some((p) => p.branchId === "branchB" && p.status === "completed")).toBe(true);
});
});

View File

@@ -0,0 +1,573 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, TaskStep, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type WorkflowLegacySeams,
} from "../workflow-node-handlers.js";
import type { WorkflowStepInstanceState } from "../workflow-graph-foreach.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
/** Build a TaskDetail with a fixed step list. */
function taskWithSteps(n: number): TaskDetail {
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
}));
return { id: "FN-FOREACH", steps } as unknown as TaskDetail;
}
/**
* Build a graph: start → foreach → end. The foreach template is provided inline.
* Extra edges from the foreach node (e.g. outcome:rework-exhausted) are appended.
*/
function foreachIr(
template: { nodes: WorkflowIrNode[]; edges: WorkflowIr["edges"] },
opts: {
config?: Record<string, unknown>;
extraNodes?: WorkflowIrNode[];
foreachEdges?: WorkflowIr["edges"];
} = {},
): WorkflowIr {
return {
version: "v2",
name: "foreach-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "fe",
kind: "foreach",
config: { source: "task-steps", template, ...(opts.config ?? {}) },
},
{ id: "end", kind: "end" },
...(opts.extraNodes ?? []),
],
edges: [
{ from: "start", to: "fe" },
{ from: "fe", to: "end", condition: "success" },
...(opts.foreachEdges ?? []),
],
};
}
/** A single-node template: one step-execute prompt. */
function singleExecuteTemplate() {
return {
nodes: [{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } }],
edges: [],
};
}
describe("WorkflowGraphExecutor foreach (U3)", () => {
it("3-step expansion runs instances in step order, all 3 template-node instances", async () => {
const order: string[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
order.push(`exec#${active.stepIndex}`);
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(3), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
expect(order).toEqual(["exec#0", "exec#1", "exec#2"]);
// Instance ids are materialized deterministically.
expect(result.visitedNodeIds).toEqual(
expect.arrayContaining(["fe#0:exec", "fe#1:exec", "fe#2:exec"]),
);
// The foreach itself is visited and routes its success edge to end (end is
// intentionally not pushed to visited — same posture as other tail edges).
expect(result.visitedNodeIds).toContain("fe");
});
it("zero steps → foreach traverses its success edge without running any instance", async () => {
const exec = vi.fn(async () => ({ outcome: "success" as const }));
const seams = baseSeams({ stepExecute: exec });
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(0), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
expect(exec).not.toHaveBeenCalled();
expect(result.visitedNodeIds).toContain("fe");
expect(result.visitedNodeIds.some((id) => id.startsWith("fe#"))).toBe(false);
});
it("revise-style rework loops twice then completes (custom node routes a rework edge)", async () => {
// Template: exec → review. review routes a rework edge back to exec for the
// first 2 passes, then approves (success edge → exit).
let reviewCalls = 0;
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
// rework loop back to exec when review says "revise"
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
// success/approve exits (no outgoing edge → template exit)
],
};
const reviewHandler: WorkflowNodeHandler = async () => {
reviewCalls += 1;
if (reviewCalls <= 2) return { outcome: "success", value: "revise" };
return { outcome: "success", value: "approve" };
};
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(taskWithSteps(1), settingsOn(), foreachIr(template));
expect(result.outcome).toBe("success");
expect(reviewCalls).toBe(3); // 2 revises + 1 approve
});
it("rework exhaustion routes the outcome:rework-exhausted edge", async () => {
// review always says revise → budget (2) exhausts → foreach emits
// rework-exhausted, routed to a hold node.
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
const reviewHandler: WorkflowNodeHandler = async () => ({ outcome: "success", value: "revise" });
const holdHandler = vi.fn(async () => ({ outcome: "success" as const }));
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: {
prompt: makePromptRouter(seams, { review: reviewHandler }),
hold: holdHandler,
},
});
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
foreachIr(template, {
config: { maxReworkCycles: 2 },
extraNodes: [{ id: "exhausted-hold", kind: "hold" }],
foreachEdges: [
{ from: "fe", to: "exhausted-hold", condition: "outcome:rework-exhausted" },
{ from: "exhausted-hold", to: "end", condition: "success" },
],
}),
);
expect(holdHandler).toHaveBeenCalledTimes(1);
expect(result.visitedNodeIds).toContain("exhausted-hold");
});
it("rework exhaustion with NO routed edge falls back to failure", async () => {
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
const reviewHandler: WorkflowNodeHandler = async () => ({ outcome: "success", value: "revise" });
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
foreachIr(template, { config: { maxReworkCycles: 1 } }),
);
expect(result.outcome).toBe("failure");
});
it("rework budget is per-instance, not shared across instances", async () => {
// 2 steps, budget 1 each. Each instance reworks exactly once then approves.
// If the budget were shared, the second instance would exhaust on its first
// rework. Per-instance, both succeed.
const reviewCallsByStep = new Map<number, number>();
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
const reviewHandler: WorkflowNodeHandler = async (_node, ctx) => {
const active = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
const n = (reviewCallsByStep.get(active.stepIndex) ?? 0) + 1;
reviewCallsByStep.set(active.stepIndex, n);
if (n === 1) return { outcome: "success", value: "revise" }; // 1 rework per step
return { outcome: "success", value: "approve" };
};
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(
taskWithSteps(2),
settingsOn(),
foreachIr(template, { config: { maxReworkCycles: 1 } }),
);
expect(result.outcome).toBe("success");
expect(reviewCallsByStep.get(0)).toBe(2);
expect(reviewCallsByStep.get(1)).toBe(2);
});
it("a non-rework cycle outside an active instance still throws (recursive detector untouched)", async () => {
// Top-level graph with a plain cycle (no rework kind) — the recursive walk's
// inStack detector must still throw.
const ir: WorkflowIr = {
version: "v2",
name: "cycle",
columns: [{ id: "w", name: "W", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt", config: {} },
{ id: "b", kind: "prompt", config: {} },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "b", condition: "success" },
{ from: "b", to: "a", condition: "success" }, // non-rework cycle
],
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: async () => ({ outcome: "success" as const }) },
});
await expect(executor.run(taskWithSteps(0), settingsOn(), ir)).rejects.toThrow(/Cycle detected/);
});
it("abort mid-instance stops cleanly (signal honored between nodes)", async () => {
const controller = new AbortController();
const seen: string[] = [];
// Template: exec → second. exec aborts the controller; `second` must not run
// (abort is checked at the top of the loop before the next node).
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "second", kind: "prompt" as const, config: {} },
],
edges: [{ from: "exec", to: "second", condition: "success" }],
};
const secondHandler: WorkflowNodeHandler = async () => {
seen.push("second");
return { outcome: "success" };
};
const seams = baseSeams({
stepExecute: async () => {
seen.push("exec");
controller.abort();
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { second: secondHandler }) },
signal: controller.signal,
});
const result = await executor.run(taskWithSteps(2), settingsOn(), foreachIr(template));
expect(result.outcome).toBe("failure");
expect(seen).toEqual(["exec"]); // second never ran; instance 1 never started
});
it("foreach:active context is visible to template handlers and absent outside instances", async () => {
const insideValues: Array<number | undefined> = [];
let outsideAfter: unknown = "unset";
// Template node records the active stepIndex; a tail node after the foreach
// asserts the key was cleared.
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
insideValues.push(active?.stepIndex);
return { outcome: "success", value: "step-done" };
},
});
const tailHandler: WorkflowNodeHandler = async (_node, ctx) => {
outsideAfter = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY];
return { outcome: "success" };
};
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { tail: tailHandler }) },
});
const ir = foreachIr(singleExecuteTemplate(), {
extraNodes: [{ id: "tail", kind: "prompt", config: {} }],
foreachEdges: [
{ from: "fe", to: "tail", condition: "success" },
{ from: "tail", to: "end", condition: "success" },
],
});
// Remove the direct fe→end edge so fe→tail is the only success route.
ir.edges = ir.edges.filter((e) => !(e.from === "fe" && e.to === "end"));
const result = await executor.run(taskWithSteps(2), settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(insideValues).toEqual([0, 1]);
expect(outsideAfter).toBeUndefined(); // cleared on instance exit
});
it("step-execute seam is invoked with the correct stepIndex and captured baseline flows into context", async () => {
const captured: Array<{ stepIndex: number; baseline?: string }> = [];
// step-execute sets a baseline; a following review node reads it from the
// active context to prove the capture threads forward within the instance.
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [{ from: "exec", to: "review", condition: "success" }],
};
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
active.baselineSha = `sha-for-${active.stepIndex}`;
active.checkpointId = `ckpt-${active.stepIndex}`;
return {
outcome: "success",
value: "step-done",
contextPatch: { [FOREACH_ACTIVE_CONTEXT_KEY]: active },
};
},
});
const reviewHandler: WorkflowNodeHandler = async (_node, ctx) => {
const active = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
captured.push({ stepIndex: active.stepIndex, baseline: active.baselineSha });
return { outcome: "success" };
};
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(taskWithSteps(2), settingsOn(), foreachIr(template));
expect(result.outcome).toBe("success");
expect(captured).toEqual([
{ stepIndex: 0, baseline: "sha-for-0" },
{ stepIndex: 1, baseline: "sha-for-1" },
]);
});
it("step-execute with no seam wired fails closed (does not silently succeed)", async () => {
// No stepExecute seam provided → step-execute node fails with a clear value.
const seams = baseSeams({});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(1), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("failure");
});
it("parallel mode (now worktree isolation, U10) fails cleanly without isolation wiring", async () => {
// U10: parallel mode defaults to worktree isolation. Without the worktree /
// integration deps wired, the foreach fails with a routable value rather than
// running shared-mode physics (which would be an unguardable concurrent-write race).
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(
taskWithSteps(2),
settingsOn(),
foreachIr(singleExecuteTemplate(), { config: { mode: "parallel", concurrency: 2 } }),
);
expect(result.outcome).toBe("failure");
expect(result.context["node:fe:value"]).toBe("worktree-isolation-unwired");
});
it("getTaskSteps dep is used to read a fresh count when injected", async () => {
const exec = vi.fn(async () => ({ outcome: "success" as const, value: "step-done" }));
const seams = baseSeams({ stepExecute: exec });
// task.steps is empty, but the injected accessor returns 2 steps.
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => [
{ name: "fresh-1", status: "pending" },
{ name: "fresh-2", status: "pending" },
],
});
const result = await executor.run(taskWithSteps(0), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
expect(exec).toHaveBeenCalledTimes(2);
});
it("step instance persistence hook is called at start/completion/rework (no-op default safe)", async () => {
const saved: WorkflowStepInstanceState[] = [];
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
let reviewCalls = 0;
const reviewHandler: WorkflowNodeHandler = async () => {
reviewCalls += 1;
return reviewCalls === 1
? { outcome: "success", value: "revise" }
: { outcome: "success", value: "approve" };
};
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
stepInstancePersistence: {
saveInstanceState: (s) => {
saved.push({ ...s });
},
},
});
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
foreachIr(template, { config: { maxReworkCycles: 2 } }),
);
expect(result.outcome).toBe("success");
// in-progress at start, a rework in-progress bump, and a final completed.
expect(saved.some((s) => s.status === "in-progress" && s.reworkCount === 0)).toBe(true);
expect(saved.some((s) => s.status === "in-progress" && s.reworkCount === 1)).toBe(true);
expect(saved.some((s) => s.status === "completed")).toBe(true);
expect(saved.every((s) => s.pinnedStepCount === 1)).toBe(true);
});
// ── U6: projection discipline ──────────────────────────────────────────────
it("projection-first ordering: step projection writes precede the completed instance row", async () => {
// The merge-blocker race (KTD-7) is closed by ordering: the step projection
// (updateStep) must be observable BEFORE the instance row flips to completed.
// We interleave both into one event log: the stepExecute seam stands in for
// the projection write; the persistence hook records the row status.
const events: string[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
events.push(`projection:done#${active.stepIndex}`);
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({
seams,
stepInstancePersistence: {
saveInstanceState: (s) => {
events.push(`row:${s.status}#${s.stepIndex}`);
},
},
});
const result = await executor.run(taskWithSteps(1), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
const projectionIdx = events.indexOf("projection:done#0");
const completedIdx = events.indexOf("row:completed#0");
expect(projectionIdx).toBeGreaterThanOrEqual(0);
expect(completedIdx).toBeGreaterThanOrEqual(0);
// Projection (done) is observable before the instance row flips to completed.
expect(projectionIdx).toBeLessThan(completedIdx);
});
it("sets deferDoneToReview on the active instance when the template has a step-review node", async () => {
// U6/KTD-4: with a step-review node present, step-execute must NOT mark the
// step done (markDoneOnSuccess:false) — the active context flags this so the
// step-execute seam can pass the flag to runTaskStep.
let observedDefer: boolean | undefined;
let observedNoReviewDefer: boolean | undefined;
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
observedDefer = active.deferDoneToReview;
return { outcome: "success", value: "step-done" };
},
stepReview: async () => ({ verdict: "APPROVE" as const }),
});
const reviewTemplate = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "step-review" as const, config: { type: "code" } },
],
edges: [{ from: "exec", to: "review", condition: "success" }],
};
const executor = new WorkflowGraphExecutor({ seams });
await executor.run(taskWithSteps(1), settingsOn(), foreachIr(reviewTemplate));
expect(observedDefer).toBe(true);
// Without a step-review node, deferDoneToReview is false (step-execute is the
// done authority).
const seamsNoReview = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
observedNoReviewDefer = active.deferDoneToReview;
return { outcome: "success", value: "step-done" };
},
});
const executor2 = new WorkflowGraphExecutor({ seams: seamsNoReview });
await executor2.run(taskWithSteps(1), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(observedNoReviewDefer).toBe(false);
});
});
// ── helpers ───────────────────────────────────────────────────────────────
/** Base no-op seams with an optional override (stepExecute etc.). */
function baseSeams(overrides: Partial<WorkflowLegacySeams>): WorkflowLegacySeams {
const ok = async () => ({ outcome: "success" as const });
return {
planning: ok,
execute: ok,
review: ok,
merge: ok,
schedule: ok,
...overrides,
};
}
/**
* A prompt handler that dispatches: step-execute seam → seams.stepExecute;
* otherwise to a per-node-id custom handler map (review/tail/second/etc.).
*/
function makePromptRouter(
seams: WorkflowLegacySeams,
byId: Record<string, WorkflowNodeHandler>,
): WorkflowNodeHandler {
return async (node, ctx) => {
if (node.config?.seam === "step-execute") {
if (!seams.stepExecute) return { outcome: "failure", value: "step-execute-unwired" };
return seams.stepExecute(ctx.task, ctx.context);
}
const handler = byId[node.id];
if (handler) return handler(node, ctx);
return { outcome: "success" };
};
}

View File

@@ -0,0 +1,153 @@
// -nocheck
import { describe, it, expect, beforeEach, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
import type { Task } from "@fusion/core";
/**
* FIX 3: runGraphTaskStep single-flight-per-attempt + rejection memo clearing.
*
* The implementation phase is memoized once per run (graphStepRunOnce) so each
* foreach instance's runStep observes the projection instead of re-running the
* agent. Two regressions are covered:
* - a REJECTED phase must clear the memo so a rework cycle RE-INVOKES the
* implementation (the prior code re-awaited the stored rejection forever);
* - the projection consult must NOT mask a step-session failure: a non-terminal
* step with no deferred review returns success:false (the prior code returned
* success on both branches).
*/
describe("runGraphTaskStep (FIX 3)", () => {
beforeEach(() => resetExecutorMocks());
function makeExecutor(stepStatus: string | undefined, active?: { deferDoneToReview?: boolean }) {
const store = createMockStore();
store.getTask = vi.fn().mockResolvedValue({
id: "FN-001",
steps: stepStatus ? [{ name: "S1", status: stepStatus }] : [{ name: "S1", status: "pending" }],
});
const executor: any = new TaskExecutor(store, "/tmp/test", {});
// Stamp the active foreach context the seam would normally stamp, keyed by
// the composite per-instance key (T7).
if (active) {
executor.graphStepActiveContext.set(
executor.graphActiveContextKey("FN-001", "inst-0"),
{ stepIndex: 0, instanceId: "inst-0", ...active },
);
}
return { executor, store };
}
const task = { id: "FN-001" } as Task;
it("re-invokes the implementation after a rejected phase (rework retries)", async () => {
const { executor } = makeExecutor("pending", { deferDoneToReview: true });
let calls = 0;
executor.runImplementationPhase = vi.fn().mockImplementation(async () => {
calls += 1;
if (calls === 1) throw new Error("impl failed");
return { taskDone: true, modifiedFiles: [] };
});
// First attempt: implementation rejects → failure, memo cleared.
const first = await executor.runGraphTaskStep(task, 0);
expect(first.success).toBe(false);
expect(calls).toBe(1);
// Rework re-run: the memo was cleared, so the implementation is invoked AGAIN
// (the bug left a poisoned rejected promise that was re-awaited forever).
const second = await executor.runGraphTaskStep(task, 0);
expect(calls).toBe(2);
expect(second.success).toBe(true);
});
it("single-flight within one attempt: concurrent callers share one phase", async () => {
const { executor } = makeExecutor("done");
let calls = 0;
executor.runImplementationPhase = vi.fn().mockImplementation(async () => {
calls += 1;
await Promise.resolve();
return { taskDone: true, modifiedFiles: [] };
});
const [a, b] = await Promise.all([
executor.runGraphTaskStep(task, 0),
executor.runGraphTaskStep(task, 0),
]);
expect(a.success).toBe(true);
expect(b.success).toBe(true);
expect(calls).toBe(1); // memoized — exactly one implementation pass.
});
it("does NOT mask a step-session failure: non-terminal step without review → failure", async () => {
const { executor } = makeExecutor("in-progress"); // never reaches done/skipped, no deferDoneToReview
executor.runImplementationPhase = vi.fn().mockResolvedValue({ taskDone: false, modifiedFiles: [] });
const result = await executor.runGraphTaskStep(task, 0);
expect(result.success).toBe(false);
expect(result.error).toMatch(/not completed/);
});
it("deferDoneToReview: a non-terminal step is success (review authors done)", async () => {
const { executor } = makeExecutor("in-progress", { deferDoneToReview: true });
executor.runImplementationPhase = vi.fn().mockResolvedValue({ taskDone: false, modifiedFiles: [] });
const result = await executor.runGraphTaskStep(task, 0);
expect(result.success).toBe(true);
});
it("terminal step (done) is success regardless of review", async () => {
const { executor } = makeExecutor("done");
executor.runImplementationPhase = vi.fn().mockResolvedValue({ taskDone: true, modifiedFiles: [] });
const result = await executor.runGraphTaskStep(task, 0);
expect(result.success).toBe(true);
});
// T9: a RETHINK after a SUCCESSFUL pass must clear the memoized implementation
// so the rework re-runs implementation rather than re-awaiting the resolved memo.
it("clears the memo on rethink reset so implementation re-runs after a successful pass", async () => {
const { executor, store } = makeExecutor("done", { deferDoneToReview: true });
// No-op the git/step reset machinery — only the memo-clearing path matters here.
store.getTask = vi.fn().mockResolvedValue({ id: "FN-001", steps: [{ name: "S1", status: "done" }] });
let calls = 0;
executor.runImplementationPhase = vi.fn().mockImplementation(async () => {
calls += 1;
return { taskDone: true, modifiedFiles: [] };
});
// First pass: succeeds and the memo is now resolved.
const first = await executor.runGraphTaskStep(task, 0, "inst-0");
expect(first.success).toBe(true);
expect(calls).toBe(1);
expect(executor.graphStepRunOnce.has("FN-001")).toBe(true);
// RETHINK reset clears the SETTLED memo (guarded against in-flight clobber).
await executor.applyGraphRethinkReset("FN-001", { stepIndex: 0, instanceId: "inst-0" });
expect(executor.graphStepRunOnce.has("FN-001")).toBe(false);
// Rework re-run: implementation is invoked AGAIN (the bug re-awaited the memo).
const second = await executor.runGraphTaskStep(task, 0, "inst-0");
expect(second.success).toBe(true);
expect(calls).toBe(2);
});
// T7: parallel instances of the same task keep independent active contexts.
it("keys active context per-instance so parallel foreach instances do not clobber", async () => {
const store = createMockStore();
store.getTask = vi.fn().mockResolvedValue({ id: "FN-001", steps: [{ name: "S1", status: "in-progress" }] });
const executor: any = new TaskExecutor(store, "/tmp/test", {});
// Instance A defers done to review (non-terminal → success); instance B does not
// (non-terminal → failure). A per-task key would let one overwrite the other.
executor.graphStepActiveContext.set(
executor.graphActiveContextKey("FN-001", "inst-A"),
{ stepIndex: 0, instanceId: "inst-A", deferDoneToReview: true },
);
executor.graphStepActiveContext.set(
executor.graphActiveContextKey("FN-001", "inst-B"),
{ stepIndex: 0, instanceId: "inst-B", deferDoneToReview: false },
);
executor.runImplementationPhase = vi.fn().mockResolvedValue({ taskDone: false, modifiedFiles: [] });
const a = await executor.runGraphTaskStep(task, 0, "inst-A");
const b = await executor.runGraphTaskStep(task, 0, "inst-B");
expect(a.success).toBe(true); // review authors done
expect(b.success).toBe(false); // implementation left it incomplete
});
});

View File

@@ -242,4 +242,66 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
});
// #1407/#1412: the runner forwards its injected branchPersistence into the
// WorkflowGraphExecutor, which writes per-branch state and prunes stale runs.
// Uses a real in-memory persistence whose method shape matches the store-
// backed adapter the production executor builds (saveBranchState /
// loadBranchStates / clearStaleBranchStates) — no mock of a nonexistent API.
function fanoutIr(): WorkflowIr {
return {
version: "v1",
name: "fanout",
nodes: [
{ id: "start", kind: "start" },
{ id: "split", kind: "split" },
{ id: "a", kind: "prompt", config: { prompt: "a" } },
{ id: "b", kind: "prompt", config: { prompt: "b" } },
{ id: "join", kind: "join", config: { mode: "all" } },
{ id: "zend", kind: "end" },
],
edges: [
{ from: "start", to: "split" },
{ from: "split", to: "a" },
{ from: "split", to: "b" },
{ from: "a", to: "join" },
{ from: "b", to: "join" },
{ from: "join", to: "zend", condition: "success" },
],
};
}
it("forwards branchPersistence to the executor: writes branch state and prunes stale runs", async () => {
const saved: Array<{ branchId: string; currentNodeId: string; status: string }> = [];
const pruneCalls: Array<{ taskId: string; keepRunId: string }> = [];
const persistence = {
saveBranchState: (s: { branchId: string; currentNodeId: string; status: string }) => {
saved.push({ branchId: s.branchId, currentNodeId: s.currentNodeId, status: s.status });
},
loadBranchStates: () => [],
clearStaleBranchStates: (taskId: string, keepRunId: string) => {
pruneCalls.push({ taskId, keepRunId });
},
};
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fanoutIr())),
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success" }),
branchPersistence: persistence,
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
// Both branches persisted, and each reached "completed" at the join.
expect(saved.some((s) => s.branchId === "a")).toBe(true);
expect(saved.some((s) => s.branchId === "b")).toBe(true);
expect(saved.some((s) => s.status === "completed")).toBe(true);
// Prune ran (on start AND completion) keyed by the runner's runId.
expect(pruneCalls.length).toBeGreaterThanOrEqual(2);
expect(pruneCalls.every((c) => c.taskId === task.id)).toBe(true);
expect(pruneCalls.every((c) => c.keepRunId === `${task.id}:WF-001`)).toBe(true);
});
});

View File

@@ -0,0 +1,235 @@
/**
* U12 (KTD-12) — parse-steps node handler, parser registry resolution, pin
* protection, and plugin-parser fail-closed posture.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { TaskDetail, TaskStep, WorkflowIr } from "@fusion/core";
import { getStepParserRegistry, __resetStepParserRegistryForTests } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import { createNoopLegacySeams, type ParseStepsHandlerDeps } from "../workflow-node-handlers.js";
import {
registerPluginStepParsers,
unregisterPluginStepParsers,
} from "../plugin-parser-adapter.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
function task(): TaskDetail {
return { id: "FN-PARSE", title: "t", steps: [] as TaskStep[] } as unknown as TaskDetail;
}
/** start → parse → end, with optional outcome edges off the parse node. */
function parseIr(parser: string, artifact?: string, parseEdges?: WorkflowIr["edges"], extraNodes: WorkflowIr["nodes"] = []): WorkflowIr {
return {
version: "v2",
name: "parse-test",
columns: [{ id: "work", name: "Work", traits: [] }],
artifacts: artifact && artifact !== "PROMPT.md" ? [{ key: artifact }] : undefined,
nodes: [
{ id: "start", kind: "start" },
{ id: "parse", kind: "parse-steps", config: { artifact: artifact ?? "PROMPT.md", parser } },
{ id: "end", kind: "end" },
...extraNodes,
],
edges: [
{ from: "start", to: "parse" },
{ from: "parse", to: "end", condition: "success" },
...(parseEdges ?? []),
],
} as WorkflowIr;
}
function makeDeps(over: Partial<ParseStepsHandlerDeps> = {}): {
deps: ParseStepsHandlerDeps;
written: TaskStep[][];
audits: Array<{ reason: string; detail: string }>;
} {
const written: TaskStep[][] = [];
const audits: Array<{ reason: string; detail: string }> = [];
const deps: ParseStepsHandlerDeps = {
readArtifact: async () => "### Step 1: do a\n### Step 2: do b",
writeSteps: async (_t, steps) => {
written.push(steps);
},
audit: (reason, detail) => audits.push({ reason, detail }),
...over,
};
return { deps, written, audits };
}
async function runParse(ir: WorkflowIr, deps: ParseStepsHandlerDeps) {
const exec = new WorkflowGraphExecutor({ seams: createNoopLegacySeams(), parseStepsDeps: deps });
return exec.run(task(), settingsOn(), ir);
}
describe("parse-steps node handler (U12, KTD-12)", () => {
beforeEach(() => {
__resetStepParserRegistryForTests();
});
it("registry resolution: step-headings parses and writes steps with statuses pending", async () => {
const { deps, written } = makeDeps();
const result = await runParse(parseIr("step-headings"), deps);
expect(result.outcome).toBe("success");
expect(written).toHaveLength(1);
expect(written[0]).toEqual([
{ name: "do a", status: "pending" },
{ name: "do b", status: "pending" },
]);
});
it("preserves dependsOn from the headings (depends:) annotation", async () => {
const { deps, written } = makeDeps({
readArtifact: async () => "### Step 1: a\n### Step 2 (depends: 1): b",
});
const result = await runParse(parseIr("step-headings"), deps);
expect(result.outcome).toBe("success");
expect(written[0]).toEqual([
{ name: "a", status: "pending" },
{ name: "b", status: "pending", dependsOn: [0] },
]);
});
it("json-steps parser writes structured steps", async () => {
const { deps, written } = makeDeps({
readArtifact: async () => JSON.stringify([{ name: "x" }, { name: "y", depends: [1] }]),
});
const result = await runParse(parseIr("json-steps"), deps);
expect(result.outcome).toBe("success");
expect(written[0]).toEqual([
{ name: "x", status: "pending" },
{ name: "y", status: "pending", dependsOn: [0] },
]);
});
it("unknown parser → parse-error (audited), no write", async () => {
const { deps, written, audits } = makeDeps();
// Route outcome:parse-error so the run does not just propagate failure off end.
const ir = parseIr("does-not-exist", undefined, [
{ from: "parse", to: "end", condition: "outcome:parse-error" },
]);
const result = await runParse(ir, deps);
// The parse node fails; with the parse-error edge routed to end, the run
// surfaces the parse node's own failure outcome.
expect(written).toHaveLength(0);
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
expect(result.context["node:parse:value"]).toBe("parse-error");
});
it("parser throw (malformed artifact) → parse-error, never crashes", async () => {
const { deps, audits } = makeDeps({
readArtifact: async () => "not json at all",
});
const result = await runParse(parseIr("json-steps"), deps);
expect(result.executed).toBe(true);
expect(result.context["node:parse:value"]).toBe("parse-error");
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
});
it("missing artifact (undefined content) → parse-error", async () => {
const { deps, audits } = makeDeps({ readArtifact: async () => undefined });
const result = await runParse(parseIr("step-headings"), deps);
expect(result.context["node:parse:value"]).toBe("parse-error");
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
});
it("clean empty parse → no-steps outcome (success), writes empty list", async () => {
const { deps, written } = makeDeps({ readArtifact: async () => "no headings here" });
const ir = parseIr("step-headings", undefined, [
{ from: "parse", to: "end", condition: "outcome:no-steps" },
]);
const result = await runParse(ir, deps);
expect(result.outcome).toBe("success");
expect(result.context["node:parse:value"]).toBe("no-steps");
expect(written).toEqual([[]]);
});
it("pin protection: parse after a foreach expanded → pin-mismatch failure, no write", async () => {
const { deps, written, audits } = makeDeps({
hasExpandedForeach: async () => true,
});
const ir = parseIr("step-headings", undefined, [
{ from: "parse", to: "end", condition: "outcome:pin-mismatch" },
]);
const result = await runParse(ir, deps);
expect(written).toHaveLength(0);
expect(result.context["node:parse:value"]).toBe("pin-mismatch");
expect(audits.some((a) => a.reason === "pin-mismatch")).toBe(true);
});
it("default workflow parity: registry step-headings == direct parseStepHeadings call", async () => {
const { parseStepHeadings } = await import("@fusion/core");
const content = "### Step 1: alpha\n### Step 2 (depends: 1): beta";
const direct = parseStepHeadings(content);
const viaRegistry = getStepParserRegistry().getParser("step-headings")!.parse(content);
expect(viaRegistry.steps.map((s) => ({ name: s.name, dependsOn: s.dependsOn }))).toEqual(
direct.map((s) => ({ name: s.name, dependsOn: s.dependsOn })),
);
});
});
describe("plugin step-parser fail-closed (U12, KTD-12)", () => {
beforeEach(() => {
__resetStepParserRegistryForTests();
});
it("happy path: a registered plugin parser resolves and writes steps", async () => {
registerPluginStepParsers({
pluginId: "acme",
contributions: [{ parserId: "yaml", parse: () => ({ steps: [{ name: "from-plugin" }] }) }],
});
const { deps, written } = makeDeps({ readArtifact: async () => "ignored" });
const result = await runParse(parseIr("plugin:acme:yaml"), deps);
expect(result.outcome).toBe("success");
expect(written[0]).toEqual([{ name: "from-plugin", status: "pending" }]);
unregisterPluginStepParsers("acme", ["yaml"]);
});
it("a throwing plugin parser maps to parse-error (fail-closed, audited), never crashes", async () => {
registerPluginStepParsers({
pluginId: "acme",
contributions: [
{
parserId: "boom",
parse: () => {
throw new Error("kaboom");
},
},
],
});
const { deps, audits } = makeDeps({ readArtifact: async () => "x" });
const ir = parseIr("plugin:acme:boom", undefined, [
{ from: "parse", to: "end", condition: "outcome:parse-error" },
]);
const result = await runParse(ir, deps);
expect(result.context["node:parse:value"]).toBe("parse-error");
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
unregisterPluginStepParsers("acme", ["boom"]);
});
it("a plugin parser returning a bad result maps to parse-error", async () => {
registerPluginStepParsers({
pluginId: "acme",
contributions: [{ parserId: "bad", parse: () => ({ steps: [{} as { name: string }] }) }],
});
const { deps, audits } = makeDeps({ readArtifact: async () => "x" });
const result = await runParse(parseIr("plugin:acme:bad"), deps);
expect(audits.some((a) => a.reason === "parse-error")).toBe(true);
expect(result.context["node:parse:value"]).toBe("parse-error");
unregisterPluginStepParsers("acme", ["bad"]);
});
it("registry rejects a non-namespaced plugin parser id", () => {
expect(() =>
registerPluginStepParsers({
pluginId: "acme",
// pluginParserRegistryId always namespaces, so registration succeeds —
// verify the resulting id is correctly namespaced.
contributions: [{ parserId: "ok", parse: () => ({ steps: [] }) }],
}),
).not.toThrow();
expect(getStepParserRegistry().has("plugin:acme:ok")).toBe(true);
unregisterPluginStepParsers("acme", ["ok"]);
});
});

View File

@@ -0,0 +1,77 @@
// -nocheck
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks, mockedExecSync } from "./executor-test-helpers.js";
/**
* T8: the integration rebase (and rebase --abort) must run in the INSTANCE
* worktree — the instance branch is checked out there, so running the rebase
* from the task's MAIN worktree fails with "branch is already checked out in
* another worktree". The final fast-forward merge still runs from the main
* worktree (it advances the target branch checked out there).
*
* The shared executor harness routes the promisified `exec` through the
* `execSync` mock (see executor-test-helpers), so we drive behavior + capture
* cwds via `mockedExecSync`.
*/
describe("buildForeachWorktreeDeps integrate() cwd (T8)", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExecSync.mockReset();
});
afterEach(() => vi.restoreAllMocks());
function makeDeps() {
const store = createMockStore();
store.getTask = vi.fn().mockResolvedValue({
id: "FN-PAR",
worktree: "/main/wt",
branch: "fusion/FN-PAR",
});
const executor: any = new TaskExecutor(store, "/root", {});
// Stub createWorktree so allocateInstanceWorktree records the instance path
// without touching the filesystem.
executor.createWorktree = vi.fn(async (branch: string, _path: string) => ({
path: `/inst/step-${branch}`,
branch,
}));
const deps = executor.buildForeachWorktreeDeps({ id: "FN-PAR", branch: "fusion/FN-PAR" });
return { executor, deps };
}
it("runs rebase in the instance worktree and ff-merge in the main worktree", async () => {
const { deps } = makeDeps();
const alloc = await deps.allocateInstanceWorktree(2, "base-sha");
const calls: Array<{ cmd: string; cwd: string }> = [];
mockedExecSync.mockImplementation((cmd: string, opts: any) => {
calls.push({ cmd, cwd: String(opts?.cwd ?? "") });
return "deadbeef";
});
const result = await deps.integrationGitOps.integrate(alloc.branchName, 2);
expect(result.kind).toBe("integrated");
const rebase = calls.find((c) => c.cmd.startsWith("git rebase ") && !c.cmd.includes("--abort"));
const merge = calls.find((c) => c.cmd.startsWith("git merge --ff-only"));
expect(rebase?.cwd).toBe(`/inst/step-${alloc.branchName}`); // instance worktree
expect(merge?.cwd).toBe("/main/wt"); // main worktree
});
it("falls back to the main worktree cwd when no instance path is recorded", async () => {
// Defensive: an integrate() for a stepIndex with no allocated instance path
// (e.g. shared isolation) must not pass an undefined cwd to the rebase.
const { deps } = makeDeps();
const calls: Array<{ cmd: string; cwd: string }> = [];
mockedExecSync.mockImplementation((cmd: string, opts: any) => {
calls.push({ cmd, cwd: String(opts?.cwd ?? "") });
return "deadbeef";
});
const result = await deps.integrationGitOps.integrate("fusion/FN-PAR-step-9", 9);
expect(result.kind).toBe("integrated");
const rebase = calls.find((c) => c.cmd.startsWith("git rebase ") && !c.cmd.includes("--abort"));
expect(rebase?.cwd).toBe("/main/wt"); // fallback to main worktree, never undefined
});
});

View File

@@ -0,0 +1,637 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, TaskStep, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type WorkflowLegacySeams,
} from "../workflow-node-handlers.js";
import {
IntegrationQueue,
type IntegrationGitOps,
type IntegrationProjection,
type IntegrationAttemptResult,
} from "../step-integration.js";
import type { WorkflowStepInstanceState } from "../workflow-graph-foreach.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
// ── shared test scaffolding ─────────────────────────────────────────────────
/** Build a TaskDetail with a step list; dependsOn (0-indexed) per step optional. */
function taskWithSteps(specs: Array<{ dependsOn?: number[] }> | number): TaskDetail {
const list: Array<{ dependsOn?: number[] }> =
typeof specs === "number" ? Array.from({ length: specs }, () => ({})) : specs;
const steps: TaskStep[] = list.map((s, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
...(s.dependsOn ? { dependsOn: s.dependsOn } : {}),
}));
return { id: "FN-PAR", steps } as unknown as TaskDetail;
}
/** Base no-op seams with an optional override. */
function baseSeams(overrides: Partial<WorkflowLegacySeams>): WorkflowLegacySeams {
const ok = async () => ({ outcome: "success" as const });
return { planning: ok, execute: ok, review: ok, merge: ok, schedule: ok, ...overrides };
}
/** A single step-execute template. */
function singleExecuteTemplate() {
return {
nodes: [{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } }],
edges: [],
};
}
/** exec → step-review template (review routes approve/revise/rethink). */
function reviewTemplate() {
return {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "step-review" as const, config: { type: "code" } },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
{ from: "review", to: "exec", condition: "outcome:rethink", kind: "rework" as const },
],
};
}
function foreachIr(
template: { nodes: WorkflowIrNode[]; edges: WorkflowIr["edges"] },
config: Record<string, unknown> = {},
): WorkflowIr {
return {
version: "v2",
name: "parallel-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template, ...config } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "fe" },
{ from: "fe", to: "end", condition: "success" },
],
};
}
/**
* A fake worktree+git+integration backend the executor deps are wired to. Models
* a per-instance branch and an ordered integration base purely in-memory; tests
* script which (stepIndex) integrations conflict.
*/
function makeFakeBackend(opts: {
conflictSteps?: Set<number>;
/** Steps whose integration conflicts only on the FIRST attempt (then succeed). */
conflictOnceSteps?: Set<number>;
} = {}) {
const conflictSteps = opts.conflictSteps ?? new Set<number>();
const conflictOnceSteps = opts.conflictOnceSteps ?? new Set<number>();
const integrateAttempts = new Map<number, number>();
const allocations: Array<{ stepIndex: number; branchName: string; base: string | undefined }> = [];
const integrationOrder: number[] = [];
const discarded: string[] = [];
const released: string[] = [];
const doneSteps: number[] = [];
const instanceIntegrated: Array<{ stepIndex: number; at: string }> = [];
let integrationBase = "main@0";
let integratedCount = 0;
const resetBranches: string[] = [];
const gitOps: IntegrationGitOps = {
integrate: async (branchName, stepIndex): Promise<IntegrationAttemptResult> => {
const attempt = (integrateAttempts.get(stepIndex) ?? 0) + 1;
integrateAttempts.set(stepIndex, attempt);
const conflictNow =
conflictSteps.has(stepIndex) || (conflictOnceSteps.has(stepIndex) && attempt === 1);
if (conflictNow) {
return { kind: "conflict", conflictedFiles: [`step-${stepIndex}.ts`] };
}
integrationOrder.push(stepIndex);
integratedCount += 1;
integrationBase = `main@${integratedCount}`; // base advances on each integration
return { kind: "integrated", integratedAt: `t${stepIndex}` };
},
discardBranch: async (branchName) => {
discarded.push(branchName);
released.push(branchName);
},
};
const projection: IntegrationProjection = {
markStepDone: async (stepIndex) => {
doneSteps.push(stepIndex);
},
markInstanceIntegrated: async (stepIndex, at) => {
instanceIntegrated.push({ stepIndex, at });
},
};
return {
gitOps,
projection,
allocations,
integrationOrder,
discarded,
released,
doneSteps,
instanceIntegrated,
resetBranches,
getBase: () => integrationBase,
deps: {
allocateInstanceWorktree: async (stepIndex: number, base: string | undefined) => {
const branchName = `fusion/fn-par-step-${stepIndex}`;
allocations.push({ stepIndex, branchName, base });
return { worktreePath: `/wt/step-${stepIndex}`, branchName };
},
resolveIntegrationBase: async () => integrationBase,
integrationGitOps: gitOps,
integrationProjection: projection,
},
};
}
// ── IntegrationQueue state machine (TEST-FIRST) ─────────────────────────────
describe("IntegrationQueue (ordered integration state machine)", () => {
function queueHarness(pinned: number, integrate: (b: string, i: number) => IntegrationAttemptResult) {
const order: number[] = [];
const done: number[] = [];
const integrated: number[] = [];
const discarded: string[] = [];
const git: IntegrationGitOps = {
integrate: async (b, i) => {
const r = integrate(b, i);
if (r.kind === "integrated") order.push(i);
return r;
},
discardBranch: async (b) => {
discarded.push(b);
},
};
const proj: IntegrationProjection = {
markStepDone: async (i) => {
done.push(i);
},
markInstanceIntegrated: async (i) => {
integrated.push(i);
},
};
const q = new IntegrationQueue(git, proj, pinned);
return { q, order, done, integrated, discarded };
}
it("integrates strictly in step order even when completion order inverts", async () => {
const h = queueHarness(3, () => ({ kind: "integrated", integratedAt: "t" }));
// Enqueue out of order: 2 first, then 0, then 1.
h.q.enqueue(2, "b2");
let outcomes = await h.q.drain();
expect(outcomes).toEqual([]); // 0 not ready → nothing integrates.
h.q.enqueue(0, "b0");
outcomes = await h.q.drain();
expect(h.order).toEqual([0]); // only 0 (1 is the next gap).
h.q.enqueue(1, "b1");
await h.q.drain();
expect(h.order).toEqual([0, 1, 2]); // 1 then 2 cascade.
expect(h.q.isDrained()).toBe(true);
});
it("projection-first: markStepDone precedes markInstanceIntegrated per step", async () => {
const events: string[] = [];
const git: IntegrationGitOps = {
integrate: async () => ({ kind: "integrated", integratedAt: "t" }),
discardBranch: async () => {},
};
const proj: IntegrationProjection = {
markStepDone: async (i) => {
events.push(`done:${i}`);
},
markInstanceIntegrated: async (i) => {
events.push(`row:${i}`);
},
};
const q = new IntegrationQueue(git, proj, 1);
q.enqueue(0, "b0");
await q.drain();
expect(events).toEqual(["done:0", "row:0"]);
});
it("conflict stops the drain, discards the branch, and does not mark done", async () => {
const h = queueHarness(2, (_b, i) =>
i === 0 ? { kind: "conflict", conflictedFiles: ["x"] } : { kind: "integrated", integratedAt: "t" },
);
h.q.enqueue(0, "b0");
h.q.enqueue(1, "b1");
const outcomes = await h.q.drain();
expect(outcomes).toEqual([{ stepIndex: 0, status: "conflict", conflictedFiles: ["x"] }]);
expect(h.done).toEqual([]);
expect(h.discarded).toContain("b0");
// Step 1 must NOT integrate ahead of the unresolved step 0.
expect(h.order).toEqual([]);
});
it("skip advances the cursor past a failed step", async () => {
const h = queueHarness(3, () => ({ kind: "integrated", integratedAt: "t" }));
h.q.skip(0);
h.q.enqueue(1, "b1");
h.q.enqueue(2, "b2");
await h.q.drain();
expect(h.order).toEqual([1, 2]);
expect(h.q.isDrained()).toBe(true);
});
});
// ── full U10 scenarios ──────────────────────────────────────────────────────
describe("WorkflowGraphExecutor parallel/worktree foreach (U10)", () => {
/** Run a foreach IR with a fake backend; record the order steps START. */
async function runScenario(
task: TaskDetail,
config: Record<string, unknown>,
backend: ReturnType<typeof makeFakeBackend>,
overrides: Partial<{
semaphoreAvailability: () => number;
stepExecute: WorkflowLegacySeams["stepExecute"];
stepReview: WorkflowLegacySeams["stepReview"];
onReworkReset: (a: ForeachActiveContext) => void;
template: { nodes: WorkflowIrNode[]; edges: WorkflowIr["edges"] };
signal: AbortSignal;
logTaskEntry: (summary: string, detail?: string) => void;
}> = {},
) {
const startOrder: number[] = [];
const seams = baseSeams({
stepExecute:
overrides.stepExecute ??
(async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
startOrder.push(active.stepIndex);
return { outcome: "success", value: "step-done" };
}),
...(overrides.stepReview ? { stepReview: overrides.stepReview } : {}),
});
const executor = new WorkflowGraphExecutor({
seams,
...backend.deps,
...(overrides.semaphoreAvailability ? { semaphoreAvailability: overrides.semaphoreAvailability } : {}),
...(overrides.onReworkReset ? { onReworkReset: overrides.onReworkReset as never } : {}),
...(overrides.signal ? { signal: overrides.signal } : {}),
...(overrides.logTaskEntry ? { logTaskEntry: overrides.logTaskEntry } : {}),
});
const ir = foreachIr(overrides.template ?? singleExecuteTemplate(), config);
const result = await executor.run(task, settingsOn(), ir);
return { result, startOrder };
}
it("diamond dep graph (0 ← 1,2 ← 3) runs 1∥2 then 3", async () => {
// Step 0 root; 1 and 2 depend on 0; 3 depends on 1 and 2.
const task = taskWithSteps([
{ dependsOn: [] },
{ dependsOn: [0] },
{ dependsOn: [0] },
{ dependsOn: [1, 2] },
]);
const backend = makeFakeBackend();
const { result, startOrder } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 4 },
backend,
);
expect(result.outcome).toBe("success");
// 0 first; 1 and 2 after 0 integrated; 3 last.
expect(startOrder[0]).toBe(0);
expect(new Set([startOrder[1], startOrder[2]])).toEqual(new Set([1, 2]));
expect(startOrder[3]).toBe(3);
// Ordered integration is step order.
expect(backend.integrationOrder).toEqual([0, 1, 2, 3]);
expect(backend.doneSteps).toEqual([0, 1, 2, 3]);
});
it("sequential + worktree runs one at a time with per-step branches + ordered integration", async () => {
const task = taskWithSteps(3);
const backend = makeFakeBackend();
const concurrentPeak = { value: 0 };
let active = 0;
const { result } = await runScenario(task, { mode: "sequential", isolation: "worktree" }, backend, {
stepExecute: async () => {
active += 1;
concurrentPeak.value = Math.max(concurrentPeak.value, active);
await Promise.resolve();
active -= 1;
return { outcome: "success", value: "step-done" };
},
});
expect(result.outcome).toBe("success");
expect(concurrentPeak.value).toBe(1); // never more than one at a time.
expect(backend.allocations.map((a) => a.branchName)).toEqual([
"fusion/fn-par-step-0",
"fusion/fn-par-step-1",
"fusion/fn-par-step-2",
]);
expect(backend.integrationOrder).toEqual([0, 1, 2]);
});
it("unannotated plan stays fully sequential at concurrency 4", async () => {
const task = taskWithSteps(4); // no dependsOn → each implicitly depends on prev.
const backend = makeFakeBackend();
const concurrentPeak = { value: 0 };
const order: number[] = [];
let active = 0;
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 4 },
backend,
{
stepExecute: async (_t, ctx) => {
const a = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
order.push(a.stepIndex);
active += 1;
concurrentPeak.value = Math.max(concurrentPeak.value, active);
await Promise.resolve();
active -= 1;
return { outcome: "success", value: "step-done" };
},
},
);
expect(result.outcome).toBe("success");
expect(concurrentPeak.value).toBe(1);
expect(order).toEqual([0, 1, 2, 3]);
expect(backend.integrationOrder).toEqual([0, 1, 2, 3]);
});
it("conflict between parallel steps → loser reworks on updated base and succeeds", async () => {
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [] }]);
// Step 1 conflicts the first integration attempt, then succeeds.
const backend = makeFakeBackend({ conflictOnceSteps: new Set([1]) });
const execStarts: number[] = [];
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 2 },
backend,
{
stepExecute: async (_t, ctx) => {
const a = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
execStarts.push(a.stepIndex);
return { outcome: "success", value: "step-done" };
},
},
);
expect(result.outcome).toBe("success");
// Step 1 executed twice (initial + rework after conflict).
expect(execStarts.filter((s) => s === 1).length).toBe(2);
// Both eventually integrated, in step order.
expect(backend.integrationOrder).toEqual([0, 1]);
expect(backend.doneSteps).toEqual([0, 1]);
// The conflicting branch was discarded before re-running.
expect(backend.discarded).toContain("fusion/fn-par-step-1");
// The rework re-allocated off the UPDATED base (after step 0 integrated).
const step1Allocs = backend.allocations.filter((a) => a.stepIndex === 1);
expect(step1Allocs.length).toBe(2);
expect(step1Allocs[1].base).toBe("main@1");
});
it("FIX 4: an integration conflict writes a task-level log entry naming the conflicted files", async () => {
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [] }]);
const backend = makeFakeBackend({ conflictOnceSteps: new Set([1]) });
const logged: Array<{ summary: string; detail?: string }> = [];
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 2 },
backend,
{ logTaskEntry: (summary, detail) => logged.push({ summary, detail }) },
);
expect(result.outcome).toBe("success");
const conflictLog = logged.find((l) => l.summary.includes("integration conflict on step 1"));
expect(conflictLog).toBeDefined();
expect(conflictLog!.summary).toContain("reworking on updated base");
// The fake backend reports `step-1.ts` as the conflicted file.
expect(conflictLog!.summary).toContain("step-1.ts");
expect(conflictLog!.detail).toContain("step-1.ts");
});
it("conflict rework exhaustion routes rework-exhausted", async () => {
const task = taskWithSteps([{ dependsOn: [] }]);
const backend = makeFakeBackend({ conflictSteps: new Set([0]) }); // always conflicts.
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 1, maxReworkCycles: 2 },
backend,
);
expect(result.outcome).toBe("failure");
expect(result.context).toBeDefined();
// The foreach node's value surfaces rework-exhausted.
expect(result.visitedNodeIds).toContain("fe");
// Never marked done.
expect(backend.doneSteps).toEqual([]);
});
it("FIX 2: a failed instance releases its allocated worktree exactly once", async () => {
const task = taskWithSteps([{ dependsOn: [] }]);
const backend = makeFakeBackend();
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 1 },
backend,
{
// The instance allocates a worktree, then its step-execute FAILS — the
// scheduler never enqueues it for integration, so without explicit
// release its worktree+branch would leak.
stepExecute: async () => ({ outcome: "failure", value: "boom" }),
},
);
expect(result.outcome).toBe("failure");
// The allocated branch was released exactly once (discard==release in the fake).
expect(backend.allocations.map((a) => a.branchName)).toEqual(["fusion/fn-par-step-0"]);
expect(backend.released).toEqual(["fusion/fn-par-step-0"]);
expect(backend.released.filter((b) => b === "fusion/fn-par-step-0").length).toBe(1);
// It never integrated.
expect(backend.doneSteps).toEqual([]);
});
it("FIX 2: abort mid-run releases every allocated instance worktree", async () => {
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [] }]);
const backend = makeFakeBackend();
const controller = new AbortController();
let executed = 0;
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 2 },
backend,
{
// Both instances allocate worktrees and run; abort after the first batch's
// step-execute so the scheduler's top-of-loop abort check fires while their
// worktrees are still allocated.
stepExecute: async () => {
executed += 1;
if (executed >= 1) controller.abort();
return { outcome: "success", value: "step-done" };
},
signal: controller.signal,
},
);
expect(result.outcome).toBe("failure");
// Every allocated branch was released (no leak), each exactly once.
const allocated = backend.allocations.map((a) => a.branchName);
expect(allocated.length).toBeGreaterThan(0);
for (const b of allocated) {
expect(backend.released.filter((r) => r === b).length).toBe(1);
}
});
it("integration order is step order even when completion order inverts", async () => {
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [] }, { dependsOn: [] }]);
const backend = makeFakeBackend();
// Make later steps complete FIRST by delaying step 0's execution.
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 3 },
backend,
{
stepExecute: async (_t, ctx) => {
const a = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
// step 0 yields the most → completes last.
const delays = 2 - a.stepIndex;
for (let i = 0; i < delays; i++) await Promise.resolve();
return { outcome: "success", value: "step-done" };
},
},
);
expect(result.outcome).toBe("success");
expect(backend.integrationOrder).toEqual([0, 1, 2]);
expect(backend.doneSteps).toEqual([0, 1, 2]);
});
it("semaphore starvation degrades to sequential without deadlock", async () => {
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [] }, { dependsOn: [] }]);
const backend = makeFakeBackend();
const concurrentPeak = { value: 0 };
let active = 0;
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 4 },
backend,
{
semaphoreAvailability: () => 0, // fully starved.
stepExecute: async () => {
active += 1;
concurrentPeak.value = Math.max(concurrentPeak.value, active);
await Promise.resolve();
active -= 1;
return { outcome: "success", value: "step-done" };
},
},
);
expect(result.outcome).toBe("success");
expect(concurrentPeak.value).toBe(1); // forced to 1 under starvation, no deadlock.
expect(backend.integrationOrder).toEqual([0, 1, 2]);
});
it("dependency cycle at expansion fails audited", async () => {
// Step 1 depends on step 2 (a forward reference → cycle signature).
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [2] }, { dependsOn: [] }]);
const backend = makeFakeBackend();
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 4 },
backend,
);
expect(result.outcome).toBe("failure");
expect(backend.allocations).toEqual([]); // never expanded any instance.
});
it("RETHINK resets only the instance branch (branch-scoped)", async () => {
const task = taskWithSteps([{ dependsOn: [] }]);
const backend = makeFakeBackend();
const resetBranches: string[] = [];
let reviewCalls = 0;
const { result } = await runScenario(
task,
{ mode: "parallel", isolation: "worktree", concurrency: 1 },
backend,
{
template: reviewTemplate(),
stepReview: async () => {
reviewCalls += 1;
return reviewCalls === 1 ? { verdict: "RETHINK" as const } : { verdict: "APPROVE" as const };
},
onReworkReset: (active: ForeachActiveContext) => {
// Branch-scoped reset: the active context carries THIS instance's branch.
resetBranches.push(active.branchName ?? "<none>");
},
},
);
expect(result.outcome).toBe("success");
expect(resetBranches).toEqual(["fusion/fn-par-step-0"]);
expect(backend.integrationOrder).toEqual([0]);
});
it("merge-blocker stays blocked until last integration (projection rule)", async () => {
const task = taskWithSteps([{ dependsOn: [] }, { dependsOn: [0] }]);
const backend = makeFakeBackend();
// Capture doneSteps progression: step 1 must not be done until it integrates.
const doneAfterStep0Integrated: number[] = [];
const origMarkDone = backend.projection.markStepDone;
backend.projection.markStepDone = async (i) => {
await origMarkDone(i);
doneAfterStep0Integrated.push(i);
};
const { result } = await runScenario(
task,
{ mode: "sequential", isolation: "worktree" },
backend,
);
expect(result.outcome).toBe("success");
// done flips strictly in integration order — step 1 done ONLY after step 0.
expect(doneAfterStep0Integrated).toEqual([0, 1]);
});
it("worktree isolation without wiring fails cleanly (routable)", async () => {
const task = taskWithSteps(2);
const executor = new WorkflowGraphExecutor({ seams: baseSeams({}) });
const result = await executor.run(
task,
settingsOn(),
foreachIr(singleExecuteTemplate(), { mode: "parallel", isolation: "worktree" }),
);
expect(result.outcome).toBe("failure");
});
});
// ── crash-resume reconciliation ─────────────────────────────────────────────
describe("worktree-isolation crash-resume reconciliation (U10)", () => {
it("persists branchName + awaiting-integration through the persistence hook", async () => {
const task = taskWithSteps([{ dependsOn: [] }]);
const backend = makeFakeBackend();
const saved: WorkflowStepInstanceState[] = [];
const persistence = {
saveInstanceState: (s: WorkflowStepInstanceState) => {
saved.push({ ...s });
},
};
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
...backend.deps,
stepInstancePersistence: persistence,
});
const result = await executor.run(
task,
settingsOn(),
foreachIr(singleExecuteTemplate(), { mode: "sequential", isolation: "worktree" }),
);
expect(result.outcome).toBe("success");
// The instance row carried branchName and reached awaiting-integration.
const awaiting = saved.find((s) => s.status === "awaiting-integration");
expect(awaiting).toBeDefined();
expect(awaiting?.branchName).toBe("fusion/fn-par-step-0");
});
});

View File

@@ -0,0 +1,263 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, TaskStep, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
SPLIT_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type StepReviewSeamResult,
type WorkflowLegacySeams,
} from "../workflow-node-handlers.js";
import type { WorkflowStepInstanceState } from "../workflow-graph-foreach.js";
/**
* U5 — step-review node + verdict wiring (KTD-4). These scenarios exercise the
* real {@link createStepReviewHandler} (registered by default in the executor)
* driving a `seams.stepReview` fake, with the foreach sub-walk providing the
* `foreach:active` context, rework edges, and the RETHINK reset hook.
*/
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
function taskWithSteps(n: number): TaskDetail {
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
}));
return { id: "FN-REVIEW", steps } as unknown as TaskDetail;
}
/** Base no-op seams with overrides. */
function baseSeams(overrides: Partial<WorkflowLegacySeams>): WorkflowLegacySeams {
const ok = async () => ({ outcome: "success" as const });
return { planning: ok, execute: ok, review: ok, merge: ok, schedule: ok, ...overrides };
}
/**
* Build: start → foreach{ exec(step-execute) → review(step-review) } → end.
* Verdict edges from review: approve → exit (no edge = template exit), revise →
* rework to exec, rethink → rework to exec. Foreach exhaustion routes to a hold.
*/
function reviewForeachIr(opts: { config?: Record<string, unknown> } = {}): WorkflowIr {
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "step-review" as const, config: { type: "code" } },
] as WorkflowIrNode[],
edges: [
{ from: "exec", to: "review", condition: "success" },
// approve (and unavailable) have NO outgoing edge from review → template exit
// (instance done / advisory continuation).
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
{ from: "review", to: "exec", condition: "outcome:rethink", kind: "rework" as const },
],
};
return {
version: "v2",
name: "review-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template, ...(opts.config ?? {}) } },
{ id: "hold", kind: "prompt", config: {} },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "fe" },
{ from: "fe", to: "end", condition: "success" },
{ from: "fe", to: "hold", condition: "outcome:rework-exhausted" },
],
};
}
describe("WorkflowGraphExecutor step-review (U5)", () => {
it("APPROVE marks the step done via the projection and routes the approve edge", async () => {
const doneMarks: Array<{ index: number; status: string }> = [];
const stepReview = vi.fn(async (): Promise<StepReviewSeamResult> => ({ verdict: "APPROVE" }));
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
active.baselineSha = `base-${active.stepIndex}`;
// step-execute leaves the step in-progress (review decides done) — record
// that nothing was done here.
return { outcome: "success", value: "step-done", contextPatch: { [FOREACH_ACTIVE_CONTEXT_KEY]: active } };
},
stepReview: async (_t, _ctx, cfg) => {
const r = await stepReview();
// Simulate the executor's APPROVE projection write.
if (r.verdict === "APPROVE" && !cfg.advisory) doneMarks.push({ index: 0, status: "done" });
return r;
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(1), settingsOn(), reviewForeachIr());
expect(result.outcome).toBe("success");
expect(stepReview).toHaveBeenCalledTimes(1);
expect(doneMarks).toEqual([{ index: 0, status: "done" }]);
});
it("REVISE routes a rework edge without triggering a reset", async () => {
const resets: string[] = [];
let reviewCalls = 0;
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
stepReview: async (): Promise<StepReviewSeamResult> => {
reviewCalls += 1;
return reviewCalls === 1 ? { verdict: "REVISE" } : { verdict: "APPROVE" };
},
});
const executor = new WorkflowGraphExecutor({
seams,
onReworkReset: async (active, reason) => {
resets.push(`${active.stepIndex}:${reason}`);
},
});
const result = await executor.run(taskWithSteps(1), settingsOn(), reviewForeachIr());
expect(result.outcome).toBe("success");
expect(reviewCalls).toBe(2); // revise → rework → approve
expect(resets).toEqual([]); // REVISE never resets
});
it("RETHINK resets to baseline then re-executes the step", async () => {
const resets: Array<{ index: number; reason: string; baseline?: string }> = [];
let reviewCalls = 0;
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
active.baselineSha = "base-rethink";
active.checkpointId = "ckpt-1";
return { outcome: "success", value: "step-done", contextPatch: { [FOREACH_ACTIVE_CONTEXT_KEY]: active } };
},
stepReview: async (): Promise<StepReviewSeamResult> => {
reviewCalls += 1;
return reviewCalls === 1 ? { verdict: "RETHINK" } : { verdict: "APPROVE" };
},
});
const executor = new WorkflowGraphExecutor({
seams,
onReworkReset: async (active, reason) => {
resets.push({ index: active.stepIndex, reason, baseline: active.baselineSha });
},
});
const result = await executor.run(taskWithSteps(1), settingsOn(), reviewForeachIr());
expect(result.outcome).toBe("success");
expect(reviewCalls).toBe(2);
expect(resets).toEqual([{ index: 0, reason: "rethink", baseline: "base-rethink" }]);
});
it("UNAVAILABLE retries inside the handler (cap 2) then routes outcome:unavailable", async () => {
let reviewCalls = 0;
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
stepReview: async (): Promise<StepReviewSeamResult> => {
reviewCalls += 1;
return { verdict: "UNAVAILABLE" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(1), settingsOn(), reviewForeachIr());
// The handler retries up to the cap (3 invocations: initial + 2 retries).
expect(reviewCalls).toBe(3);
// value routed is "unavailable"; the IR has no unavailable edge from review,
// so the instance exits the template (advisory) and the foreach succeeds.
expect(result.outcome).toBe("success");
});
it("persists the verdict into the instance row", async () => {
const saved: WorkflowStepInstanceState[] = [];
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
stepReview: async (): Promise<StepReviewSeamResult> => ({ verdict: "APPROVE" }),
});
const executor = new WorkflowGraphExecutor({
seams,
stepInstancePersistence: {
saveInstanceState: (s) => {
saved.push({ ...s });
},
},
});
const result = await executor.run(taskWithSteps(1), settingsOn(), reviewForeachIr());
expect(result.outcome).toBe("success");
// The final (completed) instance row carries the authoritative APPROVE verdict.
const completed = saved.filter((s) => s.status === "completed");
expect(completed.length).toBeGreaterThan(0);
expect(completed[completed.length - 1].verdict).toBe("APPROVE");
});
it("split-branch review is advisory-only: no authoritative verdict, no projection write", async () => {
// Simulate the split-active marker the executor sets around branches: the
// handler reads SPLIT_ACTIVE_CONTEXT_KEY from the shared context and flags the
// review advisory. We assert the seam was told advisory=true and that an
// advisory APPROVE does not write the projection.
const calls: Array<{ advisory: boolean | undefined }> = [];
const projectionWrites: number[] = [];
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
stepReview: async (_t, _ctx, cfg) => {
calls.push({ advisory: cfg.advisory });
if (cfg.type === "code" && !cfg.advisory) projectionWrites.push(1);
return { verdict: "APPROVE" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
// Build a foreach whose template puts the step-review behind a manual
// split-active marker on the shared context via a custom prelude node.
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "mark", kind: "prompt" as const, config: {} },
{ id: "review", kind: "step-review" as const, config: { type: "code" } },
{ id: "exit", kind: "prompt" as const, config: {} },
] as WorkflowIrNode[],
edges: [
{ from: "exec", to: "mark", condition: "success" },
{ from: "mark", to: "review", condition: "success" },
{ from: "review", to: "exit", condition: "outcome:approve" },
],
};
const ir: WorkflowIr = {
version: "v2",
name: "advisory-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "fe" },
{ from: "fe", to: "end", condition: "success" },
],
};
// Custom handler for the "mark" node sets split:active on the shared context
// to simulate running inside a split branch window.
const exec = new WorkflowGraphExecutor({
seams,
handlers: {
prompt: async (node, ctx) => {
if (node.config?.seam === "step-execute") return seams.stepExecute!(ctx.task, ctx.context);
if (node.id === "mark") {
ctx.context[SPLIT_ACTIVE_CONTEXT_KEY] = true;
return { outcome: "success" };
}
return { outcome: "success" };
},
},
});
void executor;
const result = await exec.run(taskWithSteps(1), settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(calls).toEqual([{ advisory: true }]);
expect(projectionWrites).toEqual([]); // advisory APPROVE never writes projection
});
});

View File

@@ -12,6 +12,8 @@ import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
@@ -63,6 +65,14 @@ export const taskDocumentReadParams = Type.Object({
export const workflowListParams = Type.Object({});
export const workflowGetParams = Type.Object({
workflow_id: Type.String({
description:
"The workflow definition ID to fetch (e.g. 'WF-003', or a 'builtin:*' id). " +
"Use fn_workflow_list to discover available IDs.",
}),
});
export const workflowSelectParams = Type.Object({
workflow_id: Type.String({
description:
@@ -74,6 +84,47 @@ export const workflowSelectParams = Type.Object({
),
});
export const taskPromoteParams = Type.Object({
task_id: Type.Optional(
Type.String({ description: "Held task to promote. Defaults to the current task." }),
),
});
export const workflowCreateParams = Type.Object({
name: Type.String({ description: "Workflow name (required, non-empty)." }),
description: Type.Optional(Type.String({ description: "Optional human-readable description." })),
ir: Type.Unknown({
description:
"Workflow graph (intermediate representation). Validated server-side; a malformed graph is rejected.",
}),
layout: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Optional node layout map keyed by node id.",
}),
),
});
export const workflowUpdateParams = Type.Object({
workflow_id: Type.String({ description: "The workflow definition ID to update (built-ins cannot be edited)." }),
name: Type.Optional(Type.String({ description: "New name." })),
description: Type.Optional(Type.String({ description: "New description." })),
ir: Type.Optional(Type.Unknown({ description: "Replacement workflow graph (validated server-side)." })),
layout: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Replacement node layout map." })),
rehome_to: Type.Optional(
Type.String({
description:
"When an IR update removes a column that still holds cards, supply the column id to re-home those occupants into. " +
"Required to resolve an OccupiedColumns conflict; the target must exist in the new IR.",
}),
),
});
export const workflowDeleteParams = Type.Object({
workflow_id: Type.String({ description: "The workflow definition ID to delete (built-ins cannot be deleted)." }),
});
export const traitListParams = Type.Object({});
export const reflectOnPerformanceParams = Type.Object({
focus_area: Type.Optional(
Type.String({ description: "Optional focus area for reflection (e.g., 'code quality', 'speed', 'testing')" }),
@@ -988,6 +1039,68 @@ export function createWorkflowListTool(store: TaskStore): ToolDefinition {
};
}
/**
* Create a `fn_workflow_get` tool that returns a single workflow definition by
* id — id/name/description, whether it is a read-only built-in, and the full
* resolved IR (nodes/edges/columns/artifacts/fields) as JSON. Agent-native
* read parity with the dashboard's workflow inspector; the companion read tool
* to fn_workflow_list. Read-only; an unknown id is reported as a tool error.
*/
export function createWorkflowGetTool(store: TaskStore): ToolDefinition {
return {
name: "fn_workflow_get",
label: "Get Workflow",
description:
"Fetch a single workflow definition by its ID — its name, description, whether it is a " +
"read-only built-in, and its full IR (nodes, edges, columns, artifacts, and custom fields) " +
"as JSON. Use fn_workflow_list to discover IDs first.",
parameters: workflowGetParams,
execute: async (_id: string, params: Static<typeof workflowGetParams>) => {
const workflowId = params.workflow_id?.trim();
if (!workflowId) {
return {
content: [{ type: "text" as const, text: "ERROR: workflow_id is required." }],
details: {},
isError: true,
};
}
try {
const def = await store.getWorkflowDefinition(workflowId);
if (!def) {
return {
content: [{ type: "text" as const, text: `ERROR: Unknown workflow id '${workflowId}'. Use fn_workflow_list to discover valid IDs.` }],
details: {},
isError: true,
};
}
const builtin = isBuiltinWorkflowId(def.id);
const payload = {
id: def.id,
name: def.name,
description: def.description,
builtin,
ir: def.ir,
// Preserve editor node positions so a read→modify→write cycle does not
// strip the layout. May be absent for older/built-in defs; only include
// when present to keep the payload tidy.
...(def.layout ? { layout: def.layout } : {}),
};
return {
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
details: { workflowId: def.id, builtin, ...(def.layout ? { layout: def.layout } : {}) },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `ERROR: Failed to get workflow: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
/**
* Create a `fn_workflow_select` tool that assigns a workflow definition to a
* task (defaulting to the current task). Mirrors the dashboard's per-task
@@ -1006,13 +1119,23 @@ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string
execute: async (_id: string, params: Static<typeof workflowSelectParams>) => {
const taskId = params.task_id?.trim() || currentTaskId;
try {
const enabled = await store.selectTaskWorkflow(taskId, params.workflow_id);
const { enabledWorkflowSteps: enabled, reconciliation } =
await store.selectTaskWorkflowAndReconcile(taskId, params.workflow_id);
const stepSummary = `${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled`;
// Surface the reconciliation outcome so the agent observes any re-home:
// a preserved card stays put; an unpreserved card moves fromColumn→toColumn.
const rehomeNote =
reconciliation && !reconciliation.preserved && reconciliation.fromColumn !== reconciliation.toColumn
? ` Re-homed from '${reconciliation.fromColumn}' to '${reconciliation.toColumn}'.`
: reconciliation
? ` Card preserved in '${reconciliation.toColumn}'.`
: "";
return {
content: [{
type: "text" as const,
text: `Selected workflow ${params.workflow_id} for ${taskId} (${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled).`,
text: `Selected workflow ${params.workflow_id} for ${taskId} (${stepSummary}).${rehomeNote}`,
}],
details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled },
details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled, reconciliation },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
@@ -1026,6 +1149,257 @@ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string
};
}
/**
* Create a `fn_task_promote` tool that manually releases a held task out of its
* hold column — the agent-native equivalent of the dashboard's "promote" action.
* Defaults to the current task. Wraps {@link promoteHeldTask}.
*/
export function createTaskPromoteTool(store: TaskStore, currentTaskId: string): ToolDefinition {
return {
name: "fn_task_promote",
label: "Promote Held Task",
description:
"Manually promote a held task out of its hold column, releasing it regardless of the " +
"hold's release kind (the explicit operator action a 'manual' hold waits for). Defaults " +
"to the current task. Returns the destination column, or a rejection reason when the task " +
"is not held or the destination is full.",
parameters: taskPromoteParams,
execute: async (_id: string, params: Static<typeof taskPromoteParams>) => {
const taskId = params.task_id?.trim() || currentTaskId;
try {
const outcome = await promoteHeldTask(store, taskId);
if (outcome.released) {
return {
content: [{
type: "text" as const,
text: `Promoted ${taskId} to column '${outcome.toColumn}'.`,
}],
details: { taskId, released: true, toColumn: outcome.toColumn },
};
}
return {
content: [{
type: "text" as const,
text: `ERROR: Could not promote ${taskId}: ${outcome.rejection ?? "unknown"}.`,
}],
details: { taskId, released: false, rejection: outcome.rejection },
isError: true,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `ERROR: Failed to promote task: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
/**
* Create a `fn_workflow_create` tool — a thin wrapper over the store's workflow
* definition create. The IR is validated server-side; a malformed graph rejects.
*/
export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
return {
name: "fn_workflow_create",
label: "Create Workflow",
description:
"Create a new custom workflow definition from a name and a workflow graph (IR). " +
"The IR is validated server-side; a malformed graph rejects. Returns the new workflow ID.\n" +
"v2 IR supports step-inversion constructs (all additive, opt-in): " +
"`parse-steps` node {artifact, parser} writes the task step list from a declared artifact " +
"(built-in parsers: `step-headings`, `json-steps`; routable `no-steps`/`parse-error` outcomes) — " +
"it must precede any `foreach`; " +
"`foreach` node {source:'task-steps', template:{nodes,edges}, mode:'sequential'|'parallel', " +
"isolation:'shared'|'worktree', concurrency (parallel only, 1-8), maxReworkCycles (1-10)} " +
"instantiates its single-entry/exit template subgraph once per planned step " +
"(parallel+shared is rejected); a `step-execute` node is legal only inside a foreach template; " +
"`step-review` node {type:'plan'|'code', model?} surfaces verdicts as outcome edges " +
"(`outcome:approve|revise|rethink|unavailable`); edges may set `kind:'rework'` (the only legal cycles, " +
"back to step-execute within an instance; rethink edges trigger a reset-to-baseline); " +
"`code` node {source, timeoutMs?} runs sandboxed TypeScript returning {outcome?, contextPatch?, customFields?}. " +
"Declare task documents via `artifacts: [{key, title?, producedBy?, role?}]` and custom task fields via " +
"`fields: [{id, name, type, required?, default?, options?, render?}]` (types: string/text/number/boolean/" +
"enum/multi-enum/date/url; render.placement card|detail|detail-section, render.badge for card chips).",
parameters: workflowCreateParams,
execute: async (_id: string, params: Static<typeof workflowCreateParams>) => {
try {
const created = await store.createWorkflowDefinition({
name: params.name,
description: params.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ir: params.ir as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layout: params.layout as any,
});
return {
content: [{ type: "text" as const, text: `Created workflow ${created.id} (${created.name}).` }],
details: { workflowId: created.id, name: created.name },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `ERROR: Failed to create workflow: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
/**
* Create a `fn_workflow_update` tool — a thin wrapper over the store's workflow
* definition update. When an IR change removes a still-occupied column, the store
* throws an OccupiedColumnsError; we surface it as a structured response carrying
* the per-column occupant counts so the agent can retry with `rehome_to`.
*/
export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition {
return {
name: "fn_workflow_update",
label: "Update Workflow",
description:
"Update a custom workflow definition (name/description/ir/layout). Built-ins cannot be edited. " +
"If an IR change removes a column that still holds cards, the update is blocked and returns the " +
"occupied columns — retry with rehome_to set to a column id that survives in the new IR. " +
"The IR accepts the same step-inversion constructs as fn_workflow_create (foreach with mode/isolation/" +
"concurrency, step-execute, step-review, parse-steps, code nodes, rework edges, artifacts, fields). " +
"Editing `fields` orphans (never destroys) existing task values for removed/incompatible fields.",
parameters: workflowUpdateParams,
execute: async (_id: string, params: Static<typeof workflowUpdateParams>) => {
try {
const updated = await store.updateWorkflowDefinition(params.workflow_id, {
name: params.name,
description: params.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ir: params.ir as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layout: params.layout as any,
rehomeTo: params.rehome_to,
});
return {
content: [{ type: "text" as const, text: `Updated workflow ${updated.id} (${updated.name}).` }],
details: { workflowId: updated.id, name: updated.name },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
// Surface the typed OccupiedColumnsError as a structured, retryable result.
if (err?.name === "OccupiedColumnsError") {
const occupancies = err.occupancies ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", ");
return {
content: [{
type: "text" as const,
text:
`ERROR: Update removes occupied column(s): ${summary}. ` +
`Retry with rehome_to set to a surviving column id.`,
}],
details: { occupiedColumns: occupancies, workflowId: err.workflowId, retryWith: "rehome_to" },
isError: true,
};
}
return {
content: [{ type: "text" as const, text: `ERROR: Failed to update workflow: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
/**
* Create a `fn_workflow_delete` tool — a thin wrapper over the store's workflow
* definition delete. Surfaces built-in protection and not-found errors as
* structured responses. (The store auto-re-homes occupants to the default
* workflow on delete, so no rehome target is required here.)
*/
export function createWorkflowDeleteTool(store: TaskStore): ToolDefinition {
return {
name: "fn_workflow_delete",
label: "Delete Workflow",
description:
"Delete a custom workflow definition. Built-ins cannot be deleted. Any tasks using it have " +
"their selection cleared and are re-homed to the default workflow's entry column.",
parameters: workflowDeleteParams,
execute: async (_id: string, params: Static<typeof workflowDeleteParams>) => {
try {
await store.deleteWorkflowDefinition(params.workflow_id);
return {
content: [{ type: "text" as const, text: `Deleted workflow ${params.workflow_id}.` }],
details: { workflowId: params.workflow_id },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err?.name === "OccupiedColumnsError") {
const occupancies = err.occupancies ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", ");
return {
content: [{
type: "text" as const,
text: `ERROR: Delete blocked by occupied column(s): ${summary}.`,
}],
details: { occupiedColumns: occupancies, workflowId: err.workflowId },
isError: true,
};
}
return {
content: [{ type: "text" as const, text: `ERROR: Failed to delete workflow: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
/**
* Create a `fn_trait_list` tool that returns the trait catalog from
* {@link listTraits} — the column-behavior building blocks (id, name, flags)
* used when authoring workflow columns.
*/
export function createTraitListTool(): ToolDefinition {
return {
name: "fn_trait_list",
label: "List Traits",
description:
"List the available column traits (the behavior building blocks for workflow columns): " +
"id, name, description, and behavior flags. Use when authoring or updating a workflow IR.",
parameters: traitListParams,
execute: async () => {
try {
const traits = listTraits();
if (traits.length === 0) {
return {
content: [{ type: "text" as const, text: "No traits are registered." }],
details: { traits: [] },
};
}
const lines = traits.map(
(t) => `- ${t.id}: ${t.name}${t.description ? ` — ${t.description}` : ""}`,
);
return {
content: [{ type: "text" as const, text: `Available traits:\n${lines.join("\n")}` }],
details: {
traits: traits.map((t) => ({ id: t.id, name: t.name, description: t.description, flags: t.flags })),
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return {
content: [{ type: "text" as const, text: `ERROR: Failed to list traits: ${err?.message ?? err}` }],
details: {},
isError: true,
};
}
},
};
}
export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition {
return {
name: "fn_memory_search",

View File

@@ -0,0 +1,555 @@
/**
* Code-node runner (U14, KTD-15).
*
* Executes a workflow `code` node: arbitrary user-authored TypeScript that runs
* as a general computation escape hatch (derive a field, compute routing data,
* call an internal API). The source is:
*
* 1. compiled in-memory with esbuild (TS → ESM, no bundling, no resolution);
* 2. written to a temp module in the OS temp dir;
* 3. executed in a CHILD `node` PROCESS with `cwd = task worktree`, a minimal
* env, and the serialized `ctx` delivered on stdin;
* 4. the child default-exports `async (ctx) => result`; its JSON result is
* written to stdout between sentinels and parsed back here.
*
* Harness contract:
* ctx = {
* task: { id, title, description, column, steps, customFields },
* context: <walk context snapshot, JSON-safe>,
* artifacts: { read(key): string | undefined }, // pre-read, plain object
* instance?: <foreach:active when inside a foreach template>,
* }
* result = { outcome?, value?, contextPatch?, customFields? }
* - outcome string → routes outcome:<value>; absent → success
* - contextPatch → merged into the walk context
* - customFields → written through the U11 validation authority by the
* handler wiring (NOT here — the runner has no store)
*
* Failure posture (fail-closed, audited): throw / timeout / non-zero exit /
* compile error → a thrown {@link CodeNodeError} carrying captured stderr
* (capped). The handler maps it to a `failure` node outcome with the error in
* the audit/node result. The runner never gets a store handle, engine
* internals, or the step-list write path (KTD-15 boundaries).
*
* DEVIATION (documented per the plan): artifacts are PRE-READ into a plain
* `ctx.artifacts` object (the script calls `artifacts.read(key)` synchronously
* against the pre-read map) rather than an RPC-over-stdio bridge. This is the
* plan's explicitly-sanctioned "SIMPLER" path — the child process needs no live
* channel back to the engine, keeping the boundary a one-shot stdin→stdout call.
*/
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
// esbuild is loaded lazily at first compile: a top-level import would run its
// environment invariant check (TextEncoder) at module-load time in any process
// that merely imports @fusion/engine — including jsdom test environments where
// that invariant fails. Lazy loading confines esbuild to actual code-node use.
let cachedTransformSync: typeof import("esbuild").transformSync | undefined;
function getTransformSync(): typeof import("esbuild").transformSync {
if (!cachedTransformSync) {
const req = createRequire(import.meta.url);
cachedTransformSync = (req("esbuild") as typeof import("esbuild")).transformSync;
}
return cachedTransformSync;
}
import type { CustomFieldRejection, TaskDetail, WorkflowIrNode } from "@fusion/core";
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
import { FOREACH_ACTIVE_CONTEXT_KEY, type CodeNodeRunner } from "./workflow-node-handlers.js";
/** Default code-node timeout (KTD-15). */
export const CODE_NODE_DEFAULT_TIMEOUT_MS = 30_000;
/** Hard cap on the code-node timeout (KTD-15). */
export const CODE_NODE_MAX_TIMEOUT_MS = 300_000;
/** Defensive re-check of the core source-size cap (KTD-15: ≤64KB). */
export const CODE_NODE_MAX_SOURCE_BYTES = 65_536;
/** Cap on captured stdout/stderr surfaced into the node result (~16KB each). */
export const CODE_NODE_OUTPUT_CAP_BYTES = 16_384;
/** Sentinels framing the JSON result on the child's stdout. */
const RESULT_BEGIN = "__FUSION_CODE_NODE_RESULT_BEGIN__";
const RESULT_END = "__FUSION_CODE_NODE_RESULT_END__";
/** The JSON-safe task subset handed to the code node (KTD-15). */
export interface CodeNodeTaskSubset {
id: string;
title: string;
description?: string;
column?: string;
steps: unknown[];
customFields: Record<string, unknown>;
}
/** The harness ctx assembled for a code-node run. */
export interface CodeNodeContext {
task: CodeNodeTaskSubset;
context: Record<string, unknown>;
/** Declared artifacts, pre-read into a plain map (see module DEVIATION note). */
artifacts: Record<string, string>;
/** `foreach:active` instance when the node runs inside a foreach template. */
instance?: Record<string, unknown>;
}
/** The result shape a code node returns (KTD-15). */
export interface CodeNodeResult {
outcome?: string;
value?: string;
contextPatch?: Record<string, unknown>;
customFields?: Record<string, unknown>;
}
/** Reason codes for a code-node failure (audit-stable). */
export type CodeNodeFailureReason =
| "compile-error"
| "source-too-large"
| "timeout"
| "nonzero-exit"
| "runtime-throw"
| "bad-result";
/** Thrown on any code-node failure; carries the audit-stable reason + captured
* stderr (capped). The handler maps it to a `failure` node outcome. */
export class CodeNodeError extends Error {
readonly reason: CodeNodeFailureReason;
readonly stderr: string;
constructor(reason: CodeNodeFailureReason, message: string, stderr = "") {
super(message);
this.name = "CodeNodeError";
this.reason = reason;
this.stderr = stderr;
}
}
/** Cap a string to a byte budget, appending a truncation marker. */
function capOutput(s: string): string {
if (Buffer.byteLength(s, "utf8") <= CODE_NODE_OUTPUT_CAP_BYTES) return s;
// Slice by characters then trim until under the byte cap (good enough; output
// is for audit display, not byte-exact reconstruction).
let out = s.slice(0, CODE_NODE_OUTPUT_CAP_BYTES);
while (Buffer.byteLength(out, "utf8") > CODE_NODE_OUTPUT_CAP_BYTES) {
out = out.slice(0, -64);
}
return `${out}\n…[truncated]`;
}
/** Resolve and clamp the configured timeout (KTD-15). */
export function resolveCodeNodeTimeout(timeoutMs: unknown): number {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
return CODE_NODE_DEFAULT_TIMEOUT_MS;
}
return Math.max(1000, Math.min(CODE_NODE_MAX_TIMEOUT_MS, Math.floor(timeoutMs)));
}
/**
* Compile a code-node source (TS) to ESM in-memory. Throws {@link CodeNodeError}
* with reason `compile-error` on a syntax/transform failure (this is the same
* transform the save-time validator runs via {@link validateCodeNodeSources}).
*/
export async function compileCodeNodeSource(source: string): Promise<string> {
if (Buffer.byteLength(source, "utf8") > CODE_NODE_MAX_SOURCE_BYTES) {
throw new CodeNodeError(
"source-too-large",
`code node source exceeds ${CODE_NODE_MAX_SOURCE_BYTES} bytes`,
);
}
try {
// `transformSync` runs a short-lived per-call child that exits cleanly,
// avoiding esbuild's long-lived service process (which the test harness's
// subprocess guard would otherwise flag as a lingering child).
const out = getTransformSync()(source, {
loader: "ts",
format: "esm",
target: "node18",
});
return out.code;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new CodeNodeError("compile-error", `code node failed to compile: ${message}`);
}
}
/** The child harness wrapper. Reads ctx JSON from stdin, imports the compiled
* user module (default export), invokes it, frames the JSON result on stdout. */
function buildChildHarness(userModuleFile: string): string {
return `
import userMod from ${JSON.stringify(userModuleFile)};
function readStdin() {
return new Promise((resolve) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => { data += c; });
process.stdin.on("end", () => resolve(data));
});
}
(async () => {
const raw = await readStdin();
const parsed = JSON.parse(raw);
// Reconstruct ctx.artifacts.read from the pre-read plain map.
const artifactsMap = parsed.artifacts || {};
const ctx = {
task: parsed.task,
context: parsed.context || {},
artifacts: {
read(key) {
return Object.prototype.hasOwnProperty.call(artifactsMap, key)
? artifactsMap[key]
: undefined;
},
},
instance: parsed.instance,
};
const fn = userMod;
if (typeof fn !== "function") {
throw new Error("code node module must default-export an async (ctx) => result function");
}
const result = await fn(ctx);
process.stdout.write("${RESULT_BEGIN}" + JSON.stringify(result === undefined ? {} : result) + "${RESULT_END}");
})().catch((err) => {
process.stderr.write(String(err && err.stack ? err.stack : err));
process.exit(7);
});
`;
}
/** Options for {@link runCodeNode}. */
export interface RunCodeNodeOptions {
source: string;
timeoutMs?: number;
cwd: string;
ctx: CodeNodeContext;
/** Override the node executable (tests). Defaults to the current process. */
nodeExecPath?: string;
/** Injected process runner seam (tests). Defaults to the real child-process
* execution. Lets the suite unit-test mapping logic without spawning. */
spawnRunner?: (params: {
nodeExecPath: string;
harnessFile: string;
cwd: string;
timeoutMs: number;
stdin: string;
}) => Promise<{ stdout: string; stderr: string }>;
}
/**
* Compile + execute a code node and return its parsed result. Throws
* {@link CodeNodeError} on any failure (compile/timeout/exit/throw/bad-result).
*/
export async function runCodeNode(opts: RunCodeNodeOptions): Promise<CodeNodeResult> {
const timeoutMs = resolveCodeNodeTimeout(opts.timeoutMs);
const compiled = await compileCodeNodeSource(opts.source);
const dir = await mkdtemp(join(tmpdir(), "fusion-code-node-"));
const userModuleFile = join(dir, "user.mjs");
const harnessFile = join(dir, "harness.mjs");
try {
await writeFile(userModuleFile, compiled, "utf8");
await writeFile(harnessFile, buildChildHarness(userModuleFile), "utf8");
const stdin = JSON.stringify({
task: opts.ctx.task,
context: opts.ctx.context,
artifacts: opts.ctx.artifacts,
instance: opts.ctx.instance,
});
const nodeExecPath = opts.nodeExecPath ?? process.execPath;
const run = opts.spawnRunner ?? defaultSpawnRunner;
let stdout: string;
let stderr: string;
try {
({ stdout, stderr } = await run({ nodeExecPath, harnessFile, cwd: opts.cwd, timeoutMs, stdin }));
} catch (err) {
// Classify the child failure. execFile's error carries `killed`
// (timeout/SIGTERM), `signal`, and `code` (numeric exit code) or the string
// ETIMEDOUT; we narrow with a permissive shape.
const e = err as {
killed?: boolean;
signal?: string | null;
code?: number | string;
message?: string;
stderr?: string;
};
const capturedStderr = capOutput(typeof e.stderr === "string" ? e.stderr : "");
if (e.killed || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") {
throw new CodeNodeError("timeout", `code node timed out after ${timeoutMs}ms`, capturedStderr);
}
// Exit code 7 is our harness's caught-throw sentinel; any numeric exit code
// is a runtime/non-zero-exit failure.
if (typeof e.code === "number") {
throw new CodeNodeError(
"runtime-throw",
`code node threw at runtime${capturedStderr ? `: ${capturedStderr.split("\n")[0]}` : ""}`,
capturedStderr,
);
}
throw new CodeNodeError("nonzero-exit", `code node exited abnormally: ${e.message ?? "unknown error"}`, capturedStderr);
}
// Parse the framed result.
const begin = stdout.indexOf(RESULT_BEGIN);
const end = stdout.indexOf(RESULT_END);
if (begin < 0 || end < 0 || end < begin) {
throw new CodeNodeError(
"bad-result",
"code node produced no parseable result",
capOutput(stderr),
);
}
const jsonStr = stdout.slice(begin + RESULT_BEGIN.length, end);
let parsed: unknown;
try {
parsed = JSON.parse(jsonStr);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new CodeNodeError("bad-result", `code node result was not valid JSON: ${message}`, capOutput(stderr));
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new CodeNodeError("bad-result", "code node result must be an object", capOutput(stderr));
}
return parsed as CodeNodeResult;
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
}
/** The real child-process runner: spawns `node harness.mjs`, pipes ctx on stdin,
* captures stdout/stderr, enforces the timeout. */
function defaultSpawnRunner(params: {
nodeExecPath: string;
harnessFile: string;
cwd: string;
timeoutMs: number;
stdin: string;
}): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = execFile(
params.nodeExecPath,
[params.harnessFile],
{
cwd: params.cwd,
timeout: params.timeoutMs,
// Ensure the timeout actually KILLS a child that traps/ignores SIGTERM:
// execFile defaults to SIGTERM, which a long-running or signal-trapping
// script can swallow, letting it outlive the timeout. SIGKILL cannot be
// trapped, so the timeout is enforceable.
killSignal: "SIGKILL",
// Minimal env: PATH + a few harmless basics; no inherited secrets beyond
// what the worktree-scoped script tier already has access to (KTD-15:
// same trust as existing script steps).
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
NODE_ENV: process.env.NODE_ENV ?? "",
},
maxBuffer: 8 * 1024 * 1024,
encoding: "utf8",
},
(err, stdout, stderr) => {
if (err) {
(err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }).stderr = stderr;
reject(err);
return;
}
resolve({ stdout: stdout ?? "", stderr: stderr ?? "" });
},
);
child.stdin?.end(params.stdin);
});
}
/**
* Save-time syntax validation (U14, KTD-15). Compiles every `code` node's source
* with the same esbuild transform the runner uses; returns the nodes that fail
* to compile with the error message. Exported so the dashboard workflow-save
* route can reject IR with uncompilable code nodes BEFORE persistence.
*
* HANDOFF: the dashboard route (`register-workflow-routes.ts` →
* `store.createWorkflowDefinition/update`) is owned by a concurrent agent and is
* NOT wired here. Until that route calls this helper, code-node sources are
* validated at EXECUTION time (a compile error surfaces as a `failure` node
* outcome via {@link CodeNodeError} reason `compile-error`). See the report
* handoff item.
*/
export async function validateCodeNodeSources(
ir: { nodes: WorkflowIrNode[] },
): Promise<Array<{ nodeId: string; error: string }>> {
const failures: Array<{ nodeId: string; error: string }> = [];
for (const node of ir.nodes) {
if (node.kind !== "code") continue;
const source = (node.config as { source?: unknown } | undefined)?.source;
if (typeof source !== "string" || source.length === 0) {
failures.push({ nodeId: node.id, error: "code node has no source" });
continue;
}
try {
await compileCodeNodeSource(source);
} catch (err) {
failures.push({
nodeId: node.id,
error: err instanceof CodeNodeError ? err.message : String(err),
});
}
// (A `code` node has no `template` — the only template recursion is the
// foreach pass below; the prior code-node-loop recursion here was dead.)
}
// Recurse into any foreach templates (code nodes are legal inside them, KTD-15).
for (const node of ir.nodes) {
if (node.kind !== "foreach") continue;
const template = (node.config as { template?: { nodes?: unknown } } | undefined)?.template;
// Guard with an explicit array check: a malformed config where `nodes` is a
// non-array truthy value would otherwise break the `for...of` inside the
// recursive validateCodeNodeSources call and bubble as a 500 rather than a
// clean validation failure.
if (Array.isArray(template?.nodes)) {
failures.push(...(await validateCodeNodeSources({ nodes: template.nodes as WorkflowIrNode[] })));
} else if (template?.nodes != null) {
failures.push({ nodeId: node.id, error: "foreach template.nodes must be an array" });
}
}
return failures;
}
/** Build the JSON-safe task subset handed to a code node (KTD-15). Only the
* allowlisted fields cross the boundary — no store handle, no engine internals. */
export function buildCodeNodeTaskSubset(task: TaskDetail): CodeNodeTaskSubset {
return {
id: task.id,
title: task.title ?? "",
description: task.description,
column: task.column,
steps: Array.isArray(task.steps) ? (task.steps as unknown[]) : [],
customFields: (task.customFields as Record<string, unknown>) ?? {},
};
}
/** A JSON-safe deep snapshot of the walk context (drops functions/cycles via
* JSON round-trip; the reserved `foreach:active` instance is surfaced
* separately as ctx.instance, so strip it from the generic context). */
function jsonSafeContext(context: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(context)) {
if (k === FOREACH_ACTIVE_CONTEXT_KEY) continue;
try {
out[k] = JSON.parse(JSON.stringify(v));
} catch {
// Drop non-serializable values rather than failing the whole snapshot.
}
}
return out;
}
/** Injected dependencies for {@link createCodeNodeRunner} (U14). */
export interface CodeNodeRunnerDeps {
/** Worktree cwd for the child process (defaults to rootDir if unresolved). */
resolveCwd: (task: TaskDetail) => Promise<string> | string;
/** Pre-read the declared artifacts into a plain map (DEVIATION note above).
* Returns key→content for every artifact the workflow declares (or that the
* node references); missing artifacts are simply absent from the map. */
readArtifacts: (task: TaskDetail) => Promise<Record<string, string>> | Record<string, string>;
/** Write the returned customFields patch through the U11 validation authority.
* Resolves a typed rejection (not throw) so the runner maps it to a node
* failure surfacing the rejection. */
writeCustomFields: (
task: TaskDetail,
patch: Record<string, unknown>,
) => Promise<{ ok: true } | { ok: false; rejection: CustomFieldRejection }>;
/** Optional audit sink for failures (reason + detail). Never throws. */
audit?: (reason: string, detail: string) => void;
/** Test seam: inject a process runner (forwarded to {@link runCodeNode}). */
spawnRunner?: RunCodeNodeOptions["spawnRunner"];
}
/**
* Build a {@link CodeNodeRunner} bound to the executor environment. The returned
* function assembles the harness ctx (task subset, JSON-safe context,
* pre-read artifacts, `foreach:active` instance), runs the node, and maps the
* result to a {@link WorkflowNodeResult}: `outcome` string → `outcome:<value>`
* (absent → success); `contextPatch` merged into the walk context; `customFields`
* written through the U11 authority (a typed rejection → node failure). A throw
* / timeout / non-zero exit / compile error → `failure` with the reason as the
* value and the captured stderr audited.
*/
export function createCodeNodeRunner(deps: CodeNodeRunnerDeps): CodeNodeRunner {
const audit = (reason: string, detail: string): void => {
try {
deps.audit?.(reason, detail);
} catch {
// Audit must never affect the run.
}
};
return async (node: WorkflowIrNode, task: TaskDetail, context: Record<string, unknown>): Promise<WorkflowNodeResult> => {
const cfg = (node.config ?? {}) as { source?: unknown; timeoutMs?: unknown };
const source = typeof cfg.source === "string" ? cfg.source : "";
const cwd = await deps.resolveCwd(task);
const artifacts = await deps.readArtifacts(task);
const instance = context[FOREACH_ACTIVE_CONTEXT_KEY] as Record<string, unknown> | undefined;
let result: CodeNodeResult;
try {
result = await runCodeNode({
source,
timeoutMs: typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : undefined,
cwd,
ctx: {
task: buildCodeNodeTaskSubset(task),
context: jsonSafeContext(context),
artifacts,
instance: instance ? (JSON.parse(JSON.stringify(instance)) as Record<string, unknown>) : undefined,
},
spawnRunner: deps.spawnRunner,
});
} catch (err) {
const reason = err instanceof CodeNodeError ? err.reason : "runtime-throw";
const stderr = err instanceof CodeNodeError ? err.stderr : "";
const message = err instanceof Error ? err.message : String(err);
audit(reason, `code node '${node.id}' failed (${reason}): ${message}${stderr ? `\n${stderr}` : ""}`);
return {
outcome: "failure",
value: reason,
contextPatch: { [`node:${node.id}:error`]: message, [`node:${node.id}:stderr`]: capOutput(stderr) },
};
}
// customFields patch → write through the U11 authority. A typed rejection
// surfaces as a node failure (KTD-15: fields only via the validated patch).
if (result.customFields && Object.keys(result.customFields).length > 0) {
const write = await deps.writeCustomFields(task, result.customFields);
if (!write.ok) {
const detail = `${write.rejection.code} (${write.rejection.fieldId}): ${write.rejection.detail}`;
audit("custom-field-rejected", `code node '${node.id}' customFields write rejected — ${detail}`);
return {
outcome: "failure",
value: "custom-field-rejected",
contextPatch: { [`node:${node.id}:rejection`]: detail },
};
}
}
const patch: Record<string, unknown> = { ...(result.contextPatch ?? {}) };
// KTD-15: a returned `outcome` string routes `outcome:<value>` edges; absent
// → success. The graph executor routes `outcome:` edges off the node result's
// `value`, so the returned outcome string becomes the routing value while the
// node outcome stays `success` (an explicit `outcome:"failure"` routes the
// `failure` edge — a routable choice, distinct from a thrown/timeout failure).
const routingValue =
typeof result.value === "string"
? result.value
: typeof result.outcome === "string" && result.outcome.length > 0
? result.outcome
: undefined;
const nodeOutcome = result.outcome === "failure" ? "failure" : "success";
return {
outcome: nodeOutcome,
value: routingValue,
contextPatch: patch,
};
};
}

View File

@@ -111,6 +111,22 @@ export class AgentSemaphore {
});
}
/**
* Synchronously reserve a slot if one is immediately available, without
* queuing. Returns true (and bumps `activeCount`) when a slot was taken,
* false when the semaphore is full. Used by the U6 hold/release sweep's
* reservation-first ordering (KTD-10): reserve worktree + semaphore BEFORE
* issuing a release move, and {@link release} the reservation if the move
* rejects on capacity. Unlike {@link acquire} it never enqueues a waiter.
*/
tryAcquire(): boolean {
if (this._active < this.limit) {
this._active++;
return true;
}
return false;
}
/**
* Release a previously acquired slot and unblock the next waiting caller
* (if any).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
/**
* Hold/release sweep — the generalized scheduler (U6, KTD-10, R3 behavior half).
*
* Flag-ON, the scheduler's poll becomes a *hold/release sweep*: for each
* workflow in use by live tasks, it finds cards resting at `hold`-trait columns
* and evaluates their release condition:
*
* - `manual` — released ONLY by an explicit {@link promoteHeldTask}
* call (U9's promote endpoint / CLI). The sweep never
* auto-releases a manual hold.
* - `external-event` — released ONLY by {@link releaseHeldTaskByEvent} (a
* webhook/API release, same shape as manual + an event
* tag).
* - `timer` — released when the injected clock passes the hold's
* deadline (`columnMovedAt + durationMs`, or an explicit
* `deadlineAt`). Fake-timer friendly (FN-5048): the clock
* is injected, never `Date.now()` baked in.
* - `capacity` — released when a downstream capacity (`wip`) column has a
* free slot (same counting rules as the in-txn check).
* - `dependency` — released when the card's dependencies are satisfied
* (KTD-5: dependency task's column has the `complete`
* trait flag in ITS resolved workflow; FN-5719 dual-accept
* also honors the legacy completion signal, logging an
* audit-diff when the two disagree).
*
* Eligible cards move via `store.moveTask(..., { moveSource: "scheduler" })`.
* A scheduler move bypasses trait guards (it is substrate-driven) but the in-txn
* capacity check is NOT a guard — it still runs (KTD-10), so two holds racing
* into one slot serialize: exactly one commits, the other rejects with
* `capacity-exhausted` and retries next sweep.
*
* Reservation ordering (KTD-10): for releases into a processing (capacity)
* column, the sweep reserves worktree + semaphore slots BEFORE issuing the move
* and releases the reservation if the move rejects on capacity — a card is never
* moved into a column it cannot actually start in, and a semaphore-exhausted
* interleaving leaves the card held with no commit.
*/
import {
isWorkflowColumnsEnabled,
resolveColumnCapacity,
resolveColumnFlags,
resolveColumnAdjacency,
DEFAULT_WORKFLOW_POOL_ID,
TransitionRejectionError,
resolveWorkflowIrForTask,
type TaskStore,
type Task,
type WorkflowIr,
type WorkflowIrV2,
type WorkflowIrColumn,
} from "@fusion/core";
import { schedulerLog } from "./logger.js";
/** A reservation handle returned by {@link HoldReleaseDeps.reserveSlot}. The
* sweep calls `release()` if the subsequent move rejects on capacity. */
export interface SlotReservation {
release(): void;
}
/** Injected dependencies so the sweep stays unit-testable with fake timers and
* without real worktree/session allocation. */
export interface HoldReleaseDeps {
/** Monotonic clock (ms). Inject a fake-timer-driven clock in tests; production
* passes `() => Date.now()`. */
now: () => number;
/**
* Reserve a worktree + semaphore slot for a card about to be released into a
* processing column (KTD-10 reservation-first). Returns `null` when no slot
* could be reserved (e.g. semaphore exhausted) — the sweep then leaves the
* card held without issuing a move. Returns a {@link SlotReservation} whose
* `release()` the sweep calls if the move rejects on capacity.
*
* Optional: when absent, releases into processing columns proceed without a
* reservation (the in-txn capacity check still arbitrates), which is the
* default-workflow legacy parity path where the scheduler dispatch loop owns
* worktree allocation via `allocateWorktree`.
*/
reserveSlot?: (task: Task, targetColumn: string) => SlotReservation | null;
/** Allocate a worktree path for a release into a processing column (passed
* through to `moveTask`'s `allocateWorktree`). */
allocateWorktree?: (task: Task, reservedNames: Set<string>) => string | null;
}
/** Outcome of one sweep pass (for tests + observability). */
export interface HoldReleaseResult {
released: string[];
/** taskId → reason it stayed held this pass. */
held: Array<{ taskId: string; reason: string }>;
}
// ── Workflow IR resolution (read-only) ────────────────────────────────────────
// The selection → builtin/custom → default rule lives in @fusion/core's
// resolveWorkflowIrForTask (GitHub #1402); the optional per-sweep irCache Map is
// threaded straight through.
function effectiveWorkflowId(store: TaskStore, taskId: string): string {
try {
return store.getTaskWorkflowSelection(taskId)?.workflowId ?? DEFAULT_WORKFLOW_POOL_ID;
} catch {
return DEFAULT_WORKFLOW_POOL_ID;
}
}
function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined {
if (ir.version !== "v2") return undefined;
return (ir as WorkflowIrV2).columns.find((c) => c.id === columnId);
}
/** The hold trait config on a column, if any. */
function resolveHoldConfig(column: WorkflowIrColumn): Record<string, unknown> | undefined {
const flags = resolveColumnFlags(column);
if (!flags.hold) return undefined;
const ct = column.traits.find((t) => t.trait === "hold");
return ct?.config ?? {};
}
/** True when the card currently rests at a hold column. */
function isHeldTask(ir: WorkflowIr, task: Task): boolean {
const column = findColumn(ir, task.column);
if (!column) return false;
return resolveColumnFlags(column).hold === true;
}
/**
* Resolve the release target column for a held card.
*
* For `capacity` holds, the target is the nearest downstream column (by the
* workflow's column adjacency, breadth-first from the hold column) that carries
* a capacity (`wip`) trait — for the default workflow this is `in-progress`.
* For other release kinds the target is the first adjacency neighbor that is not
* the hold column itself (the forward step out of the hold).
*/
function resolveReleaseTarget(ir: WorkflowIr, fromColumn: string, preferCapacity: boolean): string | undefined {
const v2 = ir as WorkflowIrV2;
const orderedIds = Array.isArray(v2.columns) ? v2.columns.map((c) => c.id) : [];
const fromIdx = orderedIds.indexOf(fromColumn);
const adjacency = resolveColumnAdjacency(ir);
const neighbors = adjacency.get(fromColumn) ?? [];
if (preferCapacity) {
// Walk FORWARD in declared order for the nearest capacity-bearing column;
// the hold releases downstream, never backward.
for (let i = fromIdx + 1; i < orderedIds.length; i++) {
const col = findColumn(ir, orderedIds[i]);
if (col && resolveColumnFlags(col).countsTowardWip && neighbors.includes(orderedIds[i])) {
return orderedIds[i];
}
}
// No directly-adjacent capacity column: fall back to the nearest forward
// capacity column reachable via adjacency BFS.
const seen = new Set<string>([fromColumn]);
const queue = [...neighbors];
while (queue.length > 0) {
const candidate = queue.shift()!;
if (seen.has(candidate)) continue;
seen.add(candidate);
const col = findColumn(ir, candidate);
if (col && resolveColumnFlags(col).countsTowardWip) return candidate;
for (const next of adjacency.get(candidate) ?? []) {
if (!seen.has(next)) queue.push(next);
}
}
}
// Forward neighbor (declared-order next) if it is adjacent; else any neighbor
// that is forward in declared order; else the first neighbor.
const forwardId = fromIdx >= 0 ? orderedIds[fromIdx + 1] : undefined;
if (forwardId && neighbors.includes(forwardId)) return forwardId;
const forwardNeighbor = neighbors.find((n) => orderedIds.indexOf(n) > fromIdx);
if (forwardNeighbor) return forwardNeighbor;
return neighbors.find((n) => n !== fromColumn);
}
// ── Dependency satisfaction (KTD-5 + FN-5719 dual-accept) ─────────────────────
/** Legacy completion signal: dependency's column is a terminal/handoff column. */
function legacyDependencySatisfied(dep: Task): boolean {
return dep.column === "done" || dep.column === "in-review" || dep.column === "archived";
}
/**
* KTD-5 dependency satisfaction: the dependency task's current column has the
* `complete` trait flag in ITS resolved workflow. Dual-accept (FN-5719): the
* legacy completion signal (done/in-review/archived column, or an accepted
* completion-handoff marker) is also honored; when the two disagree an
* audit-diff event is logged.
*/
async function dependencySatisfied(store: TaskStore, dep: Task): Promise<boolean> {
const ir = await resolveWorkflowIrForTask(store, dep.id);
const column = findColumn(ir, dep.column);
const completeFlag = column ? resolveColumnFlags(column).complete === true : false;
let markerAccepted = false;
try {
markerAccepted = store.getCompletionHandoffAcceptedMarker(dep.id) !== null;
} catch {
markerAccepted = false;
}
const legacy = legacyDependencySatisfied(dep) || markerAccepted;
if (completeFlag !== legacy) {
try {
void store.recordRunAuditEvent?.({
taskId: dep.id,
agentId: "scheduler",
runId: `hold-release:${dep.id}`,
domain: "database",
mutationType: "merge:dependency-parity-diff",
target: dep.id,
metadata: {
depId: dep.id,
completeFlagResult: completeFlag,
legacyResult: legacy,
source: "hold-release.dependency",
},
});
} catch {
// Audit is best-effort.
}
}
// Dual-accept: satisfied if EITHER signal says so (the dual-accept window
// closes at graduation per U12; until then both are accepted).
return completeFlag || legacy;
}
async function allDependenciesSatisfied(store: TaskStore, task: Task, allTasks: Task[]): Promise<boolean> {
for (const depId of task.dependencies ?? []) {
const dep = allTasks.find((t) => t.id === depId);
if (!dep) continue; // missing dep does not block (matches scheduler posture)
if (!(await dependencySatisfied(store, dep))) return false;
}
return true;
}
// ── Timer release ─────────────────────────────────────────────────────────────
/** Resolve the timer deadline (ms epoch) for a timer hold, or `undefined` if not
* resolvable. Supports an explicit `deadlineAt` (ISO or ms) or a relative
* `durationMs`/`timerMs` measured from `columnMovedAt`. */
function resolveTimerDeadline(holdConfig: Record<string, unknown>, task: Task): number | undefined {
const deadlineAt = holdConfig.deadlineAt;
if (typeof deadlineAt === "number" && Number.isFinite(deadlineAt)) return deadlineAt;
if (typeof deadlineAt === "string") {
const parsed = Date.parse(deadlineAt);
if (Number.isFinite(parsed)) return parsed;
}
const duration =
(typeof holdConfig.durationMs === "number" ? holdConfig.durationMs : undefined) ??
(typeof holdConfig.timerMs === "number" ? holdConfig.timerMs : undefined);
if (typeof duration === "number" && Number.isFinite(duration)) {
const base = Date.parse(task.columnMovedAt ?? task.createdAt);
if (Number.isFinite(base)) return base + duration;
}
return undefined;
}
// ── Capacity availability (same counting rule as the in-txn check) ────────────
/**
* Count cards occupying the (workflow, column) capacity slot from a task
* snapshot, mirroring the store's in-txn count: cards in the column now, plus
* (when countPending) cards mid-`transitionPending` targeting it, scoped to the
* SAME effective workflow. This is the sweep's *pre-check* — the authoritative
* arbitration is still the in-txn check, which rejects a losing racer.
*/
function countCapacitySlot(
allTasks: Task[],
// Pre-built taskId → effective workflowId map (one pass per sweep) so this
// counting loop avoids a per-task `effectiveWorkflowId` DB call.
effectiveWorkflowIdByTask: Map<string, string>,
targetColumn: string,
workflowId: string,
countPending: boolean,
): number {
let count = 0;
for (const t of allTasks) {
if ((effectiveWorkflowIdByTask.get(t.id) ?? DEFAULT_WORKFLOW_POOL_ID) !== workflowId) continue;
if (t.column === targetColumn) {
count += 1;
continue;
}
if (!countPending) continue;
const tp = (t as Task & { transitionPending?: { toColumn?: string } | null }).transitionPending;
if (tp && typeof tp === "object" && tp.toColumn === targetColumn) count += 1;
}
return count;
}
// ── The sweep ─────────────────────────────────────────────────────────────────
/**
* Run one hold/release sweep pass. No-op (returns empty) when the workflowColumns
* flag is OFF — flag-OFF scheduler behavior is byte-identical (the legacy
* pull-from-todo loop is untouched).
*/
export async function runHoldReleaseSweep(
store: TaskStore,
deps: HoldReleaseDeps,
): Promise<HoldReleaseResult> {
const result: HoldReleaseResult = { released: [], held: [] };
const settings = await store.getSettings();
if (!isWorkflowColumnsEnabled(settings)) return result;
const allTasks = await store.listTasks({ includeArchived: false });
// Per-sweep caches. `allTasks` is a snapshot-stable read within a sweep, so we
// resolve each workflow's IR at most once (irCache) and pre-build the
// taskId → effective-workflowId map a single time rather than per-task DB
// calls inside the capacity counting loop. The authoritative in-txn capacity
// check is unaffected — this only trims the sweep pre-check cost.
const irCache = new Map<string, WorkflowIr>();
const effectiveWorkflowIdByTask = new Map<string, string>();
for (const t of allTasks) {
effectiveWorkflowIdByTask.set(t.id, effectiveWorkflowId(store, t.id));
}
for (const task of allTasks) {
// Skip paused / recovery-backoff tasks exactly as the legacy scheduler does.
if (task.paused || task.userPaused) {
continue;
}
if (task.nextRecoveryAt && Date.parse(task.nextRecoveryAt) > deps.now()) {
continue;
}
const ir = await resolveWorkflowIrForTask(store, task.id, irCache);
if (!isHeldTask(ir, task)) continue;
const column = findColumn(ir, task.column);
const holdConfig = column ? resolveHoldConfig(column) : undefined;
if (!column || !holdConfig) continue;
const release = typeof holdConfig.release === "string" ? holdConfig.release : "manual";
// manual / external-event are NEVER auto-released by the sweep.
if (release === "manual" || release === "external-event") {
result.held.push({ taskId: task.id, reason: `${release}-only` });
continue;
}
let shouldRelease = false;
if (release === "timer") {
const deadline = resolveTimerDeadline(holdConfig, task);
shouldRelease = deadline !== undefined && deps.now() >= deadline;
if (!shouldRelease) {
result.held.push({ taskId: task.id, reason: "timer-not-elapsed" });
continue;
}
} else if (release === "dependency") {
shouldRelease = await allDependenciesSatisfied(store, task, allTasks);
if (!shouldRelease) {
result.held.push({ taskId: task.id, reason: "deps-unsatisfied" });
continue;
}
} else if (release === "capacity") {
// Capacity holds release into the nearest downstream capacity column when a
// slot is free (pre-check); the in-txn check is the authority.
const target = resolveReleaseTarget(ir, task.column, true);
if (!target) {
result.held.push({ taskId: task.id, reason: "no-downstream-capacity-column" });
continue;
}
const capacity = resolveColumnCapacity(ir, target, settings);
if (capacity.hasCapacity && Number.isFinite(capacity.limit)) {
const workflowId = effectiveWorkflowIdByTask.get(task.id) ?? DEFAULT_WORKFLOW_POOL_ID;
const occupants = countCapacitySlot(allTasks, effectiveWorkflowIdByTask, target, workflowId, capacity.countPending);
if (occupants >= capacity.limit) {
result.held.push({ taskId: task.id, reason: "downstream-full" });
continue;
}
}
shouldRelease = true;
}
if (!shouldRelease) continue;
const target = resolveReleaseTarget(ir, task.column, release === "capacity");
if (!target) {
result.held.push({ taskId: task.id, reason: "no-release-target" });
continue;
}
const released = await issueRelease(store, deps, task, target, ir);
if (released) {
result.released.push(task.id);
} else {
result.held.push({ taskId: task.id, reason: "move-rejected-or-no-slot" });
}
}
return result;
}
/**
* Issue a single release move (`moveSource: "scheduler"`). For releases into a
* processing (capacity) column the reservation-first ordering (KTD-10) reserves
* worktree + semaphore before the move and releases the reservation if the move
* rejects on capacity. Returns true on a committed move, false otherwise (the
* card stays held).
*/
async function issueRelease(
store: TaskStore,
deps: HoldReleaseDeps,
task: Task,
target: string,
ir: WorkflowIr,
): Promise<boolean> {
const targetColumn = findColumn(ir, target);
const targetIsProcessing = targetColumn ? resolveColumnFlags(targetColumn).countsTowardWip === true : false;
let reservation: SlotReservation | null = null;
if (targetIsProcessing && deps.reserveSlot) {
reservation = deps.reserveSlot(task, target);
if (!reservation) {
// Semaphore/worktree exhausted — reservation-first means no move at all.
schedulerLog.log(`Hold release for ${task.id} deferred — no reservable slot for ${target}`);
return false;
}
}
// A concurrent sweep (or explicit promote) can win the move for this same card
// while we hold a reservation. The store serializes the move under a per-task
// lock and resolves a redundant same-column move to a silent no-op: it returns
// the card already at the target WITHOUT re-allocating a slot or emitting a
// `task:moved`. A snapshot/pre-read can't tell winner from loser (both reads
// race ahead of either commit on the per-task lock). Instead we attribute the
// transition by OBJECT IDENTITY: a real move emits `task:moved` with the very
// Task object it then returns, whereas a no-op returns a freshly-read object
// and emits nothing. So the call whose `moveTask` result IS the emitted task is
// the real mover; any other call that reserved performed a redundant no-op and
// must release the slot it grabbed (FN-1415).
const movedTaskObjects = new Set<object>();
const onMoved = (data: { task: object; to: string }): void => {
if (data.to === target) movedTaskObjects.add(data.task);
};
store.on("task:moved", onMoved);
try {
const result = await store.moveTask(task.id, target, {
moveSource: "scheduler",
allocateWorktree:
targetIsProcessing && deps.allocateWorktree
? (reservedNames) => deps.allocateWorktree!(task, reservedNames)
: undefined,
});
if (reservation && !movedTaskObjects.has(result)) {
// Same-column no-op: a racing sweep already moved this card to the target.
reservation.release();
schedulerLog.log(`Hold release for ${task.id} skipped — already at ${target} (racing sweep won)`);
return false;
}
return true;
} catch (error) {
if (error instanceof TransitionRejectionError && error.rejection.code === "capacity-exhausted") {
// Lost the in-txn race for the slot — release the reservation, stay held.
reservation?.release();
schedulerLog.log(`Hold release for ${task.id} rejected on capacity for ${target} — staying held`);
return false;
}
// Any other failure: release the reservation and let the card stay held.
reservation?.release();
schedulerLog.warn(
`Hold release for ${task.id} into ${target} failed: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
store.off("task:moved", onMoved);
}
}
// ── Explicit (manual / external-event) releases ───────────────────────────────
/**
* Manually promote a held card out of its hold column (U9's promote endpoint /
* CLI calls this). Releases regardless of the hold's release kind — a manual
* promote is the explicit operator action the `manual` release kind waits for,
* and it is also accepted for other kinds as an operator override. The move
* still serializes through the in-txn capacity check (KTD-10): a promote into a
* full column rejects with `capacity-exhausted`, surfaced to the caller.
*/
export async function promoteHeldTask(
store: TaskStore,
taskId: string,
deps: Pick<HoldReleaseDeps, "reserveSlot" | "allocateWorktree"> = {},
): Promise<{ released: boolean; toColumn?: string; rejection?: string }> {
const task = await store.getTask(taskId);
if (!task) return { released: false, rejection: "task-not-found" };
const ir = await resolveWorkflowIrForTask(store, taskId);
if (!isHeldTask(ir, task)) {
return { released: false, rejection: "not-held" };
}
const target = resolveReleaseTarget(ir, task.column, true);
if (!target) return { released: false, rejection: "no-release-target" };
const released = await issueRelease(
store,
{ now: () => Date.now(), reserveSlot: deps.reserveSlot, allocateWorktree: deps.allocateWorktree },
task,
target,
ir,
);
return released ? { released: true, toColumn: target } : { released: false, rejection: "capacity-exhausted-or-no-slot" };
}
/**
* Release a held card on an external event (webhook/API). Same shape as
* {@link promoteHeldTask} plus an `eventTag` recorded in the audit; only acts on
* `external-event` holds (a no-op otherwise so a stray webhook can't release a
* manual/timer/capacity hold).
*/
export async function releaseHeldTaskByEvent(
store: TaskStore,
taskId: string,
eventTag: string,
deps: Pick<HoldReleaseDeps, "reserveSlot" | "allocateWorktree"> = {},
): Promise<{ released: boolean; toColumn?: string; rejection?: string }> {
const task = await store.getTask(taskId);
if (!task) return { released: false, rejection: "task-not-found" };
const ir = await resolveWorkflowIrForTask(store, taskId);
const column = findColumn(ir, task.column);
const holdConfig = column ? resolveHoldConfig(column) : undefined;
if (!column || !holdConfig || holdConfig.release !== "external-event") {
return { released: false, rejection: "not-external-event-hold" };
}
try {
void store.recordRunAuditEvent?.({
taskId,
agentId: "scheduler",
runId: `hold-release:event:${taskId}`,
domain: "database",
mutationType: "task:hold-release-event",
target: taskId,
metadata: { eventTag, fromColumn: task.column },
});
} catch {
// best-effort
}
const target = resolveReleaseTarget(ir, task.column, true);
if (!target) return { released: false, rejection: "no-release-target" };
const released = await issueRelease(
store,
{ now: () => Date.now(), reserveSlot: deps.reserveSlot, allocateWorktree: deps.allocateWorktree },
task,
target,
ir,
);
return released ? { released: true, toColumn: target } : { released: false, rejection: "capacity-exhausted-or-no-slot" };
}

View File

@@ -8,6 +8,7 @@ export {
createSendMessageTool,
createReadMessagesTool,
createWorkflowListTool,
createWorkflowGetTool,
createWorkflowSelectTool,
taskCreateParams,
taskDocumentReadParams,
@@ -25,12 +26,25 @@ export {
type WorkflowGraphExecutorDeps,
type WorkflowGraphExecutorResult,
} from "./workflow-graph-executor.js";
export {
runSplitJoin,
type WorkflowBranchPersistence,
type WorkflowBranchProgress,
type WorkflowBranchRunState,
type WorkflowBranchSemaphore,
} from "./workflow-graph-branches.js";
export {
createDefaultNodeHandlers,
createNoopLegacySeams,
createParseStepsHandler,
createCodeNodeHandler,
PARSE_STEPS_DEFAULT_ARTIFACT,
type WorkflowCustomNodeRunner,
type WorkflowLegacySeams,
type WorkflowSeamName,
type ParseStepsHandlerDeps,
type CodeNodeRunner,
type DefaultNodeHandlerDeps,
} from "./workflow-node-handlers.js";
export {
WorkflowGraphTaskRunner,
@@ -63,6 +77,13 @@ export {
getConflictedFiles,
type AutostashHandle,
} from "./merger.js";
export {
registerMergeTraitHooks,
resolveMergePolicy,
type ResolvedMergePolicy,
type MergeFileScopeMode,
type MergeTraitStrategy,
} from "./merge-trait.js";
export {
resolveIntegrationBranch,
resolveIntegrationBranchSync,
@@ -449,6 +470,47 @@ export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from ".
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
export { SelfHealingManager, type SelfHealingOptions, type RebindResult } from "./self-healing.js";
export { PluginRunner, type PluginRunnerOptions } from "./plugin-runner.js";
export {
registerPluginTraits,
degradePluginTraits,
unregisterPluginTraits,
findLivePluginTraitDependents,
pluginTraitToDefinition,
pluginTraitRegistryId,
evaluatePluginGate,
PluginTraitHasDependentsError,
type PluginTraitDependent,
} from "./plugin-trait-adapter.js";
// Step-inversion U12 (KTD-12): plugin step-parser adapter.
export {
registerPluginStepParsers,
unregisterPluginStepParsers,
pluginParserRegistryId,
pluginParserToRegistryParser,
PluginParserError,
PLUGIN_PARSER_TIMEOUT_MS,
type PluginStepParserContribution,
} from "./plugin-parser-adapter.js";
// Step-inversion U14 (KTD-15): code-node runner + save-time validation helper.
export {
runCodeNode,
createCodeNodeRunner,
compileCodeNodeSource,
validateCodeNodeSources,
buildCodeNodeTaskSubset,
resolveCodeNodeTimeout,
CodeNodeError,
CODE_NODE_DEFAULT_TIMEOUT_MS,
CODE_NODE_MAX_TIMEOUT_MS,
CODE_NODE_MAX_SOURCE_BYTES,
CODE_NODE_OUTPUT_CAP_BYTES,
type CodeNodeContext,
type CodeNodeResult,
type CodeNodeRunnerDeps,
type CodeNodeTaskSubset,
type CodeNodeFailureReason,
type RunCodeNodeOptions,
} from "./code-node-runner.js";
// Agent runtime abstraction
export { type AgentRuntime, type AgentRuntimeOptions, type AgentSessionResult } from "./agent-runtime.js";
export {
@@ -517,8 +579,33 @@ export {
} from "./remote-access/index.js";
export { RemoteNodeClient } from "./runtimes/remote-node-client.js";
export { RemoteNodeRuntime, type RemoteNodeRuntimeConfig } from "./runtimes/remote-node-runtime.js";
// Hold/release sweep + manual promote (U6/U9). Exported so the dashboard
// promote endpoint can release a manually-held card via the same authority.
export {
promoteHeldTask,
releaseHeldTaskByEvent,
runHoldReleaseSweep,
type HoldReleaseDeps,
type HoldReleaseResult,
type SlotReservation,
} from "./hold-release.js";
export { StepSessionExecutor } from "./step-session-executor.js";
export type { StepResult, ParallelWave, StepSessionExecutorOptions } from "./step-session-executor.js";
export {
runTaskStep,
resetStepToBaseline,
makeAncestryBlastRadiusGuard,
} from "./step-runner.js";
export type {
RunTaskStepDeps,
RunTaskStepOptions,
RunTaskStepResult,
ResetStepDeps,
ResetStepResult,
RunSingleStep,
SessionRef,
StepRunnerTask,
} from "./step-runner.js";
// Multi-project runtime types
export {
type ProjectRuntime,

View File

@@ -0,0 +1,261 @@
/**
* Merge trait behavior (U7, R10) — `@fusion/engine` side.
*
* The merge trait turns merge/PR orchestration, merge strategy, squash posture
* and file-scope enforcement mode into *configuration* over the substrate merge
* capability (KTD-6). This module owns two things:
*
* 1. The merge trait's hook implementations, registered into core's trait
* registry via the `registerTraitHookImpl` DI seam (mirrors
* `setCreateFnAgent`):
* - `onEnter` → enqueue the task onto the *persisted* merge-request
* queue (reuse the store's existing enqueue path). It NEVER awaits a
* merge inline; completion is driven by the merge-queue worker loop
* (`ProjectEngine.pickNextMergeTaskId` → `aiMergeTask` →
* `store.moveTask(id, "done")`) and resolved via the queue, so a
* graph walk / transition never blocks on a merge (the plan-002
* deadlock hazard).
* - `onExit` → leaving the merge column dequeues a pending request.
* The store already performs this in-lock inside `moveTaskInternal`
* (`dequeueMergeQueueOnColumnExit`, a private method); the hook
* delegates to that existing mechanism rather than reimplementing the
* dequeue (see the onExit impl note). It is registered so the registry
* resolves a real impl (not a degraded no-op + audit warning).
*
* 2. `resolveMergePolicy` — a small read-through resolver consulted by
* `merger.ts` at its existing policy-knob read sites. When the
* `workflowColumns` flag is ON it reads the merge-trait config from the
* task's resolved workflow; otherwise (and when the workflow's merge
* trait carries no config, e.g. the built-in default workflow) it falls
* back to the existing settings knobs (`directMergeCommitStrategy`,
* `mergeStrategy`, scope settings) for back-compat.
*
* The three 2026-05-23 lost-work guards stay in `merger.ts` mechanics and are
* UNREACHABLE from this config (KTD-6 / R10): sibling `fusion/fn-*` merge-target
* rejection, line-anchored commit attribution, and the no-op-finalize
* `modifiedFiles` preservation are not gated by any field this resolver
* exposes.
*/
import {
isWorkflowColumnsEnabled,
registerTraitHookImpl,
resolveWorkflowIrForTask,
type DirectMergeCommitStrategy,
type Settings,
type Task,
type TaskStore,
type WorkflowIr,
type WorkflowIrColumn,
} from "@fusion/core";
import { mergerLog } from "./logger.js";
// ── Resolved merge policy ────────────────────────────────────────────────────
/** File-scope enforcement mode (R10). `custom` evaluates `rules` in place of
* the task's File Scope section. */
export type MergeFileScopeMode = "strict" | "warn" | "off" | "custom";
/** The merge strategy as authored on the trait. Direct-merge commit strategies
* plus `pr-only` (which routes to the pull-request flow without a direct
* merge). Absent on the trait → resolved from settings. */
export type MergeTraitStrategy = DirectMergeCommitStrategy | "pr-only";
/** Fully-resolved merge policy consumed by `merger.ts`. */
export interface ResolvedMergePolicy {
/** Direct-merge commit strategy. For `pr-only` this is the fallback used if
* a direct merge is ever taken; `pullRequestOnly` is the authoritative
* routing signal. */
commitStrategy: DirectMergeCommitStrategy;
/** True when the trait authored `strategy: "pr-only"` — the merge is routed
* through the PR flow (enqueue-with-prState marker) without a direct merge. */
pullRequestOnly: boolean;
/** File-scope enforcement mode. */
fileScope: MergeFileScopeMode;
/** Custom scope rules (only meaningful when `fileScope === "custom"`). */
fileScopeRules: string[];
/** Where the policy came from — `workflow` when read from the task's merge
* trait config (flag ON), `settings` for the legacy/back-compat read-through. */
source: "workflow" | "settings";
}
// ── Workflow IR resolution (read-only, flag-gated) ───────────────────────────
// The selection → builtin/custom → default rule is shared via @fusion/core's
// resolveWorkflowIrForTask (GitHub #1402); a missing/corrupt definition degrades
// to the default workflow so policy resolution never throws.
/** Find the column the task currently sits in (by id). */
function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined {
if (ir.version !== "v2") return undefined;
return ir.columns.find((c) => c.id === columnId);
}
/** Extract the merge trait's config from a column, if it carries one. */
function readMergeTraitConfig(column: WorkflowIrColumn | undefined): Record<string, unknown> | undefined {
if (!column) return undefined;
const ct = column.traits.find((t) => t.trait === "merge");
if (!ct) return undefined;
return ct.config ?? {};
}
// ── Policy read-through resolver ─────────────────────────────────────────────
const VALID_COMMIT_STRATEGIES: ReadonlySet<string> = new Set([
"auto",
"always-squash",
"always-rebase",
]);
const VALID_FILE_SCOPE_MODES: ReadonlySet<string> = new Set(["strict", "warn", "off", "custom"]);
/** The settings-only fallback policy (legacy / flag-OFF / no trait config). */
function settingsPolicy(settings: Pick<Settings, "directMergeCommitStrategy" | "mergeStrategy">): ResolvedMergePolicy {
return {
commitStrategy: settings.directMergeCommitStrategy ?? "always-squash",
pullRequestOnly: settings.mergeStrategy === "pull-request",
// Legacy file-scope behavior is a soft warn (see
// `enforceSquashFileScopeInvariant`, which logs + proceeds), so the
// back-compat read-through reports `warn` — the existing call path is
// unchanged when the flag is OFF.
fileScope: "warn",
fileScopeRules: [],
source: "settings",
};
}
/**
* Resolve the effective merge policy for a task (R10). Flag ON: read the merge
* trait's config from the task's resolved workflow column; fall back to
* settings for any field the trait leaves unset (the built-in default
* workflow's merge trait carries no config, so it resolves entirely from
* settings — verbatim back-compat). Flag OFF: settings only.
*
* The lost-work guard trio is intentionally NOT represented here: no field this
* resolver returns can disable the sibling-branch rejection, line-anchored
* attribution, or the no-op-finalize `modifiedFiles` guard (KTD-6).
*/
export async function resolveMergePolicy(
store: TaskStore,
task: Pick<Task, "id" | "column">,
settings?: Pick<Settings, "directMergeCommitStrategy" | "mergeStrategy" | "experimentalFeatures">,
): Promise<ResolvedMergePolicy> {
const resolvedSettings = settings ?? (await store.getSettings());
const fallback = settingsPolicy(resolvedSettings);
if (!isWorkflowColumnsEnabled(resolvedSettings)) {
return fallback;
}
let config: Record<string, unknown> | undefined;
try {
const ir = await resolveWorkflowIrForTask(store, task.id);
config = readMergeTraitConfig(findColumn(ir, task.column));
} catch {
config = undefined;
}
// No merge trait, or a merge trait carrying no policy fields (e.g. the
// built-in default workflow's `{ trait: "merge" }` with no config) → resolve
// entirely from settings (verbatim back-compat).
if (!config || (config.strategy === undefined && config.fileScope === undefined)) {
return fallback;
}
// strategy → commitStrategy + pullRequestOnly
let commitStrategy = fallback.commitStrategy;
let pullRequestOnly = fallback.pullRequestOnly;
const rawStrategy = config.strategy;
if (rawStrategy === "pr-only") {
pullRequestOnly = true;
} else if (typeof rawStrategy === "string" && VALID_COMMIT_STRATEGIES.has(rawStrategy)) {
commitStrategy = rawStrategy as DirectMergeCommitStrategy;
pullRequestOnly = false;
}
// fileScope → mode + rules
let fileScope = fallback.fileScope;
const rawFileScope = config.fileScope;
if (typeof rawFileScope === "string" && VALID_FILE_SCOPE_MODES.has(rawFileScope)) {
fileScope = rawFileScope as MergeFileScopeMode;
}
const fileScopeRules = Array.isArray(config.rules)
? (config.rules.filter((r): r is string => typeof r === "string"))
: [];
return {
commitStrategy,
pullRequestOnly,
fileScope,
fileScopeRules,
source: "workflow",
};
}
// ── Merge trait hook implementations (DI into core's trait registry) ─────────
/**
* onEnter: enqueue the task onto the persisted merge-request queue. NEVER awaits
* a merge (KTD-6) — the merge-queue worker loop drives the actual merge and the
* subsequent move to the `complete`-flagged column. Delegates to the store's
* existing `enqueueMergeQueue` so the queue mechanics (audit, priority,
* idempotent ON CONFLICT insert) are not reimplemented.
*
* Idempotent: `enqueueMergeQueue` is `ON CONFLICT(taskId) DO NOTHING`, so a
* crash-then-rerun (recovery sweep replaying `transitionPending` hooks) holds
* exactly one queue entry.
*
* Invoked by the store's post-commit hook runner with `(store, task)`.
*/
async function mergeOnEnter(store: TaskStore, task: Pick<Task, "id" | "priority">): Promise<void> {
try {
store.enqueueMergeQueue(task.id, { priority: task.priority });
} catch (err) {
// Enqueue rejects (e.g. task not in the merge column) degrade to a no-op:
// the card is never stranded and the queue is never corrupted. The store
// already audits the rejection.
const message = err instanceof Error ? err.message : String(err);
mergerLog.warn(`merge enqueue skipped for task ${task.id}: ${message}`);
}
}
/**
* onExit: leaving the merge column dequeues a pending (unleased) request.
*
* NOTE (design / delegation): the store ALREADY performs dequeue-on-column-exit
* in-lock inside `moveTaskInternal` via the private
* `dequeueMergeQueueOnColumnExit`, which runs unconditionally on every move and
* owns the lease-aware semantics (drop an unleased entry; audit a leased one as
* a stale-lease event). The merge trait's onExit therefore *delegates to that
* existing mechanism* — it does not reissue a dequeue (which would be a
* redundant second pass and could not see the lease columns without a store API
* change the prompt forbids). Registering the hook makes the registry resolve a
* real impl (not a degraded no-op + audit warning) and documents that the
* substrate, not the trait, owns the dequeue mechanic (KTD-6: traits configure
* and invoke capabilities; they never reimplement them).
*/
function mergeOnExit(): void {
// Intentional no-op: dequeue is owned by the store's in-lock
// `dequeueMergeQueueOnColumnExit` (see note above).
}
let registered = false;
/**
* Register the merge trait's hook implementations into core's shared trait
* registry. Idempotent (guarded), so importing this module (or calling it from
* engine startup) more than once is safe. Mirrors the `setCreateFnAgent` DI
* pattern: core declares the hook descriptors; the engine supplies the impls.
*/
export function registerMergeTraitHooks(): void {
if (registered) return;
registered = true;
registerTraitHookImpl("merge", "onEnter", mergeOnEnter as never);
registerTraitHookImpl("merge", "onExit", mergeOnExit as never);
}
/** Test-only: re-arm registration so a fresh registry can be exercised. */
export function __resetMergeTraitRegistrationForTests(): void {
registered = false;
}
// Register on import (idempotent) so the engine's trait registry resolves real
// merge-hook impls without a separate wiring call.
registerMergeTraitHooks();

View File

@@ -92,6 +92,7 @@ import {
normalizeMergeAdvanceAutoSyncMode,
isMergeRequestContractShadowEnabled,
} from "@fusion/core";
import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js";
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js";
@@ -4930,10 +4931,16 @@ export async function assertSquashOverlapsFileScope(params: {
taskId: string;
rootDir: string;
task: Task;
/** U7 (R10): when the merge trait's `fileScope: "custom"` mode is active,
* these glob/path rules replace the task's File Scope section as the
* declared scope. `scopeOverride` is a documented no-op only under
* `fileScope: "off"` (handled by the caller, which skips this assert). */
customScopeRules?: string[];
}): Promise<void> {
const { store, taskId, rootDir, task } = params;
const { store, taskId, rootDir, task, customScopeRules } = params;
const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0;
if (task.scopeOverride === true) {
if (!hasCustomRules && task.scopeOverride === true) {
const reasonSuffix = task.scopeOverrideReason?.trim()
? ` — reason: ${task.scopeOverrideReason.trim()}`
: "";
@@ -4947,11 +4954,16 @@ export async function assertSquashOverlapsFileScope(params: {
return;
}
if (typeof (store as Partial<TaskStore>).parseFileScopeFromPrompt !== "function") {
return;
let declaredScope: string[];
if (hasCustomRules) {
// Custom rules replace the parsed File Scope section entirely.
declaredScope = customScopeRules;
} else {
if (typeof (store as Partial<TaskStore>).parseFileScopeFromPrompt !== "function") {
return;
}
declaredScope = await store.parseFileScopeFromPrompt(taskId);
}
const declaredScope = await store.parseFileScopeFromPrompt(taskId);
if (declaredScope.length === 0) {
return;
}
@@ -4986,12 +4998,70 @@ export async function enforceSquashFileScopeInvariant(params: {
resetLabel: string;
auditor?: RunAuditor;
}): Promise<void> {
// U7 (R10): resolve the file-scope enforcement mode from the merge trait
// (flag ON) or settings (back-compat). The lost-work guard trio is NOT gated
// by this mode — it lives elsewhere in the mechanics and stays enforced for
// every mode (KTD-6).
const policy = await resolveMergePolicy(params.store, params.task);
const mode: MergeFileScopeMode = policy.fileScope;
if (mode === "off") {
// Skip the violation throw, but emit exactly one per-merge audit event
// recording that scope enforcement was disabled by workflow config. Per-task
// `scopeOverride` is a documented no-op in this mode (the scope check itself
// is disabled, so there is nothing to override).
if (params.auditor) {
try {
await params.auditor.git({
type: "merge:file-scope-enforcement-disabled",
target: params.taskId,
metadata: {
resetLabel: params.resetLabel,
mode: "off",
disabledByWorkflowConfig: true,
scopeOverrideIsNoOp: params.task.scopeOverride === true,
},
});
} catch (auditErr) {
mergerLog.warn(`${params.taskId}: failed to emit run_audit event for file-scope-enforcement-disabled: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
}
}
return;
}
const customScopeRules = mode === "custom" ? policy.fileScopeRules : undefined;
try {
await assertSquashOverlapsFileScope(params);
await assertSquashOverlapsFileScope({ ...params, customScopeRules });
} catch (error: unknown) {
if (!(error instanceof FileScopeViolationError)) {
throw error;
}
// `strict` re-throws the violation (hard guardrail that blocks the merge);
// `warn`/`custom` log + proceed, with the audit carrying the violating file
// list (same payload as the error).
if (mode === "strict") {
if (params.auditor) {
try {
await params.auditor.git({
type: "merge:file-scope-violation",
target: params.taskId,
metadata: {
resetLabel: params.resetLabel,
mode: "strict",
stagedFiles: error.stagedFiles,
declaredScope: error.declaredScope,
stagedFileCount: error.stagedFiles.length,
declaredScopeCount: error.declaredScope.length,
warningOnly: false,
},
});
} catch (auditErr) {
mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError (strict): ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
}
}
throw error;
}
const warningMessage = `${error.message} Warning only — continuing merge.`;
await params.store.appendAgentLog(
params.taskId,
@@ -7534,6 +7604,11 @@ export async function aiMergeTask(
const projectRootDir = rootDir;
const settings = await store.getSettings();
// U7 (R10): resolve the merge trait's policy (strategy / fileScope / rules)
// from the task's workflow when the workflowColumns flag is ON, falling back
// to the existing settings knobs otherwise. Read-through only — merge
// mechanics (and the non-configurable lost-work guard trio) are untouched.
const mergePolicy = await resolveMergePolicy(store, task, settings);
const resolvedIntegrationBranch = await resolveIntegrationBranch(projectRootDir, settings);
const groupRouting = await resolveBranchGroupMergeRouting({
task,
@@ -9204,8 +9279,17 @@ export async function aiMergeTask(
let selectedPostMergeAuditStrategy: PostMergeAuditStrategy = "squash";
let classifiedBranchCommits: BranchCommitClassification[] = [];
if (settings.mergeStrategy !== "pull-request") {
const configuredRoute = resolveDirectMergeCommitStrategy(settings, task.prompt);
// U7 (R10): `pr-only` authored on the merge trait routes through the PR flow
// exactly like `settings.mergeStrategy === "pull-request"` — no direct-merge
// commit routing runs.
const isPullRequestRoute = settings.mergeStrategy === "pull-request" || mergePolicy.pullRequestOnly;
if (!isPullRequestRoute) {
// When the workflow's merge trait authored a commit strategy, it takes
// precedence over the project/prompt setting (read-through, mechanics
// unchanged); otherwise fall back to the existing resolver.
const configuredRoute = mergePolicy.source === "workflow"
? { strategy: mergePolicy.commitStrategy, source: "workflow" as const }
: resolveDirectMergeCommitStrategy(settings, task.prompt);
if (configuredRoute.strategy === "auto") {
try {
const classification = await classifyBranchCommitsForDirectMerge(

View File

@@ -0,0 +1,157 @@
/**
* Plugin step-parser adapter (U12, KTD-12).
*
* Bridges plugin-contributed step parsers into core's {@link StepParserRegistry},
* mirroring {@link import("./plugin-trait-adapter.js")} for traits. Plugins
* register parsers under namespaced ids (`plugin:<pluginId>:<parserId>`) so they
* can never collide with or override the built-ins (`step-headings`,
* `json-steps`) — the registry enforces builtin-namespace protection and the
* `plugin:` id shape on registration.
*
* Contract (KTD-12): a plugin parser is `(artifactContent) => { steps }`. The
* adapter wraps each contributed parser so that:
* - a throw is re-thrown as a {@link PluginParserError} (fail-closed): the
* engine's `parse-steps` handler maps any throw to a routable
* `outcome:parse-error` (audited) — never a crash;
* - an unavailable parser (the plugin provides no usable `parse` function) is
* likewise a fail-closed throw;
* - a result that is not a `{ steps: [...] }` object is rejected (fail-closed).
*
* Timeout posture (documented deviation): the core registry's `parse` is
* synchronous (the engine handler calls it inline), so a plugin parser cannot be
* pre-empted mid-call by a timer the way an async runtime hook (trait adapter)
* can. Plugin parsers run with the same trust tier as project-local script steps
* (KTD-15 framing). The adapter therefore enforces the timeout BUDGET it is
* given by measuring wall time AROUND the synchronous call and failing closed
* (throw → parse-error) when the parser overran — the result is discarded so a
* slow parser can never silently feed a stale/partial step list. A truly
* runaway synchronous parser is a plugin bug bounded by the same posture as a
* runaway script step.
*/
import { StepParserRegistry, getStepParserRegistry } from "@fusion/core";
import type { ParsedStep, StepParseResult, StepParser } from "@fusion/core";
/** Default budget for a plugin parser invocation (ms). */
export const PLUGIN_PARSER_TIMEOUT_MS = 5_000;
/** Build the registry-facing id for a plugin parser. */
export function pluginParserRegistryId(pluginId: string, parserId: string): string {
return `plugin:${pluginId}:${parserId}`;
}
/** A plugin's step-parser contribution. `parse` is synchronous (project-local
* trust tier); the adapter wraps it fail-closed. */
export interface PluginStepParserContribution {
parserId: string;
/** `(artifactContent) => { steps }`. May throw on malformed input. */
parse: (content: string) => StepParseResult;
}
/** Fail-closed error the wrapped parser throws; the parse-steps handler maps any
* throw to a routable `outcome:parse-error` (audited). */
export class PluginParserError extends Error {
readonly parserId: string;
readonly reason: "unavailable" | "throw" | "timeout" | "bad-result";
constructor(parserId: string, reason: PluginParserError["reason"], message: string) {
super(message);
this.name = "PluginParserError";
this.parserId = parserId;
this.reason = reason;
}
}
/** Validate that a value matches the `{ steps: ParsedStep[] }` contract. */
function assertStepParseResult(registryId: string, value: unknown): StepParseResult {
if (typeof value !== "object" || value === null || !Array.isArray((value as { steps?: unknown }).steps)) {
throw new PluginParserError(registryId, "bad-result", `plugin parser '${registryId}' returned a non-{steps} result`);
}
const steps = (value as { steps: unknown[] }).steps;
for (const s of steps) {
if (typeof s !== "object" || s === null || typeof (s as { name?: unknown }).name !== "string") {
throw new PluginParserError(registryId, "bad-result", `plugin parser '${registryId}' returned a step without a string name`);
}
}
return { steps: steps as ParsedStep[] };
}
/**
* Wrap a plugin contribution into a registry {@link StepParser} (fail-closed).
* The wrapped `parse` re-throws every failure as a {@link PluginParserError};
* the engine's parse-steps handler maps the throw to `outcome:parse-error`.
*/
export function pluginParserToRegistryParser(
pluginId: string,
contribution: PluginStepParserContribution,
timeoutMs: number = PLUGIN_PARSER_TIMEOUT_MS,
): StepParser {
const registryId = pluginParserRegistryId(pluginId, contribution.parserId);
return {
id: registryId,
parse(content: string): StepParseResult {
if (typeof contribution.parse !== "function") {
throw new PluginParserError(registryId, "unavailable", `plugin parser '${registryId}' has no parse function`);
}
const started = Date.now();
let raw: StepParseResult;
try {
raw = contribution.parse(content);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new PluginParserError(registryId, "throw", `plugin parser '${registryId}' threw: ${message}`);
}
// Wall-time budget enforcement (documented sync-timeout posture): discard a
// result produced after the budget rather than feed a stale step list.
if (Date.now() - started > timeoutMs) {
throw new PluginParserError(
registryId,
"timeout",
`plugin parser '${registryId}' exceeded ${timeoutMs}ms budget`,
);
}
return assertStepParseResult(registryId, raw);
},
};
}
/**
* Register a plugin's step-parser contributions into the registry. Idempotent
* per id (a re-register of an already-present id is skipped). Returns the
* registry ids registered so the caller can later unregister them. Mirrors
* {@link import("./plugin-trait-adapter.js").registerPluginTraits}.
*/
export function registerPluginStepParsers(params: {
registry?: StepParserRegistry;
pluginId: string;
contributions: PluginStepParserContribution[];
timeoutMs?: number;
}): string[] {
const registry = params.registry ?? getStepParserRegistry();
const registered: string[] = [];
for (const contribution of params.contributions) {
const parser = pluginParserToRegistryParser(params.pluginId, contribution, params.timeoutMs);
if (!registry.has(parser.id)) {
// Registration enforces the `plugin:` id shape + builtin protection.
registry.register(parser, { builtin: false });
}
registered.push(parser.id);
}
return registered;
}
/**
* Unregister a plugin's step parsers (plugin teardown / reload). Built-ins are
* never removed (the registry refuses). Returns the removed registry ids.
*/
export function unregisterPluginStepParsers(
pluginId: string,
parserIds: string[],
registry: StepParserRegistry = getStepParserRegistry(),
): string[] {
const removed: string[] = [];
for (const parserId of parserIds) {
const id = pluginParserRegistryId(pluginId, parserId);
if (registry.unregister(id)) removed.push(id);
}
return removed;
}

View File

@@ -21,6 +21,8 @@ import type {
PluginContext,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginTraitContribution,
WorkflowIr,
PluginPromptContribution,
PluginPromptContributions,
PluginPromptSurface,
@@ -32,7 +34,26 @@ import type {
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "@earendil-works/pi-ai";
import { isAbsolute } from "node:path";
import {
getTraitRegistry,
resolveWorkflowIrForTask,
} from "@fusion/core";
import { createLogger, executorLog } from "./logger.js";
import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js";
import {
registerPluginTraits,
degradePluginTraits,
unregisterPluginTraits,
findLivePluginTraitDependents,
pluginTraitRegistryId,
PluginTraitHasDependentsError,
type PluginTraitDependent,
} from "./plugin-trait-adapter.js";
import {
registerPluginStepParsers,
unregisterPluginStepParsers,
type PluginStepParserContribution,
} from "./plugin-parser-adapter.js";
// Type for the task store's event data
interface TaskMovedEvent {
@@ -106,6 +127,11 @@ interface CachedWorkflowStepTemplates {
version: number;
}
interface CachedTraits {
traits: Array<{ pluginId: string; trait: PluginTraitContribution }>;
version: number;
}
interface CachedPromptContributions {
contributions: Array<{
pluginId: string;
@@ -133,6 +159,7 @@ export class PluginRunner {
private cachedSkills: CachedSkills | null = null;
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
private cachedTraits: CachedTraits | null = null;
private cachedPromptContributions: CachedPromptContributions | null = null;
private cachedSetupInfo: CachedSetupInfo | null = null;
private toolsCacheVersion = 0;
@@ -144,7 +171,16 @@ export class PluginRunner {
private skillsCacheVersion = 0;
private workflowStepsCacheVersion = 0;
private workflowStepTemplatesCacheVersion = 0;
private traitsCacheVersion = 0;
private promptContributionsCacheVersion = 0;
/** Map of pluginId → the registry trait ids it currently has registered. */
private registeredPluginTraitIds = new Map<string, string[]>();
/** Map of pluginId → the step-parser registry ids it currently has registered
* (U12, KTD-12; mirrors registeredPluginTraitIds). */
private registeredPluginParserIds = new Map<string, string[]>();
/** The custom-node runner used to execute plugin trait hooks (set via
* setTraitHookRunner; mirrors how the executor wires runGraphCustomNode). */
private traitHookRunner: WorkflowCustomNodeRunner | undefined;
private setupCacheVersion = 0;
private hookTimeoutMs: number;
@@ -221,6 +257,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -359,6 +396,205 @@ export class PluginRunner {
return this.cachedWorkflowSteps.steps;
}
/**
* Get all plugin trait contributions with their plugin ids (U8). Aggregated /
* cached / invalidated exactly like workflow steps.
*/
getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> {
if (!this.cachedTraits || this.cachedTraits.version !== this.traitsCacheVersion) {
// Older loaders (and some test fakes) predate the traits API — degrade to
// an empty contribution set rather than crashing the runner.
const getter = this.options.pluginLoader.getPluginTraits;
this.cachedTraits = {
traits: typeof getter === "function" ? getter.call(this.options.pluginLoader) : [],
version: this.traitsCacheVersion,
};
}
return this.cachedTraits.traits;
}
/**
* Wire the custom-node runner that executes plugin trait hooks (gate / onEnter
* / onExit / releaseCondition) through the prompt-session/script machinery.
* The executor sets this the way it wires its own runGraphCustomNode. Must be
* set before traits are synced for hooks to actually run (otherwise the
* registry resolves declared hooks to the degraded no-op + audit path).
*/
setTraitHookRunner(runner: WorkflowCustomNodeRunner): void {
this.traitHookRunner = runner;
// Re-sync so already-loaded plugin traits pick up the runner.
this.syncPluginTraits();
}
/**
* Register all currently-loaded plugins' trait contributions into the core
* TraitRegistry (plugin-namespaced ids). Re-runs on cache invalidation. Traits
* for plugins no longer present are dropped from the registry (degraded path
* is the force-disable route; a clean unload removes them).
*/
syncPluginTraits(): void {
const registry = getTraitRegistry();
const runner = this.traitHookRunner;
const current = this.getPluginTraits();
// Group contributions by plugin id.
const byPlugin = new Map<string, PluginTraitContribution[]>();
for (const { pluginId, trait } of current) {
const list = byPlugin.get(pluginId) ?? [];
list.push(trait);
byPlugin.set(pluginId, list);
}
// Drop traits for plugins no longer present.
for (const [pluginId, ids] of [...this.registeredPluginTraitIds.entries()]) {
if (!byPlugin.has(pluginId)) {
unregisterPluginTraits(registry, ids);
this.registeredPluginTraitIds.delete(pluginId);
}
}
if (!runner) {
// No runner yet: don't register hooks (they'd degrade to no-ops anyway).
// Definitions still register so the catalog/validation see them.
for (const [pluginId, contributions] of byPlugin) {
const ids = registerPluginTraits({
registry,
pluginId,
contributions,
runCustomNode: async () => ({ outcome: "success" as const }),
});
this.registeredPluginTraitIds.set(pluginId, ids);
}
return;
}
for (const [pluginId, contributions] of byPlugin) {
try {
const ids = registerPluginTraits({ registry, pluginId, contributions, runCustomNode: runner });
this.registeredPluginTraitIds.set(pluginId, ids);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.log.warn(`Failed to register traits for plugin '${pluginId}': ${msg}`);
}
}
}
/**
* Register all currently-loaded plugins' step-parser contributions into the
* core StepParserRegistry (plugin-namespaced ids, U12/KTD-12). Mirrors
* {@link syncPluginTraits}. Parsers for plugins no longer present are dropped.
* Reads contributions via the loader's optional `getPluginStepParsers` getter
* (graceful absence — a loader that predates parser contributions yields none).
* Fail-closed at registration is the adapter's concern; a registration error
* for one plugin is logged and never aborts the others.
*/
syncPluginStepParsers(): void {
const loader = this.options.pluginLoader as unknown as {
getPluginStepParsers?: () => Array<{ pluginId: string; parser: PluginStepParserContribution }>;
};
const current = typeof loader.getPluginStepParsers === "function" ? loader.getPluginStepParsers() : [];
const byPlugin = new Map<string, PluginStepParserContribution[]>();
for (const { pluginId, parser } of current) {
const list = byPlugin.get(pluginId) ?? [];
list.push(parser);
byPlugin.set(pluginId, list);
}
// Drop parsers for plugins no longer present.
for (const [pluginId, ids] of [...this.registeredPluginParserIds.entries()]) {
if (!byPlugin.has(pluginId)) {
const parserIds = ids.map((id) => id.split(":")[2]).filter(Boolean);
unregisterPluginStepParsers(pluginId, parserIds);
this.registeredPluginParserIds.delete(pluginId);
}
}
for (const [pluginId, contributions] of byPlugin) {
try {
const ids = registerPluginStepParsers({ pluginId, contributions });
this.registeredPluginParserIds.set(pluginId, ids);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.log.warn(`Failed to register step parsers for plugin '${pluginId}': ${msg}`);
}
}
}
/**
* The live-dependents guard (KTD-7). Returns the tasks currently sitting in a
* column that uses one of the plugin's traits. A non-force disable/unregister
* with a non-empty result must be blocked; the force path degrades instead.
*/
async findPluginTraitDependents(pluginId: string): Promise<PluginTraitDependent[]> {
const ids = this.collectPluginTraitRegistryIds(pluginId);
if (ids.length === 0) return [];
return findLivePluginTraitDependents({
store: this.options.taskStore,
resolveTaskWorkflowIr: (taskId) => this.resolveTaskWorkflowIr(taskId),
pluginTraitIds: ids,
});
}
/**
* Disable a plugin's traits. With live dependents and `force !== true`, throws
* `PluginTraitHasDependentsError`. With `force`, degrades the columns to
* passive (hooks become no-ops + audit warning) and emits one audit event;
* cards remain movable.
*/
async disablePluginTraits(pluginId: string, opts?: { force?: boolean }): Promise<{
degraded: string[];
dependents: PluginTraitDependent[];
}> {
const registry = getTraitRegistry();
const ids = this.collectPluginTraitRegistryIds(pluginId);
const dependents = await this.findPluginTraitDependents(pluginId);
if (dependents.length > 0 && !opts?.force) {
throw new PluginTraitHasDependentsError(pluginId, dependents);
}
const degraded = degradePluginTraits(registry, ids);
if (degraded.length > 0) {
try {
this.options.taskStore.recordRunAuditEvent({
agentId: "system",
runId: `plugin-trait-degrade-${pluginId}-${Date.now()}`,
domain: "database",
mutationType: "plugin:trait-degraded",
target: pluginId,
metadata: {
pluginId,
degradedTraitIds: degraded,
affectedTasks: dependents.map((d) => d.taskId),
note: "hooks now resolve to no-ops; cards remain movable",
},
});
} catch {
// Audit is best-effort; degradation already applied.
}
}
return { degraded, dependents };
}
/** Collect the registry trait ids for a plugin (from the registration map, or
* derived from current contributions as a fallback). */
private collectPluginTraitRegistryIds(pluginId: string): string[] {
const tracked = this.registeredPluginTraitIds.get(pluginId);
if (tracked && tracked.length > 0) return tracked;
return this.getPluginTraits()
.filter((t) => t.pluginId === pluginId)
.map((t) => pluginTraitRegistryId(pluginId, t.trait.traitId));
}
/**
* Resolve a task's workflow IR through the shared @fusion/core resolver
* (selection → builtin/custom → default fallback) on the public store surface
* — the adapter never reaches into store internals (GitHub #1402; previously a
* divergent raw-SQL copy via getDatabase()).
*/
private resolveTaskWorkflowIr(taskId: string): Promise<WorkflowIr> {
return resolveWorkflowIrForTask(this.options.taskStore, taskId);
}
getPluginWorkflowStepTemplates(): Array<{ pluginId: string; template: WorkflowStepTemplate }> {
if (!this.cachedWorkflowStepTemplates || this.cachedWorkflowStepTemplates.version !== this.workflowStepTemplatesCacheVersion) {
this.cachedWorkflowStepTemplates = {
@@ -572,6 +808,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
executorLog.log(`Plugin ${pluginId} reloaded`);
@@ -593,6 +830,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
@@ -619,6 +857,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
@@ -645,6 +884,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
@@ -670,6 +910,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -687,6 +928,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -704,6 +946,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -721,6 +964,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -738,6 +982,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -970,6 +1215,16 @@ export class PluginRunner {
this.log.log(`Workflow step templates cache invalidated (version: ${this.workflowStepTemplatesCacheVersion})`);
}
private invalidateTraitsCache(): void {
this.traitsCacheVersion++;
this.log.log(`Plugin traits cache invalidated (version: ${this.traitsCacheVersion})`);
// Re-register/deregister plugin traits in the core registry to match the
// newly-loaded/unloaded set (mirrors the workflow-step contribution flow).
this.syncPluginTraits();
// Step parsers (U12, KTD-12) ride the same plugin lifecycle as traits.
this.syncPluginStepParsers();
}
private invalidatePromptContributionsCache(): void {
this.promptContributionsCacheVersion++;
this.log.log(`Prompt contributions cache invalidated (version: ${this.promptContributionsCacheVersion})`);

View File

@@ -0,0 +1,276 @@
/**
* Plugin trait adapter (U8, R6/R15/R22, KTD-7).
*
* Bridges plugin-contributed traits (`PluginTraitContribution`) into core's
* `TraitRegistry` and routes their executable hooks through the SAME
* prompt-session / script / verdict machinery contributed workflow STEPS use.
*
* Design (mirrors the workflow-step contribution pattern):
* - Plugin trait ids are namespaced `plugin:<pluginId>:<traitId>` so they can
* never collide with built-ins or be overridden (TraitRegistry rejects
* builtin-namespace overrides + restricted flags already).
* - Hooks are async-only (gate/onEnter/onExit/releaseCondition). A sync
* `guard` key is rejected at contribution validation (core), so it never
* reaches the registry.
* - Executable hooks do NOT run raw in-process code. The adapter builds a
* synthetic `WorkflowIrNode` from the hook descriptor (mode + prompt /
* scriptName + gateMode) and delegates to the injected
* `WorkflowCustomNodeRunner` — the exact path contributed workflow steps
* execute through. Gates additionally reuse `createGateHandler` semantics
* (blocking fails closed; advisory records-and-allows).
* - Gates are evaluated PRE-MOVE, outside the task lock (KTD-2): the verdict
* is recorded into the store via `recordPluginGateVerdict`; the store's
* in-lock guard re-checks it cheaply. No plugin code runs in-lock.
*
* Disable/uninstall protection (KTD-7):
* - `findLivePluginTraitDependents` resolves every live task's workflow +
* current column and reports tasks sitting in a column that uses one of the
* plugin's traits. A non-force disable with dependents is blocked.
* - `degradePluginTraits` (force path) deregisters the hook impls so the
* registry resolves them to the no-op + audit-warning path — columns become
* passive, cards stay movable, one audit event is emitted.
*/
import type {
PluginTraitContribution,
PluginTraitHookDescriptor,
TaskStore,
TaskDetail,
TraitDefinition,
TraitHookKind,
WorkflowIr,
WorkflowIrNode,
} from "@fusion/core";
import { TraitRegistry, findWorkflowColumn } from "@fusion/core";
import { createGateHandler } from "./workflow-node-handlers.js";
import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js";
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
/** Build the registry-facing id for a plugin trait. */
export function pluginTraitRegistryId(pluginId: string, traitId: string): string {
return `plugin:${pluginId}:${traitId}`;
}
/** The async hook points a plugin trait may carry. */
const PLUGIN_HOOK_KINDS: readonly Exclude<TraitHookKind, "guard">[] = [
"gate",
"onEnter",
"onExit",
"releaseCondition",
];
/**
* Convert a `PluginTraitContribution` into a core `TraitDefinition`. The result
* is NOT built-in (`builtin` stays falsy), so the registry enforces R22
* (restricted flags / sync guard rejected) on registration as a backstop even
* though core's `validatePluginTraitContribution` already rejected them.
*/
export function pluginTraitToDefinition(
pluginId: string,
contribution: PluginTraitContribution,
): TraitDefinition {
const hooks: TraitDefinition["hooks"] = {};
if (contribution.hooks?.gate) hooks.gate = true;
if (contribution.hooks?.onEnter) hooks.onEnter = true;
if (contribution.hooks?.onExit) hooks.onExit = true;
if (contribution.hooks?.releaseCondition) hooks.releaseCondition = true;
return {
id: pluginTraitRegistryId(pluginId, contribution.traitId),
name: contribution.name,
description: contribution.description,
flags: { ...(contribution.flags ?? {}) },
configSchema: contribution.configSchema
? { fields: contribution.configSchema.fields.map((f) => ({ ...f })) }
: undefined,
hooks: Object.keys(hooks).length > 0 ? hooks : undefined,
builtin: false,
};
}
/**
* Build a synthetic workflow node from a hook descriptor so the hook executes
* through the existing custom-node runner (the contributed-workflow-step path).
*/
function hookDescriptorToNode(
traitRegistryId: string,
hookKind: Exclude<TraitHookKind, "guard">,
descriptor: PluginTraitHookDescriptor,
): WorkflowIrNode {
const isGate = hookKind === "gate";
// The custom-node runner reads `config.gateMode === "gate"` (blocking) vs
// anything else (advisory). Map our blocking/advisory onto that contract.
const gateModeForRunner = descriptor.gateMode === "advisory" ? "advisory" : "gate";
return {
id: `trait:${traitRegistryId}:${hookKind}`,
kind: isGate ? "gate" : "prompt",
config: {
name: traitRegistryId,
prompt: descriptor.prompt ?? "",
scriptName: descriptor.scriptName,
gateMode: isGate ? gateModeForRunner : undefined,
},
} as WorkflowIrNode;
}
/**
* Evaluate a plugin gate descriptor through the gate handler + custom-node
* runner (the same machinery contributed steps use). Returns the node result;
* blocking gates fail closed (a failure outcome → not allowed), advisory gates
* always pass at the handler level (the verdict is still recorded).
*/
export async function evaluatePluginGate(params: {
traitRegistryId: string;
descriptor: PluginTraitHookDescriptor;
task: TaskDetail;
context?: Record<string, unknown>;
runCustomNode: WorkflowCustomNodeRunner;
}): Promise<WorkflowNodeResult> {
const { traitRegistryId, descriptor, task, context, runCustomNode } = params;
const node = hookDescriptorToNode(traitRegistryId, "gate", descriptor);
const handler = createGateHandler(runCustomNode);
return handler(node, { task, context: context ?? {}, settings: undefined });
}
/**
* Register a plugin's trait contributions into the registry and wire each async
* hook's implementation. Hook impls delegate to the injected custom-node runner
* (gate/onEnter/onExit/releaseCondition). Returns the registry ids registered so
* the caller can later degrade/unregister them.
*
* Idempotent per id: a trait already present (same plugin reload) is skipped for
* the definition but its hook impls are refreshed.
*/
export function registerPluginTraits(params: {
registry: TraitRegistry;
pluginId: string;
contributions: PluginTraitContribution[];
/** Resolves the custom-node runner for a given task (the executor's). */
runCustomNode: WorkflowCustomNodeRunner;
}): string[] {
const { registry, pluginId, contributions, runCustomNode } = params;
const registered: string[] = [];
for (const contribution of contributions) {
const def = pluginTraitToDefinition(pluginId, contribution);
if (!registry.has(def.id)) {
// Registration enforces R22 as a backstop (restricted flag / guard hook).
registry.register(def);
}
registered.push(def.id);
for (const hookKind of PLUGIN_HOOK_KINDS) {
const descriptor = contribution.hooks?.[hookKind];
if (!descriptor) continue;
registry.registerTraitHookImpl(def.id, hookKind, ((...args: unknown[]) => {
const ctx = args[0] as
| { task?: TaskDetail; context?: Record<string, unknown> }
| undefined;
const task = ctx?.task;
if (!task) return undefined;
const node = hookDescriptorToNode(def.id, hookKind, descriptor);
return runCustomNode(node, task, ctx?.context ?? {});
}) as (...args: unknown[]) => unknown);
}
}
return registered;
}
/** A live task sitting in a column that uses one of a plugin's traits. */
export interface PluginTraitDependent {
taskId: string;
column: string;
/** The registry ids of the plugin's traits used by that column. */
traitIds: string[];
}
/** Typed error for a blocked disable/unregister with live dependents (KTD-7). */
export class PluginTraitHasDependentsError extends Error {
readonly pluginId: string;
readonly dependents: PluginTraitDependent[];
constructor(pluginId: string, dependents: PluginTraitDependent[]) {
super(
`Cannot disable plugin '${pluginId}': ${dependents.length} task(s) are in columns using its traits ` +
`(${dependents.map((d) => `${d.taskId}@${d.column}`).join(", ")}). ` +
`Force-disable to degrade those columns to passive.`,
);
this.name = "PluginTraitHasDependentsError";
this.pluginId = pluginId;
this.dependents = dependents;
}
}
/**
* Resolve every live (non-archived) task's workflow + current column and report
* those sitting in a column that uses one of the given plugin trait registry
* ids. Pure read-side: resolves the workflow IR through the injected resolver
* (so we don't reach into the store's private methods).
*/
export async function findLivePluginTraitDependents(params: {
store: Pick<TaskStore, "listTasks">;
/** Resolve the (already-parsed) workflow IR for a task id. May resolve
* asynchronously (the shared @fusion/core resolver awaits the definition). */
resolveTaskWorkflowIr: (taskId: string) => WorkflowIr | undefined | Promise<WorkflowIr | undefined>;
/** The registry ids of the plugin's traits to check for. */
pluginTraitIds: string[];
}): Promise<PluginTraitDependent[]> {
const { store, resolveTaskWorkflowIr, pluginTraitIds } = params;
const traitSet = new Set(pluginTraitIds);
if (traitSet.size === 0) return [];
const dependents: PluginTraitDependent[] = [];
const tasks = await store.listTasks({ slim: true, includeArchived: false });
for (const task of tasks) {
const ir = await resolveTaskWorkflowIr(task.id);
if (!ir) continue;
const column = findWorkflowColumn(ir, task.column);
if (!column) continue;
const used = column.traits
.map((ct) => ct.trait)
.filter((id) => traitSet.has(id));
if (used.length > 0) {
dependents.push({ taskId: task.id, column: task.column, traitIds: used });
}
}
return dependents;
}
/**
* Degrade a plugin's traits to passive (force-disable path, KTD-7). Deregisters
* the hook impls so the registry resolves them to the no-op + audit-warning
* path; the trait definitions stay registered so columns referencing them keep
* resolving (cards remain movable). Returns the list of degraded registry ids.
*/
export function degradePluginTraits(
registry: TraitRegistry,
pluginTraitIds: string[],
): string[] {
const degraded: string[] = [];
for (const id of pluginTraitIds) {
const def = registry.getTrait(id);
if (!def) continue;
let any = false;
for (const hookKind of PLUGIN_HOOK_KINDS) {
if (registry.deregisterTraitHookImpl(id, hookKind)) any = true;
}
if (any || def.hooks) degraded.push(id);
}
return degraded;
}
/**
* Fully unregister a plugin's traits from the registry (no live dependents).
* Removes the definitions and any hook impls. Returns removed registry ids.
*/
export function unregisterPluginTraits(
registry: TraitRegistry,
pluginTraitIds: string[],
): string[] {
const removed: string[] = [];
for (const id of pluginTraitIds) {
if (registry.unregisterTrait(id)) removed.push(id);
}
return removed;
}

View File

@@ -152,6 +152,7 @@ export type GitMutationType =
| "merge:start"
| "merge:resolve"
| "merge:file-scope-violation"
| "merge:file-scope-enforcement-disabled"
| "merge:auto-prerebase:applied"
| "merge:auto-prerebase:skipped"
| "merge:auto-prerebase:failed"

View File

@@ -32,6 +32,8 @@ import type { AutoClaimSnapshotManager } from "./auto-claim-snapshot.js";
import { StaleTaskReporter } from "./stale-task-reporter.js";
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
/**
* Check whether two sets of file scope paths overlap.
@@ -278,6 +280,20 @@ interface ConcurrencyGateSnapshot {
slack: number;
}
/**
* U6 (KTD-10): a per-(workflow, column) capacity gate, the generalization of the
* three legacy gates to workflow-defined WIP columns. Additive — the three-gate
* report shape (maxConcurrent/maxWorktrees/semaphore) is preserved verbatim; this
* is an optional extra field populated only when the workflowColumns flag is ON.
*/
interface PerColumnCapacityGate {
workflowId: string;
columnId: string;
used: number;
limit: number;
slack: number;
}
interface ConcurrencyGateDiagnostic {
available: number;
bindingGates: ConcurrencyGateName[];
@@ -289,6 +305,9 @@ interface ConcurrencyGateDiagnostic {
maxWorktrees: string[];
semaphore?: string[];
};
/** U6: additive per-column capacity gates (flag-ON only; omitted otherwise so
* the legacy three-gate report shape is byte-identical when the flag is OFF). */
perColumnGates?: PerColumnCapacityGate[];
}
function computeConcurrencyGateDiagnostic(params: {
@@ -299,6 +318,9 @@ function computeConcurrencyGateDiagnostic(params: {
semaphore?: AgentSemaphore;
inProgressTaskIds: string[];
available: number;
/** U6: additive per-column capacity gates (flag-ON only). Omitted → the legacy
* three-gate report is byte-identical. */
perColumnGates?: PerColumnCapacityGate[];
}): ConcurrencyGateDiagnostic {
const maxConcurrentGate: ConcurrencyGateSnapshot = {
used: params.agentSlots,
@@ -334,6 +356,8 @@ function computeConcurrencyGateDiagnostic(params: {
maxWorktrees: [...params.inProgressTaskIds],
semaphore: semaphoreGate ? [...params.inProgressTaskIds] : undefined,
},
// U6: additive only — present when flag-ON, omitted otherwise.
...(params.perColumnGates ? { perColumnGates: params.perColumnGates } : {}),
};
}
@@ -938,6 +962,8 @@ export class Scheduler {
semaphore: diagnostic.semaphoreGate,
holders: diagnostic.holders,
available: diagnostic.available,
// U6: additive per-column capacity gates (present only flag-ON).
...(diagnostic.perColumnGates ? { perColumnGates: diagnostic.perColumnGates } : {}),
},
});
} catch (error) {
@@ -1171,6 +1197,18 @@ export class Scheduler {
}
this.wasEnginePaused = false;
// ── U6: hold/release sweep (flag-ON only) ──────────────────────────────
// Flag OFF: this is skipped entirely — the legacy pull-from-todo loop
// below is byte-identical. Flag ON: the sweep evaluates hold-column
// release conditions (manual/timer/capacity/dependency/external-event) and
// releases eligible cards via moveSource:"scheduler", serializing through
// the in-txn capacity check. For the DEFAULT workflow the legacy loop below
// still drives todo→in-progress pickup (parity); the sweep adds custom-
// workflow hold handling and the generalized capacity-release path.
if (isWorkflowColumnsEnabled(settings)) {
await this.runHoldReleaseSweepPass();
}
// Count only in-progress tasks toward the worktree limit.
// In-review tasks with worktrees are idle (waiting to merge) and
// should not block new tasks from starting.
@@ -1207,6 +1245,19 @@ export class Scheduler {
semaphoreAvailable,
);
const inProgressTaskIds = inProgress.map((task) => task.id);
// U6 (KTD-10): when the workflowColumns flag is ON, report the default
// workflow's in-progress capacity as a per-column gate — the generalization
// of the legacy maxConcurrent gate (which reads through to the same value).
// Additive: omitted flag-OFF so the three-gate report shape is unchanged.
const perColumnGates = isWorkflowColumnsEnabled(settings)
? [{
workflowId: DEFAULT_WORKFLOW_POOL_ID,
columnId: "in-progress",
used: agentSlots,
limit: maxConcurrent,
slack: maxConcurrent - agentSlots,
}]
: undefined;
const concurrencyGateDiagnostic = computeConcurrencyGateDiagnostic({
agentSlots,
maxConcurrent,
@@ -1215,6 +1266,7 @@ export class Scheduler {
semaphore: this.options.semaphore,
inProgressTaskIds,
available,
perColumnGates,
});
if (available <= 0) return;
@@ -1892,13 +1944,44 @@ export class Scheduler {
}
}
/**
* U6: run one hold/release sweep pass, wiring the scheduler's semaphore +
* worktree allocation into the reservation-first ordering (KTD-10). Failures
* are isolated so a sweep error never breaks the scheduling pass.
*/
private async runHoldReleaseSweepPass(): Promise<void> {
try {
await runHoldReleaseSweep(this.store, {
now: () => Date.now(),
reserveSlot: this.options.semaphore
? (): SlotReservation | null => {
const sem = this.options.semaphore!;
if (!sem.tryAcquire()) return null;
let released = false;
return {
release: () => {
if (released) return;
released = true;
sem.release();
},
};
}
: undefined,
allocateWorktree: (task, reservedNames) =>
planTaskWorktreePath(task, this.store.getRootDir(), undefined, reservedNames, {}),
});
} catch (error) {
schedulerLog.error("Hold/release sweep failed:", error);
}
}
/**
* Handle a mission-linked task column move.
* Keeps feature state synchronized with task columns across the full task
* lifecycle, including review/merge transitions and older tasks whose task
* row has mission/slice metadata but whose feature row lacks taskId.
*/
private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").Column): Promise<void> {
private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").ColumnId): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;

View File

@@ -28,7 +28,7 @@ import { promisify } from "node:util";
import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
@@ -110,7 +110,9 @@ export async function archiveAsGhostBug(
findings: decision.findings.slice(0, 10),
},
});
await store.moveTask(taskId, "archived");
// #1411: recovery/terminal move — recoveryRehome skips order-derived adjacency
// so a custom-workflow card can always reach the terminal column.
await store.moveTask(taskId, "archived", { moveSource: "engine", recoveryRehome: true });
}
async function classifyOwnedLandedEvidenceForSelfHealing(rootDir: string, task: Task, mergeTargetBranch: string): Promise<OwnedLandedClassification> {
@@ -437,9 +439,10 @@ export async function autoRecoverWorktreeSessionStartFailure(
: `Auto-recovered: retry/verification session targeted unusable worktree${staleWorktree ? ` (${staleWorktree})` : ""} — cleared stale session metadata and requeued to todo (attempt ${nextCount}/${MAX_WORKTREE_SESSION_RETRIES}, failure: ${failureExcerpt})`,
);
if (noProgress) {
await store.moveTask(task.id, "todo");
// #1411: backward recovery move — recoveryRehome skips order-derived adjacency.
await store.moveTask(task.id, "todo", { moveSource: "engine", recoveryRehome: true });
} else {
await store.moveTask(task.id, "todo", { preserveProgress: true });
await store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
}
return { outcome: "requeue-todo", retries: nextCount, classification };
}
@@ -1112,6 +1115,9 @@ export class SelfHealingManager {
await this.store.moveTask(taskId, "todo", {
preserveProgress: true,
preserveStatus: true,
// #1411: backward recovery — skip order-derived adjacency.
moveSource: "engine",
recoveryRehome: true,
});
} catch (moveErr: unknown) {
const moveErrMessage = moveErr instanceof Error ? moveErr.message : String(moveErr);
@@ -1768,6 +1774,10 @@ export class SelfHealingManager {
{ name: "auto-archive-meta-resolved", fn: () => this.autoArchiveResolvedMetaTasks() },
{ name: "auto-archive-meta-stalled", fn: () => this.autoArchiveStalledMetaTasks() },
{ name: "board-stall-auto-recovery", fn: () => this.runBoardStallAutoRecoverySweep() },
// #1401: periodically recover transitionPending markers stranded by a
// crash between the in-txn write and the post-commit clear (flag-ON
// only; a no-op when there are no markers).
{ name: "recover-stale-transition-pending", fn: () => this.runStaleTransitionPendingSweep() },
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies() },
{ name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) },
{ name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts() },
@@ -2200,6 +2210,8 @@ export class SelfHealingManager {
});
await this.store.moveTask(task.id, "todo", {
moveSource: "engine",
// #1411: backward recovery — skip order-derived adjacency.
recoveryRehome: true,
preserveWorktree: true,
preserveProgress: true,
preserveResumeState: true,
@@ -2467,6 +2479,8 @@ export class SelfHealingManager {
} else {
await this.store.moveTask(task.id, "todo", {
moveSource: "engine",
// #1411: backward recovery — skip order-derived adjacency.
recoveryRehome: true,
preserveProgress: true,
preserveResumeState: true,
});
@@ -2570,6 +2584,8 @@ export class SelfHealingManager {
} else {
await this.store.moveTask(task.id, "todo", {
moveSource: "engine",
// #1411: backward recovery — skip order-derived adjacency.
recoveryRehome: true,
preserveProgress: true,
preserveResumeState: true,
});
@@ -2637,6 +2653,8 @@ export class SelfHealingManager {
const idleMs = Number.isFinite(idleAnchorMs) ? Math.max(0, Date.now() - idleAnchorMs) : null;
await this.store.moveTask(task.id, "todo", {
moveSource: "engine",
// #1411: backward recovery — skip order-derived adjacency.
recoveryRehome: true,
preserveWorktree: true,
preserveProgress: true,
preserveResumeState: true,
@@ -2703,6 +2721,8 @@ export class SelfHealingManager {
} else {
await this.store.moveTask(task.id, "todo", {
moveSource: "engine",
// #1411: backward recovery — skip order-derived adjacency.
recoveryRehome: true,
preserveWorktree: true,
preserveProgress: true,
preserveResumeState: true,
@@ -3621,6 +3641,8 @@ export class SelfHealingManager {
preserveWorktree: true,
preserveResumeState: true,
moveSource: "engine",
// #1411: backward recovery — skip order-derived adjacency.
recoveryRehome: true,
});
await this.store.logEntry(
task.id,
@@ -3886,6 +3908,19 @@ export class SelfHealingManager {
return archived;
}
/**
* #1401: periodic transitionPending recovery sweep. Flag-ON only — when
* `workflowColumns` is OFF the legacy path never writes markers, so there is
* nothing to recover. Delegates to the store's idempotent recovery method
* (a no-op when no stale markers exist), keeping capacity counts honest after
* a crash between the in-txn marker write and the post-commit clear.
*/
async runStaleTransitionPendingSweep(): Promise<void> {
const settings = await this.store.getSettings();
if (!isWorkflowColumnsEnabled(settings)) return;
await this.store.recoverStaleTransitionPending();
}
async runBoardStallAutoRecoverySweep(): Promise<{ holders: string[]; recovered: number; unrecovered: boolean }> {
const settings = await this.store.getSettings();
const windowMs = Number(settings.boardStallSweepWindowMs ?? 2 * 60 * 60_000);
@@ -4616,7 +4651,8 @@ export class SelfHealingManager {
await this.emitBackwardMoveNoAction(task, "finalize-no-op-review", "task:finalize-no-op-review-no-action", proof);
continue;
}
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" });
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
continue;
}
@@ -4666,7 +4702,8 @@ export class SelfHealingManager {
classification: "proven-no-op",
baseRef: classification.baseRef,
});
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" });
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
recovered++;
continue;
}
@@ -5102,7 +5139,8 @@ export class SelfHealingManager {
task.id,
"Auto-recovered: in-review task still had incomplete steps — moved back to todo for retry",
);
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
log.log(`Recovered stale incomplete review task ${task.id}: moved back to todo`);
recovered++;
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
@@ -5506,7 +5544,8 @@ export class SelfHealingManager {
task.id,
"Auto-recovered: in-review task idle past stuck-task timeout — kicked back to todo",
);
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
log.log(`Kicked ghost review task ${task.id} back to todo`);
recovered++;
} catch (err: unknown) {
@@ -7248,7 +7287,8 @@ export class SelfHealingManager {
stepStatuses,
},
});
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
recovered++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -7785,7 +7825,8 @@ export class SelfHealingManager {
task.id,
"Auto-recovered no-progress no-task_done failure — clean worktree, moved back to todo",
);
await this.store.moveTask(task.id, "todo");
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { moveSource: "engine", recoveryRehome: true });
recovered++;
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`Failed to recover no-progress no-task_done failure ${task.id}: ${errorMessage}`);
@@ -7952,7 +7993,8 @@ export class SelfHealingManager {
task.id,
`Auto-retry ${nextCount}/${MAX_TASK_DONE_RETRIES}: agent finished without fn_task_done — requeuing to todo to resume partial work`,
);
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
// #1411: backward recovery — skip order-derived adjacency.
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
recovered++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);

View File

@@ -0,0 +1,273 @@
/**
* step-integration — the ordered integration stage for worktree-isolated foreach
* instances (step-inversion KTD-11, U10).
*
* Under `isolation: "worktree"` each foreach step instance runs in its OWN
* worktree/branch off a common integration base. Completing the instance's
* sub-walk does NOT mark the step done — instead the instance enqueues here as
* `awaiting-integration`. The {@link IntegrationQueue} then lands completed
* branches onto the task's main branch (the integration base) **strictly in step
* order**: instance `i` integrates only after instances `0..i-1` are integrated
* or skipped. This is the single place where a worktree-isolated instance's work
* becomes visible on main history, so:
*
* - **Success**: flip the projection FIRST (`updateStep(..., "done", graph)` —
* the dependency-order guard admits it because predecessors are done), THEN
* mark the instance row `completed`/`integratedAt` (projection-first ordering,
* KTD-7: closes the merge-blocker race), THEN release the instance worktree
* (pool hygiene).
* - **Conflict**: discard the instance branch (release worktree), and emit
* `outcome:integration-conflict` for that instance. The foreach sub-walk
* routes that like a rework — re-execute the step on the UPDATED integration
* base in a fresh worktree, counting against the instance's `maxReworkCycles`
* budget (exhaustion → rework-exhausted as usual).
*
* All git mechanics are behind the injectable {@link IntegrationGitOps} so this
* module is hermetically testable with fakes. Production wires it (executor.ts)
* to a rebase/cherry-pick onto the task's main branch that reuses merger.ts's
* conflict-classification helpers (`getConflictedFiles`) for conflict detection —
* NOT reimplemented here.
*
* Reconcile alignment (KTD-11): `complete step N` commits live on instance
* branches before integration and on main history after it; the projection rule
* ("done iff integrated") therefore agrees with `reconcileStepsFromGitHistory`
* (which reads main-worktree history) by construction.
*/
import { schedulerLog } from "./logger.js";
/** The outcome of attempting to integrate one instance branch onto the base. */
export type IntegrationAttemptResult =
| { kind: "integrated"; integratedAt: string }
| { kind: "conflict"; conflictedFiles: string[] };
/**
* Injectable git mechanics for the ordered integration stage (KTD-11). Production
* (executor.ts) implements these over real git — `integrate` does a rebase /
* cherry-pick of the instance branch onto the task's main branch and uses
* merger.ts's `getConflictedFiles` to detect conflicts; `discardBranch` deletes
* the conflicting branch. Tests inject fakes for fast, deterministic runs.
*/
export interface IntegrationGitOps {
/**
* Land `branchName` onto the integration base (the task's main branch) for step
* `stepIndex`. Returns `integrated` on a clean rebase/cherry-pick (the base now
* contains the step's commits), or `conflict` with the conflicting file list.
* MUST NOT mutate the projection or instance rows — that is the queue's job
* (projection-first ordering). On `conflict` the implementation MUST leave the
* base clean (abort the rebase) so the next instance can integrate.
*/
integrate(
branchName: string,
stepIndex: number,
): Promise<IntegrationAttemptResult>;
/**
* Discard a conflicting (or abandoned) instance branch and release its worktree
* (pool hygiene). Best-effort — never throws into the queue.
*/
discardBranch(branchName: string, stepIndex: number): Promise<void>;
}
/**
* Identity of the persisted instance row to flip on integration. The queue
* sources this from the foreach environment (the SINGLE source of truth for
* runId/foreachNodeId/pinnedStepCount — the same values the sub-walk persisted
* the row under) so `markInstanceIntegrated` updates the EXISTING row keyed by
* `(taskId, runId, foreachNodeId, stepIndex)` instead of writing an orphan.
*/
export interface IntegrationInstanceIdentity {
runId: string;
foreachNodeId: string;
pinnedStepCount: number;
/** The instance branch being integrated (carried onto the flipped row). */
branchName: string;
}
/** Projection + persistence side-effects the queue performs on a successful
* integration (KTD-7 projection-first ordering). Injected so the queue stays
* engine-agnostic and unit-testable. */
export interface IntegrationProjection {
/**
* Flip the projection FIRST (KTD-7): `updateStep(taskId, stepIndex, "done")`
* with graph source so the dependency-order guard admits it. Awaited before the
* instance row flips to `completed`, closing the merge-blocker race.
*/
markStepDone(stepIndex: number): Promise<void>;
/**
* Mark the instance row `completed` with `integratedAt` AFTER the projection
* flip (projection-first ordering). The queue passes the row's REAL identity
* (runId/foreachNodeId/pinnedStepCount/branchName) so the production impl flips
* the SAME row the sub-walk persisted — never an orphan. Optional — a fully
* in-memory run needs none.
*/
markInstanceIntegrated?(
stepIndex: number,
integratedAt: string,
identity: IntegrationInstanceIdentity,
): Promise<void> | void;
}
/** One enqueued, completed instance awaiting ordered integration. */
export interface PendingIntegration {
stepIndex: number;
branchName: string;
}
/** The disposition of one instance after the queue drained as far as it could. */
export type InstanceIntegrationOutcome =
| { stepIndex: number; status: "integrated"; integratedAt: string }
| { stepIndex: number; status: "conflict"; conflictedFiles: string[] };
/**
* Per-(task, run, foreach) ordered integration queue (KTD-11).
*
* Completed worktree-isolated instances enqueue via {@link enqueue}; the queue
* lands them onto the integration base **strictly in step order**. The scheduler
* calls {@link drain} whenever a new instance becomes available (or on each
* scheduler tick); `drain` integrates every contiguous run of ready instances
* starting at the lowest not-yet-resolved step index, stopping at the first gap
* (a step not yet completed) or the first conflict. Conflicts are reported back
* so the scheduler can route `outcome:integration-conflict` for that instance.
*
* The queue NEVER skips ahead past a gap: instance `i` integrates only after
* `0..i-1` are integrated or skipped, so completion-order inversion (a later step
* finishing first) cannot reorder integration — integration order is step order.
*/
export class IntegrationQueue {
/** Instances that have completed and are waiting to integrate, by step index. */
private readonly pending = new Map<number, PendingIntegration>();
/** Step indices whose integration is resolved (integrated OR routed conflict). */
private readonly resolved = new Set<number>();
/** The next step index eligible to integrate (advances as the queue drains). */
private cursor = 0;
/** Steps the scheduler told us to SKIP (e.g. dependency-failed) — treated as
* resolved so the cursor advances past them without blocking later steps. */
private readonly skipped = new Set<number>();
constructor(
private readonly gitOps: IntegrationGitOps,
private readonly projection: IntegrationProjection,
private readonly pinnedStepCount: number,
/** Identity context for instance-row flips on integration (KTD-6/KTD-11):
* the REAL runId + foreachNodeId the sub-walk persisted rows under, so
* `markInstanceIntegrated` updates the existing row, not an orphan. Optional
* for fully in-memory runs that pass no `markInstanceIntegrated`. */
private readonly rowIdentity?: { runId: string; foreachNodeId: string },
) {}
/** Enqueue a completed instance awaiting integration. Idempotent per step. */
enqueue(stepIndex: number, branchName: string): void {
if (this.resolved.has(stepIndex)) return;
this.pending.set(stepIndex, { stepIndex, branchName });
}
/**
* Mark a step index as skipped (resolved without integration), so the ordered
* cursor can advance past it. Used when an instance failed before producing a
* branch (the projection stays non-done; the foreach reports the failure).
*/
skip(stepIndex: number): void {
if (this.resolved.has(stepIndex)) return;
this.skipped.add(stepIndex);
this.resolved.add(stepIndex);
this.advanceCursor();
}
/** Whether step `i` is still awaiting integration in the queue. */
isPending(stepIndex: number): boolean {
return this.pending.has(stepIndex);
}
/** Whether step `i` has been integrated or routed to conflict/skip. */
isResolved(stepIndex: number): boolean {
return this.resolved.has(stepIndex);
}
/**
* Integrate every contiguous ready instance starting at the cursor, in step
* order. Stops at the first gap (the cursor's step hasn't completed yet) or the
* first conflict (the conflicting step is reported and NOT marked resolved here
* — the scheduler routes it to rework, then re-enqueues or re-skips). Returns
* the per-instance outcomes produced THIS drain (callers act on conflicts).
*/
async drain(): Promise<InstanceIntegrationOutcome[]> {
const outcomes: InstanceIntegrationOutcome[] = [];
for (;;) {
// Advance past any already-resolved/skipped steps so the cursor points at
// the lowest unresolved step.
this.advanceCursor();
if (this.cursor >= this.pinnedStepCount) break;
const ready = this.pending.get(this.cursor);
if (!ready) break; // Gap: the lowest unresolved step hasn't completed yet.
const result = await this.gitOps.integrate(ready.branchName, ready.stepIndex);
if (result.kind === "integrated") {
// Projection-first ordering (KTD-7): flip the step done BEFORE the instance
// row, then release the worktree (the discard path releases on conflict;
// here the worktree is released after a clean integration via discardBranch
// which the production op treats as "release, branch already merged").
await this.projection.markStepDone(ready.stepIndex);
await this.projection.markInstanceIntegrated?.(ready.stepIndex, result.integratedAt, {
runId: this.rowIdentity?.runId ?? "",
foreachNodeId: this.rowIdentity?.foreachNodeId ?? "",
pinnedStepCount: this.pinnedStepCount,
branchName: ready.branchName,
});
// Release the instance worktree post-integration (pool hygiene). The branch
// is already on the base; discardBranch in production only releases here.
await this.safeDiscard(ready.branchName, ready.stepIndex);
this.pending.delete(this.cursor);
this.resolved.add(this.cursor);
outcomes.push({
stepIndex: ready.stepIndex,
status: "integrated",
integratedAt: result.integratedAt,
});
this.advanceCursor();
continue;
}
// Conflict: discard the branch + release worktree, report the conflict, and
// STOP draining (the conflicting step is not resolved — the scheduler routes
// it to rework on the updated base, then re-enqueues a fresh branch or skips).
await this.safeDiscard(ready.branchName, ready.stepIndex);
this.pending.delete(this.cursor);
outcomes.push({
stepIndex: ready.stepIndex,
status: "conflict",
conflictedFiles: result.conflictedFiles,
});
break;
}
return outcomes;
}
/** True once every step index is resolved (integrated or skipped). */
isDrained(): boolean {
return this.resolved.size >= this.pinnedStepCount;
}
/** Drain-and-release any remaining pending branches (abort/cleanup path). */
async discardAllPending(): Promise<void> {
for (const { branchName, stepIndex } of this.pending.values()) {
await this.safeDiscard(branchName, stepIndex);
}
this.pending.clear();
}
private advanceCursor(): void {
while (this.cursor < this.pinnedStepCount && this.resolved.has(this.cursor)) {
this.cursor += 1;
}
}
private async safeDiscard(branchName: string, stepIndex: number): Promise<void> {
try {
await this.gitOps.discardBranch(branchName, stepIndex);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
schedulerLog.warn(`integration discardBranch failed for ${branchName} (step ${stepIndex}): ${message}`);
}
}
}

View File

@@ -0,0 +1,391 @@
/**
* step-runner — the two substrate seams for graph-owned stepwise execution
* (plan 2026-06-04-001, KTD-2 / U2).
*
* This module exposes exactly two capabilities that the workflow-graph executor
* (U3/U5) will drive — it does NOT wire itself into any graph path here:
*
* - {@link runTaskStep} — run exactly step `i` of a task inside its
* session/worktree and return the outcome plus
* the per-step `baselineSha` / `checkpointId`
* that a later RETHINK needs.
* - {@link resetStepToBaseline} — the RETHINK mechanics, extracted verbatim
* from `executor.ts`'s `fn_review_step` RETHINK
* block (`git reset --hard <baseline>` + session
* rewind via `navigateTree`/`branchWithSummary`
* fallback + `store.updateStep(..., "pending")`),
* plus a defensive blast-radius guard (KTD-2).
*
* Both are parameterized via an explicit `deps` object (the DI style used by
* `hold-release.ts` / `merge-trait.ts`) so they stay unit-testable without real
* git, real sessions, or a real `StepSessionExecutor`. Production callers (U3/U5)
* pass thin adapters over the existing engine machinery; the legacy in-session
* `fn_review_step` path is untouched and keeps its own copy's behavior — this
* extraction is the single implementation the executor's RETHINK block now
* delegates to (see `TaskExecutor.applyStepRethink`).
*/
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { TaskStore } from "@fusion/core";
const execAsync = promisify(exec);
import type { AgentSession as PiAgentSession } from "@earendil-works/pi-coding-agent";
import { executorLog } from "./logger.js";
import type { RunAuditor } from "./run-audit.js";
// ── Shared minimal shapes ───────────────────────────────────────────────
/** The slice of `Task` the step runner reads. */
export interface StepRunnerTask {
id: string;
steps: Array<{ name?: string; status?: string }>;
}
/** A minimal session ref mirroring the executor's `{ current: AgentSession }`. */
export interface SessionRef {
current: PiAgentSession | null;
}
/**
* Run exactly one step inside the task's session/worktree. Production wires this
* to a {@link import("./step-session-executor.js").StepSessionExecutor} configured
* for a single step (graph-owned runs force step-session physics, KTD-2/KTD-8);
* tests inject a fake. Returns whether the step's session completed successfully.
*/
export type RunSingleStep = (stepIndex: number) => Promise<{ success: boolean; error?: string }>;
// ── runTaskStep ─────────────────────────────────────────────────────────
/** Dependencies for {@link runTaskStep}. */
export interface RunTaskStepDeps {
/** Step-state projection sink (KTD-7). */
store: Pick<TaskStore, "updateStep" | "logEntry">;
/** Absolute path to the task's worktree (where `git rev-parse HEAD` runs). */
worktreePath: string;
/** Run exactly step `i` (step-session physics). */
runStep: RunSingleStep;
/**
* Capture HEAD in the worktree before step work begins (the per-step baseline,
* KTD-2 documented behavior change). Defaults to
* `git rev-parse HEAD` in {@link RunTaskStepDeps.worktreePath}; inject in tests.
*/
gitRevParse?: (worktreePath: string) => Promise<string | undefined>;
/**
* Capture the session checkpoint (leaf) id for the step — observed the same way
* the legacy `stepCheckpoints` map is populated (`session.sessionManager.getLeafId()`).
* Defaults to reading {@link RunTaskStepOptions.sessionRef}; inject in tests.
*/
captureCheckpointId?: () => string | undefined;
}
/** Options for {@link runTaskStep}. */
export interface RunTaskStepOptions {
/** Session ref used for the default checkpoint capture. */
sessionRef?: SessionRef;
/**
* Whether a successful step run marks the step `done` through the projection
* (KTD-7). Default `true` — the step is the terminal authority on its own
* completion (no review node present). The foreach sub-walk passes `false` when
* the template contains a `step-review` node (U6/KTD-4): in that case
* `step-execute` SUCCESS leaves the step `in-progress` and the step-review
* node's APPROVE verdict marks it `done` through the projection instead — so a
* single authority (the review) decides done-ness.
*/
markDoneOnSuccess?: boolean;
}
/** Result of {@link runTaskStep}. */
export interface RunTaskStepResult {
outcome: "success" | "failure";
baselineSha?: string;
checkpointId?: string;
}
/**
* Drive execution of exactly step `stepIndex` of `task`.
*
* Order of operations (matches the legacy step-session lifecycle the
* characterization tests pin):
* 1. mark the step `in-progress` via `store.updateStep` (projection sink);
* 2. capture `baselineSha` = HEAD in the worktree, BEFORE any step work;
* 3. run exactly step `i` as a step-session (the agent authors its own
* `complete Step N` commit — this driver only observes);
* 4. capture `checkpointId` (session leaf) for a later RETHINK rewind;
* 5. on success, mark the step `done`; on failure, leave the step non-done
* (the graph decides routing — KTD-4).
*/
export async function runTaskStep(
deps: RunTaskStepDeps,
task: StepRunnerTask,
stepIndex: number,
opts: RunTaskStepOptions = {},
): Promise<RunTaskStepResult> {
const { store, worktreePath } = deps;
const gitRevParse = deps.gitRevParse ?? defaultGitRevParse;
const captureCheckpointId =
deps.captureCheckpointId ?? (() => defaultCaptureCheckpointId(opts.sessionRef));
// 1. Projection: step → in-progress (KTD-7). updateStep's own guards apply.
try {
await store.updateStep(task.id, stepIndex, "in-progress");
} catch (err) {
executorLog.warn(
`${task.id}: runTaskStep failed to mark step ${stepIndex} in-progress: ${errMsg(err)}`,
);
}
// 2. Baseline capture at instance start, before step work (KTD-2).
let baselineSha: string | undefined;
try {
baselineSha = await gitRevParse(worktreePath);
} catch (err) {
executorLog.warn(`${task.id}: runTaskStep baseline capture failed: ${errMsg(err)}`);
}
// 3. Run exactly step i. The agent authors the commit; we observe only.
const result = await deps.runStep(stepIndex);
// 4. Capture the session checkpoint (leaf) for a later RETHINK rewind.
let checkpointId: string | undefined;
try {
checkpointId = captureCheckpointId() ?? undefined;
} catch (err) {
executorLog.warn(`${task.id}: runTaskStep checkpoint capture failed: ${errMsg(err)}`);
}
// 5. Projection: success → done; failure leaves the step non-done.
// When a step-review node will decide done-ness (markDoneOnSuccess === false,
// U6/KTD-4), leave the step `in-progress` so the review's APPROVE verdict is
// the single authority that marks it done.
const markDoneOnSuccess = opts.markDoneOnSuccess ?? true;
if (result.success) {
if (markDoneOnSuccess) {
try {
await store.updateStep(task.id, stepIndex, "done");
} catch (err) {
executorLog.warn(
`${task.id}: runTaskStep failed to mark step ${stepIndex} done: ${errMsg(err)}`,
);
}
}
return { outcome: "success", baselineSha, checkpointId };
}
return { outcome: "failure", baselineSha, checkpointId };
}
// ── resetStepToBaseline ──────────────────────────────────────────────────
/** Dependencies for {@link resetStepToBaseline}. */
export interface ResetStepDeps {
/** Step-state projection sink (KTD-7). */
store: Pick<TaskStore, "updateStep" | "logEntry">;
/** Absolute path to the task's worktree (where `git reset --hard` runs). */
worktreePath: string;
/** Session ref for the conversation rewind (`navigateTree` / `branchWithSummary`). */
sessionRef: SessionRef;
/**
* Review type — `code` reverts file changes via git reset; `plan` skips the
* git reset (no code was written), matching the legacy RETHINK branch.
*/
reviewType?: "code" | "plan";
/** Optional reviewer summary used as the `branchWithSummary` fallback label. */
summary?: string;
/** Optional auditor for the blast-radius guard refusal warning (KTD-2). */
audit?: Pick<RunAuditor, "database">;
/**
* Blast-radius guard hook (KTD-2, shared isolation). Returns `null` when the
* reset is safe, or a refusal `reason` string when it would destroy other
* steps' approved work (baseline not an ancestor of HEAD, or a later step is
* already done/skipped past the baseline). When omitted the guard is skipped
* (worktree isolation makes it structural — KTD-11). Tests inject a fake;
* production wires {@link makeAncestryBlastRadiusGuard}.
*/
blastRadiusGuard?: (baselineSha: string | undefined) => Promise<string | null>;
}
/** Result of {@link resetStepToBaseline}. */
export interface ResetStepResult {
ok: boolean;
reason?: string;
}
/**
* Reset step `stepIndex` to its per-step baseline — the verbatim RETHINK
* mechanics extracted from `executor.ts` (`fn_review_step` RETHINK case):
*
* - `git reset --hard <baseline>` in the worktree (code review only; skipped
* when `baselineSha` is missing or for plan reviews — today's semantics);
* - session rewind to the pre-step checkpoint via `navigateTree`, falling back
* to `sessionManager.branchWithSummary` (skipped when `checkpointId` is
* missing — today's semantics);
* - `store.updateStep(..., "pending")`.
*
* Before any mutation, the KTD-2 blast-radius guard runs (when provided): on a
* violation it returns `{ ok: false, reason }`, emits an audit warning, and
* mutates NOTHING.
*/
export async function resetStepToBaseline(
deps: ResetStepDeps,
task: StepRunnerTask,
stepIndex: number,
baselineSha?: string,
checkpointId?: string,
): Promise<ResetStepResult> {
const { store, worktreePath, sessionRef } = deps;
const reviewType = deps.reviewType ?? "code";
const taskId = task.id;
const step = stepIndex + 1; // legacy log lines are 1-indexed
// ── KTD-2 blast-radius guard — assert BEFORE mutating anything. ──────────
if (deps.blastRadiusGuard) {
let refusal: string | null = null;
try {
refusal = await deps.blastRadiusGuard(baselineSha);
} catch (err) {
// A guard that itself fails is treated as a refusal — fail closed.
refusal = `blast-radius guard error: ${errMsg(err)}`;
}
if (refusal) {
executorLog.warn(
`${taskId}: RETHINK reset for step ${step} REFUSED by blast-radius guard: ${refusal}`,
);
await deps.audit?.database({
type: "task:integrity-warning",
target: taskId,
metadata: {
guard: "step-reset-blast-radius",
stepIndex,
baselineSha: baselineSha ?? null,
reason: refusal,
},
});
return { ok: false, reason: refusal };
}
}
// ── git reset --hard <baseline> (code reviews only). ─────────────────────
if (reviewType === "code" && baselineSha) {
try {
await execAsync(`git reset --hard ${baselineSha}`, { cwd: worktreePath });
executorLog.log(`${taskId}: RETHINK — git reset --hard ${baselineSha}`);
} catch (gitErr: unknown) {
executorLog.error(`${taskId}: RETHINK git reset failed: ${errMsg(gitErr)}`);
}
} else if (reviewType === "code") {
executorLog.log(`${taskId}: RETHINK — no baseline SHA, skipping git reset`);
}
// ── Rewind conversation to the pre-step checkpoint. ──────────────────────
if (checkpointId && sessionRef.current) {
try {
await sessionRef.current.navigateTree(checkpointId, { summarize: false });
executorLog.log(`${taskId}: RETHINK — session rewound to checkpoint ${checkpointId}`);
} catch (rewindErr: unknown) {
executorLog.warn(
`${taskId}: RETHINK navigateTree rewind failed, falling back to branchWithSummary: ${errMsg(rewindErr)}`,
);
try {
sessionRef.current.sessionManager.branchWithSummary(
checkpointId,
`RETHINK: ${deps.summary || "Approach rejected by reviewer"}`,
);
executorLog.log(`${taskId}: RETHINK — branched from checkpoint ${checkpointId}`);
} catch (branchErr: unknown) {
executorLog.error(`${taskId}: RETHINK session rewind failed: ${errMsg(branchErr)}`);
}
}
} else {
executorLog.log(`${taskId}: RETHINK — no session checkpoint for step ${step}, skipping rewind`);
}
// ── Reset step status to pending (projection sink). ──────────────────────
await store.updateStep(taskId, stepIndex, "pending");
if (reviewType === "plan") {
await store.logEntry(
taskId,
`RETHINK: Step ${step} plan rewound — session checkpoint ${checkpointId || "N/A"}`,
deps.summary,
);
} else {
await store.logEntry(
taskId,
`RETHINK: Step ${step} rewound — git reset to ${baselineSha || "N/A"}, session checkpoint ${checkpointId || "N/A"}`,
deps.summary,
);
}
return { ok: true };
}
// ── Blast-radius guard factory (shared isolation, KTD-2) ─────────────────
/**
* Build the shared-isolation blast-radius guard: a reset for step `stepIndex` is
* legal only when (a) `baselineSha` is an ancestor of HEAD in the worktree
* (`git merge-base --is-ancestor`), and (b) no LATER step is already
* `done`/`skipped` (which would postdate the baseline). On violation it returns
* the refusal reason; otherwise `null`. A missing baseline is allowed (the reset
* simply skips its git portion — today's partial-recovery semantics).
*/
export function makeAncestryBlastRadiusGuard(opts: {
worktreePath: string;
task: StepRunnerTask;
stepIndex: number;
isAncestor?: (baselineSha: string, worktreePath: string) => Promise<boolean>;
}): (baselineSha: string | undefined) => Promise<string | null> {
const isAncestor = opts.isAncestor ?? defaultIsAncestorOfHead;
return async (baselineSha: string | undefined): Promise<string | null> => {
// (b) No later step may already be terminal-done past this baseline.
const laterDone = opts.task.steps.findIndex(
(s, i) => i > opts.stepIndex && (s.status === "done" || s.status === "skipped"),
);
if (laterDone !== -1) {
return `later step ${laterDone} is ${opts.task.steps[laterDone]?.status} — reset would destroy approved work`;
}
// (a) Baseline must be an ancestor of HEAD (skipped when no baseline).
if (baselineSha) {
let ancestor = false;
try {
ancestor = await isAncestor(baselineSha, opts.worktreePath);
} catch (err) {
return `ancestry check failed: ${errMsg(err)}`;
}
if (!ancestor) {
return `baseline ${baselineSha} is not an ancestor of HEAD`;
}
}
return null;
};
}
// ── Defaults (production adapters over real git/session) ─────────────────
async function defaultGitRevParse(worktreePath: string): Promise<string | undefined> {
const { stdout } = await execAsync("git rev-parse HEAD", { cwd: worktreePath });
const sha = stdout.trim();
return sha.length > 0 ? sha : undefined;
}
function defaultCaptureCheckpointId(sessionRef?: SessionRef): string | undefined {
const leaf = sessionRef?.current?.sessionManager?.getLeafId?.();
return leaf ?? undefined;
}
async function defaultIsAncestorOfHead(baselineSha: string, worktreePath: string): Promise<boolean> {
try {
await execAsync(`git merge-base --is-ancestor ${baselineSha} HEAD`, { cwd: worktreePath });
return true;
} catch {
// Non-zero exit → not an ancestor.
return false;
}
}
function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

View File

@@ -139,6 +139,12 @@ Follow this structure exactly:
## Steps
> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed
> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps
> that are genuinely independent of their immediate predecessor; an unannotated step is
> assumed to depend on the one before it (fully sequential). Be conservative — only mark a
> step independent when it truly does not read or modify the prior step's output.
### Step 0: Preflight
- [ ] Required files and paths exist
@@ -437,6 +443,12 @@ Follow this structure exactly:
## Steps
> Optional: a step heading may carry a \`(depends: N,M)\` annotation listing the 1-indexed
> step numbers it depends on — e.g. \`### Step 3 (depends: 1): Title\`. Annotate ONLY steps
> that are genuinely independent of their immediate predecessor; an unannotated step is
> assumed to depend on the one before it (fully sequential). Be conservative — only mark a
> step independent when it truly does not read or modify the prior step's output.
### Step 0: Preflight
- [ ] Required files and paths exist

View File

@@ -0,0 +1,364 @@
import type { Settings, TaskDetail, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import { WorkflowIrError } from "@fusion/core";
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
import { schedulerLog } from "./logger.js";
/**
* Concurrent fan-out/join branch execution (U13, KTD-11, R21).
*
* When the sequential walker reaches a `split` node, every outgoing edge becomes
* a branch that walks concurrently up to the matching `join`. The join then
* synchronizes per its config (`all | any | quorum(n)`) and either fails fast
* (cancelling siblings via an AbortSignal) or collects all branch outcomes.
*
* This module owns ONLY the parallel window: it is handed a `runBranchNode`
* callback that reuses the executor's per-node retry logic, and a small set of
* graph lookups. The card's board position never forks — that invariant is
* upheld by the executor (no handler-driven column moves happen here).
*/
/** Per-branch persisted run state (ADR-0001 reconstructible). */
export interface WorkflowBranchRunState {
taskId: string;
runId: string;
branchId: string;
/** Node the branch is currently at / last completed. */
currentNodeId: string;
status: "running" | "completed" | "failed" | "aborted";
}
/**
* Persistence callback surface. Kept as an injected interface so the executor
* stays DI-pure and fake-friendly; the SQLite-backed implementation is wired
* separately (see workflow_run_branches table). All methods are optional so a
* fully in-memory run (tests, flag-off) needs no persistence at all.
*/
export interface WorkflowBranchPersistence {
/** Idempotent upsert of a branch's progress, keyed by (taskId, runId, branchId). */
saveBranchState?(state: WorkflowBranchRunState): void | Promise<void>;
/** Load any persisted branch states for a run (used on resume). */
loadBranchStates?(taskId: string, runId: string): WorkflowBranchRunState[] | Promise<WorkflowBranchRunState[]>;
/**
* Prune stale branch rows for a task, keeping only `keepRunId` (#1412).
* Called on run start and run completion to bound unbounded growth across a
* long-lived task's repeated runs.
*/
clearStaleBranchStates?(taskId: string, keepRunId: string): void | Promise<void>;
}
/**
* Await a `saveBranchState` call inside a guard so a Promise-returning impl
* cannot escape as an unhandled rejection, and so a persistence failure never
* kills branch execution (log-and-continue). For a synchronous impl this
* preserves the prior behavior (the write completes before the caller proceeds).
*/
async function persistBranchState(
persistence: WorkflowBranchPersistence | undefined,
state: WorkflowBranchRunState,
): Promise<void> {
try {
await persistence?.saveBranchState?.(state);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
schedulerLog.warn(
`saveBranchState failed for task ${state.taskId} run ${state.runId} branch ${state.branchId}: ${message}`,
);
}
}
/** Minimal semaphore shape — structurally compatible with AgentSemaphore. */
export interface WorkflowBranchSemaphore {
run<T>(fn: () => Promise<T>): Promise<T>;
}
/** Snapshot of a single branch's progress, surfaced for dashboard badges (U9). */
export interface WorkflowBranchProgress {
branchId: string;
nodeId: string;
status: WorkflowBranchRunState["status"];
}
export interface BranchEnvironment {
task: TaskDetail;
settings: Pick<Settings, "experimentalFeatures"> | undefined;
runId: string;
nodeMap: Map<string, WorkflowIrNode>;
outgoingMap: Map<string, WorkflowIrEdge[]>;
/** Reuses the executor's executeNodeWithRetries (+ context bookkeeping). */
runBranchNode: (
node: WorkflowIrNode,
signal: AbortSignal,
) => Promise<WorkflowNodeResult>;
shouldTraverseEdge: (edge: WorkflowIrEdge, source: WorkflowNodeResult) => boolean;
persistence?: WorkflowBranchPersistence;
semaphore?: WorkflowBranchSemaphore;
/** Reports live per-branch progress for the card (no column move). */
onBranchProgress?: (progress: WorkflowBranchProgress) => void;
/** Node IDs already completed in a prior (crashed) run — skipped on resume. */
completedNodeIds?: Set<string>;
}
export interface SplitJoinResult {
/** The join node where branches converged. */
joinNodeId: string;
/** Join outcome: success if the mode was satisfied, else failure. */
outcome: WorkflowNodeOutcome;
/** Per-branch outcomes, exposed so the join's outgoing edge conditions can read them. */
branchOutcomes: { branchId: string; outcome: WorkflowNodeOutcome; nodeId: string }[];
/** Node IDs visited across all branches (for the executor's visited list). */
visitedNodeIds: string[];
}
interface ResolvedJoinConfig {
mode: "all" | "any" | { quorum: number };
onBranchFailure: "fail-fast" | "collect";
}
function resolveJoinConfig(join: WorkflowIrNode): ResolvedJoinConfig {
const rawMode = join.config?.mode;
let mode: ResolvedJoinConfig["mode"] = "all";
if (rawMode === "all" || rawMode === "any") mode = rawMode;
else if (rawMode && typeof rawMode === "object" && "quorum" in rawMode) {
const n = (rawMode as { quorum: unknown }).quorum;
if (typeof n === "number" && Number.isInteger(n) && n > 0) mode = { quorum: n };
else throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`);
}
const rawFail = join.config?.onBranchFailure;
const onBranchFailure: ResolvedJoinConfig["onBranchFailure"] =
rawFail === "collect" ? "collect" : "fail-fast";
return { mode, onBranchFailure };
}
/** How many successful completions satisfy this join mode for `branchCount` branches. */
function requiredCompletions(mode: ResolvedJoinConfig["mode"], branchCount: number): number {
if (mode === "all") return branchCount;
if (mode === "any") return 1;
return Math.min(mode.quorum, branchCount);
}
/**
* Execute a split's branches concurrently and synchronize at the join.
*
* Returns once the join is satisfied (or definitively cannot be). Sibling
* branches are aborted on fail-fast via the shared AbortSignal; on collect they
* are awaited. Nested splits recurse: a branch walk that itself hits a `split`
* calls back into this function for its inner window.
*/
export async function runSplitJoin(
split: WorkflowIrNode,
env: BranchEnvironment,
): Promise<SplitJoinResult> {
const branchEdges = (env.outgoingMap.get(split.id) ?? []).filter(
(e) => env.shouldTraverseEdge(e, { outcome: "success" }),
);
if (branchEdges.length === 0) {
throw new WorkflowIrError(`split '${split.id}' has no traversable branches`);
}
const join = findMatchingJoin(branchEdges[0].to, env);
if (!join) throw new WorkflowIrError(`split '${split.id}' has no reachable matching join`);
const joinConfig = resolveJoinConfig(env.nodeMap.get(join)!);
const controller = new AbortController();
const branchCount = branchEdges.length;
const required = requiredCompletions(joinConfig.mode, branchCount);
const visitedNodeIds: string[] = [];
const branchOutcomes: SplitJoinResult["branchOutcomes"] = [];
let succeeded = 0;
let failed = 0;
let settled = false;
let resolveJoin!: (outcome: WorkflowNodeOutcome) => void;
const joinReached = new Promise<WorkflowNodeOutcome>((res) => {
resolveJoin = res;
});
const settle = (outcome: WorkflowNodeOutcome): void => {
if (settled) return;
settled = true;
resolveJoin(outcome);
};
// Re-evaluate the join after each branch settles. `lastWasFailure` only
// affects fail-fast (one failure cancels siblings immediately).
const evaluateJoin = (lastWasFailure: boolean): void => {
if (settled) return;
if (joinConfig.onBranchFailure === "fail-fast" && lastWasFailure) {
controller.abort();
settle("failure");
return;
}
if (succeeded >= required) {
// Mode satisfied. Fail-fast cancels any laggards; collect lets them finish.
if (joinConfig.onBranchFailure === "fail-fast") controller.abort();
settle("success");
return;
}
if (succeeded + failed >= branchCount) {
// All branches settled but the mode is unmet.
settle("failure");
}
};
const branchPromises = branchEdges.map((edge) => {
const branchId = edge.to;
return walkBranch(branchId, join, env, controller.signal, visitedNodeIds)
.then((result) => {
branchOutcomes.push({ branchId, outcome: result.outcome, nodeId: result.lastNodeId });
if (result.outcome === "success") succeeded += 1;
else failed += 1;
evaluateJoin(result.outcome === "failure");
})
.catch((err) => {
// An aborted branch settles silently; any other throw fails the join.
if (controller.signal.aborted) {
branchOutcomes.push({ branchId, outcome: "failure", nodeId: branchId });
return;
}
failed += 1;
branchOutcomes.push({ branchId, outcome: "failure", nodeId: branchId });
evaluateJoin(true);
void err;
});
});
// Wait for the join to resolve, then let in-flight branches settle so collect
// semantics (and persistence writes) complete before we return.
const outcome = await joinReached;
await Promise.allSettled(branchPromises);
return { joinNodeId: join, outcome, branchOutcomes, visitedNodeIds };
}
interface BranchWalkResult {
outcome: WorkflowNodeOutcome;
lastNodeId: string;
}
/**
* Walk a single branch from `startNodeId` up to (but not including) the join.
* Reuses the injected per-node runner; supports nested splits by recursing into
* runSplitJoin. Honors the AbortSignal (fail-fast cancellation) and skips nodes
* already completed in a prior run (crash resume idempotency).
*/
async function walkBranch(
startNodeId: string,
joinId: string,
env: BranchEnvironment,
signal: AbortSignal,
visitedNodeIds: string[],
): Promise<BranchWalkResult> {
let currentId = startNodeId;
let lastResult: WorkflowNodeResult = { outcome: "success" };
for (;;) {
if (signal.aborted) return { outcome: "failure", lastNodeId: currentId };
if (currentId === joinId) return { outcome: lastResult.outcome, lastNodeId: currentId };
const node = env.nodeMap.get(currentId);
if (!node) throw new WorkflowIrError(`Unknown workflow node: ${currentId}`);
if (node.kind === "split") {
// Nested split: resolve its inner window, then continue from the inner join.
const inner = await runSplitJoin(node, env);
visitedNodeIds.push(...inner.visitedNodeIds);
lastResult = { outcome: inner.outcome };
const next = nextEdge(inner.joinNodeId, env, lastResult);
if (!next) return { outcome: inner.outcome, lastNodeId: inner.joinNodeId };
currentId = next;
continue;
}
visitedNodeIds.push(currentId);
const alreadyDone = env.completedNodeIds?.has(currentId) ?? false;
if (alreadyDone) {
lastResult = { outcome: "success" };
} else {
const exec = async (): Promise<WorkflowNodeResult> => env.runBranchNode(node, signal);
lastResult = env.semaphore ? await env.semaphore.run(exec) : await exec();
await persistBranchState(env.persistence, {
taskId: env.task.id,
runId: env.runId,
branchId: startNodeId,
currentNodeId: currentId,
status: lastResult.outcome === "success" ? "running" : "failed",
});
env.onBranchProgress?.({
branchId: startNodeId,
nodeId: currentId,
status: lastResult.outcome === "success" ? "running" : "failed",
});
}
if (lastResult.outcome === "failure") {
await persistBranchState(env.persistence, {
taskId: env.task.id,
runId: env.runId,
branchId: startNodeId,
currentNodeId: currentId,
status: "failed",
});
return { outcome: "failure", lastNodeId: currentId };
}
const next = nextEdge(currentId, env, lastResult);
if (!next) {
// Dead-end before the join — treat as branch completion.
return { outcome: lastResult.outcome, lastNodeId: currentId };
}
if (next === joinId) {
await persistBranchState(env.persistence, {
taskId: env.task.id,
runId: env.runId,
branchId: startNodeId,
currentNodeId: currentId,
status: "completed",
});
env.onBranchProgress?.({ branchId: startNodeId, nodeId: currentId, status: "completed" });
return { outcome: lastResult.outcome, lastNodeId: currentId };
}
currentId = next;
}
}
/** The next node along a matching outgoing edge, or undefined if none matches. */
function nextEdge(
nodeId: string,
env: BranchEnvironment,
source: WorkflowNodeResult,
): string | undefined {
const edges = (env.outgoingMap.get(nodeId) ?? [])
.filter((e) => env.shouldTraverseEdge(e, source))
.sort((a, b) => a.to.localeCompare(b.to));
return edges[0]?.to;
}
/**
* Find the join node a branch starting at `startNodeId` converges on. Walks
* forward through the (non-failure) edges; recurses one level for nested splits
* so balanced nesting resolves to the correct outer join.
*/
function findMatchingJoin(startNodeId: string, env: BranchEnvironment): string | undefined {
const seen = new Set<string>();
let currentId: string | undefined = startNodeId;
while (currentId && !seen.has(currentId)) {
seen.add(currentId);
const node = env.nodeMap.get(currentId);
if (!node) return undefined;
if (node.kind === "join") return currentId;
if (node.kind === "split") {
const innerJoin = findMatchingJoin(
(env.outgoingMap.get(currentId) ?? [])[0]?.to ?? "",
env,
);
if (!innerJoin) return undefined;
currentId = (env.outgoingMap.get(innerJoin) ?? []).find((e) => e.condition !== "failure")?.to;
continue;
}
const out = env.outgoingMap.get(currentId) ?? [];
currentId = out.find((e) => e.condition !== "failure")?.to ?? out[0]?.to;
}
return undefined;
}

View File

@@ -1,12 +1,29 @@
import type { Settings, TaskDetail, WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import type { Settings, TaskDetail, TaskStep, WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, isExperimentalFeatureEnabled } from "@fusion/core";
import {
createDefaultNodeHandlers,
createNoopLegacySeams,
SPLIT_ACTIVE_CONTEXT_KEY,
type CodeNodeRunner,
type ForeachActiveContext,
type ParseStepsHandlerDeps,
type WorkflowCustomNodeRunner,
type WorkflowLegacySeams,
} from "./workflow-node-handlers.js";
import {
runSplitJoin,
type BranchEnvironment,
type WorkflowBranchPersistence,
type WorkflowBranchProgress,
type WorkflowBranchRunState,
type WorkflowBranchSemaphore,
} from "./workflow-graph-branches.js";
import {
runForeach,
type ForeachEnvironment,
type WorkflowStepInstancePersistence,
} from "./workflow-graph-foreach.js";
export type WorkflowNodeOutcome = "success" | "failure";
@@ -20,6 +37,9 @@ export interface WorkflowNodeExecutionContext {
task: TaskDetail;
settings: Pick<Settings, "experimentalFeatures"> | undefined;
context: Record<string, unknown>;
/** Set during concurrent branch execution; fail-fast aborts via this signal.
* Undefined on the sequential path (zero behavior change for linear graphs). */
signal?: AbortSignal;
}
export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeExecutionContext) => Promise<WorkflowNodeResult>;
@@ -29,7 +49,75 @@ export interface WorkflowGraphExecutorDeps {
seams?: WorkflowLegacySeams;
/** Executes custom (non-seam) prompt/script/gate nodes. */
runCustomNode?: WorkflowCustomNodeRunner;
/** Step-inversion (U12, KTD-12): dependencies for the `parse-steps` node
* handler (artifact read, projection write, pin-protection probe, audit).
* Absent → a parse-steps node fails cleanly. */
parseStepsDeps?: ParseStepsHandlerDeps;
/** Step-inversion (U14, KTD-15): runner for the `code` node (esbuild compile +
* child-process execution). Absent → a code node fails cleanly. */
runCode?: CodeNodeRunner;
maxRetriesPerNode?: number;
/** Per-branch run-state persistence (U13). Optional — fully in-memory without it. */
branchPersistence?: WorkflowBranchPersistence;
/** Bounds concurrent branch-node execution. Omit when the semaphore is
* enforced beneath runCustomNode (the session layer) to avoid double-acquire. */
branchSemaphore?: WorkflowBranchSemaphore;
/** Live per-branch progress (dashboard badges). */
onBranchProgress?: (progress: WorkflowBranchProgress) => void;
/** Stable identifier for this run, used to key persisted branch state. */
runId?: string;
/**
* Step-inversion (KTD-3, U3): fresh `Task.steps[]` accessor used by a `foreach`
* node at expansion time. Defaults to reading `task.steps` off the run's task.
* A production caller may inject a fresh store fetch so the count reflects the
* planning seam's latest write; tests inject a fixed list.
*/
getTaskSteps?: (task: TaskDetail) => Promise<TaskStep[]> | TaskStep[];
/**
* Step-inversion (KTD-6, U3 stub): per-instance run-state persistence for
* foreach instances. Optional with no-op default — the real SQLite adapter is
* U4's executor-half wiring; the sub-walk already calls into this so that
* wiring is purely additive.
*/
stepInstancePersistence?: WorkflowStepInstancePersistence;
/**
* Step-inversion (KTD-4, U5): RETHINK reset-on-rework hook passed through to the
* foreach sub-walk. Invoked before re-entering step-execute when a rework edge
* was triggered by an `outcome:rethink` verdict. Optional with a no-op default
* (REVISE-driven rework never calls it).
*/
onReworkReset?: (
active: ForeachActiveContext,
reason: string,
) => void | Promise<void>;
/**
* Step-inversion (U3): top-level abort signal honored between foreach instance
* nodes (existing posture, mirrors the branch path's per-branch signal). When a
* run is cancelled (pause/abort), the in-flight instance stops cleanly between
* nodes and the foreach fails with `value: "aborted"`. Undefined on normal
* runs (zero behavior change for non-foreach graphs).
*/
signal?: AbortSignal;
/** Step-inversion (KTD-11, U10): per-instance worktree/branch allocation off the
* integration base, for `isolation: "worktree"`. Absent → worktree isolation
* fails cleanly (shared isolation is unaffected). */
allocateInstanceWorktree?: ForeachEnvironment["allocateInstanceWorktree"];
/** Step-inversion (KTD-11, U10): resolve the current integration base (main tip)
* so reworks land on the updated base. */
resolveIntegrationBase?: ForeachEnvironment["resolveIntegrationBase"];
/** Step-inversion (KTD-11, U10): ordered-integration git mechanics (rebase /
* cherry-pick + conflict detection via merger helpers). */
integrationGitOps?: ForeachEnvironment["integrationGitOps"];
/** Step-inversion (KTD-11, U10): projection-first integration writes
* (updateStep done, then instance row). */
integrationProjection?: ForeachEnvironment["integrationProjection"];
/** Step-inversion (KTD-11, U10): non-blocking free-semaphore-slot accessor for
* parallel scheduling (clamps concurrency without hold-and-wait). */
semaphoreAvailability?: ForeachEnvironment["semaphoreAvailability"];
/** Step-inversion (KTD-11, U10): crash-resume reconciliation hook. */
resumeReconcile?: ForeachEnvironment["resumeReconcile"];
/** FIX 4 (context gap): task-level log sink for integration-conflict rework. */
logTaskEntry?: ForeachEnvironment["logTaskEntry"];
}
export interface WorkflowGraphExecutorResult {
@@ -54,7 +142,10 @@ export class WorkflowGraphExecutor {
public constructor(private readonly deps: WorkflowGraphExecutorDeps) {
this.maxRetriesPerNode = Math.max(1, Math.floor(deps.maxRetriesPerNode ?? 2));
this.handlers = {
...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams(), deps.runCustomNode),
...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams(), deps.runCustomNode, {
parseSteps: deps.parseStepsDeps,
runCode: deps.runCode,
}),
...(deps.handlers ?? {}),
};
}
@@ -85,6 +176,44 @@ export class WorkflowGraphExecutor {
const context: Record<string, unknown> = {};
const visitedNodeIds: string[] = [];
const inStack = new Set<string>();
const runId = this.deps.runId ?? `${task.id}:run`;
// 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;
const persisted = await this.deps.branchPersistence?.loadBranchStates?.(task.id, runId);
if (persisted && persisted.length > 0) {
completedNodeIds = new Set(
persisted
.filter((s: WorkflowBranchRunState) => s.status === "completed")
.map((s) => s.currentNodeId),
);
}
// Prune prior-run branch rows on run start (#1412). Done after the resume
// load so this run's own (taskId, runId) rows survive while every stale run
// is removed. Never throws into the run.
await this.pruneStaleBranches(task.id, runId);
// Same posture for foreach step-instance rows (KTD-6, U4): prune every stale
// run's instance rows, keeping only this run's, so the table does not
// accumulate historical runs for a long-lived task. The resume reconcile path
// (foreach worktree scheduler) loads THIS run's rows, which survive.
await this.pruneStaleInstances(task.id, runId);
// Shared branch environment: built lazily so the sequential path pays nothing.
const branchEnv = (): BranchEnvironment => ({
task,
settings,
runId,
nodeMap,
outgoingMap,
runBranchNode: (node, signal) => this.executeNodeWithRetries(node, task, settings, context, signal),
shouldTraverseEdge: (edge, source) => this.shouldTraverseEdge(edge, source),
persistence: this.deps.branchPersistence,
semaphore: this.deps.branchSemaphore,
onBranchProgress: this.deps.onBranchProgress,
completedNodeIds,
});
const walk = async (nodeId: string): Promise<WorkflowNodeResult> => {
const node = nodeMap.get(nodeId);
@@ -101,6 +230,75 @@ export class WorkflowGraphExecutor {
return { outcome: "success" };
}
if (node.kind === "split") {
// Concurrent fan-out: branches run in parallel up to their join, which
// synchronizes per its config. The card stays in the split's column for
// the whole window (no handler-driven move happens in here). Execution
// then continues sequentially from the join node.
//
// Single-writer rule (KTD-4, U5): mark the shared context "inside a
// split" for the branch window so a step-review node inside a branch is
// advisory-only (no projection write, no authoritative verdict). The
// marker is set before launching branches and cleared at the join;
// step-execute is validator-forbidden in splits, so only step-review
// consults it. Restore the prior value to support balanced nesting.
const priorSplitActive = context[SPLIT_ACTIVE_CONTEXT_KEY];
context[SPLIT_ACTIVE_CONTEXT_KEY] = true;
let splitResult: Awaited<ReturnType<typeof runSplitJoin>>;
try {
splitResult = await runSplitJoin(node, branchEnv());
} finally {
if (priorSplitActive === undefined) delete context[SPLIT_ACTIVE_CONTEXT_KEY];
else context[SPLIT_ACTIVE_CONTEXT_KEY] = priorSplitActive;
}
visitedNodeIds.push(...splitResult.visitedNodeIds);
context[`node:${node.id}:outcome`] = splitResult.outcome;
context[`node:${splitResult.joinNodeId}:outcome`] = splitResult.outcome;
context[`node:${splitResult.joinNodeId}:branchOutcomes`] = splitResult.branchOutcomes;
if (!inStack.has(splitResult.joinNodeId)) visitedNodeIds.push(splitResult.joinNodeId);
return await traverseChildren(
nodeMap.get(splitResult.joinNodeId)!,
{ outcome: splitResult.outcome },
);
}
if (node.kind === "foreach") {
// Step-inversion (KTD-3/KTD-5, U3): expand the foreach into per-step
// instances run through an iterative region sub-walk. The recursive
// walk's inStack cycle detector is untouched — rework loops are
// expressed inside the sub-walk only. The foreach node's own outcome
// routes its outgoing edges (success / outcome:rework-exhausted / ...).
const steps = await this.resolveTaskSteps(task);
const foreachResult = await runForeach(node, {
task,
runId,
steps,
context,
runTemplateNode: (tNode, sig, contextOverride) =>
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, sig),
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
persistence: this.deps.stepInstancePersistence,
onReworkReset: this.deps.onReworkReset,
signal: this.deps.signal,
// Worktree isolation + parallel scheduling (KTD-11, U10).
allocateInstanceWorktree: this.deps.allocateInstanceWorktree,
resolveIntegrationBase: this.deps.resolveIntegrationBase,
integrationGitOps: this.deps.integrationGitOps,
integrationProjection: this.deps.integrationProjection,
semaphoreAvailability: this.deps.semaphoreAvailability,
resumeReconcile: this.deps.resumeReconcile,
logTaskEntry: this.deps.logTaskEntry,
});
visitedNodeIds.push(...foreachResult.visitedNodeIds);
const result: WorkflowNodeResult = {
outcome: foreachResult.outcome,
value: foreachResult.value,
};
context[`node:${node.id}:outcome`] = result.outcome;
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
return await traverseChildren(node, result);
}
const result = await this.executeNodeWithRetries(node, task, settings, context);
if (result.contextPatch) Object.assign(context, result.contextPatch);
context[`node:${node.id}:outcome`] = result.outcome;
@@ -141,6 +339,10 @@ export class WorkflowGraphExecutor {
};
const terminal = await walk(startNode.id);
// 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);
await this.pruneStaleInstances(task.id, runId);
return {
executed: true,
outcome: terminal.outcome,
@@ -149,6 +351,37 @@ export class WorkflowGraphExecutor {
};
}
/**
* Resolve the task's step list for a foreach expansion (KTD-3). Defaults to
* the steps already on the run's task; a caller may inject `getTaskSteps` to
* fetch fresh state (e.g. after the planning seam populated steps).
*/
private async resolveTaskSteps(task: TaskDetail): Promise<TaskStep[]> {
if (this.deps.getTaskSteps) {
return await this.deps.getTaskSteps(task);
}
return task.steps ?? [];
}
/** Best-effort prune of stale-run branch rows; never throws into the run. */
private async pruneStaleBranches(taskId: string, keepRunId: string): Promise<void> {
try {
await this.deps.branchPersistence?.clearStaleBranchStates?.(taskId, keepRunId);
} catch {
// Pruning is additive bookkeeping — a failure must not affect the run.
}
}
/** Best-effort prune of stale-run foreach instance rows (KTD-6, U4); identical
* keepRunId posture as {@link pruneStaleBranches}. Never throws into the run. */
private async pruneStaleInstances(taskId: string, keepRunId: string): Promise<void> {
try {
await this.deps.stepInstancePersistence?.clearStaleInstanceStates?.(taskId, keepRunId);
} catch {
// Pruning is additive bookkeeping — a failure must not affect the run.
}
}
private shouldTraverseEdge(edge: WorkflowIrEdge, sourceResult: WorkflowNodeResult): boolean {
if (!edge.condition) return sourceResult.outcome === "success";
if (edge.condition === "success") return sourceResult.outcome === "success";
@@ -164,6 +397,7 @@ export class WorkflowGraphExecutor {
task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined,
context: Record<string, unknown>,
signal?: AbortSignal,
): Promise<WorkflowNodeResult> {
const handler = this.handlers[node.kind];
if (!handler) {
@@ -178,8 +412,10 @@ export class WorkflowGraphExecutor {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Fail-fast cancellation: a branch aborted mid-retry stops re-trying.
if (signal?.aborted) return { outcome: "failure", value: "aborted" };
try {
return await handler(node, { task, settings, context });
return await handler(node, { task, settings, context, signal });
} catch (error) {
lastError = error;
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,19 @@ 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";
import type {
CodeNodeRunner,
ForeachActiveContext,
ParseStepsHandlerDeps,
WorkflowCustomNodeRunner,
WorkflowLegacySeams,
} from "./workflow-node-handlers.js";
import type {
WorkflowBranchPersistence,
WorkflowBranchProgress,
WorkflowBranchSemaphore,
} from "./workflow-graph-branches.js";
import type { ForeachEnvironment, WorkflowStepInstancePersistence } from "./workflow-graph-foreach.js";
// (Both types are also used as values in the side-effect tracking wrappers below.)
/**
@@ -37,6 +49,44 @@ export interface WorkflowGraphTaskRunnerDeps {
maxRetriesPerNode?: number;
/** Optional diagnostics hook (audit/log emission). Never throws into the run. */
onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void;
/** Per-branch run-state persistence + resume (U13). Additive; in-memory without it. */
branchPersistence?: WorkflowBranchPersistence;
/** Bounds concurrent branch-node execution (U13); omit when the semaphore is
* enforced beneath runCustomNode at the session layer. */
branchSemaphore?: WorkflowBranchSemaphore;
/** Live per-branch progress for dashboard badges (U9/U13). */
onBranchProgress?: (progress: WorkflowBranchProgress) => void;
/** Step-inversion (KTD-6, U3/U4): per-instance run-state persistence for
* foreach instances. Additive; in-memory without it. */
stepInstancePersistence?: WorkflowStepInstancePersistence;
/** Step-inversion (KTD-4, U5): RETHINK reset-on-rework hook — invoked before
* re-entering step-execute when a rework edge was triggered by an
* `outcome:rethink`. Wired to `resetStepToBaseline` in production. */
onReworkReset?: (active: ForeachActiveContext, reason: string) => void | Promise<void>;
/** Step-inversion (U12, KTD-12): `parse-steps` node handler deps. Additive;
* a workflow with no parse-steps node never invokes it. */
parseStepsDeps?: ParseStepsHandlerDeps;
/** Step-inversion (U14, KTD-15): `code` node runner. Additive; a workflow with
* no code node never invokes it. */
runCode?: CodeNodeRunner;
/** Step-inversion (KTD-11, U10): worktree-isolation + parallel-scheduling deps.
* Additive; a shared-isolation foreach never invokes them. */
allocateInstanceWorktree?: ForeachEnvironment["allocateInstanceWorktree"];
resolveIntegrationBase?: ForeachEnvironment["resolveIntegrationBase"];
integrationGitOps?: ForeachEnvironment["integrationGitOps"];
integrationProjection?: ForeachEnvironment["integrationProjection"];
semaphoreAvailability?: ForeachEnvironment["semaphoreAvailability"];
resumeReconcile?: ForeachEnvironment["resumeReconcile"];
/** FIX 4 (context gap): task-level log sink for integration-conflict rework. */
logTaskEntry?: ForeachEnvironment["logTaskEntry"];
/**
* Step-inversion (KTD-6): the production run id, threaded from the caller so it
* is the SINGLE source of truth shared with the executor-side persistence deps
* (`buildParseStepsDeps` / `buildForeachWorktreeDeps` probe and flip rows under
* the SAME id). When omitted the runner derives `${task.id}:${definition.id}` —
* the same formula — so a caller that does not thread it keeps prior behavior.
*/
runId?: string;
}
/**
@@ -47,8 +97,18 @@ export interface WorkflowGraphTaskRunnerDeps {
* run the legacy pipeline; a task is never stranded by interpreter bugs.
*/
export class WorkflowGraphTaskRunner {
/** Latest per-branch progress, keyed by branchId. Store/dashboard-readable
* (U9 badges). Reset at the start of each run; the card never moves during a
* parallel window (KTD-11) so this is purely presentational state. */
private readonly branchProgress = new Map<string, WorkflowBranchProgress>();
public constructor(private readonly deps: WorkflowGraphTaskRunnerDeps) {}
/** Snapshot of current per-branch progress (branchId, nodeId, status). */
public getBranchProgress(): WorkflowBranchProgress[] {
return [...this.branchProgress.values()];
}
private emit(type: "start" | "terminal" | "fallback", taskId: string, detail: string): void {
try {
this.deps.onEvent?.({ type, taskId, detail });
@@ -91,6 +151,7 @@ export class WorkflowGraphTaskRunner {
}
this.emit("start", task.id, definition.id);
this.branchProgress.clear();
// Track whether any node side effects ran. A pre-run interpreter error
// (bad IR structure, wiring) can safely fall back to the legacy pipeline;
@@ -105,6 +166,14 @@ export class WorkflowGraphTaskRunner {
review: (t, c) => ((sideEffectsRan = true), invoked.push("review"), seams.review(t, c)),
merge: (t, c) => ((sideEffectsRan = true), invoked.push("merge"), seams.merge(t, c)),
schedule: (t, c) => ((sideEffectsRan = true), invoked.push("schedule"), seams.schedule(t, c)),
// Step-inversion seams (U3/U5) — forwarded only when wired so a workflow
// without foreach/step-review keeps the omitted-optional posture.
...(seams.stepExecute
? { stepExecute: (t, c) => ((sideEffectsRan = true), invoked.push("step-execute"), seams.stepExecute!(t, c)) }
: {}),
...(seams.stepReview
? { stepReview: (t, c, cfg) => ((sideEffectsRan = true), invoked.push("step-review"), seams.stepReview!(t, c, cfg)) }
: {}),
};
const wrappedRunCustomNode: WorkflowCustomNodeRunner = (node, t, c) => {
sideEffectsRan = true;
@@ -117,6 +186,32 @@ export class WorkflowGraphTaskRunner {
seams: wrappedSeams,
runCustomNode: wrappedRunCustomNode,
maxRetriesPerNode: this.deps.maxRetriesPerNode,
branchPersistence: this.deps.branchPersistence,
branchSemaphore: this.deps.branchSemaphore,
stepInstancePersistence: this.deps.stepInstancePersistence,
onReworkReset: this.deps.onReworkReset,
parseStepsDeps: this.deps.parseStepsDeps,
runCode: this.deps.runCode,
// Step-inversion (KTD-11, U10): worktree isolation + parallel scheduling.
allocateInstanceWorktree: this.deps.allocateInstanceWorktree,
resolveIntegrationBase: this.deps.resolveIntegrationBase,
integrationGitOps: this.deps.integrationGitOps,
integrationProjection: this.deps.integrationProjection,
semaphoreAvailability: this.deps.semaphoreAvailability,
resumeReconcile: this.deps.resumeReconcile,
logTaskEntry: this.deps.logTaskEntry,
// Single source of truth (KTD-6): prefer the caller-threaded run id so the
// executor's persistence deps probe/flip rows under the SAME id; fall back
// to the canonical derivation when unthreaded.
runId: this.deps.runId ?? `${task.id}:${definition.id}`,
onBranchProgress: (progress) => {
this.branchProgress.set(progress.branchId, progress);
try {
this.deps.onBranchProgress?.(progress);
} catch {
// Progress reporting must never affect the run.
}
},
});
const result = await executor.run(task, settings, definition.ir);
if (!result.executed) {

View File

@@ -1,9 +1,9 @@
import { WorkflowIrError } from "@fusion/core";
import type { TaskDetail, WorkflowIrNode } from "@fusion/core";
import { WorkflowIrError, getStepParser } from "@fusion/core";
import type { TaskDetail, TaskStep, 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,114 @@ 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>;
/**
* Step-inversion (KTD-4, U5): review the foreach-active step. Only invoked for
* `step-review` nodes inside a foreach template, where `context["foreach:active"]`
* carries the active instance. The seam calls `reviewStep` (reviewer.ts) under
* `semaphore.runNested` against the instance's step + the task's PROMPT content
* (the same way `fn_review_step` does), and — on an authoritative (non-advisory)
* APPROVE — marks the step `done` through the projection (`updateStep(source:"graph")`,
* KTD-7). It persists the verdict back into the active context so the foreach
* sub-walk can write it into the instance row (KTD-6). It returns the raw verdict;
* the {@link createStepReviewHandler} handler maps it to the outcome value the
* `outcome:approve|revise|rethink|unavailable` edges route on. Optional — a
* workflow without a step-review node needs no implementation.
*
* @param advisory when true (the node is inside a `split` branch — single-writer
* rule, KTD-4) the seam must NOT write the projection and only logs an audit
* note; the verdict is advisory and never routes the authoritative instance.
*/
stepReview?: (
task: TaskDetail,
context: Record<string, unknown>,
config: StepReviewConfig,
) => Promise<StepReviewSeamResult>;
}
/** Config a `step-review` node carries (KTD-4). */
export interface StepReviewConfig {
type: "plan" | "code";
model?: string;
/** Single-writer rule (KTD-4): true when the node is inside a split branch, so
* the review is advisory-only — no projection write, no authoritative verdict. */
advisory?: boolean;
}
/** Verdict surface the step-review seam returns (mirrors reviewer.ts ReviewResult). */
export interface StepReviewSeamResult {
verdict: "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
review?: string;
summary?: string;
}
/** 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";
/**
* Reserved context marker set by the split sub-walk (`runSplitJoin`) for the
* duration of its branches' execution and cleared at the join (KTD-4, U5). A
* `step-review` node that reads this as `true` is running inside a split branch,
* so its verdict is **advisory-only** (single-writer rule): it never writes the
* projection nor authors the routing verdict. `step-execute` is validator-forbidden
* in splits, so only step-review needs to consult this.
*/
export const SPLIT_ACTIVE_CONTEXT_KEY = "split:active";
/**
* Reserved context marker (KTD-11) the worktree-isolation foreach seeds into an
* instance's context for ONE re-run after an integration-conflict, when the
* template authored an explicit `outcome:integration-conflict` edge. Author nodes
* read it to branch on the conflict; the sub-walk clears it after seeding so a
* later clean rework does not re-surface a stale conflict signal.
*/
export const INTEGRATION_CONFLICT_CONTEXT_KEY = "integration:conflict";
/** Shape of the value stored under {@link FOREACH_ACTIVE_CONTEXT_KEY}. */
export interface ForeachActiveContext {
foreachNodeId: string;
stepIndex: number;
instanceId: string;
baselineSha?: string;
checkpointId?: string;
/** Latest authoritative step-review verdict for this instance (KTD-4/KTD-6, U5).
* Written by the step-review handler (non-advisory only); the foreach sub-walk
* persists it into the instance row. */
verdict?: "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
/**
* True when the foreach template contains a `step-review` node (U6/KTD-4), so a
* successful `step-execute` must NOT mark the step done — the review's APPROVE
* verdict is the single authority that does (`markDoneOnSuccess: false`). The
* foreach sub-walk sets this at instance entry; the step-execute seam reads it.
*/
deferDoneToReview?: boolean;
/**
* Worktree-isolation (KTD-11, U10): the instance's OWN worktree path, branched
* off the integration base. Set by the foreach sub-walk at instance entry under
* `isolation: "worktree"`; the step-execute / step-review / RETHINK seams run
* against THIS path instead of the task's main worktree. Absent under
* `isolation: "shared"` (work lands directly in the main worktree). The file-scope
* guard still fires for anything the instance session commits in this worktree
* (the session machinery is unchanged — see executor stepExecute seam).
*/
worktreePath?: string;
/** Worktree-isolation (KTD-11, U10): the instance's OWN branch name (e.g.
* `fusion/<task>-step-<i>`). Set with {@link worktreePath}; the ordered
* integration stage lands this branch onto the task's main branch. */
branchName?: string;
}
/**
@@ -31,7 +139,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 +162,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 +225,321 @@ export function createGateHandler(runCustomNode?: WorkflowCustomNodeRunner): Wor
};
}
/** Per-step-review-node cap on UNAVAILABLE retries before routing the
* `outcome:unavailable` edge (KTD-4 — mirrors the in-session
* `planSpecUnavailableCounts` limiter posture, executor.ts ~7297). */
const STEP_REVIEW_UNAVAILABLE_RETRY_CAP = 2;
/** Resolve a step-review node's config (KTD-4). Defaults `type` to `code` (the
* enforcing review level — matches the legacy code-review authority). */
function resolveStepReviewConfig(node: WorkflowIrNode, advisory: boolean): StepReviewConfig {
const raw = (node.config ?? {}) as { type?: unknown; model?: unknown };
const type = raw.type === "plan" ? "plan" : "code";
const model = typeof raw.model === "string" ? raw.model : undefined;
return { type, model, advisory };
}
/**
* Handler for the `step-review` node kind (KTD-4, U5). Resolves the active
* foreach instance from {@link FOREACH_ACTIVE_CONTEXT_KEY}, detects the
* single-writer/advisory posture from {@link SPLIT_ACTIVE_CONTEXT_KEY}, delegates
* the actual review to `seams.stepReview` (which calls `reviewStep` under the
* semaphore and — on an authoritative APPROVE — marks the step done through the
* projection), and maps the verdict to the outcome value the
* `outcome:approve|revise|rethink|unavailable` edges route on:
*
* - APPROVE → `value: "approve"` (seam already marked the step done)
* - REVISE → `value: "revise"` (rework edge, no reset — revise in place)
* - RETHINK → `value: "rethink"` (rework edge whose traversal resets, U5 foreach)
* - UNAVAILABLE → bounded retry (cap {@link STEP_REVIEW_UNAVAILABLE_RETRY_CAP});
* still unavailable → `value: "unavailable"`
*
* The verdict + reworkCount are persisted via the foreach sub-walk: the handler
* writes the latest verdict back onto the active context so the sub-walk's
* `saveInstanceState` carries it into the instance row (KTD-6).
*/
export function createStepReviewHandler(seams: WorkflowLegacySeams): 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`,
);
}
if (!seams.stepReview) {
// Fail closed: a step-review node with no seam wired must NOT silently pass
// — that would let an unreviewed step route forward (mirrors step-execute).
return { outcome: "failure", value: "step-review-unwired" };
}
const advisory = ctx.context[SPLIT_ACTIVE_CONTEXT_KEY] === true;
const config = resolveStepReviewConfig(node, advisory);
// UNAVAILABLE bounded retry (KTD-4): re-invoke the reviewer up to the cap,
// mirroring the in-session planSpecUnavailableCounts limiter. A usable verdict
// short-circuits; exhaustion routes outcome:unavailable.
let result: StepReviewSeamResult = { verdict: "UNAVAILABLE" };
for (let attempt = 0; attempt <= STEP_REVIEW_UNAVAILABLE_RETRY_CAP; attempt++) {
result = await seams.stepReview(ctx.task, ctx.context, config);
if (result.verdict !== "UNAVAILABLE") break;
}
// Persist the verdict onto the active context so the foreach sub-walk writes
// it into the instance row (KTD-6). Advisory (split-branch) reviews record the
// verdict for audit but never become the authoritative instance verdict.
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
* artifacts (mirrors core's IMPLICIT_DEFAULT_ARTIFACT). */
export const PARSE_STEPS_DEFAULT_ARTIFACT = "PROMPT.md";
/**
* Engine-side dependencies the `parse-steps` handler needs (U12, KTD-12). All
* injected so the handler stays unit-testable with fakes and the graph layer
* stays engine-agnostic. The production wiring (executor.ts) reads the artifact
* through the task-documents machinery (falling back to the task's PROMPT
* content for the default `PROMPT.md` artifact), writes the parsed step list
* through the graph-source projection (`updateTask({ steps })`), and reports
* whether the foreach pin is already established (KTD-3 pin protection).
*/
export interface ParseStepsHandlerDeps {
/**
* Read an artifact's text content for a task. Resolves `undefined` when the
* artifact does not exist (the handler maps that to `parse-error`). The
* executor wires this to the task-documents read path with a PROMPT.md
* fallback to the task's own PROMPT content.
*/
readArtifact: (task: TaskDetail, key: string) => Promise<string | undefined>;
/**
* Write the canonical parsed step list through the projection sink (the single
* graph-side step-list writer, KTD-12). All statuses are `pending`;
* `dependsOn` is preserved. The executor wires this to
* `store.updateTask(taskId, { steps })`.
*/
writeSteps: (task: TaskDetail, steps: TaskStep[]) => Promise<void>;
/**
* Pin-protection probe (KTD-3): resolves true when a foreach has already
* expanded for this task+run — either persisted instance rows exist OR a
* foreach expanded earlier in this walk. Re-parsing after expansion is illegal
* (it would silently desynchronize the pinned instance set), so the handler
* fails with an audited `pin-mismatch` outcome. Optional — absent means no
* pin established (always safe to parse).
*/
hasExpandedForeach?: (task: TaskDetail) => Promise<boolean> | boolean;
/** Optional audit sink: called with a stable reason code on every routable
* failure outcome (`parse-error`, `pin-mismatch`) so the run audit records it.
* Never throws into the handler. */
audit?: (reason: string, detail: string) => void;
}
/**
* Handler for the `parse-steps` node kind (U12, KTD-12). Reads the declared
* artifact, resolves the parser from the core registry, runs it, and writes the
* step list through the projection — the ONLY graph-side step-list writer.
*
* Outcomes:
* - unknown parser → `outcome:failure value:"parse-error"` (audited)
* - missing artifact → `outcome:failure value:"parse-error"` (audited)
* - parser throws → `outcome:failure value:"parse-error"` (audited, never crashes)
* - clean empty parse → `outcome:success value:"no-steps"` (routable; defaults to success)
* - foreach already expanded → `outcome:failure value:"pin-mismatch"` (audited, KTD-3)
* - steps parsed → `outcome:success` (steps written through projection)
*/
export function createParseStepsHandler(deps: ParseStepsHandlerDeps): 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 cfg = (node.config ?? {}) as { artifact?: unknown; parser?: unknown };
const parserId = typeof cfg.parser === "string" ? cfg.parser : "";
const artifactKey =
typeof cfg.artifact === "string" && cfg.artifact.trim() !== ""
? cfg.artifact
: PARSE_STEPS_DEFAULT_ARTIFACT;
// Pin protection (KTD-3): re-parsing after a foreach has expanded is illegal.
try {
if (deps.hasExpandedForeach && (await deps.hasExpandedForeach(ctx.task))) {
audit(
"pin-mismatch",
`parse-steps node '${node.id}' reached after a foreach already expanded for task ${ctx.task.id}`,
);
return { outcome: "failure", value: "pin-mismatch" };
}
} catch (err) {
// A pin-probe failure must fail closed (never silently re-parse).
const message = err instanceof Error ? err.message : String(err);
audit("pin-mismatch", `parse-steps node '${node.id}' pin probe failed: ${message}`);
return { outcome: "failure", value: "pin-mismatch" };
}
// Resolve the parser from the registry (built-ins + plugin parsers, KTD-12).
const parser = getStepParser(parserId);
if (!parser) {
audit(
"parse-error",
`parse-steps node '${node.id}' references unknown parser '${parserId}'`,
);
return { outcome: "failure", value: "parse-error" };
}
// Read the artifact content.
let content: string | undefined;
try {
content = await deps.readArtifact(ctx.task, artifactKey);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
audit(
"parse-error",
`parse-steps node '${node.id}' artifact '${artifactKey}' read failed: ${message}`,
);
return { outcome: "failure", value: "parse-error" };
}
if (content === undefined) {
audit(
"parse-error",
`parse-steps node '${node.id}' artifact '${artifactKey}' not found for task ${ctx.task.id}`,
);
return { outcome: "failure", value: "parse-error" };
}
// Run the parser; a throw (malformed artifact) maps to parse-error.
let parsedSteps;
try {
parsedSteps = parser.parse(content).steps;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
audit(
"parse-error",
`parse-steps node '${node.id}' parser '${parserId}' threw: ${message}`,
);
return { outcome: "failure", value: "parse-error" };
}
// Clean empty parse → routable no-steps outcome (defaults to success).
if (parsedSteps.length === 0) {
// Still write the (empty) projection so a re-parse is idempotent and the
// foreach reads a definitive zero-step list.
try {
await deps.writeSteps(ctx.task, []);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
audit(
"parse-error",
`parse-steps node '${node.id}' failed to write empty step list: ${message}`,
);
return { outcome: "failure", value: "parse-error" };
}
return { outcome: "success", value: "no-steps" };
}
// Project the parsed steps onto the task step list — all pending, dependsOn
// preserved. This is the single graph-side step-list write (KTD-12).
const steps: TaskStep[] = parsedSteps.map((s) => {
const step: TaskStep = { name: s.name, status: "pending" };
if (s.dependsOn && s.dependsOn.length > 0) step.dependsOn = s.dependsOn;
return step;
});
try {
await deps.writeSteps(ctx.task, steps);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
audit(
"parse-error",
`parse-steps node '${node.id}' failed to write ${steps.length} steps: ${message}`,
);
return { outcome: "failure", value: "parse-error" };
}
return { outcome: "success" };
};
}
// ── code node (U14, KTD-15) ─────────────────────────────────────────────────
/**
* Runs a `code` node's source against the harness contract (U14, KTD-15) and
* returns the result mapped to graph behavior. Injected so the handler stays
* engine-agnostic; the production wiring (executor.ts) drives the esbuild
* compile + child-process runner in code-node-runner.ts, assembling the ctx
* (task subset, walk context, declared artifacts, `foreach:active` instance) and
* routing the returned `{ outcome, value, contextPatch, customFields }`.
*/
export type CodeNodeRunner = (
node: WorkflowIrNode,
task: TaskDetail,
context: Record<string, unknown>,
) => Promise<WorkflowNodeResult>;
/**
* Handler for the `code` node kind (U14, KTD-15). Delegates to the injected
* runner. Fail-closed: a code node with no runner wired must NOT silently
* succeed (it would route an unverified path forward) — it fails with an audited
* value, mirroring the step-execute/step-review unwired posture.
*/
export function createCodeNodeHandler(runCode?: CodeNodeRunner): WorkflowNodeHandler {
return async (node, ctx) => {
if (!runCode) {
return { outcome: "failure", value: "code-node-unwired" };
}
return runCode(node, ctx.task, ctx.context);
};
}
export interface DefaultNodeHandlerDeps {
/** 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. */
runCode?: CodeNodeRunner;
}
export function createDefaultNodeHandlers(
seams: WorkflowLegacySeams,
runCustomNode?: WorkflowCustomNodeRunner,
): Record<"prompt" | "script" | "gate", WorkflowNodeHandler> {
deps?: DefaultNodeHandlerDeps,
): Record<
"prompt" | "script" | "gate" | "step-review" | "parse-steps" | "code",
WorkflowNodeHandler
> {
const promptLike = 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
? createParseStepsHandler(deps.parseSteps)
: async () => ({ outcome: "failure", value: "parse-steps-unwired" });
return {
prompt: promptLike,
script: promptLike,
gate: createGateHandler(runCustomNode),
"step-review": createStepReviewHandler(seams),
"parse-steps": parseSteps,
code: createCodeNodeHandler(deps?.runCode),
};
}

View File

@@ -33,6 +33,17 @@ export function canonicalFusionBranchName(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
/**
* Canonical per-instance branch name for a worktree-isolated foreach step
* (step-inversion KTD-11, U10): `fusion/<task>-step-<i>`. Deterministic from the
* task id + 0-based step index so crash-resume can reconstruct the branch name
* (and probe its existence) without persisting it separately — though the
* instance row also carries `branchName` for the integration/reconcile path.
*/
export function canonicalStepInstanceBranchName(taskId: string, stepIndex: number): string {
return `${canonicalFusionBranchName(taskId)}-step-${stepIndex}`;
}
export function resolveTaskWorkingBranch(task: Pick<Task, "id" | "branch" | "branchContext">): string {
if (task.branchContext?.assignmentMode === "shared") {
return canonicalFusionBranchName(task.id);

View File

@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, rmSync, realpathSync } from "node:fs";
import { basename, join, relative, resolve, isAbsolute } from "node:path";
import type { Column, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
import { worktreePoolLog } from "./logger.js";
import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js";
@@ -943,7 +943,7 @@ export async function reapOrphanWorktrees(
}
/** Columns where merger/finalization owns branch lifecycle. */
const MERGER_MANAGED_COLUMNS: ReadonlySet<Column> = new Set(["in-review", "done"]);
const MERGER_MANAGED_COLUMNS: ReadonlySet<ColumnId> = new Set<ColumnId>(["in-review", "done"]);
/**
* Return local `fusion/*` branches not associated with any active task.