merge: integrate origin/main into feature/workflow-steps (FN-7039)
Resolve conflicts from 56 upstream commits: - db.ts: renumber my migrations around main's new migration 130 (columnDwellMs) — enable-id normalization 130→131, drop-table 131→132, SCHEMA_VERSION→132. - index.ts / routes.ts: take main's new MCP exports/imports; keep WORKFLOW_STEP_TEMPLATES array removed (kept the WorkflowStepTemplate type). - merger.ts: keep the legacy post-merge execution path removed (U7c) over main's version. - ci-workflow.test.ts (from main): add port-4040/process-supervisor allowlist markers to the gate-script assertions that reference the checker filenames (pre-existing self-match). Gate green (engine-core 299, ci-shape 62); boot smoke PASS; migration tests + typecheck clean.
This commit is contained in:
@@ -216,6 +216,48 @@ describe("agent skills flow - full integration", () => {
|
||||
expect(disabledWarnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("flow with slash and namespaced requested names resolves through shared override", async () => {
|
||||
const projectRootDir = createMockProjectDir({});
|
||||
const mockAgent: Agent = {
|
||||
id: "agent-001",
|
||||
name: "Slash Skill Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: { skills: ["review/pr", "source::skills/gamma/SKILL.md"] },
|
||||
} as unknown as Agent;
|
||||
const mockAgentStore = {
|
||||
getAgent: vi.fn().mockResolvedValue(mockAgent),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const sessionResult = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: { assignedAgentId: "agent-001" },
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir,
|
||||
});
|
||||
|
||||
expect(sessionResult.skillSource).toBe("assigned-agent");
|
||||
expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["review/pr", "gamma/SKILL.md"]);
|
||||
|
||||
const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!);
|
||||
const override = createSkillsOverrideFromSelection(resolvedSkills, {
|
||||
requestedSkillNames: sessionResult.skillSelectionContext?.requestedSkillNames,
|
||||
sessionPurpose: sessionResult.skillSelectionContext?.sessionPurpose,
|
||||
});
|
||||
|
||||
const result = override({
|
||||
skills: [
|
||||
{ name: "pr", filePath: "skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "gamma", filePath: "skills/gamma/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "lint", filePath: "skills/lint/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(result.skills.map((skill) => skill.name)).toEqual(["pr", "gamma"]);
|
||||
expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false);
|
||||
});
|
||||
|
||||
it("flow with role fallback when assigned agent has no skills", async () => {
|
||||
// Step 1: Set up mock filesystem
|
||||
const projectRootDir = createMockProjectDir({
|
||||
|
||||
@@ -183,6 +183,34 @@ describe("CronRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requests coding tools and forwards the allowed tool list", async () => {
|
||||
let capturedOptions: any;
|
||||
piModuleMocks.createFnAgent.mockImplementation(async (options: any) => {
|
||||
capturedOptions = options;
|
||||
return { session: { dispose: vi.fn() } };
|
||||
});
|
||||
|
||||
const executor = await createAiPromptExecutor("/test/project");
|
||||
await executor("Summarize this", "anthropic", "claude-sonnet-4-5", ["Read", "Grep"]);
|
||||
|
||||
expect(capturedOptions.tools).toBe("coding");
|
||||
expect(capturedOptions.toolsAllowlist).toEqual(["Read", "Grep"]);
|
||||
});
|
||||
|
||||
it("leaves toolsAllowlist undefined when the step omits allowedTools", async () => {
|
||||
let capturedOptions: any;
|
||||
piModuleMocks.createFnAgent.mockImplementation(async (options: any) => {
|
||||
capturedOptions = options;
|
||||
return { session: { dispose: vi.fn() } };
|
||||
});
|
||||
|
||||
const executor = await createAiPromptExecutor("/test/project");
|
||||
await executor("Summarize this");
|
||||
|
||||
expect(capturedOptions.tools).toBe("coding");
|
||||
expect(capturedOptions.toolsAllowlist).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns response text even when session disposal throws", async () => {
|
||||
piModuleMocks.createFnAgent.mockImplementation(async (options: { onText?: (delta: string) => void }) => {
|
||||
options.onText?.("hello ");
|
||||
@@ -834,6 +862,7 @@ describe("CronRunner", () => {
|
||||
"Analyze the codebase",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-5",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1004,6 +1033,29 @@ describe("CronRunner", () => {
|
||||
expect(result.stepResults![0].output).toContain("Analysis complete: 3 findings");
|
||||
});
|
||||
|
||||
it("passes step allowedTools to executor", async () => {
|
||||
const store = createMockStore();
|
||||
const mockExecutor = createAiMockExecutor("response");
|
||||
const schedule = createMockSchedule({
|
||||
command: "",
|
||||
steps: [
|
||||
makeStep({
|
||||
type: "ai-prompt",
|
||||
name: "Step with tools",
|
||||
prompt: "Use selected tools",
|
||||
allowedTools: ["Read", "Grep"],
|
||||
command: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const automationStore = createMockAutomationStore([schedule]);
|
||||
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(mockExecutor).toHaveBeenCalledWith("Use selected tools", "anthropic", "claude-sonnet-4-5", ["Read", "Grep"]);
|
||||
});
|
||||
|
||||
it("passes step model provider and model ID to executor", async () => {
|
||||
const store = createMockStore();
|
||||
const mockExecutor = createAiMockExecutor("response");
|
||||
@@ -1025,7 +1077,7 @@ describe("CronRunner", () => {
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(mockExecutor).toHaveBeenCalledWith("Do something", "openai", "gpt-4o");
|
||||
expect(mockExecutor).toHaveBeenCalledWith("Do something", "openai", "gpt-4o", undefined);
|
||||
});
|
||||
|
||||
it("falls back to settings defaults when step has no model", async () => {
|
||||
@@ -1050,7 +1102,7 @@ describe("CronRunner", () => {
|
||||
|
||||
await runner.executeSchedule(schedule);
|
||||
|
||||
expect(mockExecutor).toHaveBeenCalledWith("Use defaults", "anthropic", "claude-sonnet-4-5");
|
||||
expect(mockExecutor).toHaveBeenCalledWith("Use defaults", "anthropic", "claude-sonnet-4-5", undefined);
|
||||
});
|
||||
|
||||
it("returns configuration error when no executor is provided", async () => {
|
||||
|
||||
@@ -79,10 +79,8 @@ vi.mock("../agent-session-helpers.js", async () => {
|
||||
settings: Record<string, unknown> | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
) => {
|
||||
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
|
||||
const slash = model.indexOf("/");
|
||||
if (slash > 0 && slash < model.length - 1) {
|
||||
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
|
||||
if (settings?.testMode === true || (typeof settings?.defaultProvider === "string" && settings.defaultProvider.trim().toLowerCase() === "mock")) {
|
||||
return { provider: "mock", modelId: "scripted" };
|
||||
}
|
||||
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
|
||||
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
|
||||
@@ -97,6 +95,11 @@ vi.mock("../agent-session-helpers.js", async () => {
|
||||
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
|
||||
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
|
||||
}
|
||||
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
|
||||
const slash = model.indexOf("/");
|
||||
if (slash > 0 && slash < model.length - 1) {
|
||||
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
|
||||
}
|
||||
return { provider: undefined, modelId: undefined };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./executor-test-helpers.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import {
|
||||
createMockStore,
|
||||
mockedCreateFnAgent,
|
||||
mockedExecSync,
|
||||
resetExecutorMocks,
|
||||
} from "./executor-test-helpers.js";
|
||||
|
||||
type CapturedSession = {
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
};
|
||||
|
||||
function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: CapturedSession } {
|
||||
const holder: { last?: CapturedSession } = {};
|
||||
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
||||
holder.last = {
|
||||
defaultProvider: opts.defaultProvider,
|
||||
defaultModelId: opts.defaultModelId,
|
||||
};
|
||||
|
||||
const listeners: Array<(event: any) => void> = [];
|
||||
const session: any = {
|
||||
state: {},
|
||||
subscribe: (fn: (event: any) => void) => {
|
||||
listeners.push(fn);
|
||||
return () => {};
|
||||
},
|
||||
prompt: vi.fn(async () => {
|
||||
for (const fn of listeners) {
|
||||
fn({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
partial: output,
|
||||
contentIndex: 0,
|
||||
delta: output,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session };
|
||||
});
|
||||
return holder;
|
||||
}
|
||||
|
||||
function quietGit() {
|
||||
mockedExecSync.mockImplementation(() => Buffer.from(""));
|
||||
}
|
||||
|
||||
function makeExecutor(store: ReturnType<typeof createMockStore>) {
|
||||
const agentStore = { getAgent: vi.fn().mockResolvedValue(null), createAgent: vi.fn() };
|
||||
return new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
|
||||
}
|
||||
|
||||
function baseTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "FN-MODEL-1",
|
||||
title: "Model resolution",
|
||||
description: "verify model resolution",
|
||||
column: "in-progress" as const,
|
||||
worktree: "/tmp/wt",
|
||||
branch: "fusion/fn-model-1",
|
||||
baseCommitSha: "abc123",
|
||||
dependencies: [],
|
||||
steps: [{ name: "s", status: "in-progress" as const }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workflowStep(overrides: Record<string, unknown> = {}) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: "step:model",
|
||||
name: "Model Step",
|
||||
description: "",
|
||||
mode: "prompt" as const,
|
||||
phase: "pre-merge" as const,
|
||||
gateMode: "advisory" as const,
|
||||
prompt: "Check the model.",
|
||||
toolMode: "readonly" as const,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function runStepWithSettings(
|
||||
settings: Record<string, unknown>,
|
||||
options: {
|
||||
task?: Record<string, unknown>;
|
||||
step?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue(settings);
|
||||
const executor = makeExecutor(store);
|
||||
const captured = captureSession();
|
||||
|
||||
await (executor as any).executeWorkflowStep(
|
||||
baseTask(options.task),
|
||||
workflowStep(options.step),
|
||||
"/tmp/wt",
|
||||
settings,
|
||||
undefined,
|
||||
);
|
||||
|
||||
return captured.last;
|
||||
}
|
||||
|
||||
describe("executor workflow-step model resolution", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
quietGit();
|
||||
});
|
||||
|
||||
it("uses the project execution lane instead of the global default when the step has no override", async () => {
|
||||
const captured = await runStepWithSettings({
|
||||
executionProvider: "openai",
|
||||
executionModelId: "gpt-4o",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-3-5-sonnet",
|
||||
});
|
||||
|
||||
expect(captured).toMatchObject({
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
});
|
||||
expect(captured).not.toMatchObject({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-3-5-sonnet",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps step and task overrides ahead of execution-lane settings", async () => {
|
||||
await expect(
|
||||
runStepWithSettings(
|
||||
{
|
||||
executionProvider: "project-exec-provider",
|
||||
executionModelId: "project-exec-model",
|
||||
},
|
||||
{
|
||||
step: {
|
||||
modelProvider: "step-provider",
|
||||
modelId: "step-model",
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
defaultProvider: "step-provider",
|
||||
defaultModelId: "step-model",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runStepWithSettings(
|
||||
{
|
||||
executionProvider: "project-exec-provider",
|
||||
executionModelId: "project-exec-model",
|
||||
},
|
||||
{
|
||||
task: {
|
||||
modelProvider: "task-provider",
|
||||
modelId: "task-model",
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
defaultProvider: "task-provider",
|
||||
defaultModelId: "task-model",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through the execution hierarchy without mixing partial pairs", async () => {
|
||||
await expect(
|
||||
runStepWithSettings({
|
||||
executionProvider: "partial-project-provider",
|
||||
executionGlobalProvider: "global-exec-provider",
|
||||
executionGlobalModelId: "global-exec-model",
|
||||
defaultProvider: "global-default-provider",
|
||||
defaultModelId: "global-default-model",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
defaultProvider: "global-exec-provider",
|
||||
defaultModelId: "global-exec-model",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runStepWithSettings({
|
||||
executionGlobalModelId: "partial-global-model",
|
||||
defaultProviderOverride: "project-default-provider",
|
||||
defaultModelIdOverride: "project-default-model",
|
||||
defaultProvider: "global-default-provider",
|
||||
defaultModelId: "global-default-model",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
defaultProvider: "project-default-provider",
|
||||
defaultModelId: "project-default-model",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runStepWithSettings({
|
||||
defaultProvider: "global-default-provider",
|
||||
defaultModelId: "global-default-model",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
defaultProvider: "global-default-provider",
|
||||
defaultModelId: "global-default-model",
|
||||
});
|
||||
});
|
||||
|
||||
it("forces mock/scripted for workflow steps when test mode is active", async () => {
|
||||
await expect(
|
||||
runStepWithSettings({
|
||||
testMode: true,
|
||||
executionProvider: "project-exec-provider",
|
||||
executionModelId: "project-exec-model",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-3-5-sonnet",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
defaultProvider: "mock",
|
||||
defaultModelId: "scripted",
|
||||
});
|
||||
});
|
||||
});
|
||||
95
packages/engine/src/__tests__/mcp-lane-forwarding.test.ts
Normal file
95
packages/engine/src/__tests__/mcp-lane-forwarding.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { ResolvedMcpServerDefinition } from "@fusion/core";
|
||||
|
||||
const { createFnAgentMock } = vi.hoisted(() => ({
|
||||
createFnAgentMock: vi.fn(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn(async () => undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../pi.js", () => ({
|
||||
createFnAgent: createFnAgentMock,
|
||||
promptWithFallback: vi.fn(async () => undefined),
|
||||
describeModel: vi.fn(() => "mock-model"),
|
||||
}));
|
||||
|
||||
import { createResolvedAgentSession } from "../agent-session-helpers.js";
|
||||
|
||||
const mcpServers: ResolvedMcpServerDefinition[] = [
|
||||
{
|
||||
name: "docs",
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: { MCP_TOKEN: "materialized-secret" },
|
||||
},
|
||||
];
|
||||
|
||||
async function createLaneSession(sessionPurpose: "executor" | "reviewer" | "validation" | "merger") {
|
||||
return createResolvedAgentSession({
|
||||
sessionPurpose,
|
||||
cwd: "/tmp/fusion-test-worktree",
|
||||
systemPrompt: `You are the ${sessionPurpose} lane`,
|
||||
tools: "readonly",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4",
|
||||
mcpServers,
|
||||
});
|
||||
}
|
||||
|
||||
describe("MCP lane forwarding", () => {
|
||||
beforeEach(() => {
|
||||
createFnAgentMock.mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["executor"],
|
||||
["reviewer"],
|
||||
["validation"],
|
||||
["merger"],
|
||||
] as const)("forwards materialized MCP servers through the shared %s lane runtime seam", async (sessionPurpose) => {
|
||||
await createLaneSession(sessionPurpose);
|
||||
|
||||
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mcpServers,
|
||||
systemPrompt: `You are the ${sessionPurpose} lane`,
|
||||
}));
|
||||
});
|
||||
|
||||
it("passes mcpServers through the shared helper seam used by workflow-node and summarization callers", async () => {
|
||||
await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
cwd: "/tmp/fusion-test-worktree",
|
||||
systemPrompt: "Workflow model node and summarization lanes share this helper.",
|
||||
tools: "readonly",
|
||||
defaultProvider: "anthropic",
|
||||
mcpServers,
|
||||
runtimeContext: { lane: "workflow-node+summarization" },
|
||||
});
|
||||
|
||||
expect(createFnAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(createFnAgentMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mcpServers,
|
||||
runtimeContext: expect.objectContaining({ lane: "workflow-node+summarization" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not send mock-provider sessions through the pi createFnAgent seam", async () => {
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
cwd: "/tmp/fusion-test-worktree",
|
||||
systemPrompt: "Mock providers are MCP-incapable.",
|
||||
tools: "readonly",
|
||||
defaultProvider: "mock",
|
||||
defaultModelId: "scripted",
|
||||
mcpServers,
|
||||
});
|
||||
|
||||
expect(createFnAgentMock).not.toHaveBeenCalled();
|
||||
expect(result.runtimeId).toBe("mock");
|
||||
});
|
||||
});
|
||||
104
packages/engine/src/__tests__/mcp-resolution.test.ts
Normal file
104
packages/engine/src/__tests__/mcp-resolution.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { McpSecretReader } from "@fusion/core";
|
||||
import { resolveMcpServersForRuntime } from "../mcp-resolution.js";
|
||||
|
||||
function secrets(values: Record<string, string>): McpSecretReader {
|
||||
return {
|
||||
async revealSecret(id) {
|
||||
const plaintextValue = values[id];
|
||||
if (plaintextValue === undefined) throw new Error(`missing ${id}`);
|
||||
return { key: id, plaintextValue };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveMcpServersForRuntime", () => {
|
||||
it("resolves effective settings and materializes secret references", async () => {
|
||||
const result = await resolveMcpServersForRuntime({
|
||||
globalSettings: {
|
||||
mcpServers: {
|
||||
enabled: true,
|
||||
servers: [
|
||||
{ name: "global", transport: "stdio", command: "node", args: ["server.js"], env: { API_KEY: { secretRef: "global-key", scope: "global" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
projectSettings: null,
|
||||
secrets: secrets({ "global-key": "SECRET_VALUE" }),
|
||||
reader: { agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.servers).toEqual([
|
||||
{ name: "global", transport: "stdio", command: "node", args: ["server.js"], env: { API_KEY: "SECRET_VALUE" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes disabled servers and lets project definitions override global definitions", async () => {
|
||||
const result = await resolveMcpServersForRuntime({
|
||||
globalSettings: {
|
||||
mcpServers: {
|
||||
enabled: true,
|
||||
servers: [
|
||||
{ name: "override", transport: "stdio", command: "old" },
|
||||
{ name: "removed", transport: "stdio", command: "remove-me" },
|
||||
],
|
||||
},
|
||||
},
|
||||
projectSettings: {
|
||||
mcpServers: {
|
||||
enabled: true,
|
||||
servers: [
|
||||
{ name: "override", transport: "sse", url: "https://mcp.example/sse", headers: { Authorization: { secretRef: "auth", scope: "project" } } },
|
||||
{ name: "removed", enabled: false, transport: "stdio", command: "noop" },
|
||||
],
|
||||
},
|
||||
},
|
||||
secrets: secrets({ auth: "Bearer SECRET" }),
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.servers).toEqual([
|
||||
{ name: "override", transport: "sse", url: "https://mcp.example/sse", headers: { Authorization: "Bearer SECRET" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves through the TaskStore-compatible settings split seam", async () => {
|
||||
const { resolveMcpServersForStore } = await import("../mcp-resolution.js");
|
||||
const result = await resolveMcpServersForStore({
|
||||
async getSettingsByScope() {
|
||||
return {
|
||||
global: { mcpServers: { enabled: true, servers: [{ name: "store", transport: "stdio", command: "node" }] } },
|
||||
project: { mcpServers: { enabled: true, servers: [] } },
|
||||
};
|
||||
},
|
||||
async getSecretsStore() {
|
||||
return secrets({});
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
servers: [{ name: "store", transport: "stdio", command: "node" }],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns materialization errors without leaking through logs", async () => {
|
||||
const result = await resolveMcpServersForRuntime({
|
||||
globalSettings: {
|
||||
mcpServers: {
|
||||
enabled: true,
|
||||
servers: [
|
||||
{ name: "broken", transport: "streamable-http", url: "https://mcp.example", headers: { Authorization: { secretRef: "missing", scope: "project" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
projectSettings: null,
|
||||
secrets: secrets({}),
|
||||
});
|
||||
|
||||
expect(result.servers).toEqual([{ name: "broken", transport: "streamable-http", url: "https://mcp.example" }]);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]?.serverName).toBe("broken");
|
||||
});
|
||||
});
|
||||
44
packages/engine/src/__tests__/mcp-runtime-support.test.ts
Normal file
44
packages/engine/src/__tests__/mcp-runtime-support.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { logMcpForwardingSkipped, runtimeSupportsMcp } from "../mcp-runtime-support.js";
|
||||
|
||||
describe("runtimeSupportsMcp", () => {
|
||||
it("allows the default pi runtime for non-mock providers", () => {
|
||||
expect(runtimeSupportsMcp("pi", "anthropic")).toBe(true);
|
||||
expect(runtimeSupportsMcp("default-pi", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows Claude/ACP runtime identifiers", () => {
|
||||
expect(runtimeSupportsMcp("claude-code", "anthropic")).toBe(true);
|
||||
expect(runtimeSupportsMcp("vendor-acp-runtime", "anthropic")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects mock provider even on an otherwise supported runtime", () => {
|
||||
expect(runtimeSupportsMcp("pi", "mock")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown, undefined, or empty runtime identifiers", () => {
|
||||
expect(runtimeSupportsMcp(undefined, "anthropic")).toBe(false);
|
||||
expect(runtimeSupportsMcp("", "anthropic")).toBe(false);
|
||||
expect(runtimeSupportsMcp("paperclip", "anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logMcpForwardingSkipped", () => {
|
||||
it("logs only metadata and server count", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
logMcpForwardingSkipped({ runtimeId: "mock", provider: "mock", skippedCount: 2, lane: "executor" });
|
||||
const output = String(spy.mock.calls[0]?.[0] ?? "");
|
||||
spy.mockRestore();
|
||||
expect(output).toContain("mcp.forwarding.skipped");
|
||||
expect(output).toContain("skippedCount");
|
||||
expect(output).not.toContain("command");
|
||||
expect(output).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("does not log when no servers were skipped", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
logMcpForwardingSkipped({ runtimeId: "mock", provider: "mock", skippedCount: 0 });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
69
packages/engine/src/__tests__/mcp-validation-service.test.ts
Normal file
69
packages/engine/src/__tests__/mcp-validation-service.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedMcpServerDefinition } from "@fusion/core";
|
||||
import { validateMcpServer } from "../mcp-validation-service.js";
|
||||
|
||||
describe("mcp-validation-service", () => {
|
||||
it("uses an injected stdio probe for stdio servers", async () => {
|
||||
const server: ResolvedMcpServerDefinition = {
|
||||
name: "local",
|
||||
transport: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: { TOKEN: "secret-value" },
|
||||
};
|
||||
const stdioProbe = vi.fn(async () => ({ status: "valid" as const, message: "ok" }));
|
||||
|
||||
await expect(validateMcpServer(server, { stdioProbe, timeoutMs: 25 })).resolves.toEqual({ status: "valid", message: "ok" });
|
||||
expect(stdioProbe).toHaveBeenCalledWith(server, { timeoutMs: 25, cwd: undefined });
|
||||
});
|
||||
|
||||
it("treats reachable SSE responses below 500 as valid", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 401 }));
|
||||
const server: ResolvedMcpServerDefinition = {
|
||||
name: "events",
|
||||
transport: "sse",
|
||||
url: "https://example.test/sse",
|
||||
headers: { Authorization: "Bearer secret-value" },
|
||||
};
|
||||
|
||||
const result = await validateMcpServer(server, { fetchImpl, timeoutMs: 25 });
|
||||
|
||||
expect(result).toEqual({ status: "valid", message: "server responded with HTTP 401" });
|
||||
expect(fetchImpl).toHaveBeenCalledWith("https://example.test/sse", expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer secret-value" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns error for streamable HTTP 5xx responses", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 503 }));
|
||||
const server: ResolvedMcpServerDefinition = {
|
||||
name: "http",
|
||||
transport: "streamable-http",
|
||||
url: "https://example.test/mcp",
|
||||
};
|
||||
|
||||
await expect(validateMcpServer(server, { fetchImpl, timeoutMs: 25 })).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "server responded with HTTP 503",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unreachable for fetch failures without echoing headers", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
});
|
||||
const server: ResolvedMcpServerDefinition = {
|
||||
name: "http",
|
||||
transport: "streamable-http",
|
||||
url: "https://example.test/mcp",
|
||||
headers: { Authorization: "super-secret" },
|
||||
};
|
||||
|
||||
const result = await validateMcpServer(server, { fetchImpl, timeoutMs: 25 });
|
||||
|
||||
expect(result.status).toBe("unreachable");
|
||||
expect(result.message).toBe("ECONNREFUSED");
|
||||
expect(JSON.stringify(result)).not.toContain("super-secret");
|
||||
});
|
||||
});
|
||||
86
packages/engine/src/__tests__/merger-ai-prompts.test.ts
Normal file
86
packages/engine/src/__tests__/merger-ai-prompts.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
REVIEW_VERDICT_MARKER,
|
||||
buildMergeSystemPrompt,
|
||||
buildReviewSystemPrompt,
|
||||
parseReviewVerdict,
|
||||
} from "../merger-ai.js";
|
||||
|
||||
describe("merger-ai prompt/verdict re-exports", () => {
|
||||
it("fails safe to a blocking reject for empty reviewer output", () => {
|
||||
expect(parseReviewVerdict("")).toEqual({
|
||||
verdict: "reject",
|
||||
reasons: ["reviewer produced no output"],
|
||||
severity: "blocking",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails safe to a blocking reject for garbled reviewer output", () => {
|
||||
expect(parseReviewVerdict("looks good, ship it")).toEqual({
|
||||
verdict: "reject",
|
||||
reasons: [
|
||||
`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`,
|
||||
],
|
||||
severity: "blocking",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a reject without explicit severity as blocking", () => {
|
||||
expect(
|
||||
parseReviewVerdict(
|
||||
`${REVIEW_VERDICT_MARKER} reject\n- dropped a conflict hunk`
|
||||
)
|
||||
).toEqual({
|
||||
verdict: "reject",
|
||||
reasons: ["dropped a conflict hunk"],
|
||||
severity: "blocking",
|
||||
});
|
||||
});
|
||||
|
||||
it("honors explicit advisory severity and excludes severity from reasons", () => {
|
||||
expect(
|
||||
parseReviewVerdict(
|
||||
`${REVIEW_VERDICT_MARKER} reject\nSEVERITY: advisory\n- commit message is vague`
|
||||
)
|
||||
).toEqual({
|
||||
verdict: "reject",
|
||||
reasons: ["commit message is vague"],
|
||||
severity: "advisory",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the approve line", () => {
|
||||
expect(
|
||||
parseReviewVerdict(`All reviewed.\n${REVIEW_VERDICT_MARKER} approve`)
|
||||
).toEqual({
|
||||
verdict: "approve",
|
||||
reasons: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts inline and bulleted reject reasons", () => {
|
||||
expect(
|
||||
parseReviewVerdict(
|
||||
`${REVIEW_VERDICT_MARKER} reject: lost generated types\nSEVERITY: blocking\n1. dropped api.ts\n- skipped docs update`
|
||||
)
|
||||
).toEqual({
|
||||
verdict: "reject",
|
||||
reasons: [
|
||||
"lost generated types",
|
||||
"dropped api.ts",
|
||||
"skipped docs update",
|
||||
],
|
||||
severity: "blocking",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-negotiable clean-room and verdict-marker prompt content", () => {
|
||||
expect(buildMergeSystemPrompt()).toContain("## AI merge — clean room");
|
||||
expect(buildMergeSystemPrompt()).toContain(
|
||||
"Finish with exactly ONE new commit"
|
||||
);
|
||||
expect(buildReviewSystemPrompt()).toContain(REVIEW_VERDICT_MARKER);
|
||||
expect(buildReviewSystemPrompt()).toContain("Do NOT edit, stage, commit");
|
||||
});
|
||||
});
|
||||
810
packages/engine/src/__tests__/notifier.runtime.test.ts
Normal file
810
packages/engine/src/__tests__/notifier.runtime.test.ts
Normal file
@@ -0,0 +1,810 @@
|
||||
/*
|
||||
FNXC:EngineTests 2026-06-25-17:44:
|
||||
Notifier runtime suite split extracts the later NtfyNotifier reconfiguration, error, deduplication, runtime wiring, URL, stop, edge-case, and event-filtering describe blocks from notifier.test.ts so both sibling suites stay under MAX_LINES without weakening assertions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import { NtfyNotifier, notifyFallbackUsed } from "../notifier.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js";
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
schedulerLog: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("NtfyNotifier runtime behaviors", () => {
|
||||
let store: MockTaskStore;
|
||||
let notifier: NtfyNotifier;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new MockTaskStore();
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (notifier) {
|
||||
notifier.stop();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially disabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Enable at runtime
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
store.setSettings({ ntfyEnabled: true });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops sending notifications when disabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable at runtime
|
||||
store.setSettings({ ntfyEnabled: false });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
||||
});
|
||||
|
||||
it("uses updated topic when changed at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
||||
|
||||
// Change topic
|
||||
store.setSettings({ ntfyTopic: "new-topic" });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("catches and logs fetch errors without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles HTTP error responses without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplication", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("prevents duplicate notifications for the same event type", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// Multiple in-review events for the same task
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification due to deduplication
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-approval notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-004", "Approval Task", "awaiting-approval");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Plan needs approval for FN-004",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-user-review notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "User Review Task", "awaiting-user-review");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "User review needed for FN-005",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows different event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: merged notification (different event type - should be allowed)
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications now
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows awaiting-approval alongside other event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "Approval + Failure");
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "awaiting-approval" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "failed" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends notification only once on merge when task:moved and task:merged both fire", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// completeTask() emits task:moved to done before task:merged
|
||||
store.triggerTaskMoved(task, "in-review", "done");
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task FN-001 merged",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" has been merged to main',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate task:merged events for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// Multiple merged events for the same task
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => {
|
||||
const sharedService = new NotificationService(store, { projectId: "proj-1" });
|
||||
await sharedService.start();
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-777", "Single Merge Notification");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-777",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Title: "Task FN-777 merged",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await sharedService.stop();
|
||||
});
|
||||
|
||||
it("dispatches and deduplicates fallback-used notifications", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows notifications for different tasks independently", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task1 = createTask("FN-001", "Test Task 1");
|
||||
const task2 = createTask("FN-002", "Test Task 2");
|
||||
|
||||
store.triggerTaskMoved(task1, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Different tasks should each get their own notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard runtime wiring", () => {
|
||||
/**
|
||||
* These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts
|
||||
* where the NtfyNotifier is constructed with an optional projectId resolved
|
||||
* from the central project registry. When a registered project is found,
|
||||
* deep links include ?project=...&task=...; when no project is registered
|
||||
* (legacy / single-project mode), links fall back to ?task=... only.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("produces project-aware deep links when constructed with registered project ID", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id });
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_abc123" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?project=proj_abc123&task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("produces task-only deep links when no project ID is available (legacy mode)", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store); // no projectId
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Verify no "project=" in the URL
|
||||
const callArgs = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
expect(callArgs.headers["Click"]).not.toContain("project=");
|
||||
});
|
||||
|
||||
it("produces project-aware deep links for all notification event types", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_xyz" });
|
||||
await notifier.start();
|
||||
|
||||
// in-review event
|
||||
store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// merged event
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Task A"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Verify both calls include project
|
||||
const calls = fetchMock.mock.calls;
|
||||
for (const call of calls) {
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers["Click"]).toContain("project=proj_xyz");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom base URL", () => {
|
||||
it("uses custom ntfy base URL when provided in notifier options", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://my-ntfy.example.com/test-topic",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ntfyBaseUrl from settings when configured", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example///",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.internal.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies updated ntfyBaseUrl from settings changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object));
|
||||
|
||||
store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" });
|
||||
store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"https://ntfy.changed.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop()", () => {
|
||||
it("stops listening to events after stop() is called", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
notifier.stop();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should not increase after stop
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("allows in-review and failed notifications for the same task", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: failed notification (different event type - should be allowed)
|
||||
const failedTask = { ...task, status: "failed" };
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not notify on task:moved to columns other than in-review", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Move to todo - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to in-progress - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to done - should not notify (merged notification comes from task:merged)
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task", "in-progress");
|
||||
store.triggerTaskUpdated(task);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty topic gracefully", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Empty topic should be treated as no topic
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event filtering", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send merged notification when 'merged' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Test Task"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send failed notification when 'failed' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const failedTask = createTask("FN-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends notification for enabled events while others are disabled", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// in-review - should send
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// merged - should NOT send
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// failed - should NOT send
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-approval - should NOT send
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-user-review - should NOT send
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
});
|
||||
|
||||
it("defaults to all events when ntfyEvents is undefined", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("updates notifications when ntfyEvents changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially all events enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable in-review
|
||||
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review
|
||||
|
||||
// Enable in-review again
|
||||
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review
|
||||
});
|
||||
});
|
||||
});
|
||||
71
packages/engine/src/__tests__/notifier.test-harness.ts
Normal file
71
packages/engine/src/__tests__/notifier.test-harness.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { expect, vi } from "vitest";
|
||||
import type { Task, Column, MergeResult, Settings } from "@fusion/core";
|
||||
|
||||
/*
|
||||
FNXC:EngineTests 2026-06-25-17:44:
|
||||
Shared notifier test harness for the FN-7035 suite split. MockTaskStore, createTask, and flushAsyncWork stay in one helper so notifier.test.ts and notifier.runtime.test.ts can split whole describe blocks under the line-count cap without duplicating event-store behavior.
|
||||
*/
|
||||
|
||||
interface MockTaskStoreEvents {
|
||||
"task:moved": [{ task: Task; from: Column; to: Column }];
|
||||
"task:updated": [Task];
|
||||
"task:merged": [MergeResult];
|
||||
"settings:updated": [{ settings: Settings; previous: Settings }];
|
||||
}
|
||||
|
||||
export async function flushAsyncWork(): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
export class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||
private settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
failureNotificationMode: "all",
|
||||
failureNotificationDelayMs: 0,
|
||||
};
|
||||
|
||||
getSettings(): Settings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
setSettings(settings: Partial<Settings>): void {
|
||||
const previous = { ...this.settings };
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
this.emit("settings:updated", { settings: this.settings, previous });
|
||||
}
|
||||
|
||||
triggerTaskMoved(task: Task, from: Column, to: Column): void {
|
||||
this.emit("task:moved", { task, from, to });
|
||||
}
|
||||
|
||||
triggerTaskUpdated(task: Task): void {
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
triggerTaskMerged(result: MergeResult): void {
|
||||
this.emit("task:merged", result);
|
||||
}
|
||||
}
|
||||
|
||||
export const createTask = (id: string, title?: string, status?: string): Task => ({
|
||||
id,
|
||||
title,
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
log: [],
|
||||
});
|
||||
@@ -1,73 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task, Column, MergeResult, Settings } from "@fusion/core";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import {
|
||||
NtfyNotifier,
|
||||
DEFAULT_NTFY_EVENTS,
|
||||
buildNtfyClickUrl,
|
||||
isNtfyEventEnabled,
|
||||
resolveNtfyEvents,
|
||||
notifyFallbackUsed,
|
||||
sendNtfyNotificationWithResult,
|
||||
} from "../notifier.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
||||
import { MockTaskStore, createTask, flushAsyncWork } from "./notifier.test-harness.js";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("../logger.js", () => ({
|
||||
schedulerLog: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
interface MockTaskStoreEvents {
|
||||
"task:moved": [{ task: Task; from: Column; to: Column }];
|
||||
"task:updated": [Task];
|
||||
"task:merged": [MergeResult];
|
||||
"settings:updated": [{ settings: Settings; previous: Settings }];
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||
private settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
failureNotificationMode: "all",
|
||||
failureNotificationDelayMs: 0,
|
||||
};
|
||||
|
||||
getSettings(): Settings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
setSettings(settings: Partial<Settings>): void {
|
||||
const previous = { ...this.settings };
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
this.emit("settings:updated", { settings: this.settings, previous });
|
||||
}
|
||||
|
||||
// Helper to trigger events
|
||||
triggerTaskMoved(task: Task, from: Column, to: Column): void {
|
||||
this.emit("task:moved", { task, from, to });
|
||||
}
|
||||
|
||||
triggerTaskUpdated(task: Task): void {
|
||||
this.emit("task:updated", task);
|
||||
}
|
||||
|
||||
triggerTaskMerged(result: MergeResult): void {
|
||||
this.emit("task:merged", result);
|
||||
}
|
||||
}
|
||||
|
||||
describe("Ntfy notifier helpers", () => {
|
||||
it("includes mailbox message events in default events", () => {
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
|
||||
@@ -589,20 +536,6 @@ describe("NtfyNotifier", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createTask = (id: string, title?: string, status?: string): Task => ({
|
||||
id,
|
||||
title,
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
log: [],
|
||||
});
|
||||
|
||||
describe("when disabled", () => {
|
||||
it("does not send any notifications when ntfyEnabled is false", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "my-topic" });
|
||||
@@ -1325,780 +1258,4 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially disabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Enable at runtime
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
store.setSettings({ ntfyEnabled: true });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops sending notifications when disabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable at runtime
|
||||
store.setSettings({ ntfyEnabled: false });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
||||
});
|
||||
|
||||
it("uses updated topic when changed at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
||||
|
||||
// Change topic
|
||||
store.setSettings({ ntfyTopic: "new-topic" });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("catches and logs fetch errors without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles HTTP error responses without throwing", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplication", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("prevents duplicate notifications for the same event type", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// Multiple in-review events for the same task
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification due to deduplication
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-approval notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-004", "Approval Task", "awaiting-approval");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Plan needs approval for FN-004",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-user-review notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "User Review Task", "awaiting-user-review");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "User review needed for FN-005",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows different event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: merged notification (different event type - should be allowed)
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications now
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows awaiting-approval alongside other event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "Approval + Failure");
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "awaiting-approval" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.triggerTaskUpdated({ ...task, status: "failed" });
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends notification only once on merge when task:moved and task:merged both fire", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// completeTask() emits task:moved to done before task:merged
|
||||
store.triggerTaskMoved(task, "in-review", "done");
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task FN-001 merged",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" has been merged to main',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate task:merged events for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// Multiple merged events for the same task
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits a single merged notification when notifier shares the same already-started NotificationService (ProjectEngine wiring)", async () => {
|
||||
const sharedService = new NotificationService(store, { projectId: "proj-1" });
|
||||
await sharedService.start();
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj-1" }, sharedService);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-777", "Single Merge Notification");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "fusion/fn-777",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Title: "Task FN-777 merged",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await sharedService.stop();
|
||||
});
|
||||
|
||||
it("dispatches and deduplicates fallback-used notifications", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await notifyFallbackUsed({
|
||||
primaryModel: "anthropic/claude-sonnet-4-5",
|
||||
fallbackModel: "openai/gpt-4o",
|
||||
triggerPoint: "session-creation",
|
||||
taskId: "FN-900",
|
||||
taskTitle: "Fallback task",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("switched from anthropic/claude-sonnet-4-5 to openai/gpt-4o"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows notifications for different tasks independently", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task1 = createTask("FN-001", "Test Task 1");
|
||||
const task2 = createTask("FN-002", "Test Task 2");
|
||||
|
||||
store.triggerTaskMoved(task1, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
// Different tasks should each get their own notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard runtime wiring", () => {
|
||||
/**
|
||||
* These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts
|
||||
* where the NtfyNotifier is constructed with an optional projectId resolved
|
||||
* from the central project registry. When a registered project is found,
|
||||
* deep links include ?project=...&task=...; when no project is registered
|
||||
* (legacy / single-project mode), links fall back to ?task=... only.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("produces project-aware deep links when constructed with registered project ID", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id });
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_abc123" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?project=proj_abc123&task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("produces task-only deep links when no project ID is available (legacy mode)", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
|
||||
// Simulates: const notifier = new NtfyNotifier(store); // no projectId
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-001",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Verify no "project=" in the URL
|
||||
const callArgs = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
expect(callArgs.headers["Click"]).not.toContain("project=");
|
||||
});
|
||||
|
||||
it("produces project-aware deep links for all notification event types", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_xyz" });
|
||||
await notifier.start();
|
||||
|
||||
// in-review event
|
||||
store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// merged event
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Task A"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Verify both calls include project
|
||||
const calls = fetchMock.mock.calls;
|
||||
for (const call of calls) {
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers["Click"]).toContain("project=proj_xyz");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom base URL", () => {
|
||||
it("uses custom ntfy base URL when provided in notifier options", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://my-ntfy.example.com/test-topic",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ntfyBaseUrl from settings when configured", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example///",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.internal.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies updated ntfyBaseUrl from settings changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object));
|
||||
|
||||
store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" });
|
||||
store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"https://ntfy.changed.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop()", () => {
|
||||
it("stops listening to events after stop() is called", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
notifier.stop();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should not increase after stop
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("allows in-review and failed notifications for the same task", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: failed notification (different event type - should be allowed)
|
||||
const failedTask = { ...task, status: "failed" };
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not notify on task:moved to columns other than in-review", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Move to todo - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to in-progress - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to done - should not notify (merged notification comes from task:merged)
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify on task:updated when status is neither failed nor awaiting-approval", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-001", "Test Task", "in-progress");
|
||||
store.triggerTaskUpdated(task);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty topic gracefully", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
// Empty topic should be treated as no topic
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event filtering", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("does not send in-review notification when 'in-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send merged notification when 'merged' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Test Task"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send failed notification when 'failed' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const failedTask = createTask("FN-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-approval notification when 'awaiting-approval' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-006", "Approval Task", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends notification for enabled events while others are disabled", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// in-review - should send
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// merged - should NOT send
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// failed - should NOT send
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-approval - should NOT send
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-user-review - should NOT send
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
});
|
||||
|
||||
it("defaults to all events when ntfyEvents is undefined", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: undefined });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-002", "Test Task 2"),
|
||||
branch: "fusion/fn-002",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const failedTask = createTask("FN-003", "Test Task 3", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const awaitingApprovalTask = createTask("FN-004", "Test Task 4", "awaiting-approval");
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("updates notifications when ntfyEvents changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
// Initially all events enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable in-review
|
||||
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review
|
||||
|
||||
// Enable in-review again
|
||||
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // New call for in-review
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1603,6 +1603,50 @@ describe("createFnAgent", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("filters coding tools with a case-insensitive toolsAllowlist", async () => {
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
toolsAllowlist: [" Read ", "GREP"],
|
||||
});
|
||||
|
||||
const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }>; tools?: string[] };
|
||||
expect(createSessionArgs.customTools.map((tool) => tool.name).sort()).toEqual(["grep", "read"]);
|
||||
expect(createSessionArgs.tools).toEqual(["GREP", "Read", "grep", "read"]);
|
||||
});
|
||||
|
||||
it("keeps all coding tools when toolsAllowlist is undefined", async () => {
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
});
|
||||
|
||||
const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }>; tools?: string[] };
|
||||
expect(createSessionArgs.customTools.map((tool) => tool.name).sort()).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]);
|
||||
expect(createSessionArgs.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes no coding tools when toolsAllowlist is empty", async () => {
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
toolsAllowlist: [],
|
||||
});
|
||||
|
||||
const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }>; tools?: string[] };
|
||||
expect(createSessionArgs.customTools).toEqual([]);
|
||||
expect(createSessionArgs.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps caller customTools in coding sessions", async () => {
|
||||
createCodingToolsMock.mockReturnValueOnce([{ name: "read" }, { name: "write" }] as any);
|
||||
const customTool = {
|
||||
|
||||
@@ -714,6 +714,71 @@ describe("session failure diagnostics", () => {
|
||||
expect(sessionWithThinking.setThinkingLevel).toHaveBeenCalledWith("xhigh");
|
||||
});
|
||||
|
||||
it("forwards materialized MCP servers into session creation and prompt options for supported providers", async () => {
|
||||
const createAgentSessionMock = vi.mocked(createAgentSession);
|
||||
const session = {
|
||||
model: { provider: "test", id: "primary-model" },
|
||||
prompt: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
sessionFile: undefined,
|
||||
} as unknown as AgentSession;
|
||||
const mcpServers = [
|
||||
{ name: "docs", transport: "stdio" as const, command: "node", args: ["server.js"], env: { API_KEY: "SECRET" } },
|
||||
];
|
||||
|
||||
createAgentSessionMock.mockReset();
|
||||
createAgentSessionMock.mockResolvedValueOnce({ session } as any);
|
||||
|
||||
const created = await createFnAgent({
|
||||
cwd: "/test/project",
|
||||
systemPrompt: "Test MCP forwarding",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "primary-model",
|
||||
mcpServers,
|
||||
});
|
||||
await (created.session as any).promptWithFallback("Use docs");
|
||||
|
||||
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ mcpServers }));
|
||||
expect(session.prompt).toHaveBeenCalledWith("Use docs", expect.objectContaining({ mcpServers }));
|
||||
});
|
||||
|
||||
it("skips MCP forwarding for unsupported mock provider and emits a content-free skip log", async () => {
|
||||
const createAgentSessionMock = vi.mocked(createAgentSession);
|
||||
const session = {
|
||||
model: { provider: "test", id: "primary-model" },
|
||||
prompt: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
sessionFile: undefined,
|
||||
} as unknown as AgentSession;
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
createAgentSessionMock.mockReset();
|
||||
createAgentSessionMock.mockResolvedValueOnce({ session } as any);
|
||||
|
||||
try {
|
||||
const created = await createFnAgent({
|
||||
cwd: "/test/project",
|
||||
systemPrompt: "Test MCP skip",
|
||||
defaultProvider: "mock",
|
||||
defaultModelId: "scripted",
|
||||
mcpServers: [{ name: "docs", transport: "stdio", command: "node", env: { TOKEN: "SECRET" } }],
|
||||
});
|
||||
await (created.session as any).promptWithFallback("Use docs");
|
||||
|
||||
expect(createAgentSessionMock.mock.calls[0]?.[0]).not.toHaveProperty("mcpServers");
|
||||
expect(session.prompt).toHaveBeenCalledWith("Use docs");
|
||||
const skipLog = consoleErrorSpy.mock.calls.find(([message]) => String(message).includes("mcp.forwarding.skipped"));
|
||||
expect(skipLog?.[0]).toContain('"skippedCount":1');
|
||||
expect(skipLog?.[0]).toContain('"provider":"mock"');
|
||||
expect(skipLog?.[0]).not.toContain("SECRET");
|
||||
expect(skipLog?.[0]).not.toContain("docs");
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries prompt on thinking/reasoning conflict without switching fallback models", async () => {
|
||||
const createAgentSessionMock = vi.mocked(createAgentSession);
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ function createMockHeartbeatMonitor(): HeartbeatMonitor {
|
||||
|
||||
function createRoutineRunner(options?: Partial<RoutineRunnerOptions>): RoutineRunner {
|
||||
return new RoutineRunner({
|
||||
...options,
|
||||
routineStore: options?.routineStore ?? createMockRoutineStore(),
|
||||
heartbeatMonitor: options?.heartbeatMonitor ?? createMockHeartbeatMonitor(),
|
||||
rootDir: options?.rootDir ?? "/test/root",
|
||||
@@ -266,6 +267,33 @@ describe("RoutineRunner", () => {
|
||||
expect(runner.isRoutineRunning("routine-cleanup")).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards ai-prompt allowedTools and live callbacks to the AI executor", async () => {
|
||||
const routine = createMockRoutine({
|
||||
id: "routine-ai",
|
||||
agentId: undefined,
|
||||
steps: [
|
||||
{
|
||||
id: "step-ai",
|
||||
type: "ai-prompt",
|
||||
name: "Analyze",
|
||||
prompt: "Analyze this",
|
||||
allowedTools: ["Read", "Grep"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
const aiPromptExecutor = vi.fn().mockResolvedValue("ai output");
|
||||
const liveCallbacks = { onText: vi.fn(), onStep: vi.fn() };
|
||||
const runner = createRoutineRunner({ routineStore, aiPromptExecutor });
|
||||
|
||||
const result = await runner.executeRoutine("routine-ai", "api", undefined, liveCallbacks);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(aiPromptExecutor).toHaveBeenCalledWith("Analyze this", undefined, undefined, ["Read", "Grep"], liveCallbacks);
|
||||
expect(liveCallbacks.onStep).toHaveBeenCalledWith(expect.objectContaining({ stepId: "step-ai", status: "started" }));
|
||||
expect(liveCallbacks.onStep).toHaveBeenCalledWith(expect.objectContaining({ stepId: "step-ai", status: "completed", success: true }));
|
||||
});
|
||||
|
||||
it("cleans up inFlightExecutions map even on error", async () => {
|
||||
const routine = createMockRoutine({ id: "routine-error-cleanup", enabled: false });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeTransitionRejection, TransitionRejectionError, type Task, type TaskStore } from "@fusion/core";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
@@ -42,6 +42,7 @@ function storeWith(tasks: Task[], settings: Record<string, unknown> = {}): TaskS
|
||||
experimentalFeatures: { workflowColumns: false },
|
||||
...settings,
|
||||
})),
|
||||
updateSettings: vi.fn(async (patch: Record<string, unknown>) => ({ ...settings, ...patch })),
|
||||
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
|
||||
const current = byId.get(id);
|
||||
if (current) Object.assign(current, patch);
|
||||
@@ -73,6 +74,32 @@ describe("Scheduler workflow cutover", () => {
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nBody");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("writes the engine active heartbeat at most once per poll interval and skips while paused", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-25T00:00:00.000Z"));
|
||||
const store = storeWith([], { pollIntervalMs: 15_000 });
|
||||
const scheduler = new Scheduler(store, { onSchedule: vi.fn() });
|
||||
(scheduler as unknown as { running: boolean }).running = true;
|
||||
|
||||
await scheduler.schedule();
|
||||
await scheduler.schedule();
|
||||
expect(store.updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ engineLastActiveAt: "2026-06-25T00:00:00.000Z" });
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-25T00:00:15.000Z"));
|
||||
await scheduler.schedule();
|
||||
expect(store.updateSettings).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.mocked(store.getSettings).mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15_000, enginePaused: true } as any);
|
||||
vi.setSystemTime(new Date("2026-06-25T00:00:30.000Z"));
|
||||
await scheduler.schedule();
|
||||
expect(store.updateSettings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the workflow sweep for todo pickup even when stale workflowColumns=false is persisted", async () => {
|
||||
const ready = task({ id: "FN-100" });
|
||||
const store = storeWith([ready]);
|
||||
|
||||
@@ -180,6 +180,7 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
|
||||
archiveTaskAndCleanup: vi.fn().mockResolvedValue({} as Task),
|
||||
walCheckpoint: vi.fn().mockReturnValue({ busy: 0, log: 5, checkpointed: 5 }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
reconcileActiveTimingForEngineDowntime: vi.fn().mockResolvedValue({ shiftedTaskIds: [], downtimeMs: 0 }),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null),
|
||||
createTask: vi.fn().mockResolvedValue({ id: "FN-RESCUE", lineageId: "lin-rescue" }),
|
||||
@@ -820,6 +821,7 @@ describe("SelfHealingManager", () => {
|
||||
const surfaceInReviewStalled = vi.spyOn(manager, "surfaceInReviewStalled").mockResolvedValue(1);
|
||||
const surfaceStalePausedReviews = vi.spyOn(manager, "surfaceStalePausedReviews").mockResolvedValue(1);
|
||||
const surfaceStalePausedTodos = vi.spyOn(manager, "surfaceStalePausedTodos").mockResolvedValue(1);
|
||||
const reconcileEngineDowntimeActiveTiming = vi.spyOn(manager, "reconcileEngineDowntimeActiveTiming").mockResolvedValue({ shiftedTaskIds: [], downtimeMs: 0 });
|
||||
|
||||
await manager.runStartupRecovery();
|
||||
|
||||
@@ -833,6 +835,7 @@ describe("SelfHealingManager", () => {
|
||||
expect(recoverOrphanedAgents).toHaveBeenCalledTimes(1);
|
||||
expect(recoverAgentsRunningOnInactiveTasks).toHaveBeenCalledTimes(1);
|
||||
expect(clearStaleBlockedBy).toHaveBeenCalledTimes(1);
|
||||
expect(reconcileEngineDowntimeActiveTiming).toHaveBeenCalledTimes(1);
|
||||
expect(surfaceInReviewStalls).toHaveBeenCalledTimes(1);
|
||||
expect(surfaceInReviewStalled).toHaveBeenCalledTimes(1);
|
||||
expect(surfaceStalePausedReviews).toHaveBeenCalledTimes(1);
|
||||
@@ -855,6 +858,26 @@ describe("SelfHealingManager", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("Auto-recovered (FN-5488): cleared stale blockedBy"));
|
||||
});
|
||||
|
||||
it("runStartupRecovery emits engine downtime timing audit metadata", async () => {
|
||||
const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
|
||||
store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
|
||||
reconcileActiveTimingForEngineDowntime: vi.fn().mockResolvedValue({ shiftedTaskIds: ["FN-7011"], downtimeMs: 3_600_000 }),
|
||||
recordRunAuditEvent,
|
||||
});
|
||||
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
await manager.reconcileEngineDowntimeActiveTiming();
|
||||
|
||||
expect(store.reconcileActiveTimingForEngineDowntime).toHaveBeenCalledTimes(1);
|
||||
expect(recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
domain: "database",
|
||||
mutationType: "task:reconcile-engine-downtime-active-timing",
|
||||
target: "global",
|
||||
metadata: expect.objectContaining({ shiftedTaskIds: ["FN-7011"], downtimeMs: 3_600_000 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("runStartupRecovery skips while enginePaused is active", async () => {
|
||||
vi.mocked(store.getSettings).mockResolvedValue({
|
||||
globalPause: false,
|
||||
|
||||
@@ -605,6 +605,107 @@ describe("createSkillsOverrideFromSelection", () => {
|
||||
expect(result.diagnostics[0].message).toContain("CustomSkill");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["single token", "pr"],
|
||||
["slash form", "review/pr"],
|
||||
["skill file path form", "review/pr/SKILL.md"],
|
||||
["catalog id form", "source::skills/review/pr/SKILL.md"],
|
||||
])("loads requested skills by %s", (_label, requestedName) => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(),
|
||||
excludedSkillPaths: new Set<string>(),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
requestedSkillNames: [requestedName],
|
||||
sessionPurpose: "test",
|
||||
});
|
||||
|
||||
const result = override({
|
||||
skills: [
|
||||
{ name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "gamma", filePath: "/skills/gamma/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(result.skills.map((skill) => skill.name)).toEqual(["pr"]);
|
||||
expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false);
|
||||
});
|
||||
|
||||
it("emits the requested-skill-not-found diagnostic for genuinely absent requested names", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(),
|
||||
excludedSkillPaths: new Set<string>(),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
requestedSkillNames: ["review/absent"],
|
||||
sessionPurpose: "chat",
|
||||
});
|
||||
|
||||
const result = override({
|
||||
skills: [
|
||||
{ name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(result.skills).toHaveLength(0);
|
||||
expect(result.diagnostics).toHaveLength(1);
|
||||
expect(result.diagnostics[0].type).toBe("info");
|
||||
expect(result.diagnostics[0].message).toContain("Requested skill 'review/absent' not found");
|
||||
});
|
||||
|
||||
it("does not force-load requested skills disabled by execution settings", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(),
|
||||
excludedSkillPaths: new Set<string>(["/skills/review/pr/SKILL.md"]),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
requestedSkillNames: ["review/pr"],
|
||||
sessionPurpose: "chat",
|
||||
});
|
||||
|
||||
const result = override({
|
||||
skills: [
|
||||
{ name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(result.skills).toHaveLength(0);
|
||||
expect(result.diagnostics).toHaveLength(1);
|
||||
expect(result.diagnostics[0].type).toBe("warning");
|
||||
expect(result.diagnostics[0].message).toContain("disabled by project execution settings");
|
||||
expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves no-requested-names inactive filtering behavior unchanged", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(),
|
||||
excludedSkillPaths: new Set<string>(),
|
||||
diagnostics: [],
|
||||
filterActive: false,
|
||||
};
|
||||
const base = {
|
||||
skills: [
|
||||
{ name: "pr", filePath: "/skills/review/pr/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "gamma", filePath: "/skills/gamma/SKILL.md", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
expect(createSkillsOverrideFromSelection(selection)(base)).toBe(base);
|
||||
});
|
||||
|
||||
it("preserves base diagnostics alongside new diagnostics", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(["/path/foo"]),
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* FNXC:EngineProcessRules 2026-06-26-03:45:
|
||||
* User-configured command paths must stay on bounded async execution APIs; this focused registry intentionally excludes git-plumbing execSync call sites so deterministic repository checks can keep using synchronous child-process APIs where appropriate.
|
||||
*
|
||||
* Protected registry for user-configured commands:
|
||||
* - packages/engine/src/verification-utils.ts :: execWithProcessGroup — delegates command execution through the sandbox backend streaming API; caller-owned options must carry bounds.
|
||||
* - packages/engine/src/verification-utils.ts :: runVerificationCommand — runs configured test/build commands through execWithProcessGroup with timeout and VERIFICATION_COMMAND_MAX_BUFFER.
|
||||
* - packages/engine/src/run-verification-tool.ts :: runVerificationCommand — backs fn_run_verification with superviseSpawn and maxLifetimeMs.
|
||||
* - packages/engine/src/executor.ts :: runConfiguredCommand — runs settings.scripts, settings.setupScript, settings.worktreeInitCommand, and workflow script commands through backend.run with timeoutMs and maxBuffer.
|
||||
* - packages/engine/src/merger.ts :: runConfiguredMergeWorktreeCommand — runs configured merge-worktree commands through backend.run with timeoutMs and maxBuffer.
|
||||
* - packages/engine/src/merger.ts :: executePostMergeScriptStep — runs post-merge settings.scripts entries through backend.run with timeoutMs and maxBuffer.
|
||||
* - packages/engine/src/routine-runner.ts :: executeCommand — runs configured automation/routine commands through backend.run with timeoutMs and maxBuffer.
|
||||
* - packages/engine/src/sandbox/native.ts :: NativeSandboxBackend.run — default sandbox backend uses superviseSpawn with maxLifetimeMs plus timeoutMs/maxBuffer enforcement.
|
||||
* - packages/engine/src/sandbox/bubblewrap-backend.ts :: BubblewrapBackend.run — isolating backend delegates to native fallback or runBwrapSpawn; runBwrapSpawn uses spawn with timeoutMs and maxBuffer enforcement.
|
||||
* - packages/engine/src/sandbox/bubblewrap-backend.ts :: BubblewrapBackend.runBwrapSpawn — concrete bubblewrap spawn path uses setTimeout(options.timeoutMs) and options.maxBuffer.
|
||||
* - packages/engine/src/sandbox/sandbox-exec-backend.ts :: SandboxExecBackend.run — macOS isolating backend uses async exec with timeout, maxBuffer, and signal.
|
||||
*
|
||||
* Explicit exclusions: git-only execSync in merger.ts, self-healing.ts, already-merged-detector.ts, integration-branch.ts, worktree-prune.ts, and executor.ts git merge-base ancestry checks. The guard slices only registry function bodies instead of asserting over whole files.
|
||||
*/
|
||||
|
||||
type GuardEntry = {
|
||||
file: string;
|
||||
name: string;
|
||||
signature: string;
|
||||
requiredSafeguards: Array<{ label: string; pattern: RegExp }>;
|
||||
};
|
||||
|
||||
const protectedCommandPaths: GuardEntry[] = [
|
||||
{
|
||||
file: "src/verification-utils.ts",
|
||||
name: "execWithProcessGroup",
|
||||
signature: "export async function execWithProcessGroup(",
|
||||
requiredSafeguards: [
|
||||
{ label: "sandbox streaming backend", pattern: /backend\.runStreaming\(/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/verification-utils.ts",
|
||||
name: "runVerificationCommand",
|
||||
signature: "export async function runVerificationCommand(",
|
||||
requiredSafeguards: [
|
||||
{ label: "execWithProcessGroup async runner", pattern: /execWithProcessGroup\(/ },
|
||||
{ label: "timeout option", pattern: /timeout\s*:\s*timeoutMs/ },
|
||||
{ label: "verification maxBuffer", pattern: /maxBuffer\s*:\s*VERIFICATION_COMMAND_MAX_BUFFER/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/run-verification-tool.ts",
|
||||
name: "runVerificationCommand",
|
||||
signature: "export async function runVerificationCommand(",
|
||||
requiredSafeguards: [
|
||||
{ label: "superviseSpawn async runner", pattern: /superviseSpawn\(/ },
|
||||
{ label: "process lifetime cap", pattern: /maxLifetimeMs\s*:/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/executor.ts",
|
||||
name: "runConfiguredCommand",
|
||||
signature: "async function runConfiguredCommand(",
|
||||
requiredSafeguards: [
|
||||
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
|
||||
{ label: "timeoutMs option", pattern: /timeoutMs\s*,/ },
|
||||
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/merger.ts",
|
||||
name: "runConfiguredMergeWorktreeCommand",
|
||||
signature: "async function runConfiguredMergeWorktreeCommand(",
|
||||
requiredSafeguards: [
|
||||
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
|
||||
{ label: "timeoutMs option", pattern: /timeoutMs\s*,/ },
|
||||
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/merger.ts",
|
||||
name: "executePostMergeScriptStep",
|
||||
signature: "async function executePostMergeScriptStep(",
|
||||
requiredSafeguards: [
|
||||
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
|
||||
{ label: "timeoutMs option", pattern: /timeoutMs\s*:/ },
|
||||
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/routine-runner.ts",
|
||||
name: "executeCommand",
|
||||
signature: "private async executeCommand(",
|
||||
requiredSafeguards: [
|
||||
{ label: "sandbox backend.run", pattern: /backend\.run\(/ },
|
||||
{ label: "timeoutMs option", pattern: /timeoutMs\s*:/ },
|
||||
{ label: "maxBuffer option", pattern: /maxBuffer\s*:/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/sandbox/native.ts",
|
||||
name: "NativeSandboxBackend.run",
|
||||
signature: "async run(command: string, options: SandboxRunOptions)",
|
||||
requiredSafeguards: [
|
||||
{ label: "superviseSpawn async runner", pattern: /superviseSpawn\(/ },
|
||||
{ label: "process lifetime cap", pattern: /maxLifetimeMs\s*:/ },
|
||||
{ label: "timeoutMs enforcement", pattern: /options\.timeoutMs/ },
|
||||
{ label: "maxBuffer enforcement", pattern: /options\.maxBuffer/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/sandbox/bubblewrap-backend.ts",
|
||||
name: "BubblewrapBackend.run",
|
||||
signature: "async run(command: string, options: SandboxRunOptions)",
|
||||
requiredSafeguards: [
|
||||
{ label: "native fallback", pattern: /nativeBackend\.run\(/ },
|
||||
{ label: "bounded bubblewrap runner", pattern: /runBwrapSpawn/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/sandbox/bubblewrap-backend.ts",
|
||||
name: "BubblewrapBackend.runBwrapSpawn",
|
||||
signature: "private runBwrapSpawn(command: string, args: string[], options: SandboxRunOptions)",
|
||||
requiredSafeguards: [
|
||||
{ label: "async spawn", pattern: /spawn\(/ },
|
||||
{ label: "timeoutMs enforcement", pattern: /options\.timeoutMs/ },
|
||||
{ label: "maxBuffer enforcement", pattern: /options\.maxBuffer/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "src/sandbox/sandbox-exec-backend.ts",
|
||||
name: "SandboxExecBackend.run",
|
||||
signature: "async run(command: string, options: SandboxRunOptions)",
|
||||
requiredSafeguards: [
|
||||
{ label: "async exec", pattern: /execAsync\(/ },
|
||||
{ label: "timeout option", pattern: /timeout\s*:\s*options\.timeoutMs/ },
|
||||
{ label: "maxBuffer option", pattern: /maxBuffer\s*:\s*options\.maxBuffer/ },
|
||||
{ label: "abort signal", pattern: /signal\s*:\s*options\.signal/ },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function readSource(file: string): string {
|
||||
return readFileSync(resolve(process.cwd(), file), "utf-8");
|
||||
}
|
||||
|
||||
function sliceFunctionBody(source: string, signature: string, label: string): string {
|
||||
const signatureIndex = source.indexOf(signature);
|
||||
expect(signatureIndex, `${label}: registry signature must resolve to a real function`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const paramsStart = source.indexOf("(", signatureIndex);
|
||||
expect(paramsStart, `${label}: function signature must have parameters`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
let parenDepth = 0;
|
||||
let paramsEnd = -1;
|
||||
for (let index = paramsStart; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === "(") parenDepth += 1;
|
||||
if (char === ")") {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
paramsEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(paramsEnd, `${label}: function parameters must close`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
let angleDepth = 0;
|
||||
let openBraceIndex = -1;
|
||||
for (let index = paramsEnd + 1; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === "<") angleDepth += 1;
|
||||
if (char === ">" && angleDepth > 0) angleDepth -= 1;
|
||||
if (char === "{" && angleDepth === 0) {
|
||||
openBraceIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(openBraceIndex, `${label}: function body must have an opening brace`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
let depth = 0;
|
||||
let state: "code" | "single" | "double" | "template" | "line-comment" | "block-comment" = "code";
|
||||
let escaped = false;
|
||||
|
||||
for (let index = openBraceIndex; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
const next = source[index + 1];
|
||||
|
||||
if (state === "line-comment") {
|
||||
if (char === "\n") state = "code";
|
||||
continue;
|
||||
}
|
||||
if (state === "block-comment") {
|
||||
if (char === "*" && next === "/") {
|
||||
state = "code";
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === "single" || state === "double" || state === "template") {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ((state === "single" && char === "'") || (state === "double" && char === '"') || (state === "template" && char === "`")) {
|
||||
state = "code";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
state = "line-comment";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
state = "block-comment";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "'") {
|
||||
state = "single";
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
state = "double";
|
||||
continue;
|
||||
}
|
||||
if (char === "`") {
|
||||
state = "template";
|
||||
continue;
|
||||
}
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return source.slice(openBraceIndex, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${label}: function body did not close; registry may be stale`);
|
||||
}
|
||||
|
||||
function assertGuardInvariants(body: string, entryName: string, requiredSafeguards: GuardEntry["requiredSafeguards"]): void {
|
||||
expect(body, `${entryName}: protected body must not call execSync`).not.toContain("execSync(");
|
||||
expect(body, `${entryName}: protected body must not reference execSync at all`).not.toMatch(/\bexecSync\b/);
|
||||
|
||||
for (const safeguard of requiredSafeguards) {
|
||||
expect(body, `${entryName}: missing required safeguard ${safeguard.label}`).toMatch(safeguard.pattern);
|
||||
}
|
||||
}
|
||||
|
||||
describe("user-configured command static execSync guard", () => {
|
||||
it("protects the complete registry and required async safeguards", () => {
|
||||
expect(protectedCommandPaths.length, "registry must enumerate the protected command paths").toBeGreaterThan(0);
|
||||
|
||||
for (const entry of protectedCommandPaths) {
|
||||
const source = readSource(entry.file);
|
||||
const body = sliceFunctionBody(source, entry.signature, `${entry.file} :: ${entry.name}`);
|
||||
expect(body.trim().length, `${entry.file} :: ${entry.name}: sliced body must be non-empty`).toBeGreaterThan(2);
|
||||
assertGuardInvariants(body, `${entry.file} :: ${entry.name}`, entry.requiredSafeguards);
|
||||
}
|
||||
});
|
||||
|
||||
it("bites on synthetic execSync and missing-safeguard regressions", () => {
|
||||
const requiredSafeguards = [{ label: "timeout option", pattern: /timeout\s*:/ }];
|
||||
|
||||
expect(() => assertGuardInvariants("{ execSync('pnpm test'); timeout: 1000; }", "synthetic execSync", requiredSafeguards)).toThrow(/execSync/);
|
||||
expect(() => assertGuardInvariants("{ execAsync('pnpm test'); }", "synthetic missing timeout", requiredSafeguards)).toThrow(/timeout option/);
|
||||
expect(() => assertGuardInvariants("{ execAsync('pnpm test', { timeout: 1000 }); }", "synthetic safe", requiredSafeguards)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not false-positive on allowed git-plumbing execSync outside protected slices", () => {
|
||||
const executorSource = readSource("src/executor.ts");
|
||||
expect(executorSource, "executor keeps a git-only execSync ancestry check outside the protected configured-command helper").toContain("execSync(`git merge-base --is-ancestor");
|
||||
|
||||
const configuredCommand = protectedCommandPaths.find((entry) => entry.file === "src/executor.ts" && entry.name === "runConfiguredCommand");
|
||||
expect(configuredCommand, "executor runConfiguredCommand registry entry must exist").toBeDefined();
|
||||
const body = sliceFunctionBody(executorSource, configuredCommand!.signature, "src/executor.ts :: runConfiguredCommand");
|
||||
assertGuardInvariants(body, "src/executor.ts :: runConfiguredCommand", configuredCommand!.requiredSafeguards);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { createFnAgent, promptWithFallback } from "./pi.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
|
||||
const reflectionLog = createLogger("reflection");
|
||||
|
||||
@@ -102,12 +103,14 @@ export class AgentReflectionService {
|
||||
}
|
||||
|
||||
let responseText = "";
|
||||
// FNXC:McpConfig 2026-06-25-23:05: Agent-reflection sessions receive the resolved MCP set for the reflected agent identity while preserving the no-secret-logging contract at the runtime forwarding seam.
|
||||
const { session } = await createFnAgent({
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: REFLECTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
defaultProvider: this.modelProvider,
|
||||
defaultModelId: this.modelId,
|
||||
mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId })).servers,
|
||||
onText: (delta: string) => {
|
||||
responseText += delta;
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentSession, SessionManager, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
import type { PermanentAgentGatingContext } from "@fusion/core";
|
||||
import type { PermanentAgentGatingContext, ResolvedMcpServerDefinition } from "@fusion/core";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import type { FallbackModelUsedPayload } from "./pi.js";
|
||||
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
@@ -44,6 +44,24 @@ export interface AgentMcpServerConfig {
|
||||
env: { name: string; value: string }[];
|
||||
}
|
||||
|
||||
export type AgentRuntimeMcpServerConfig = ResolvedMcpServerDefinition | AgentMcpServerConfig;
|
||||
|
||||
export function normalizeAgentRuntimeMcpServers(
|
||||
servers: AgentRuntimeMcpServerConfig[] | undefined,
|
||||
): ResolvedMcpServerDefinition[] | undefined {
|
||||
if (!servers || servers.length === 0) return undefined;
|
||||
return servers.map((server) => {
|
||||
if ("transport" in server) return server;
|
||||
return {
|
||||
name: server.name,
|
||||
transport: "stdio",
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface AgentRuntimeOptions {
|
||||
/** Working directory for the agent session */
|
||||
cwd: string;
|
||||
@@ -97,12 +115,18 @@ export interface AgentRuntimeOptions {
|
||||
/** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */
|
||||
runtimeContext?: AgentRuntimeContext;
|
||||
/**
|
||||
* MCP servers to forward to the runtime's agent session (U10 — Route A ACP).
|
||||
* Consumed by runtimes that speak MCP (e.g. the ACP runtime forwards them on
|
||||
* `session/new`); ignored by runtimes that don't. Tool calls still route
|
||||
* through the runtime's permission floor.
|
||||
* MCP servers to forward to the runtime's agent session. New callers pass the
|
||||
* FN-7022 resolved/materialized three-transport shape; the legacy U10 Route A
|
||||
* stdio-only ACP shape remains accepted as a subset/adapter.
|
||||
*
|
||||
* FNXC:McpConfig 2026-06-25-21:55:
|
||||
* All AI lanes share this runtime option so executor, reviewer, validator,
|
||||
* merger, workflow-node, summarization, evaluator, planning, and chat sessions
|
||||
* receive the same trusted MCP server set when their selected runtime supports
|
||||
* MCP. Runtime implementations that cannot consume MCP must skip it without
|
||||
* logging server contents.
|
||||
*/
|
||||
mcpServers?: AgentMcpServerConfig[];
|
||||
mcpServers?: AgentRuntimeMcpServerConfig[];
|
||||
/** Optional task-scoped environment variables for session-local subprocesses. */
|
||||
taskEnv?: NodeJS.ProcessEnv;
|
||||
/**
|
||||
|
||||
@@ -308,6 +308,8 @@ export async function createResolvedAgentSession(
|
||||
...runtimeOptionsRaw,
|
||||
...(mergedSkillNames.length > 0 ? { skills: mergedSkillNames } : {}),
|
||||
};
|
||||
// FNXC:McpConfig 2026-06-25-22:06:
|
||||
// createResolvedAgentSession is the common lane helper for executor, reviewer, validator, workflow model-node, summarization, and merger-adjacent paths that pass MCP through this seam. Preserve `mcpServers` verbatim here; runtime-resolution/pi own support-gated forwarding and content-free skip logging.
|
||||
|
||||
const useMockRuntime = isMockProviderId(runtimeOptions.defaultProvider);
|
||||
const effectiveRuntimeOptions = useMockRuntime
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { exec } from "node:child_process";
|
||||
|
||||
import {
|
||||
resolveProjectDefaultModel,
|
||||
resolveExecutionSettingsModel,
|
||||
runScheduledEvalBatch,
|
||||
resolveTaskEvaluationSettings,
|
||||
isEvalsExperimentalEnabled,
|
||||
@@ -201,10 +201,18 @@ const MIN_POLL_INTERVAL_MS = 10 * 1000;
|
||||
* Function type for executing AI prompts.
|
||||
* Injected into CronRunner to decouple it from agent session creation.
|
||||
*/
|
||||
export type AiPromptLiveCallbacks = {
|
||||
onText?: (delta: string) => void;
|
||||
onToolStart?: (name: string, args?: Record<string, unknown>) => void;
|
||||
onToolEnd?: (name: string, isError: boolean, result?: unknown) => void;
|
||||
};
|
||||
|
||||
export type AiPromptExecutor = (
|
||||
prompt: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
allowedTools?: string[],
|
||||
liveCallbacks?: AiPromptLiveCallbacks,
|
||||
) => Promise<string>;
|
||||
|
||||
export interface CronRunnerOptions {
|
||||
@@ -855,9 +863,10 @@ export class CronRunner {
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve model: step override → project default override → global default
|
||||
// Resolve model: step override → project execution lane → global execution lane → project default override → global default
|
||||
// FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires scheduled AI-prompt automation steps to use execution-lane settings before default settings because these steps have no task/runtime model context.
|
||||
const settings = await this.store.getSettings();
|
||||
const defaultModel = resolveProjectDefaultModel(settings);
|
||||
const defaultModel = resolveExecutionSettingsModel(settings);
|
||||
const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
|
||||
const modelId = step.modelId?.trim() || defaultModel.modelId;
|
||||
|
||||
@@ -869,7 +878,7 @@ export class CronRunner {
|
||||
|
||||
try {
|
||||
// Race between executor and timeout
|
||||
const resultPromise = this.aiPromptExecutor(step.prompt, modelProvider, modelId);
|
||||
const resultPromise = this.aiPromptExecutor(step.prompt, modelProvider, modelId, step.allowedTools);
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs);
|
||||
});
|
||||
@@ -982,7 +991,7 @@ export class CronRunner {
|
||||
|
||||
const AI_AUTOMATION_SYSTEM_PROMPT = [
|
||||
"You are an AI automation agent executing a scheduled task.",
|
||||
"You have read-only access to the project files.",
|
||||
"You may use the coding tools selected for this automation step; follow any tool restrictions exactly.",
|
||||
"Execute the prompt precisely and return concise, structured results.",
|
||||
"When analyzing code or data, provide actionable summaries.",
|
||||
"Structure outputs with clear sections: Summary, Findings, Recommended Actions, and Risks/Unknowns when applicable.",
|
||||
@@ -1003,7 +1012,7 @@ const AI_AUTOMATION_SYSTEM_PROMPT = [
|
||||
export async function createAiPromptExecutor(cwd: string): Promise<AiPromptExecutor> {
|
||||
const disposeLog = createLogger("cron-runner");
|
||||
|
||||
return async (prompt: string, modelProvider?: string, modelId?: string): Promise<string> => {
|
||||
return async (prompt: string, modelProvider?: string, modelId?: string, allowedTools?: string[], liveCallbacks?: AiPromptLiveCallbacks): Promise<string> => {
|
||||
let responseText = "";
|
||||
const skillContext = buildSessionSkillContextSync(null, "executor", cwd, undefined);
|
||||
|
||||
@@ -1014,13 +1023,17 @@ export async function createAiPromptExecutor(cwd: string): Promise<AiPromptExecu
|
||||
const { session } = await createFnAgent({
|
||||
cwd,
|
||||
systemPrompt: AI_AUTOMATION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
tools: "coding",
|
||||
toolsAllowlist: allowedTools,
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
onText: (delta: string) => {
|
||||
responseText += delta;
|
||||
liveCallbacks?.onText?.(delta);
|
||||
},
|
||||
onToolStart: liveCallbacks?.onToolStart,
|
||||
onToolEnd: liveCallbacks?.onToolEnd,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { collectTaskEvaluationEvidence } from "./evaluator-evidence.js";
|
||||
import { materializeEvalFollowUps, normalizeEvalFollowUps, resolveEvalFollowUpPolicyMode } from "./eval-followups.js";
|
||||
import { createFnAgent, promptWithFallback } from "./pi.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
|
||||
const log = createLogger("evaluator");
|
||||
|
||||
@@ -149,12 +150,14 @@ export class HybridEvaluatorService {
|
||||
}
|
||||
|
||||
let text = "";
|
||||
// FNXC:McpConfig 2026-06-25-23:05: Evaluator sessions are an AI lane and receive the store-resolved MCP set at session creation; createFnAgent applies runtime support gating without logging plaintext env/header secrets.
|
||||
const { session } = await createFnAgent({
|
||||
cwd: this.deps.cwd,
|
||||
systemPrompt: "You are a strict evaluator. Reply with JSON only.",
|
||||
tools: "readonly",
|
||||
defaultProvider: provider,
|
||||
defaultModelId: modelId,
|
||||
mcpServers: this.deps.store ? (await resolveMcpServersForStore(this.deps.store)).servers : undefined,
|
||||
onText: (delta) => {
|
||||
text += delta;
|
||||
},
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
resolveAgentPrompt,
|
||||
resolvePersistAgentThinkingLog,
|
||||
resolveEffectiveAgentPermissionPolicy,
|
||||
resolveProjectDefaultModel,
|
||||
resolveAgentMemoryInclusionMode,
|
||||
loadWorkspaceConfig,
|
||||
type WorkspaceConfig,
|
||||
@@ -77,6 +76,7 @@ import {
|
||||
resolveExecutorSessionModel,
|
||||
} from "./agent-session-helpers.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
import { reviewStep, type ReviewVerdict, type ReviewResult } from "./reviewer.js";
|
||||
import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
|
||||
import { resolveSandboxBackend } from "./sandbox/index.js";
|
||||
@@ -2367,6 +2367,12 @@ export class TaskExecutor {
|
||||
* prevents new work dispatch — running sessions continue to completion.
|
||||
* Paused tasks are moved back to `todo` rather than marked as `failed`.
|
||||
*/
|
||||
private async resolveMcpServers(agentId?: string | null) {
|
||||
// FNXC:McpConfig 2026-06-25-22:20:
|
||||
// Executor-owned lanes (main execution, retry, workflow model nodes, self-fix, and spawned child sessions) resolve the same trusted MCP server set from the task store immediately before session creation so secret material is never persisted in task state.
|
||||
return (await resolveMcpServersForStore(this.store, { agentId: agentId ?? undefined })).servers;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private rootDir: string,
|
||||
@@ -7838,6 +7844,8 @@ export class TaskExecutor {
|
||||
effectiveAgentId: stepColumnAgent?.agent.id,
|
||||
actionGateContext: this.buildActionGateContext(task.id, stepIdentityAgent, settings.defaultAgentPermissionPolicy),
|
||||
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, stepIdentityAgent, settings.defaultAgentPermissionPolicy),
|
||||
// FNXC:McpConfig 2026-06-25-23:03: Per-step workflow sessions are an executor lane, so they inherit the task's resolved MCP set from the effective step identity agent and never re-read or log plaintext secret values.
|
||||
mcpServers: await this.resolveMcpServers(stepIdentityAgent?.id),
|
||||
// Pass skill selection context from the main executor session
|
||||
skillSelection: skillContext.skillSelectionContext,
|
||||
// Pass agentStore and messageStore for delegation and messaging tools
|
||||
@@ -8637,6 +8645,7 @@ export class TaskExecutor {
|
||||
settings,
|
||||
sessionManager,
|
||||
taskEnv,
|
||||
mcpServers: await this.resolveMcpServers(identityAgent?.id),
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
// Column-agent principal alignment (plan U5, R5): action gating is
|
||||
@@ -9057,6 +9066,7 @@ export class TaskExecutor {
|
||||
settings,
|
||||
sessionManager: SessionManager.create(worktreePath),
|
||||
taskEnv,
|
||||
mcpServers: await this.resolveMcpServers(identityAgent?.id),
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
// U5 (R5): retry session re-keys gating to the effective principal,
|
||||
@@ -11934,6 +11944,7 @@ Do not refactor, rename broadly, or make opportunistic improvements.
|
||||
runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)),
|
||||
settings,
|
||||
taskEnv: extraEnv,
|
||||
mcpServers: await this.resolveMcpServers(undefined),
|
||||
// FNXC:SessionRouting 2026-06-24-11:20:
|
||||
// #1675: propagate task id so verification-fix requests carry the same
|
||||
// X-Session-Id/X-Session-Affinity as the primary session.
|
||||
@@ -12671,13 +12682,21 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
});
|
||||
|
||||
// Determine primary model and an explicit fallback. The workflow step's
|
||||
// own override takes precedence; otherwise we use the project default
|
||||
// override before falling through to the global default. The
|
||||
// fallback is the per-step override's missing-counterpart settings, then
|
||||
// the global validator/fallback pair, then the executor's `fallbackProvider`.
|
||||
const defaultModel = resolveProjectDefaultModel(settings);
|
||||
const primaryProvider = workflowStep.modelProvider || defaultModel.provider;
|
||||
const primaryModelId = workflowStep.modelId || defaultModel.modelId;
|
||||
// own override takes precedence; otherwise use the canonical executor
|
||||
// hierarchy: task override → project execution lane → global execution lane
|
||||
// → project default override → global default. The fallback is the per-step
|
||||
// override's missing-counterpart settings, then the global validator/fallback
|
||||
// pair, then the executor's `fallbackProvider`.
|
||||
// FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires workflow steps to inherit project execution-lane model settings before default settings so configured Execution models reach step sessions unless the step itself overrides them.
|
||||
const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId);
|
||||
const executorModel = resolveExecutorSessionModel(
|
||||
task.modelProvider,
|
||||
task.modelId,
|
||||
settings,
|
||||
assignedRuntimeConfig,
|
||||
);
|
||||
const primaryProvider = workflowStep.modelProvider || executorModel.provider;
|
||||
const primaryModelId = workflowStep.modelId || executorModel.modelId;
|
||||
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
|
||||
|
||||
type ModelTuple = { provider?: string; modelId?: string };
|
||||
@@ -12822,6 +12841,7 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)),
|
||||
settings,
|
||||
taskEnv: stepEnv,
|
||||
mcpServers: await this.resolveMcpServers(undefined),
|
||||
// FNXC:SessionRouting 2026-06-24-11:20:
|
||||
// #1675: propagate task id so workflow-step requests carry the same
|
||||
// X-Session-Id/X-Session-Affinity as the primary session.
|
||||
@@ -15565,6 +15585,7 @@ Child agent: ${agent.id} (${name})`;
|
||||
runAuditor: createRunAuditor(this.store, this.getRunContextFor(taskId)),
|
||||
settings,
|
||||
taskEnv,
|
||||
mcpServers: await this.resolveMcpServers(agent.id),
|
||||
// FNXC:SessionRouting 2026-06-24-11:20:
|
||||
// #1675: propagate task id so child-agent requests carry the same
|
||||
// X-Session-Id/X-Session-Affinity as the parent task session.
|
||||
|
||||
@@ -298,6 +298,9 @@ export {
|
||||
} from "./merger-squash-audit.js";
|
||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp-resolution.js";
|
||||
export { runtimeSupportsMcp, logMcpForwardingSkipped } from "./mcp-runtime-support.js";
|
||||
export { validateMcpServer, type McpValidationResult, type ValidateMcpServerOptions } from "./mcp-validation-service.js";
|
||||
export {
|
||||
createInteractiveAiSessionWith,
|
||||
createCliAgentPlanningSessionWith,
|
||||
|
||||
81
packages/engine/src/mcp-resolution.ts
Normal file
81
packages/engine/src/mcp-resolution.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
materializeMcpServersSecrets,
|
||||
resolveEffectiveMcpServers,
|
||||
type GlobalSettings,
|
||||
type McpSecretReader,
|
||||
type McpSecretReaderIdentity,
|
||||
type McpSecretResolutionError,
|
||||
type ProjectSettings,
|
||||
type ResolvedMcpServerDefinition,
|
||||
} from "@fusion/core";
|
||||
|
||||
export interface ResolveMcpServersForRuntimeOptions {
|
||||
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null;
|
||||
projectSettings?: Pick<ProjectSettings, "mcpServers"> | null;
|
||||
secrets: McpSecretReader;
|
||||
reader?: McpSecretReaderIdentity;
|
||||
}
|
||||
|
||||
export interface ResolvedMcpServersForRuntime {
|
||||
servers: ResolvedMcpServerDefinition[];
|
||||
errors: McpSecretResolutionError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:McpConfig 2026-06-25-21:43:
|
||||
* Runtime MCP forwarding uses Fusion's trusted-once-enabled model: enabled effective servers are materialized once at session/probe creation and then forwarded without per-call prompts. Plaintext env/header values exist only in this in-memory return value and callers must log only counts/errors, never server contents.
|
||||
*/
|
||||
export async function resolveMcpServersForRuntime(
|
||||
options: ResolveMcpServersForRuntimeOptions,
|
||||
): Promise<ResolvedMcpServersForRuntime> {
|
||||
const effective = resolveEffectiveMcpServers(options.globalSettings, options.projectSettings);
|
||||
if (effective.length === 0) return { servers: [], errors: [] };
|
||||
|
||||
const materialized = await materializeMcpServersSecrets(
|
||||
effective,
|
||||
options.secrets,
|
||||
options.reader ?? {},
|
||||
);
|
||||
return {
|
||||
servers: materialized.value ?? [],
|
||||
errors: materialized.errors,
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpSettingsAndSecretsStore {
|
||||
getSettingsByScope?(): Promise<{
|
||||
global: Pick<GlobalSettings, "mcpServers">;
|
||||
project: Partial<Pick<ProjectSettings, "mcpServers">>;
|
||||
}>;
|
||||
getSecretsStore?(): Promise<McpSecretReader> | McpSecretReader;
|
||||
}
|
||||
|
||||
const emptyMcpSecretReader: McpSecretReader = {
|
||||
async revealSecret() {
|
||||
throw new Error("MCP secret reader is unavailable");
|
||||
},
|
||||
};
|
||||
|
||||
export async function resolveMcpServersForStore(
|
||||
store: McpSettingsAndSecretsStore,
|
||||
reader?: McpSecretReaderIdentity,
|
||||
): Promise<ResolvedMcpServersForRuntime> {
|
||||
/*
|
||||
* FNXC:McpConfig 2026-06-26-01:07:
|
||||
* Older tests and lightweight TaskStore doubles may not implement the settings/secrets seams because they never configure MCP. Treat those stores as having no enabled MCP servers so all AI lanes keep their existing behavior while real stores still forward the resolved runtime configuration.
|
||||
*/
|
||||
if (typeof store.getSettingsByScope !== "function") {
|
||||
return { servers: [], errors: [] };
|
||||
}
|
||||
|
||||
const [settings, secrets] = await Promise.all([
|
||||
store.getSettingsByScope(),
|
||||
typeof store.getSecretsStore === "function" ? store.getSecretsStore() : emptyMcpSecretReader,
|
||||
]);
|
||||
return resolveMcpServersForRuntime({
|
||||
globalSettings: settings.global,
|
||||
projectSettings: settings.project,
|
||||
secrets,
|
||||
reader,
|
||||
});
|
||||
}
|
||||
41
packages/engine/src/mcp-runtime-support.ts
Normal file
41
packages/engine/src/mcp-runtime-support.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { MOCK_PROVIDER_ID } from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const mcpRuntimeLog = createLogger("mcp-runtime");
|
||||
const SUPPORTED_RUNTIME_IDS = new Set(["pi", "default-pi", "claude", "claude-code", "claude-acp", "acp"]);
|
||||
|
||||
function normalizeId(value: string | undefined): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:McpConfig 2026-06-25-21:35:
|
||||
* MCP server definitions may contain materialized secrets by the time they reach runtime forwarding, so support decisions must be pure and content-free. Only known MCP-capable pi/Claude/ACP runtimes receive servers; mock and unknown runtimes skip forwarding without inspecting or logging server definitions.
|
||||
*/
|
||||
export function runtimeSupportsMcp(runtimeId: string | undefined, provider?: string): boolean {
|
||||
if (normalizeId(provider) === MOCK_PROVIDER_ID) return false;
|
||||
const normalizedRuntimeId = normalizeId(runtimeId);
|
||||
if (!normalizedRuntimeId) return false;
|
||||
if (SUPPORTED_RUNTIME_IDS.has(normalizedRuntimeId)) return true;
|
||||
// Vendor/plugin runtime ids often include their transport family in a prefix/suffix; keep this content-free fuzzy match so future Claude/ACP runtimes do not need to expose server definitions to prove support.
|
||||
return normalizedRuntimeId.includes("claude") || normalizedRuntimeId.includes("acp");
|
||||
}
|
||||
|
||||
export interface McpForwardingSkipDetails {
|
||||
runtimeId?: string;
|
||||
provider?: string;
|
||||
skippedCount: number;
|
||||
lane?: string;
|
||||
}
|
||||
|
||||
export function logMcpForwardingSkipped(details: McpForwardingSkipDetails): void {
|
||||
if (details.skippedCount <= 0) return;
|
||||
mcpRuntimeLog.log(JSON.stringify({
|
||||
event: "mcp.forwarding.skipped",
|
||||
reason: "unsupported-runtime",
|
||||
runtimeId: details.runtimeId ?? null,
|
||||
provider: details.provider ?? null,
|
||||
lane: details.lane ?? null,
|
||||
skippedCount: details.skippedCount,
|
||||
}));
|
||||
}
|
||||
146
packages/engine/src/mcp-validation-service.ts
Normal file
146
packages/engine/src/mcp-validation-service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { superviseSpawn, type SupervisedChild } from "@fusion/core";
|
||||
import type { ResolvedMcpServerDefinition } from "@fusion/core";
|
||||
|
||||
export type McpValidationStatus = "valid" | "unreachable" | "error";
|
||||
|
||||
export interface McpValidationResult {
|
||||
status: McpValidationStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface McpStdioProbeOptions {
|
||||
timeoutMs: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export type McpStdioProbe = (
|
||||
server: Extract<ResolvedMcpServerDefinition, { transport: "stdio" }>,
|
||||
options: McpStdioProbeOptions,
|
||||
) => Promise<McpValidationResult>;
|
||||
|
||||
export type McpFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface ValidateMcpServerOptions {
|
||||
timeoutMs?: number;
|
||||
cwd?: string;
|
||||
stdioProbe?: McpStdioProbe;
|
||||
fetchImpl?: McpFetch;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const SIGKILL_GRACE_MS = 1_000;
|
||||
|
||||
/**
|
||||
* FNXC:McpConfig 2026-06-25-23:26:
|
||||
* MCP validation probes are bounded and content-free: stdio uses superviseSpawn with a lifetime cap, HTTP transports use AbortController timeouts, and neither path logs or returns resolved env/header secret values.
|
||||
*/
|
||||
export async function validateMcpServer(
|
||||
server: ResolvedMcpServerDefinition,
|
||||
options: ValidateMcpServerOptions = {},
|
||||
): Promise<McpValidationResult> {
|
||||
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
||||
if (server.transport === "stdio") {
|
||||
return (options.stdioProbe ?? defaultStdioProbe)(server, { timeoutMs, cwd: options.cwd });
|
||||
}
|
||||
|
||||
return validateHttpMcpServer(server, {
|
||||
timeoutMs,
|
||||
fetchImpl: options.fetchImpl ?? globalThis.fetch?.bind(globalThis),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTimeout(timeoutMs: number | undefined): number {
|
||||
return Number.isFinite(timeoutMs) && timeoutMs !== undefined && timeoutMs > 0
|
||||
? Math.min(timeoutMs, 30_000)
|
||||
: DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
async function validateHttpMcpServer(
|
||||
server: Extract<ResolvedMcpServerDefinition, { transport: "sse" | "streamable-http" }>,
|
||||
options: { timeoutMs: number; fetchImpl?: McpFetch },
|
||||
): Promise<McpValidationResult> {
|
||||
if (!options.fetchImpl) {
|
||||
return { status: "error", message: "fetch is unavailable in this runtime" };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
try {
|
||||
const response = await options.fetchImpl(server.url, {
|
||||
method: "GET",
|
||||
headers: server.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { status: "error", message: `server responded with HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
return { status: "valid", message: `server responded with HTTP ${response.status}` };
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return { status: "unreachable", message: "connection timed out" };
|
||||
}
|
||||
return { status: "unreachable", message: errorMessage(error, "connection failed") };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultStdioProbe(
|
||||
server: Extract<ResolvedMcpServerDefinition, { transport: "stdio" }>,
|
||||
options: McpStdioProbeOptions,
|
||||
): Promise<McpValidationResult> {
|
||||
let supervised: SupervisedChild;
|
||||
try {
|
||||
supervised = superviseSpawn(server.command, server.args ?? [], {
|
||||
cwd: options.cwd,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
env: {
|
||||
...process.env,
|
||||
...(server.env ?? {}),
|
||||
},
|
||||
killGraceMs: SIGKILL_GRACE_MS,
|
||||
maxLifetimeMs: options.timeoutMs + SIGKILL_GRACE_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
return { status: "error", message: errorMessage(error, "failed to start stdio server") };
|
||||
}
|
||||
|
||||
return new Promise<McpValidationResult>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: McpValidationResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
supervised.kill("SIGTERM");
|
||||
settle({ status: "valid", message: "stdio server stayed reachable through the probe window" });
|
||||
}, options.timeoutMs);
|
||||
|
||||
supervised.child.once("error", (error) => {
|
||||
settle({ status: "error", message: errorMessage(error, "stdio probe failed") });
|
||||
});
|
||||
|
||||
supervised.waitExit().then((exit) => {
|
||||
if (exit.code === 0) {
|
||||
settle({ status: "valid", message: "stdio command exited successfully" });
|
||||
} else {
|
||||
settle({ status: "unreachable", message: `stdio command exited with ${exit.signal ?? exit.code ?? "unknown status"}` });
|
||||
}
|
||||
}).catch((error) => {
|
||||
settle({ status: "error", message: errorMessage(error, "stdio probe failed") });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
}
|
||||
312
packages/engine/src/merger-ai-prompts.ts
Normal file
312
packages/engine/src/merger-ai-prompts.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
FNXC:MergerAiSplit 2026-06-25-00:00:
|
||||
FN-7029 extracts the AI-merge prompt builders and review verdict parser from merger-ai.ts so the sole FN-5633 clean-room merge path stays under the 2000-line guardrail without changing prompts, verdict parsing, or the public merger-ai.js import surface.
|
||||
*/
|
||||
import {
|
||||
resolveAgentPrompt,
|
||||
type AgentPromptsConfig,
|
||||
type TaskComment,
|
||||
} from "@fusion/core";
|
||||
|
||||
import { buildUserCommentsPromptSection } from "./agent-user-comments.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiMergeReviewSeverity = "blocking" | "advisory";
|
||||
|
||||
export interface AiMergeReviewVerdict {
|
||||
verdict: "approve" | "reject";
|
||||
reasons: string[];
|
||||
severity?: AiMergeReviewSeverity;
|
||||
}
|
||||
|
||||
export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:";
|
||||
const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i;
|
||||
const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i;
|
||||
|
||||
/**
|
||||
* Parse the reviewer's free-form output. Fail-safe: no/garbled output, or a
|
||||
* rejection with no explicit severity, is treated as a BLOCKING reject — an
|
||||
* ambiguous reviewer can never wave wrong code through, nor silently downgrade
|
||||
* to advisory.
|
||||
*/
|
||||
export function parseReviewVerdict(
|
||||
agentText: string | null | undefined
|
||||
): AiMergeReviewVerdict {
|
||||
const text = (agentText ?? "").trim();
|
||||
if (!text)
|
||||
return {
|
||||
verdict: "reject",
|
||||
reasons: ["reviewer produced no output"],
|
||||
severity: "blocking",
|
||||
};
|
||||
|
||||
const lines = text.split(/\r?\n/);
|
||||
let verdictLineIndex = -1;
|
||||
let decision: "approve" | "reject" | null = null;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const m = lines[i].match(VERDICT_LINE_RE);
|
||||
if (m) {
|
||||
decision = m[1].toLowerCase() as "approve" | "reject";
|
||||
verdictLineIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!decision) {
|
||||
return {
|
||||
verdict: "reject",
|
||||
reasons: [
|
||||
`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`,
|
||||
],
|
||||
severity: "blocking",
|
||||
};
|
||||
}
|
||||
if (decision === "approve") return { verdict: "approve", reasons: [] };
|
||||
|
||||
const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text)
|
||||
? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity)
|
||||
: "blocking";
|
||||
return {
|
||||
verdict: "reject",
|
||||
reasons: extractRejectReasons(lines, verdictLineIndex),
|
||||
severity,
|
||||
};
|
||||
}
|
||||
|
||||
function extractRejectReasons(
|
||||
lines: string[],
|
||||
verdictLineIndex: number
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
const inline = lines[verdictLineIndex]
|
||||
.replace(VERDICT_LINE_RE, "")
|
||||
.replace(/^[\s:–—-]+/, "")
|
||||
.trim();
|
||||
if (inline) reasons.push(inline);
|
||||
for (let i = verdictLineIndex + 1; i < lines.length; i++) {
|
||||
if (SEVERITY_LINE_RE.test(lines[i])) continue;
|
||||
const cleaned = lines[i].replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim();
|
||||
if (cleaned) reasons.push(cleaned);
|
||||
}
|
||||
if (reasons.length === 0)
|
||||
reasons.push("reviewer rejected the merge without a stated reason");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
export function buildMergeSystemPrompt(
|
||||
agentPrompts?: AgentPromptsConfig
|
||||
): string {
|
||||
// Base persona is the editable "merger" agent prompt (Settings → Prompts);
|
||||
// the non-negotiable clean-room / verification / commit-trailer rules below
|
||||
// are always appended so a custom prompt can't drop them.
|
||||
const base = resolveAgentPrompt("merger", agentPrompts).trim();
|
||||
return [
|
||||
base,
|
||||
base ? "" : undefined,
|
||||
"## AI merge — clean room",
|
||||
"You are on a CLEAN, detached checkout at the integration branch's current",
|
||||
"tip. Land the task branch's work as a single commit.",
|
||||
"",
|
||||
"Constraints:",
|
||||
" - Resolve every conflict in favor of the task branch's intent; never drop",
|
||||
" the task's changes to make a conflict go away.",
|
||||
" - Do not make edits unrelated to reconciling the two branches.",
|
||||
" - Do NOT push, force-push, or run `git update-ref` / `git reset --hard`",
|
||||
" on any other branch. Only commit on this detached HEAD.",
|
||||
" - Finish with exactly ONE new commit on HEAD containing the task's work.",
|
||||
"",
|
||||
"Verify before committing:",
|
||||
" - After resolving the merge, run the project's checks — tests, type-check,",
|
||||
" and lint (discover them from the project config / package.json scripts,",
|
||||
" e.g. test / typecheck / lint / build).",
|
||||
" - FIX any NEW failure the merge or conflict resolution introduced (a check",
|
||||
" that passed on the task branch or the integration tip but fails on the",
|
||||
" merged tree). You do not need to fix failures that were already broken on",
|
||||
" the integration branch beforehand, but never commit a merge that adds new",
|
||||
" test, type-check, or lint failures.",
|
||||
"",
|
||||
"Commit message:",
|
||||
" - The subject line must CONCISELY SUMMARIZE the squashed changes in",
|
||||
' imperative mood (e.g. "add X", "fix Y") based on the actual diff — do',
|
||||
" not just restate the task title.",
|
||||
" - The commit BODY must include:",
|
||||
" 1) one short narrative summary line,",
|
||||
" 2) a bullet list of key changes, and",
|
||||
" 3) a `Files changed:` section populated from `git diff --stat`.",
|
||||
" - Include the task-id prefix and the trailer lines EXACTLY as given in the",
|
||||
" task instructions (they associate the commit with the board task).",
|
||||
]
|
||||
.filter((l) => l !== undefined)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function buildMergePrompt(input: {
|
||||
taskId: string;
|
||||
branch: string;
|
||||
integrationBranch: string;
|
||||
tipSha: string;
|
||||
/** Task title — a HINT for the summary, not the literal subject. */
|
||||
taskTitle?: string;
|
||||
/** Whether to prefix the subject with the task id. */
|
||||
includeTaskId: boolean;
|
||||
/** Required trailers to append (board association). */
|
||||
trailers: string[];
|
||||
correctiveReasons?: string[];
|
||||
userComments?: TaskComment[];
|
||||
}): string {
|
||||
const subjectShape = input.includeTaskId
|
||||
? `"${input.taskId}: <concise imperative summary of the squashed changes>"`
|
||||
: `"<concise imperative summary of the squashed changes>"`;
|
||||
const trailerArgs = input.trailers
|
||||
.map((t) => ` -m ${JSON.stringify(t)}`)
|
||||
.join("");
|
||||
const lines = [
|
||||
`Merge branch "${input.branch}" into "${
|
||||
input.integrationBranch
|
||||
}" (HEAD is detached at ${short(input.tipSha)}).`,
|
||||
"",
|
||||
"Steps:",
|
||||
` 1. Run: git merge --squash ${input.branch}`,
|
||||
" 2. If there are conflicts, resolve them (favor the task's intent), then `git add` the resolved files.",
|
||||
" 3. Build a merge body from the staged squash diff:",
|
||||
" - one short narrative summary line",
|
||||
" - bullet list of key changes",
|
||||
" - `Files changed:` + the output of `git diff --stat`",
|
||||
" 4. Commit the staged result as a SINGLE commit whose subject summarizes the",
|
||||
` actual changes${
|
||||
input.taskTitle
|
||||
? ` (task title hint: ${JSON.stringify(input.taskTitle)})`
|
||||
: ""
|
||||
}, including the body above and required trailers:`,
|
||||
` git commit -m ${subjectShape} -m "<narrative + bullet list + Files changed: ...>"${trailerArgs}`,
|
||||
" Keep the trailer line(s) verbatim — they link the commit to the board task.",
|
||||
" 5. Verify `git log --oneline ${tip}..HEAD` shows exactly one new commit and `git status` is clean.".replace(
|
||||
"${tip}",
|
||||
short(input.tipSha)
|
||||
),
|
||||
"",
|
||||
"If `git merge --squash` reports the branch is already up to date (nothing to",
|
||||
"merge), do nothing and leave HEAD unchanged.",
|
||||
];
|
||||
const userCommentsSection = buildUserCommentsPromptSection(
|
||||
input.userComments ?? []
|
||||
);
|
||||
if (userCommentsSection) {
|
||||
lines.push("", userCommentsSection);
|
||||
}
|
||||
if (input.correctiveReasons && input.correctiveReasons.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"A prior attempt was REJECTED by review. Redo the merge from the clean tip",
|
||||
"and address each of these problems:",
|
||||
...input.correctiveReasons.map((r) => ` - ${r}`)
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildReviewSystemPrompt(): string {
|
||||
return [
|
||||
"You are an adversarial, read-only merge reviewer. Do NOT edit, stage, commit,",
|
||||
"or run any mutating git command. Audit the squash commit that is about to be",
|
||||
"merged into the integration branch and decide whether it is safe to land.",
|
||||
"",
|
||||
"Investigate with read-only commands (git show, git diff, git log, cat, grep).",
|
||||
"Judge on four axes:",
|
||||
" 1. Completeness — does the squash contain ALL of the task branch's intended",
|
||||
" changes? Flag any hunk silently dropped during conflict resolution.",
|
||||
" 2. No collateral — does it touch only files within the task's footprint?",
|
||||
" 3. Conflict soundness — were conflicts resolved coherently (both sides'",
|
||||
" intent preserved), not by blindly discarding one side?",
|
||||
" 4. Commit message — read `git show`'s message: the subject must concisely",
|
||||
" and ACCURATELY summarize the actual changes (not vague, not a mere",
|
||||
" restatement of the task title, not misleading). A poor/inaccurate",
|
||||
" message is an ADVISORY concern (it should be rewritten on retry, but",
|
||||
" must not block the merge).",
|
||||
"",
|
||||
"Bias toward rejection when uncertain.",
|
||||
"",
|
||||
`End with a single decision line: "${REVIEW_VERDICT_MARKER} approve" or`,
|
||||
`"${REVIEW_VERDICT_MARKER} reject". When rejecting, add a "SEVERITY:" line:`,
|
||||
" - SEVERITY: blocking — a correctness problem (dropped/lost task changes,",
|
||||
" incomplete squash, or a conflict resolution that discards intent). The",
|
||||
" merge must NOT land if this is unfixable.",
|
||||
" - SEVERITY: advisory — a quality/style concern that does not risk",
|
||||
" correctness; acceptable to land if unresolved.",
|
||||
"Then list each concrete reason as a bullet.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildReviewPrompt(input: {
|
||||
taskId: string;
|
||||
branch: string;
|
||||
integrationBranch: string;
|
||||
tipSha: string;
|
||||
squashSha: string;
|
||||
diffStat: string;
|
||||
priorReasons?: string[];
|
||||
userComments?: TaskComment[];
|
||||
}): string {
|
||||
const lines = [
|
||||
`Review the squash merge for task ${input.taskId} (branch ${input.branch} → ${input.integrationBranch}).`,
|
||||
"",
|
||||
`Integration tip: ${short(input.tipSha)}`,
|
||||
`Squash commit: ${short(input.squashSha)}`,
|
||||
"",
|
||||
"Inspect with:",
|
||||
` git show ${input.squashSha}`,
|
||||
` git diff ${input.tipSha}..${input.squashSha}`,
|
||||
"",
|
||||
"Files changed (git diff --stat):",
|
||||
input.diffStat.trim() || "(none reported)",
|
||||
];
|
||||
const userCommentsSection = buildUserCommentsPromptSection(
|
||||
input.userComments ?? []
|
||||
);
|
||||
if (userCommentsSection) {
|
||||
lines.push("", userCommentsSection);
|
||||
}
|
||||
if (input.priorReasons && input.priorReasons.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"A prior pass rejected an earlier attempt for these reasons — confirm they",
|
||||
"are now resolved:",
|
||||
...input.priorReasons.map((r) => ` - ${r}`)
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildStashResolveSystemPrompt(): string {
|
||||
return [
|
||||
"You are resolving a conflict between the user's restored local working-tree",
|
||||
"edits and the freshly-merged integration branch. The user's uncommitted work",
|
||||
"was stashed, the checkout fast-forwarded to the new tip, and re-applying the",
|
||||
"stash produced conflicts.",
|
||||
"",
|
||||
"Resolve every conflict marker so BOTH sides are preserved: keep the user's",
|
||||
"local intent AND the upstream changes that just landed. Stage each resolved",
|
||||
"file with `git add`.",
|
||||
"",
|
||||
"Do NOT commit, stash, reset, checkout a different branch, or run update-ref.",
|
||||
"Leave the resolved changes in the working tree as the user's uncommitted edits.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildStashResolvePrompt(conflictedFiles: string[]): string {
|
||||
return [
|
||||
"Re-applying your stashed local changes onto the updated branch conflicted.",
|
||||
"",
|
||||
"Conflicted files:",
|
||||
...conflictedFiles.map((f) => ` - ${f}`),
|
||||
"",
|
||||
"Resolve each file's conflict markers (preserve both the local edits and the",
|
||||
"upstream changes), then `git add` it. Do not commit.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function short(sha: string): string {
|
||||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
|
||||
}
|
||||
287
packages/engine/src/merger-ai-worktree.ts
Normal file
287
packages/engine/src/merger-ai-worktree.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
FNXC:MergerAiSplit 2026-06-25-00:00:
|
||||
FN-7029 extracts AI-merge worktree lifecycle helpers from merger-ai.ts so the sole FN-5633 clean-room merge path stays under the 2000-line guardrail without changing cleanup semantics or public merger-ai.js exports.
|
||||
|
||||
FNXC:MergerAiSplit 2026-06-25-00:00:
|
||||
Keep importing MIN_TEMP_WORKTREE_REAP_AGE_MS from self-healing.js here. Do not reverse the dependency: self-healing owns the stale-temp age policy and merger-ai-worktree only consumes it for pre-merge pruning, preserving the established self-healing import-cycle constraint.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Settings } from "@fusion/core";
|
||||
|
||||
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||
import type { RunAuditor } from "./run-audit.js";
|
||||
import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js";
|
||||
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(args: string[], cwd: string, opts: { timeout?: number } = {}): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
timeout: opts.timeout ?? 120_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function getErrorStringProperty(err: unknown, key: "stderr" | "code"): string | undefined {
|
||||
if (!err || typeof err !== "object" || !(key in err)) return undefined;
|
||||
const value = (err as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function describeCleanupError(err: unknown): string {
|
||||
const stderr = getErrorStringProperty(err, "stderr");
|
||||
const message = getErrorMessage(err);
|
||||
return stderr ? `${message}: ${stderr.trim()}` : message;
|
||||
}
|
||||
|
||||
export function isBenignAbsentWorktreeError(err: unknown): boolean {
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
if (code === "ENOENT") return true;
|
||||
const description = describeCleanupError(err);
|
||||
return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description);
|
||||
}
|
||||
|
||||
function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void {
|
||||
const excludePath = join(projectRootDir, ".git", "info", "exclude");
|
||||
if (!existsSync(excludePath)) return;
|
||||
try {
|
||||
const current = readFileSync(excludePath, "utf-8");
|
||||
const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir);
|
||||
const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot);
|
||||
const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`];
|
||||
const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings);
|
||||
const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot);
|
||||
if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) {
|
||||
entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`);
|
||||
}
|
||||
|
||||
const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry));
|
||||
if (missing.length > 0) {
|
||||
appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`);
|
||||
}
|
||||
} catch {
|
||||
// Best effort only: cleanup still removes the root contents, and existing
|
||||
// projects generally ignore .fusion already.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string {
|
||||
const root = resolveAiMergeRootPath(projectRootDir, settings);
|
||||
mkdirSync(root, { recursive: true });
|
||||
ensureAiMergeRootIgnored(projectRootDir, settings);
|
||||
return root;
|
||||
}
|
||||
|
||||
function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] {
|
||||
const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()];
|
||||
const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT;
|
||||
if (testWorkerRoot) {
|
||||
try {
|
||||
for (const entry of readdirSync(testWorkerRoot)) {
|
||||
if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry));
|
||||
}
|
||||
} catch {
|
||||
// Best effort for the test harness' bounded temp-dir redirection root.
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(roots));
|
||||
}
|
||||
|
||||
export async function pruneExistingAiMergeWorktrees(
|
||||
taskId: string,
|
||||
projectRootDir: string,
|
||||
audit: RunAuditor,
|
||||
log: (message: string) => Promise<void>,
|
||||
settings?: Settings,
|
||||
): Promise<number> {
|
||||
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
|
||||
const tempRoots = getAiMergeTempSearchRoots(projectRootDir, settings);
|
||||
|
||||
let pruned = 0;
|
||||
let cleanupAttempted = false;
|
||||
for (const tempRoot of tempRoots) {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix));
|
||||
} catch (err: unknown) {
|
||||
/*
|
||||
FNXC:AiMerge 2026-06-24-23:10:
|
||||
An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory
|
||||
(e.g. `<repo>/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a
|
||||
workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means
|
||||
"nothing to prune" — skip it silently rather than emitting an alarming warning on every merge.
|
||||
Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir
|
||||
(which always exists) remains fatal.
|
||||
*/
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue;
|
||||
await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`);
|
||||
if (tempRoot === tmpdir()) throw err;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const candidatePath = join(tempRoot, entry);
|
||||
let canonicalPath = candidatePath;
|
||||
try {
|
||||
canonicalPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
canonicalPath = candidatePath;
|
||||
}
|
||||
|
||||
if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) {
|
||||
await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(canonicalPath);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
if (ageMs < MIN_TEMP_WORKTREE_REAP_AGE_MS) {
|
||||
await log(`AI merge pre-merge prune: skipping too-new worktree ${canonicalPath} (age ${Math.max(0, Math.round(ageMs))}ms)`);
|
||||
continue;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await log(`AI merge pre-merge prune: failed to stat ${canonicalPath}: ${getErrorMessage(err)} — skipping candidate`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let alreadyAbsent = false;
|
||||
try {
|
||||
cleanupAttempted = true;
|
||||
await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], {
|
||||
cwd: projectRootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
alreadyAbsent = true;
|
||||
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent/de-registered; treating cleanup as idempotent`);
|
||||
} else {
|
||||
await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cleanupAttempted = true;
|
||||
rmSync(canonicalPath, { recursive: true, force: true });
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
|
||||
pruned++;
|
||||
} catch (err: unknown) {
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent during filesystem cleanup; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, alreadyAbsent: true, idempotent: true } });
|
||||
pruned++;
|
||||
continue;
|
||||
}
|
||||
const error = getErrorMessage(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupAttempted) {
|
||||
try {
|
||||
await execFileAsync("git", ["worktree", "prune"], { cwd: projectRootDir, timeout: 30_000 });
|
||||
} catch (err: unknown) {
|
||||
await log(`AI merge pre-merge prune: git worktree prune failed: ${describeCleanupError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
export async function cleanupAiMergeWorktree(input: {
|
||||
taskId: string;
|
||||
mergeRoot: string;
|
||||
projectRootDir: string;
|
||||
worktreeAdded: boolean;
|
||||
audit: RunAuditor;
|
||||
log: (message: string) => Promise<void>;
|
||||
gitRunner?: typeof git;
|
||||
rmRunner?: typeof rm;
|
||||
}): Promise<void> {
|
||||
const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input;
|
||||
let canonicalRoot = mergeRoot;
|
||||
try {
|
||||
canonicalRoot = realpathSync(mergeRoot);
|
||||
} catch {
|
||||
canonicalRoot = mergeRoot;
|
||||
}
|
||||
const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot];
|
||||
const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot };
|
||||
let alreadyAbsent = false;
|
||||
|
||||
if (worktreeAdded) {
|
||||
if (!existsSync(canonicalRoot) && !existsSync(mergeRoot)) {
|
||||
alreadyAbsent = true;
|
||||
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" } });
|
||||
} else {
|
||||
try {
|
||||
await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } });
|
||||
} catch (err: unknown) {
|
||||
const error = describeCleanupError(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
alreadyAbsent = true;
|
||||
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent/de-registered during git removal; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
|
||||
} else {
|
||||
await log(`AI merge cleanup: git worktree remove failed for ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let removedFromFilesystem = false;
|
||||
for (const target of removalTargets) {
|
||||
try {
|
||||
await rmRunner(target, { recursive: true, force: true });
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
|
||||
removedFromFilesystem = true;
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
const error = getErrorMessage(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
await log(`AI merge cleanup: worktree ${target} was already absent during filesystem cleanup; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
|
||||
removedFromFilesystem = true;
|
||||
break;
|
||||
}
|
||||
await log(`AI merge cleanup: filesystem rm failed for ${target}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!removedFromFilesystem) {
|
||||
await log(`AI merge cleanup: filesystem cleanup did not remove ${canonicalRoot}; continuing to prune worktree metadata`);
|
||||
}
|
||||
|
||||
try {
|
||||
await gitRunner(["worktree", "prune"], projectRootDir, { timeout: 30_000 });
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: true } });
|
||||
} catch (err: unknown) {
|
||||
const error = describeCleanupError(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
await log(`AI merge cleanup: git worktree prune failed after removing ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,29 +37,25 @@
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertNotWorkspaceTaskMerge,
|
||||
buildTaskLineageTrailer,
|
||||
evaluateNoCommitsNoOpFinalize,
|
||||
getPrimaryPrInfo,
|
||||
getTaskMergeBlocker,
|
||||
resolveAgentPrompt,
|
||||
resolvePersistAgentThinkingLog,
|
||||
resolveTaskMergeTarget,
|
||||
resolveValidatorSettingsModel,
|
||||
type AgentPromptsConfig,
|
||||
type MergeDetails,
|
||||
type MergeResult,
|
||||
type Settings,
|
||||
type Task,
|
||||
type TaskComment,
|
||||
type TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js";
|
||||
import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
|
||||
import { resolveTaskWorkingBranch } from "./worktree-names.js";
|
||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
|
||||
@@ -74,16 +70,29 @@ import { createLogger } from "./logger.js";
|
||||
import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js";
|
||||
import { installWorktreeDependencies } from "./merge-dependency-sync.js";
|
||||
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||
import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js";
|
||||
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
/*
|
||||
FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved):
|
||||
`isRepoLanded` + `FUSION_TASK_ID_TRAILER_KEY` moved to the dependency-free `workspace-land-predicate`
|
||||
module so self-healing can import the predicate without re-entering the self-healing ↔ merger-ai
|
||||
import cycle (merger-ai already imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing).
|
||||
import cycle (merger-ai-worktree imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing).
|
||||
*/
|
||||
import { isRepoLanded, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js";
|
||||
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||
import {
|
||||
cleanupAiMergeWorktree,
|
||||
pruneExistingAiMergeWorktrees,
|
||||
resolveAiMergeRoot,
|
||||
} from "./merger-ai-worktree.js";
|
||||
import {
|
||||
buildMergePrompt,
|
||||
buildMergeSystemPrompt,
|
||||
buildReviewPrompt,
|
||||
buildReviewSystemPrompt,
|
||||
buildStashResolvePrompt,
|
||||
buildStashResolveSystemPrompt,
|
||||
parseReviewVerdict,
|
||||
} from "./merger-ai-prompts.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const aiMergeLog = createLogger("merger-ai");
|
||||
@@ -113,257 +122,16 @@ function getErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function getErrorStringProperty(err: unknown, key: "stderr" | "code"): string | undefined {
|
||||
if (!err || typeof err !== "object" || !(key in err)) return undefined;
|
||||
const value = (err as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
function short(sha: string): string {
|
||||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
|
||||
}
|
||||
|
||||
function describeCleanupError(err: unknown): string {
|
||||
const stderr = getErrorStringProperty(err, "stderr");
|
||||
const message = getErrorMessage(err);
|
||||
return stderr ? `${message}: ${stderr.trim()}` : message;
|
||||
}
|
||||
|
||||
export function isBenignAbsentWorktreeError(err: unknown): boolean {
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
if (code === "ENOENT") return true;
|
||||
const description = describeCleanupError(err);
|
||||
return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description);
|
||||
}
|
||||
|
||||
function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void {
|
||||
const excludePath = join(projectRootDir, ".git", "info", "exclude");
|
||||
if (!existsSync(excludePath)) return;
|
||||
try {
|
||||
const current = readFileSync(excludePath, "utf-8");
|
||||
const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir);
|
||||
const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot);
|
||||
const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`];
|
||||
const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings);
|
||||
const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot);
|
||||
if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) {
|
||||
entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`);
|
||||
}
|
||||
|
||||
const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry));
|
||||
if (missing.length > 0) {
|
||||
appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`);
|
||||
}
|
||||
} catch {
|
||||
// Best effort only: cleanup still removes the root contents, and existing
|
||||
// projects generally ignore .fusion already.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string {
|
||||
const root = resolveAiMergeRootPath(projectRootDir, settings);
|
||||
mkdirSync(root, { recursive: true });
|
||||
ensureAiMergeRootIgnored(projectRootDir, settings);
|
||||
return root;
|
||||
}
|
||||
|
||||
function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] {
|
||||
const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()];
|
||||
const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT;
|
||||
if (testWorkerRoot) {
|
||||
try {
|
||||
for (const entry of readdirSync(testWorkerRoot)) {
|
||||
if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry));
|
||||
}
|
||||
} catch {
|
||||
// Best effort for the test harness' bounded temp-dir redirection root.
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(roots));
|
||||
}
|
||||
|
||||
export async function pruneExistingAiMergeWorktrees(
|
||||
taskId: string,
|
||||
projectRootDir: string,
|
||||
audit: RunAuditor,
|
||||
log: (message: string) => Promise<void>,
|
||||
settings?: Settings,
|
||||
): Promise<number> {
|
||||
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
|
||||
const tempRoots = getAiMergeTempSearchRoots(projectRootDir, settings);
|
||||
|
||||
let pruned = 0;
|
||||
let cleanupAttempted = false;
|
||||
for (const tempRoot of tempRoots) {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix));
|
||||
} catch (err: unknown) {
|
||||
/*
|
||||
FNXC:AiMerge 2026-06-24-23:10:
|
||||
An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory
|
||||
(e.g. `<repo>/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a
|
||||
workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means
|
||||
"nothing to prune" — skip it silently rather than emitting an alarming warning on every merge.
|
||||
Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir
|
||||
(which always exists) remains fatal.
|
||||
*/
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue;
|
||||
await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`);
|
||||
if (tempRoot === tmpdir()) throw err;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const candidatePath = join(tempRoot, entry);
|
||||
let canonicalPath = candidatePath;
|
||||
try {
|
||||
canonicalPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
canonicalPath = candidatePath;
|
||||
}
|
||||
|
||||
if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) {
|
||||
await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(canonicalPath);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
if (ageMs < MIN_TEMP_WORKTREE_REAP_AGE_MS) {
|
||||
await log(`AI merge pre-merge prune: skipping too-new worktree ${canonicalPath} (age ${Math.max(0, Math.round(ageMs))}ms)`);
|
||||
continue;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await log(`AI merge pre-merge prune: failed to stat ${canonicalPath}: ${getErrorMessage(err)} — skipping candidate`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let alreadyAbsent = false;
|
||||
try {
|
||||
cleanupAttempted = true;
|
||||
await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], {
|
||||
cwd: projectRootDir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
alreadyAbsent = true;
|
||||
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent/de-registered; treating cleanup as idempotent`);
|
||||
} else {
|
||||
await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cleanupAttempted = true;
|
||||
rmSync(canonicalPath, { recursive: true, force: true });
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
|
||||
pruned++;
|
||||
} catch (err: unknown) {
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent during filesystem cleanup; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, alreadyAbsent: true, idempotent: true } });
|
||||
pruned++;
|
||||
continue;
|
||||
}
|
||||
const error = getErrorMessage(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupAttempted) {
|
||||
try {
|
||||
await execFileAsync("git", ["worktree", "prune"], { cwd: projectRootDir, timeout: 30_000 });
|
||||
} catch (err: unknown) {
|
||||
await log(`AI merge pre-merge prune: git worktree prune failed: ${describeCleanupError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return pruned;
|
||||
}
|
||||
|
||||
export async function cleanupAiMergeWorktree(input: {
|
||||
taskId: string;
|
||||
mergeRoot: string;
|
||||
projectRootDir: string;
|
||||
worktreeAdded: boolean;
|
||||
audit: RunAuditor;
|
||||
log: (message: string) => Promise<void>;
|
||||
gitRunner?: typeof git;
|
||||
rmRunner?: typeof rm;
|
||||
}): Promise<void> {
|
||||
const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input;
|
||||
let canonicalRoot = mergeRoot;
|
||||
try {
|
||||
canonicalRoot = realpathSync(mergeRoot);
|
||||
} catch {
|
||||
canonicalRoot = mergeRoot;
|
||||
}
|
||||
const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot];
|
||||
const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot };
|
||||
let alreadyAbsent = false;
|
||||
|
||||
if (worktreeAdded) {
|
||||
if (!existsSync(canonicalRoot) && !existsSync(mergeRoot)) {
|
||||
alreadyAbsent = true;
|
||||
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" } });
|
||||
} else {
|
||||
try {
|
||||
await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } });
|
||||
} catch (err: unknown) {
|
||||
const error = describeCleanupError(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
alreadyAbsent = true;
|
||||
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent/de-registered during git removal; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
|
||||
} else {
|
||||
await log(`AI merge cleanup: git worktree remove failed for ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let removedFromFilesystem = false;
|
||||
for (const target of removalTargets) {
|
||||
try {
|
||||
await rmRunner(target, { recursive: true, force: true });
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
|
||||
removedFromFilesystem = true;
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
const error = getErrorMessage(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
if (isBenignAbsentWorktreeError(err)) {
|
||||
await log(`AI merge cleanup: worktree ${target} was already absent during filesystem cleanup; treating cleanup as idempotent`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
|
||||
removedFromFilesystem = true;
|
||||
break;
|
||||
}
|
||||
await log(`AI merge cleanup: filesystem rm failed for ${target}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!removedFromFilesystem) {
|
||||
await log(`AI merge cleanup: filesystem cleanup did not remove ${canonicalRoot}; continuing to prune worktree metadata`);
|
||||
}
|
||||
|
||||
try {
|
||||
await gitRunner(["worktree", "prune"], projectRootDir, { timeout: 30_000 });
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: true } });
|
||||
} catch (err: unknown) {
|
||||
const error = describeCleanupError(err);
|
||||
const code = getErrorStringProperty(err, "code");
|
||||
await log(`AI merge cleanup: git worktree prune failed after removing ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
|
||||
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: false, error, ...(code ? { code } : {}) } });
|
||||
}
|
||||
|
||||
}
|
||||
export {
|
||||
cleanupAiMergeWorktree,
|
||||
isBenignAbsentWorktreeError,
|
||||
pruneExistingAiMergeWorktrees,
|
||||
resolveAiMergeRoot,
|
||||
} from "./merger-ai-worktree.js";
|
||||
|
||||
/** Trailers that associate the squash commit with its board task: the
|
||||
* `Fusion-Task-Id` trailer plus the canonical lineage trailer when available.
|
||||
@@ -412,263 +180,17 @@ async function ensureCommitTaskMetadata(
|
||||
// Pure helpers (unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiMergeReviewSeverity = "blocking" | "advisory";
|
||||
|
||||
export interface AiMergeReviewVerdict {
|
||||
verdict: "approve" | "reject";
|
||||
reasons: string[];
|
||||
severity?: AiMergeReviewSeverity;
|
||||
}
|
||||
|
||||
export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:";
|
||||
const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i;
|
||||
const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i;
|
||||
|
||||
/**
|
||||
* Parse the reviewer's free-form output. Fail-safe: no/garbled output, or a
|
||||
* rejection with no explicit severity, is treated as a BLOCKING reject — an
|
||||
* ambiguous reviewer can never wave wrong code through, nor silently downgrade
|
||||
* to advisory.
|
||||
*/
|
||||
export function parseReviewVerdict(agentText: string | null | undefined): AiMergeReviewVerdict {
|
||||
const text = (agentText ?? "").trim();
|
||||
if (!text) return { verdict: "reject", reasons: ["reviewer produced no output"], severity: "blocking" };
|
||||
|
||||
const lines = text.split(/\r?\n/);
|
||||
let verdictLineIndex = -1;
|
||||
let decision: "approve" | "reject" | null = null;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const m = lines[i].match(VERDICT_LINE_RE);
|
||||
if (m) {
|
||||
decision = m[1].toLowerCase() as "approve" | "reject";
|
||||
verdictLineIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!decision) {
|
||||
return {
|
||||
verdict: "reject",
|
||||
reasons: [`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`],
|
||||
severity: "blocking",
|
||||
};
|
||||
}
|
||||
if (decision === "approve") return { verdict: "approve", reasons: [] };
|
||||
|
||||
const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text)
|
||||
? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity)
|
||||
: "blocking";
|
||||
return { verdict: "reject", reasons: extractRejectReasons(lines, verdictLineIndex), severity };
|
||||
}
|
||||
|
||||
function extractRejectReasons(lines: string[], verdictLineIndex: number): string[] {
|
||||
const reasons: string[] = [];
|
||||
const inline = lines[verdictLineIndex].replace(VERDICT_LINE_RE, "").replace(/^[\s:–—-]+/, "").trim();
|
||||
if (inline) reasons.push(inline);
|
||||
for (let i = verdictLineIndex + 1; i < lines.length; i++) {
|
||||
if (SEVERITY_LINE_RE.test(lines[i])) continue;
|
||||
const cleaned = lines[i].replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim();
|
||||
if (cleaned) reasons.push(cleaned);
|
||||
}
|
||||
if (reasons.length === 0) reasons.push("reviewer rejected the merge without a stated reason");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
export function buildMergeSystemPrompt(agentPrompts?: AgentPromptsConfig): string {
|
||||
// Base persona is the editable "merger" agent prompt (Settings → Prompts);
|
||||
// the non-negotiable clean-room / verification / commit-trailer rules below
|
||||
// are always appended so a custom prompt can't drop them.
|
||||
const base = resolveAgentPrompt("merger", agentPrompts).trim();
|
||||
return [
|
||||
base,
|
||||
base ? "" : undefined,
|
||||
"## AI merge — clean room",
|
||||
"You are on a CLEAN, detached checkout at the integration branch's current",
|
||||
"tip. Land the task branch's work as a single commit.",
|
||||
"",
|
||||
"Constraints:",
|
||||
" - Resolve every conflict in favor of the task branch's intent; never drop",
|
||||
" the task's changes to make a conflict go away.",
|
||||
" - Do not make edits unrelated to reconciling the two branches.",
|
||||
" - Do NOT push, force-push, or run `git update-ref` / `git reset --hard`",
|
||||
" on any other branch. Only commit on this detached HEAD.",
|
||||
" - Finish with exactly ONE new commit on HEAD containing the task's work.",
|
||||
"",
|
||||
"Verify before committing:",
|
||||
" - After resolving the merge, run the project's checks — tests, type-check,",
|
||||
" and lint (discover them from the project config / package.json scripts,",
|
||||
" e.g. test / typecheck / lint / build).",
|
||||
" - FIX any NEW failure the merge or conflict resolution introduced (a check",
|
||||
" that passed on the task branch or the integration tip but fails on the",
|
||||
" merged tree). You do not need to fix failures that were already broken on",
|
||||
" the integration branch beforehand, but never commit a merge that adds new",
|
||||
" test, type-check, or lint failures.",
|
||||
"",
|
||||
"Commit message:",
|
||||
" - The subject line must CONCISELY SUMMARIZE the squashed changes in",
|
||||
" imperative mood (e.g. \"add X\", \"fix Y\") based on the actual diff — do",
|
||||
" not just restate the task title.",
|
||||
" - The commit BODY must include:",
|
||||
" 1) one short narrative summary line,",
|
||||
" 2) a bullet list of key changes, and",
|
||||
" 3) a `Files changed:` section populated from `git diff --stat`.",
|
||||
" - Include the task-id prefix and the trailer lines EXACTLY as given in the",
|
||||
" task instructions (they associate the commit with the board task).",
|
||||
].filter((l) => l !== undefined).join("\n");
|
||||
}
|
||||
|
||||
export function buildMergePrompt(input: {
|
||||
taskId: string;
|
||||
branch: string;
|
||||
integrationBranch: string;
|
||||
tipSha: string;
|
||||
/** Task title — a HINT for the summary, not the literal subject. */
|
||||
taskTitle?: string;
|
||||
/** Whether to prefix the subject with the task id. */
|
||||
includeTaskId: boolean;
|
||||
/** Required trailers to append (board association). */
|
||||
trailers: string[];
|
||||
correctiveReasons?: string[];
|
||||
userComments?: TaskComment[];
|
||||
}): string {
|
||||
const subjectShape = input.includeTaskId
|
||||
? `"${input.taskId}: <concise imperative summary of the squashed changes>"`
|
||||
: `"<concise imperative summary of the squashed changes>"`;
|
||||
const trailerArgs = input.trailers.map((t) => ` -m ${JSON.stringify(t)}`).join("");
|
||||
const lines = [
|
||||
`Merge branch "${input.branch}" into "${input.integrationBranch}" (HEAD is detached at ${short(input.tipSha)}).`,
|
||||
"",
|
||||
"Steps:",
|
||||
` 1. Run: git merge --squash ${input.branch}`,
|
||||
" 2. If there are conflicts, resolve them (favor the task's intent), then `git add` the resolved files.",
|
||||
" 3. Build a merge body from the staged squash diff:",
|
||||
" - one short narrative summary line",
|
||||
" - bullet list of key changes",
|
||||
" - `Files changed:` + the output of `git diff --stat`",
|
||||
" 4. Commit the staged result as a SINGLE commit whose subject summarizes the",
|
||||
` actual changes${input.taskTitle ? ` (task title hint: ${JSON.stringify(input.taskTitle)})` : ""}, including the body above and required trailers:`,
|
||||
` git commit -m ${subjectShape} -m "<narrative + bullet list + Files changed: ...>"${trailerArgs}`,
|
||||
" Keep the trailer line(s) verbatim — they link the commit to the board task.",
|
||||
" 5. Verify `git log --oneline ${tip}..HEAD` shows exactly one new commit and `git status` is clean.".replace("${tip}", short(input.tipSha)),
|
||||
"",
|
||||
"If `git merge --squash` reports the branch is already up to date (nothing to",
|
||||
"merge), do nothing and leave HEAD unchanged.",
|
||||
];
|
||||
const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []);
|
||||
if (userCommentsSection) {
|
||||
lines.push("", userCommentsSection);
|
||||
}
|
||||
if (input.correctiveReasons && input.correctiveReasons.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"A prior attempt was REJECTED by review. Redo the merge from the clean tip",
|
||||
"and address each of these problems:",
|
||||
...input.correctiveReasons.map((r) => ` - ${r}`),
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildReviewSystemPrompt(): string {
|
||||
return [
|
||||
"You are an adversarial, read-only merge reviewer. Do NOT edit, stage, commit,",
|
||||
"or run any mutating git command. Audit the squash commit that is about to be",
|
||||
"merged into the integration branch and decide whether it is safe to land.",
|
||||
"",
|
||||
"Investigate with read-only commands (git show, git diff, git log, cat, grep).",
|
||||
"Judge on four axes:",
|
||||
" 1. Completeness — does the squash contain ALL of the task branch's intended",
|
||||
" changes? Flag any hunk silently dropped during conflict resolution.",
|
||||
" 2. No collateral — does it touch only files within the task's footprint?",
|
||||
" 3. Conflict soundness — were conflicts resolved coherently (both sides'",
|
||||
" intent preserved), not by blindly discarding one side?",
|
||||
" 4. Commit message — read `git show`'s message: the subject must concisely",
|
||||
" and ACCURATELY summarize the actual changes (not vague, not a mere",
|
||||
" restatement of the task title, not misleading). A poor/inaccurate",
|
||||
" message is an ADVISORY concern (it should be rewritten on retry, but",
|
||||
" must not block the merge).",
|
||||
"",
|
||||
"Bias toward rejection when uncertain.",
|
||||
"",
|
||||
`End with a single decision line: "${REVIEW_VERDICT_MARKER} approve" or`,
|
||||
`"${REVIEW_VERDICT_MARKER} reject". When rejecting, add a "SEVERITY:" line:`,
|
||||
" - SEVERITY: blocking — a correctness problem (dropped/lost task changes,",
|
||||
" incomplete squash, or a conflict resolution that discards intent). The",
|
||||
" merge must NOT land if this is unfixable.",
|
||||
" - SEVERITY: advisory — a quality/style concern that does not risk",
|
||||
" correctness; acceptable to land if unresolved.",
|
||||
"Then list each concrete reason as a bullet.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildReviewPrompt(input: {
|
||||
taskId: string;
|
||||
branch: string;
|
||||
integrationBranch: string;
|
||||
tipSha: string;
|
||||
squashSha: string;
|
||||
diffStat: string;
|
||||
priorReasons?: string[];
|
||||
userComments?: TaskComment[];
|
||||
}): string {
|
||||
const lines = [
|
||||
`Review the squash merge for task ${input.taskId} (branch ${input.branch} → ${input.integrationBranch}).`,
|
||||
"",
|
||||
`Integration tip: ${short(input.tipSha)}`,
|
||||
`Squash commit: ${short(input.squashSha)}`,
|
||||
"",
|
||||
"Inspect with:",
|
||||
` git show ${input.squashSha}`,
|
||||
` git diff ${input.tipSha}..${input.squashSha}`,
|
||||
"",
|
||||
"Files changed (git diff --stat):",
|
||||
input.diffStat.trim() || "(none reported)",
|
||||
];
|
||||
const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []);
|
||||
if (userCommentsSection) {
|
||||
lines.push("", userCommentsSection);
|
||||
}
|
||||
if (input.priorReasons && input.priorReasons.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"A prior pass rejected an earlier attempt for these reasons — confirm they",
|
||||
"are now resolved:",
|
||||
...input.priorReasons.map((r) => ` - ${r}`),
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildStashResolveSystemPrompt(): string {
|
||||
return [
|
||||
"You are resolving a conflict between the user's restored local working-tree",
|
||||
"edits and the freshly-merged integration branch. The user's uncommitted work",
|
||||
"was stashed, the checkout fast-forwarded to the new tip, and re-applying the",
|
||||
"stash produced conflicts.",
|
||||
"",
|
||||
"Resolve every conflict marker so BOTH sides are preserved: keep the user's",
|
||||
"local intent AND the upstream changes that just landed. Stage each resolved",
|
||||
"file with `git add`.",
|
||||
"",
|
||||
"Do NOT commit, stash, reset, checkout a different branch, or run update-ref.",
|
||||
"Leave the resolved changes in the working tree as the user's uncommitted edits.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildStashResolvePrompt(conflictedFiles: string[]): string {
|
||||
return [
|
||||
"Re-applying your stashed local changes onto the updated branch conflicted.",
|
||||
"",
|
||||
"Conflicted files:",
|
||||
...conflictedFiles.map((f) => ` - ${f}`),
|
||||
"",
|
||||
"Resolve each file's conflict markers (preserve both the local edits and the",
|
||||
"upstream changes), then `git add` it. Do not commit.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function short(sha: string): string {
|
||||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
|
||||
}
|
||||
export {
|
||||
REVIEW_VERDICT_MARKER,
|
||||
buildMergePrompt,
|
||||
buildMergeSystemPrompt,
|
||||
buildReviewPrompt,
|
||||
buildReviewSystemPrompt,
|
||||
buildStashResolvePrompt,
|
||||
buildStashResolveSystemPrompt,
|
||||
parseReviewVerdict,
|
||||
} from "./merger-ai-prompts.js";
|
||||
export type { AiMergeReviewSeverity, AiMergeReviewVerdict } from "./merger-ai-prompts.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
@@ -734,6 +256,8 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
runAuditor: audit,
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-22:48: merger-ai is the production merge path, so the mutating agent resolves enabled MCP servers at session creation and relies on the shared runtime guard for unsupported providers.
|
||||
mcpServers: (await resolveMcpServersForStore(store)).servers,
|
||||
taskId,
|
||||
});
|
||||
options.onSession?.(session);
|
||||
@@ -791,6 +315,8 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
runAuditor: audit,
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-22:48: The production merge reviewer receives the same materialized MCP set as the mutating merge agent, preserving all-lane forwarding without logging server contents.
|
||||
mcpServers: (await resolveMcpServersForStore(store)).servers,
|
||||
taskId,
|
||||
});
|
||||
options.onSession?.(session);
|
||||
|
||||
@@ -112,6 +112,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
import { classifyTaskWorktree, getRegisteredWorktreeBranches, isRepoRootPath, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
|
||||
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
@@ -157,6 +158,12 @@ import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger
|
||||
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
export { IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
|
||||
|
||||
async function resolveMergerMcpServers(store?: TaskStore, agentId?: string | null) {
|
||||
// FNXC:McpConfig 2026-06-25-22:27:
|
||||
// Merger-owned sessions resolve enabled MCP servers at session creation for conflict resolution, verification fixes, autostash recovery, and post-merge workflow nodes. Secret material stays in memory and is forwarded only through the shared runtime guard.
|
||||
return store ? (await resolveMcpServersForStore(store, { agentId: agentId ?? undefined })).servers : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* After `advanceIntegrationBranchRef` ff-updates `refs/heads/<integrationBranch>`,
|
||||
* any other worktree still checked out on that branch keeps its index + working
|
||||
@@ -1965,6 +1972,7 @@ Do not refactor, rename broadly, or make opportunistic improvements.
|
||||
source: "merger",
|
||||
}),
|
||||
settings,
|
||||
mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id),
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId,
|
||||
@@ -3158,6 +3166,7 @@ ${fileList}
|
||||
source: "merger",
|
||||
}),
|
||||
settings,
|
||||
mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id),
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId,
|
||||
taskTitle: taskForSkillContext?.title,
|
||||
@@ -3574,6 +3583,7 @@ ${fileList}
|
||||
source: "merger",
|
||||
}),
|
||||
settings,
|
||||
mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id),
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId,
|
||||
taskTitle: taskForSkillContext?.title,
|
||||
@@ -7070,6 +7080,7 @@ You are assisting with a paused \`git pull --rebase\`.
|
||||
source: "merger",
|
||||
}),
|
||||
settings,
|
||||
mcpServers: await resolveMergerMcpServers(store),
|
||||
taskId,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "merger",
|
||||
@@ -11978,6 +11989,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
source: "merger",
|
||||
}),
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-23:04: The primary merge-authoring agent is part of the merger lane and receives the resolved MCP set under the shared runtime-support guard, matching conflict/verification merge sessions without exposing secret material.
|
||||
mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id),
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./agent-session-helpers.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
|
||||
/** Logger for the mission execution loop subsystem. */
|
||||
@@ -564,6 +565,8 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
defaultThinkingLevel: "medium",
|
||||
runAuditor,
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-23:19: Mission validation is a validator lane and receives the store-resolved MCP set at session creation; runtime gating and content-free skip logging remain centralized in pi.
|
||||
mcpServers: (await resolveMcpServersForStore(this.taskStore)).servers,
|
||||
onText: (_delta) => {
|
||||
// Could stream this to a log entry if needed
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
AgentPermissionPolicyActionCategory,
|
||||
PermanentAgentActionCategory,
|
||||
PermanentAgentGatingContext,
|
||||
ResolvedMcpServerDefinition,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
resolveSessionSkills,
|
||||
@@ -72,6 +73,7 @@ import type { SystemPromptLayers } from "./prompt-layers.js";
|
||||
import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js";
|
||||
import { createStreamingDeltaNormalizer } from "./streaming-delta.js";
|
||||
import { isModelAuthTierIncompatibilityError, isUnsupportedMessageRoleError } from "./transient-error-detector.js";
|
||||
import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp-runtime-support.js";
|
||||
export { isModelAuthTierIncompatibilityError } from "./transient-error-detector.js";
|
||||
|
||||
const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]);
|
||||
@@ -956,6 +958,13 @@ export interface AgentOptions {
|
||||
systemPromptLayers?: SystemPromptLayers;
|
||||
tools?: "coding" | "readonly";
|
||||
customTools?: ToolDefinition[];
|
||||
/**
|
||||
* Optional resolved tool-name allowlist. Undefined preserves the selected tool mode; an empty array deliberately exposes no matched tools.
|
||||
*
|
||||
* FNXC:AutomationTools 2026-06-26-00:00:
|
||||
* Automation AI steps can narrow coding sessions by tool name while legacy steps keep all tools. Normalize names case-insensitively at the engine boundary so dashboard labels like "Read" match pi tool names like "read".
|
||||
*/
|
||||
toolsAllowlist?: string[];
|
||||
/** Optional allowlist of builtin runtime web tools to keep enabled. */
|
||||
builtinToolsAllowlist?: BuiltinWebToolName[];
|
||||
onText?: (delta: string) => void;
|
||||
@@ -991,6 +1000,11 @@ export interface AgentOptions {
|
||||
* loader so callers (e.g. plugins that install skills to a private dir) can
|
||||
* make `skills`/`skillSelection` names discoverable in the live session. */
|
||||
additionalSkillPaths?: string[];
|
||||
/**
|
||||
* Resolved/materialized MCP servers for the session. The runtime-support guard
|
||||
* below decides whether to forward or skip them without logging contents.
|
||||
*/
|
||||
mcpServers?: ResolvedMcpServerDefinition[];
|
||||
/** Optional task-scoped env injected into this session's subprocess tools only. */
|
||||
taskEnv?: NodeJS.ProcessEnv;
|
||||
/** Last-chance abort hook fired immediately before `createAgentSession`.
|
||||
@@ -1960,8 +1974,23 @@ export function attachSessionRoutingHeaders(modelRegistry: ModelRegistry, sessio
|
||||
* Returned sessions are wrapped so `session.dispose()` emits pi's
|
||||
* `session_shutdown` extension event before teardown.
|
||||
*/
|
||||
function withMcpPromptOptions(promptOptions: unknown, mcpServers: ResolvedMcpServerDefinition[] | undefined): unknown {
|
||||
if (!mcpServers || mcpServers.length === 0) return promptOptions;
|
||||
if (promptOptions && typeof promptOptions === "object" && !Array.isArray(promptOptions)) {
|
||||
return { ...(promptOptions as Record<string, unknown>), mcpServers };
|
||||
}
|
||||
return { mcpServers };
|
||||
}
|
||||
|
||||
export async function createFnAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
piLog.log(`createFnAgent called (tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
|
||||
// FNXC:McpConfig 2026-06-25-22:02:
|
||||
// The pi session is the final shared forwarding seam for direct createFnAgent lanes. Forward the resolved MCP set only to MCP-capable provider/runtime combinations and keep unsupported lanes content-free by logging just provider/runtime/count metadata.
|
||||
const requestedMcpServers = options.mcpServers ?? [];
|
||||
const forwardedMcpServers = runtimeSupportsMcp("pi", options.defaultProvider) ? requestedMcpServers : [];
|
||||
if (requestedMcpServers.length > 0 && forwardedMcpServers.length === 0) {
|
||||
logMcpForwardingSkipped({ runtimeId: "pi", provider: options.defaultProvider, skippedCount: requestedMcpServers.length, lane: "createFnAgent" });
|
||||
}
|
||||
const authStorage = createFusionAuthStorage();
|
||||
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||
|
||||
@@ -2023,6 +2052,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
: undefined;
|
||||
|
||||
const isReadonly = options.tools === "readonly";
|
||||
const normalizedToolsAllowlist = options.toolsAllowlist === undefined
|
||||
? undefined
|
||||
: new Set(options.toolsAllowlist.map((name) => name.trim().toLowerCase()).filter(Boolean));
|
||||
const isAllowedByToolAllowlist = (toolName: string): boolean => normalizedToolsAllowlist === undefined || normalizedToolsAllowlist.has(toolName.trim().toLowerCase());
|
||||
const builtins = [
|
||||
createReadTool(options.cwd),
|
||||
createBashTool(options.cwd, bashToolOptions),
|
||||
@@ -2032,9 +2065,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
createFindTool(options.cwd),
|
||||
createLsTool(options.cwd),
|
||||
] as ToolDefinition[];
|
||||
const tools = isReadonly
|
||||
const modeFilteredTools = isReadonly
|
||||
? builtins.filter((tool) => isReadonlyAllowed(tool.name))
|
||||
: builtins;
|
||||
const tools = modeFilteredTools.filter((tool) => isAllowedByToolAllowlist(tool.name));
|
||||
// Suppress lint about unused presets — kept in scope for incremental migration.
|
||||
void createCodingTools;
|
||||
void createReadOnlyTools;
|
||||
@@ -2179,6 +2213,10 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
const readonlyFilteredCustomTools = isReadonly
|
||||
? filterCustomToolsForReadonly(options.customTools ?? [])
|
||||
: { allowed: options.customTools ?? [], denied: [] };
|
||||
const allowlistFilteredCustomTools = {
|
||||
...readonlyFilteredCustomTools,
|
||||
allowed: readonlyFilteredCustomTools.allowed.filter((tool) => isAllowedByToolAllowlist(tool.name)),
|
||||
};
|
||||
if (isReadonly && readonlyFilteredCustomTools.denied.length > 0) {
|
||||
piLog.warn(
|
||||
`[pi] readonly mode: dropped ${readonlyFilteredCustomTools.denied.length} denied custom tool(s): ${readonlyFilteredCustomTools.denied.join(", ")}`,
|
||||
@@ -2187,7 +2225,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
|
||||
const toolChainStart: ToolDefinition[] = [
|
||||
...(tools as ToolDefinition[]),
|
||||
...readonlyFilteredCustomTools.allowed,
|
||||
...allowlistFilteredCustomTools.allowed,
|
||||
];
|
||||
const toolsWithRtkRewrite = wrapToolsWithRtkRewrite(toolChainStart);
|
||||
const toolsWithPermanentGating = wrapToolsWithPermanentAgentGating(
|
||||
@@ -2222,7 +2260,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
if (options.beforeSpawnSession) {
|
||||
await options.beforeSpawnSession();
|
||||
}
|
||||
const createSessionOptions: Parameters<typeof createAgentSession>[0] = {
|
||||
const createSessionOptions: Parameters<typeof createAgentSession>[0] & { mcpServers?: ResolvedMcpServerDefinition[] } = {
|
||||
cwd: options.cwd,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
@@ -2231,13 +2269,14 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
customTools: customToolList,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
...(forwardedMcpServers.length > 0 ? { mcpServers: forwardedMcpServers } : {}),
|
||||
...(modelOverride ? { model: modelOverride } : {}),
|
||||
};
|
||||
|
||||
if (options.builtinToolsAllowlist && options.builtinToolsAllowlist.length > 0) {
|
||||
const safeBuiltinAllowlist = isReadonly
|
||||
const safeBuiltinAllowlist = (isReadonly
|
||||
? options.builtinToolsAllowlist.filter((name) => READONLY_ALLOWLIST.includes(name as (typeof READONLY_ALLOWLIST)[number]))
|
||||
: options.builtinToolsAllowlist;
|
||||
: options.builtinToolsAllowlist).filter(isAllowedByToolAllowlist);
|
||||
createSessionOptions.tools = [
|
||||
...new Set([
|
||||
...customToolList.map((tool) => tool.name),
|
||||
@@ -2245,6 +2284,14 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
]),
|
||||
].sort();
|
||||
}
|
||||
if (normalizedToolsAllowlist !== undefined) {
|
||||
createSessionOptions.tools = [
|
||||
...new Set([
|
||||
...customToolList.map((tool) => tool.name),
|
||||
...options.toolsAllowlist!.map((name) => name.trim()).filter(Boolean),
|
||||
]),
|
||||
].sort();
|
||||
}
|
||||
|
||||
return createAgentSession(createSessionOptions);
|
||||
};
|
||||
@@ -2357,14 +2404,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
};
|
||||
|
||||
promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => {
|
||||
const effectivePromptOptions = withMcpPromptOptions(promptOptions, forwardedMcpServers);
|
||||
try {
|
||||
await promptSessionAndCheck(activeSession, prompt, promptOptions);
|
||||
await promptSessionAndCheck(activeSession, prompt, effectivePromptOptions);
|
||||
return;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.message || "";
|
||||
if (isContextLimitError(errorMessage)) {
|
||||
// Context limit error — attempt auto-compaction and retry once
|
||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(activeSession, prompt, promptOptions);
|
||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(activeSession, prompt, effectivePromptOptions);
|
||||
if (promptMemoryRetry.recovered) {
|
||||
return;
|
||||
}
|
||||
@@ -2375,7 +2423,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
}
|
||||
}
|
||||
|
||||
const promptSectionRetry = await retryWithCompactedPromptSections(activeSession, prompt, promptOptions);
|
||||
const promptSectionRetry = await retryWithCompactedPromptSections(activeSession, prompt, effectivePromptOptions);
|
||||
if (promptSectionRetry.recovered) {
|
||||
return;
|
||||
}
|
||||
@@ -2392,7 +2440,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
if (compactResult) {
|
||||
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
|
||||
try {
|
||||
await promptSessionAndCheck(activeSession, prompt, promptOptions);
|
||||
await promptSessionAndCheck(activeSession, prompt, effectivePromptOptions);
|
||||
return;
|
||||
} catch (retryErr: any) {
|
||||
const retryErrorMessage = retryErr?.message || "";
|
||||
@@ -2410,7 +2458,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
thinkingCompatibilityDisabled = true;
|
||||
piLog.warn(`Prompt failed with thinking/reasoning conflict; retrying without explicit thinking level: ${errorMessage}`);
|
||||
const recoveredSession = await swapPromptSession(selectedModel);
|
||||
await promptSessionAndCheck(recoveredSession, prompt, promptOptions);
|
||||
await promptSessionAndCheck(recoveredSession, prompt, effectivePromptOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2424,12 +2472,12 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
|
||||
// Retry with fallback model, also with auto-compaction support
|
||||
try {
|
||||
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
|
||||
await promptSessionAndCheck(fallbackSession, prompt, effectivePromptOptions);
|
||||
return;
|
||||
} catch (fallbackErr: any) {
|
||||
const fallbackErrorMessage = fallbackErr?.message || "";
|
||||
if (isContextLimitError(fallbackErrorMessage)) {
|
||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, promptOptions);
|
||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, effectivePromptOptions);
|
||||
if (promptMemoryRetry.recovered) {
|
||||
return;
|
||||
}
|
||||
@@ -2440,7 +2488,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
}
|
||||
}
|
||||
|
||||
const promptSectionRetry = await retryWithCompactedPromptSections(fallbackSession, prompt, promptOptions);
|
||||
const promptSectionRetry = await retryWithCompactedPromptSections(fallbackSession, prompt, effectivePromptOptions);
|
||||
if (promptSectionRetry.recovered) {
|
||||
return;
|
||||
}
|
||||
@@ -2457,7 +2505,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
if (compactResult) {
|
||||
piLog.log(`promptWithFallback: fallback compaction succeeded (${compactResult.tokensBefore} tokens) — retrying`);
|
||||
try {
|
||||
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
|
||||
await promptSessionAndCheck(fallbackSession, prompt, effectivePromptOptions);
|
||||
return;
|
||||
} catch (retryErr: any) {
|
||||
const retryErrorMessage = retryErr?.message || "";
|
||||
|
||||
@@ -35,6 +35,7 @@ import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
import { createMemoryGetTool, createMemorySearchTool, createWebFetchTool } from "./agent-tools.js";
|
||||
import { buildUserCommentsPromptSection } from "./agent-user-comments.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
|
||||
export type ReviewType = "plan" | "code" | "spec";
|
||||
export type ReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
|
||||
@@ -362,6 +363,8 @@ export async function reviewStep(
|
||||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: options.taskId,
|
||||
taskTitle: options.taskTitle,
|
||||
// FNXC:McpConfig 2026-06-25-22:45: Reviewer and validator sessions resolve the same trusted MCP server set as executor lanes at session creation; secret values are passed only in memory to the runtime guard.
|
||||
mcpServers: options.store ? (await resolveMcpServersForStore(options.store, { agentId: options.agentId })).servers : undefined,
|
||||
onFallbackModelUsed: createFallbackModelObserver({
|
||||
agent: "reviewer",
|
||||
label: "reviewer",
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
|
||||
import type { AiPromptExecutor } from "./cron-runner.js";
|
||||
import type { AiPromptExecutor, AiPromptLiveCallbacks } from "./cron-runner.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { defaultShell } from "./shell-utils.js";
|
||||
import { resolveSandboxBackend } from "./sandbox/index.js";
|
||||
@@ -36,6 +36,14 @@ const MAX_OUTPUT_LENGTH = 10 * 1024;
|
||||
|
||||
|
||||
/** Options for RoutineRunner constructor */
|
||||
/*
|
||||
FNXC:AutomationLiveOutput 2026-06-26-00:00:
|
||||
Routine manual triggers share the automation live-output contract. Thread optional callbacks through the runner so routes can stream step boundaries, AI text/tool events, and final output without changing scheduled/background execution behavior.
|
||||
*/
|
||||
export type RoutineLiveRunCallbacks = AiPromptLiveCallbacks & {
|
||||
onStep?: (data: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export interface RoutineRunnerOptions {
|
||||
/** RoutineStore for querying and updating routines */
|
||||
routineStore: RoutineStore;
|
||||
@@ -84,6 +92,7 @@ export class RoutineRunner {
|
||||
routineId: string,
|
||||
triggerType: "cron" | "webhook" | "api",
|
||||
context?: Record<string, unknown>,
|
||||
liveCallbacks?: RoutineLiveRunCallbacks,
|
||||
): Promise<RoutineExecutionResult> {
|
||||
// 1. Load routine
|
||||
let routine: Routine;
|
||||
@@ -131,7 +140,7 @@ export class RoutineRunner {
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
// Set in-flight BEFORE starting execution to prevent race conditions
|
||||
const executionPromise = this.runExecution(routine, triggerType, context, startedAt);
|
||||
const executionPromise = this.runExecution(routine, triggerType, context, startedAt, liveCallbacks);
|
||||
this.inFlightExecutions.set(routineId, executionPromise);
|
||||
|
||||
try {
|
||||
@@ -155,12 +164,13 @@ export class RoutineRunner {
|
||||
triggerType: string,
|
||||
context: Record<string, unknown> | undefined,
|
||||
startedAt: string,
|
||||
liveCallbacks?: RoutineLiveRunCallbacks,
|
||||
): Promise<RoutineExecutionResult> {
|
||||
const routineId = routine.id;
|
||||
|
||||
try {
|
||||
const actionResult = this.hasRoutineAction(routine)
|
||||
? await this.executeRoutineAction(routine, startedAt)
|
||||
? await this.executeRoutineAction(routine, startedAt, liveCallbacks)
|
||||
: await this.executeAgentRoutine(routine, triggerType, context);
|
||||
|
||||
await this.options.routineStore.completeRoutineExecution(routineId, {
|
||||
@@ -252,11 +262,16 @@ export class RoutineRunner {
|
||||
private async executeRoutineAction(
|
||||
routine: Routine,
|
||||
startedAt: string,
|
||||
liveCallbacks?: RoutineLiveRunCallbacks,
|
||||
): Promise<AutomationRunResult> {
|
||||
if (routine.steps && routine.steps.length > 0) {
|
||||
return this.executeSteps(routine, startedAt);
|
||||
return this.executeSteps(routine, startedAt, liveCallbacks);
|
||||
}
|
||||
return this.executeCommand(routine, routine.command ?? "", routine.timeoutMs, startedAt);
|
||||
liveCallbacks?.onStep?.({ stepIndex: 0, stepId: "command", stepName: routine.name, stepType: "command", status: "started" });
|
||||
const result = await this.executeCommand(routine, routine.command ?? "", routine.timeoutMs, startedAt);
|
||||
liveCallbacks?.onStep?.({ stepIndex: 0, stepId: "command", stepName: routine.name, stepType: "command", status: "completed", success: result.success, error: result.error });
|
||||
if (result.output) liveCallbacks?.onText?.(result.output);
|
||||
return result;
|
||||
}
|
||||
|
||||
private getRoutineCommandAuditor(routine: Routine): RunAuditor | undefined {
|
||||
@@ -368,7 +383,7 @@ export class RoutineRunner {
|
||||
};
|
||||
}
|
||||
|
||||
private async executeSteps(routine: Routine, startedAt: string): Promise<AutomationRunResult> {
|
||||
private async executeSteps(routine: Routine, startedAt: string, liveCallbacks?: RoutineLiveRunCallbacks): Promise<AutomationRunResult> {
|
||||
const steps = routine.steps ?? [];
|
||||
const stepResults: AutomationStepResult[] = [];
|
||||
let overallSuccess = true;
|
||||
@@ -376,7 +391,10 @@ export class RoutineRunner {
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const result = await this.executeStep(routine, step, i);
|
||||
liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "started" });
|
||||
const result = await this.executeStep(routine, step, i, liveCallbacks);
|
||||
liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "completed", success: result.success, error: result.error });
|
||||
if (step.type !== "ai-prompt" && result.output) liveCallbacks?.onText?.(result.output);
|
||||
stepResults.push(result);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -412,6 +430,7 @@ export class RoutineRunner {
|
||||
routine: Routine,
|
||||
step: AutomationStep,
|
||||
stepIndex: number,
|
||||
liveCallbacks?: RoutineLiveRunCallbacks,
|
||||
): Promise<AutomationStepResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const timeoutMs = step.timeoutMs ?? routine.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
@@ -439,7 +458,7 @@ export class RoutineRunner {
|
||||
}
|
||||
try {
|
||||
const output = await Promise.race([
|
||||
this.options.aiPromptExecutor(step.prompt, step.modelProvider, step.modelId),
|
||||
this.options.aiPromptExecutor(step.prompt, step.modelProvider, step.modelId, step.allowedTools, liveCallbacks),
|
||||
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs)),
|
||||
]);
|
||||
return { stepId: step.id, stepName: step.name, stepIndex, success: true, output: truncateOutput(output, ""), startedAt, completedAt: new Date().toISOString() };
|
||||
@@ -560,14 +579,14 @@ export class RoutineRunner {
|
||||
* @returns The execution result
|
||||
* @throws Error if routine not found or disabled
|
||||
*/
|
||||
async triggerManual(routineId: string): Promise<RoutineExecutionResult> {
|
||||
async triggerManual(routineId: string, liveCallbacks?: RoutineLiveRunCallbacks): Promise<RoutineExecutionResult> {
|
||||
const routine = await this.options.routineStore.getRoutine(routineId);
|
||||
|
||||
if (!routine.enabled) {
|
||||
throw new Error(`Routine '${routineId}' is disabled`);
|
||||
}
|
||||
|
||||
return this.executeRoutine(routineId, "api");
|
||||
return this.executeRoutine(routineId, "api", undefined, liveCallbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -520,6 +520,10 @@ export type DatabaseMutationType =
|
||||
| "task:resume-limbo-escalated"
|
||||
/** Metadata: { taskId, executionAgeMs, graceMs, staleBindingAgeFloorMs, checkedOutBy, agentPresent, lastActivityMs, hasRecentRunAudit, worktree, branch, worktreeExists, signalReason } */
|
||||
| "task:reclaim-phantom-executor-binding"
|
||||
/** Metadata: { shiftedTaskIds: string[], downtimeMs, reason } */
|
||||
| "task:reconcile-engine-downtime-active-timing"
|
||||
/** Metadata: { shiftedTaskIds: [], downtimeMs, reason } */
|
||||
| "task:reconcile-engine-downtime-active-timing-no-action"
|
||||
/* FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode self-healing run-audit events. */
|
||||
/** Metadata: { taskId, landedRepos: string[], unlandedRepos: string[], failedRepos: string[], action: "re-enqueue" | "park-failed", reason } */
|
||||
| "task:reconcile-workspace-partial-land"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
|
||||
import { normalizeAgentRuntimeMcpServers } from "./agent-runtime.js";
|
||||
import type { PluginRunner } from "./plugin-runner.js";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
@@ -93,7 +94,12 @@ export class DefaultPiRuntime implements AgentRuntime {
|
||||
readonly name = "Default PI Runtime";
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
return createFnAgent(options);
|
||||
// FNXC:McpConfig 2026-06-25-22:04:
|
||||
// DefaultPiRuntime is the typed bridge from shared AgentRuntimeOptions into createFnAgent. Normalize the legacy stdio ACP shape here so all lanes can pass the FN-7022 three-transport shape without breaking older Route A callers.
|
||||
return createFnAgent({
|
||||
...options,
|
||||
mcpServers: normalizeAgentRuntimeMcpServers(options.mcpServers),
|
||||
});
|
||||
}
|
||||
|
||||
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||
|
||||
@@ -933,32 +933,47 @@ describe("InProcessRuntime", () => {
|
||||
}, 30000);
|
||||
|
||||
it("does not wake executeHeartbeat for runtime ownership sync of durable assigned agents", async () => {
|
||||
await runtime.start();
|
||||
/*
|
||||
FNXC:TestInfrastructure 2026-06-26-21:52:
|
||||
This negative-assertion test must verify executeHeartbeat is NOT woken by runtime ownership sync.
|
||||
Previously it paid a real `await new Promise(r => setTimeout(r, 25))` wall-clock sleep to let any
|
||||
erroneously-scheduled executeHeartbeat fire before asserting it did not — pure dead time on every run.
|
||||
Per FN-5048 (prefer fake timers over real polling/time waits) we run under fake timers and advance the
|
||||
window deterministically with advanceTimersByTimeAsync. The inflated 30000ms per-test timeout is removed
|
||||
now that no real wait remains. vi.waitFor already coexists with fake timers elsewhere in this suite.
|
||||
*/
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await runtime.start();
|
||||
|
||||
const monitor = runtime.getHeartbeatMonitor();
|
||||
expect(monitor).toBeDefined();
|
||||
const heartbeatMonitor = monitor!;
|
||||
const executeResult = { id: "run-task-worker" } as Awaited<ReturnType<typeof heartbeatMonitor.executeHeartbeat>>;
|
||||
const executeSpy = vi
|
||||
.spyOn(heartbeatMonitor, "executeHeartbeat")
|
||||
.mockResolvedValue(executeResult);
|
||||
const monitor = runtime.getHeartbeatMonitor();
|
||||
expect(monitor).toBeDefined();
|
||||
const heartbeatMonitor = monitor!;
|
||||
const executeResult = { id: "run-task-worker" } as Awaited<ReturnType<typeof heartbeatMonitor.executeHeartbeat>>;
|
||||
const executeSpy = vi
|
||||
.spyOn(heartbeatMonitor, "executeHeartbeat")
|
||||
.mockResolvedValue(executeResult);
|
||||
|
||||
const store = getAgentStore(runtime);
|
||||
const durable = await store.createAgent({ name: "Owned Exec", role: "executor" });
|
||||
const store = getAgentStore(runtime);
|
||||
const durable = await store.createAgent({ name: "Owned Exec", role: "executor" });
|
||||
|
||||
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
|
||||
onStart?: (task: Task, worktreePath: string) => void;
|
||||
};
|
||||
executorOptions.onStart?.({ id: "FN-2001", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-2001"));
|
||||
const executorOptions = mockExecutorCtor.mock.calls.at(-1)?.[0] as {
|
||||
onStart?: (task: Task, worktreePath: string) => void;
|
||||
};
|
||||
executorOptions.onStart?.({ id: "FN-2001", assignedAgentId: durable.id } as Task, join(testDir, "worktree-FN-2001"));
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const updated = await store.getAgent(durable.id);
|
||||
expect(updated?.taskId).toBe("FN-2001");
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
const updated = await store.getAgent(durable.id);
|
||||
expect(updated?.taskId).toBe("FN-2001");
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
}, 30000);
|
||||
// Drive the negative-assertion window deterministically instead of sleeping 25ms of real time.
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans up durable execution owner on completion without deleting agent", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -574,6 +574,7 @@ export class Scheduler {
|
||||
private lastStaleTaskReportAt = 0;
|
||||
private lastBacklogPressureReportAt = 0;
|
||||
private lastUnlinkedMissionsAdvisoryReportAt = 0;
|
||||
private lastHeartbeatWriteMs = 0;
|
||||
private idleSemaphoreLeakCandidateSince: number | null = null;
|
||||
private readonly lastHighOverlapFanoutWarningKey = new Map<string, string>();
|
||||
|
||||
@@ -1285,6 +1286,16 @@ export class Scheduler {
|
||||
}
|
||||
this.wasEnginePaused = false;
|
||||
|
||||
const heartbeatIntervalMs = Math.max(1, settings.pollIntervalMs ?? 15_000);
|
||||
if (Date.now() - this.lastHeartbeatWriteMs >= heartbeatIntervalMs) {
|
||||
/*
|
||||
FNXC:TaskTiming 2026-06-25-00:00:
|
||||
Persist a throttled engineLastActiveAt heartbeat only while the engine is unpaused so process-down wall-clock can be excluded from in-progress task active time after restart without per-tick DB churn.
|
||||
*/
|
||||
await this.store.updateSettings({ engineLastActiveAt: new Date().toISOString() });
|
||||
this.lastHeartbeatWriteMs = Date.now();
|
||||
}
|
||||
|
||||
// ── U6: hold/release sweep ─────────────────────────────────────────────
|
||||
/*
|
||||
FNXC:WorkflowScheduling 2026-06-23-10:32:
|
||||
|
||||
@@ -1237,6 +1237,7 @@ export class SelfHealingManager {
|
||||
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies().then(() => undefined) },
|
||||
{ name: "reconcile-dependency-blocking-leases", fn: () => this.reconcileDependencyBlockingLeases().then(() => undefined) },
|
||||
{ name: "reconcile-in-review-unmet-dependencies", fn: () => this.reconcileInReviewUnmetDependencies().then(() => undefined) },
|
||||
{ name: "reconcile-engine-downtime-active-timing", fn: () => this.reconcileEngineDowntimeActiveTiming().then(() => undefined) },
|
||||
{ name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) },
|
||||
{ name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts().then(() => undefined) },
|
||||
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
|
||||
@@ -1264,6 +1265,26 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileEngineDowntimeActiveTiming(): Promise<{ shiftedTaskIds: string[]; downtimeMs: number }> {
|
||||
const result = await this.store.reconcileActiveTimingForEngineDowntime();
|
||||
const shifted = result.shiftedTaskIds.length > 0;
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("reconcile-engine-downtime-active-timing", "global"),
|
||||
agentId: "system:self-healing",
|
||||
phase: "reconcile-engine-downtime-active-timing",
|
||||
});
|
||||
await auditor.database({
|
||||
type: (shifted ? "task:reconcile-engine-downtime-active-timing" : "task:reconcile-engine-downtime-active-timing-no-action") as DatabaseMutationType,
|
||||
target: "global",
|
||||
metadata: {
|
||||
shiftedTaskIds: result.shiftedTaskIds,
|
||||
downtimeMs: result.downtimeMs,
|
||||
reason: shifted ? "shifted-active-segments" : "no-qualifying-active-segments",
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Remove settings listener
|
||||
if (this.settingsListener) {
|
||||
|
||||
@@ -190,6 +190,19 @@ function bareSkillName(name: string): string {
|
||||
return name.replace(/\/SKILL\.md$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:SkillResolution 2026-06-26-00:00:
|
||||
* Requested skills can arrive from chat and agent metadata as `gamma`, `review/pr`, `review/pr/SKILL.md`, or `source::skills/review/pr/SKILL.md` while discovered Skill.name entries are keyed by the bare token.
|
||||
* Reduce only requested-name comparisons to the dashboard bareSkillName convention so slash/namespaced requests load without changing allow/exclude path matching, which still depends on bareSkillName plus filePath equality.
|
||||
*/
|
||||
function requestedSkillMatchKey(name: string): string {
|
||||
if (!name) return "";
|
||||
const withoutSkillMd = name.replace(/\/SKILL\.md$/i, "");
|
||||
const lastPathSegment = withoutSkillMd.split("/").pop() ?? withoutSkillMd;
|
||||
const afterNamespace = lastPathSegment.split(":").pop() ?? lastPathSegment;
|
||||
return afterNamespace.toLowerCase();
|
||||
}
|
||||
|
||||
// ── Main Resolution Logic ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -406,10 +419,10 @@ export function createSkillsOverrideFromSelection(
|
||||
};
|
||||
|
||||
if (hasRequestedNames) {
|
||||
// Filter by requested names (case-insensitive match, normalize away /SKILL.md suffix)
|
||||
const requestedBareNamesLower = new Set(requestedSkillNames!.map((n) => bareSkillName(n).toLowerCase()));
|
||||
// Filter by requested names using the chat/dashboard bare-token convention only for requested-name matching.
|
||||
const requestedMatchKeys = new Set(requestedSkillNames!.map(requestedSkillMatchKey));
|
||||
filteredSkills = base.skills.filter(
|
||||
(skill) => requestedBareNamesLower.has(bareSkillName(skill.name).toLowerCase()) && !isExcluded(skill)
|
||||
(skill) => requestedMatchKeys.has(requestedSkillMatchKey(skill.name)) && !isExcluded(skill)
|
||||
);
|
||||
} else if (hasPatterns) {
|
||||
// Filter by pattern (allowed AND not excluded)
|
||||
@@ -457,10 +470,10 @@ export function createSkillsOverrideFromSelection(
|
||||
|
||||
// Check for requested names that don't match any discovered skill
|
||||
if (requestedSkillNames) {
|
||||
const discoveredBareNamesLower = new Set(base.skills.map((s) => bareSkillName(s.name).toLowerCase()));
|
||||
const discoveredRequestedMatchKeys = new Set(base.skills.map((s) => requestedSkillMatchKey(s.name)));
|
||||
for (const requestedName of requestedSkillNames) {
|
||||
if (
|
||||
!discoveredBareNamesLower.has(bareSkillName(requestedName).toLowerCase())
|
||||
!discoveredRequestedMatchKeys.has(requestedSkillMatchKey(requestedName))
|
||||
&& !isBuiltInFallbackRequest(requestedName)
|
||||
) {
|
||||
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
|
||||
|
||||
@@ -17,7 +17,7 @@ const execAsync = promisify(exec);
|
||||
import { existsSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
import type { AgentStore, MessageStore, PermanentAgentGatingContext, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core";
|
||||
import type { AgentStore, MessageStore, PermanentAgentGatingContext, ResolvedMcpServerDefinition, TaskDetail, Settings, SteeringComment, TaskStore } from "@fusion/core";
|
||||
import { resolvePersistAgentThinkingLog } from "@fusion/core";
|
||||
|
||||
import {
|
||||
@@ -127,6 +127,8 @@ export interface StepSessionExecutorOptions {
|
||||
actionGateContext?: AgentActionGateContext;
|
||||
/** Optional permanent-agent action gating context. */
|
||||
permanentAgentGating?: PermanentAgentGatingContext;
|
||||
/** Optional resolved MCP servers to forward into workflow step sessions. */
|
||||
mcpServers?: ResolvedMcpServerDefinition[];
|
||||
/** Task-scoped environment injected into non-git subprocesses. */
|
||||
taskEnv?: NodeJS.ProcessEnv;
|
||||
/**
|
||||
@@ -1131,6 +1133,8 @@ Follow instructions precisely and avoid unrelated changes.`,
|
||||
source: "step-session-executor",
|
||||
}),
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-23:02: Workflow model-node step sessions receive the same resolved, secret-materialized MCP server set as the parent executor; runtime support is still enforced inside the pi session seam without logging server contents.
|
||||
mcpServers: this.options.mcpServers,
|
||||
customTools: [
|
||||
...pluginTools,
|
||||
...documentTools,
|
||||
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
|
||||
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
||||
import { planLog, reviewerLog, formatError } from "./logger.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
import {
|
||||
isUsageLimitError,
|
||||
checkSessionError,
|
||||
@@ -931,6 +932,8 @@ export class TriageProcessor {
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
runAuditor,
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-23:17: Primary triage planning is an AI lane, so it receives the store-resolved MCP set while the pi runtime-support guard decides whether to forward it without logging secret material.
|
||||
mcpServers: (await resolveMcpServersForStore(this.store)).servers,
|
||||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
@@ -1161,6 +1164,8 @@ export class TriageProcessor {
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
runAuditor,
|
||||
settings,
|
||||
// FNXC:McpConfig 2026-06-25-23:18: Fallback triage uses the same resolved MCP forwarding contract as the primary planning session so model fallback does not silently drop configured servers.
|
||||
mcpServers: (await resolveMcpServersForStore(this.store)).servers,
|
||||
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
|
||||
Reference in New Issue
Block a user