Merge main into fast-tests: keep both CONCEPTS additions (workflow columns/plugins + merge-gate testing vocabulary)
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
274
packages/engine/src/__tests__/code-node.test.ts
Normal file
274
packages/engine/src/__tests__/code-node.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
457
packages/engine/src/__tests__/hold-release.test.ts
Normal file
457
packages/engine/src/__tests__/hold-release.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
630
packages/engine/src/__tests__/merge-trait.test.ts
Normal file
630
packages/engine/src/__tests__/merge-trait.test.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
648
packages/engine/src/__tests__/plugin-traits.test.ts
Normal file
648
packages/engine/src/__tests__/plugin-traits.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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) ?? {}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
397
packages/engine/src/__tests__/step-runner.test.ts
Normal file
397
packages/engine/src/__tests__/step-runner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
578
packages/engine/src/__tests__/stepwise-workflow-parity.test.ts
Normal file
578
packages/engine/src/__tests__/stepwise-workflow-parity.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
237
packages/engine/src/__tests__/workflow-foreach-wiring.test.ts
Normal file
237
packages/engine/src/__tests__/workflow-foreach-wiring.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
} } });
|
||||
|
||||
360
packages/engine/src/__tests__/workflow-graph-fanout.test.ts
Normal file
360
packages/engine/src/__tests__/workflow-graph-fanout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
573
packages/engine/src/__tests__/workflow-graph-foreach.test.ts
Normal file
573
packages/engine/src/__tests__/workflow-graph-foreach.test.ts
Normal 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" };
|
||||
};
|
||||
}
|
||||
153
packages/engine/src/__tests__/workflow-graph-step-rerun.test.ts
Normal file
153
packages/engine/src/__tests__/workflow-graph-step-rerun.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
235
packages/engine/src/__tests__/workflow-parse-steps.test.ts
Normal file
235
packages/engine/src/__tests__/workflow-parse-steps.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
637
packages/engine/src/__tests__/workflow-step-parallel.test.ts
Normal file
637
packages/engine/src/__tests__/workflow-step-parallel.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
263
packages/engine/src/__tests__/workflow-step-review.test.ts
Normal file
263
packages/engine/src/__tests__/workflow-step-review.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
555
packages/engine/src/code-node-runner.ts
Normal file
555
packages/engine/src/code-node-runner.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
552
packages/engine/src/hold-release.ts
Normal file
552
packages/engine/src/hold-release.ts
Normal 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" };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
261
packages/engine/src/merge-trait.ts
Normal file
261
packages/engine/src/merge-trait.ts
Normal 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();
|
||||
@@ -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(
|
||||
|
||||
157
packages/engine/src/plugin-parser-adapter.ts
Normal file
157
packages/engine/src/plugin-parser-adapter.ts
Normal 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;
|
||||
}
|
||||
@@ -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})`);
|
||||
|
||||
276
packages/engine/src/plugin-trait-adapter.ts
Normal file
276
packages/engine/src/plugin-trait-adapter.ts
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
273
packages/engine/src/step-integration.ts
Normal file
273
packages/engine/src/step-integration.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
391
packages/engine/src/step-runner.ts
Normal file
391
packages/engine/src/step-runner.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
364
packages/engine/src/workflow-graph-branches.ts
Normal file
364
packages/engine/src/workflow-graph-branches.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1070
packages/engine/src/workflow-graph-foreach.ts
Normal file
1070
packages/engine/src/workflow-graph-foreach.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user