Merge upstream/main — v0.27.0 → v0.28.1 (303 commits)

Notable upstream changes:
- FN-4128: bundled plugin entry resolution + load error surfacing
- FN-4259: stop exhausted-task churn / deterministic exhausted contract
- FN-4114: pre-session worktree liveness + fn_task_done invariants
- FN-4080: thinking persistence toggles
- FN-4148: GitHub tracking title auto-derive
- 0.28.0 minor: mission interview drafts (Resume/Discard)

Fork patches preserved:
- Dockerfile (5 patches: ca-certs, codex symlink, 0.0.0.0 bind, /project cwd, single-stage)
- cross-spawn dep in cli/package.json
- registerPluginExemptPath in auth-middleware
- mountPluginRoutes in dashboard routes
- plugin autoload + state reset in dashboard.ts

Local validation:
- pnpm install: clean
- pnpm build: exit 0
- pnpm typecheck: exit 0 (27 packages)
This commit is contained in:
semih
2026-05-13 14:47:16 +00:00
486 changed files with 40539 additions and 9009 deletions

View File

@@ -29,6 +29,7 @@ const commandMocks = vi.hoisted(() => ({
runTaskPlan: vi.fn(),
runTaskDelete: vi.fn(),
runTaskRetry: vi.fn(),
runTaskBranchRecovery: vi.fn(),
runTaskComment: vi.fn(),
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
@@ -133,6 +134,7 @@ vi.mock("../commands/task.js", () => ({
runTaskPlan: commandMocks.runTaskPlan,
runTaskDelete: commandMocks.runTaskDelete,
runTaskRetry: commandMocks.runTaskRetry,
runTaskBranchRecovery: commandMocks.runTaskBranchRecovery,
runTaskComment: commandMocks.runTaskComment,
runTaskComments: commandMocks.runTaskComments,
runTaskSteer: commandMocks.runTaskSteer,
@@ -389,6 +391,29 @@ describe("bin command routing and fallbacks", () => {
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task show <id>");
});
it("routes task branch-recovery with reclaim/discard flags", async () => {
await runBin(["task", "branch-recovery", "FN-123", "--reclaim", "fusion/fn-123-2", "-P", "demo"]);
await runBin(["task", "branch-recovery", "FN-123", "--discard", "fusion/fn-123-2", "--yes", "-P", "demo"]);
expect(commandMocks.runTaskBranchRecovery).toHaveBeenNthCalledWith(1, "FN-123", {
reclaim: "fusion/fn-123-2",
discard: undefined,
yes: false,
}, "demo");
expect(commandMocks.runTaskBranchRecovery).toHaveBeenNthCalledWith(2, "FN-123", {
reclaim: undefined,
discard: "fusion/fn-123-2",
yes: true,
}, "demo");
});
it("errors for task branch-recovery missing id", async () => {
await expect(runBin(["task", "branch-recovery"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(
"Usage: fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]",
);
});
it("routes agent subcommands stop/start/import/mailbox", async () => {
await runBin(["agent", "stop", "agent-1", "-P", "demo"]);
await runBin(["agent", "start", "agent-1", "-P", "demo"]);
@@ -501,7 +526,12 @@ describe("bin command routing and fallbacks", () => {
it("routes mission list alias", async () => {
await runBin(["mission", "ls"]);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined, { includeDrafts: true });
});
it("routes mission list with --no-drafts", async () => {
await runBin(["mission", "list", "--no-drafts"]);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined, { includeDrafts: false });
});
it("routes mission show alias", async () => {
@@ -529,6 +559,7 @@ describe("bin command routing and fallbacks", () => {
host: "127.0.0.1",
token: "fn_abc123",
tokenOnly: true,
noAutoRegister: false,
});
});
@@ -542,6 +573,7 @@ describe("bin command routing and fallbacks", () => {
host: undefined,
token: undefined,
tokenOnly: false,
noAutoRegister: false,
});
});

View File

@@ -5,8 +5,8 @@ import { parse } from "yaml";
const workspaceRoot = join(import.meta.dirname!, "..", "..", "..", "..");
function loadWorkflow(name: string): any {
const path = join(workspaceRoot, ".github", "workflows", name);
function loadYamlFile(...pathParts: string[]): any {
const path = join(workspaceRoot, ...pathParts);
const content = readFileSync(path, "utf-8");
const parsed = parse(content) as Record<string, unknown>;
@@ -19,9 +19,18 @@ function loadWorkflow(name: string): any {
return { content, parsed };
}
function loadWorkflow(name: string): any {
return loadYamlFile(".github", "workflows", name);
}
function findCompositeSetupStep(steps: any[]) {
return steps.find((step) => step.uses === "./.github/actions/setup-node-pnpm");
}
describe("CI workflow (.github/workflows/ci.yml)", () => {
let workflow: any;
let content: string;
let compositeAction: any;
let buildSteps: any[];
let testShardJob: any;
let contributingContent: string;
@@ -35,6 +44,7 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
const result = loadWorkflow("ci.yml");
workflow = result.parsed;
content = result.content;
compositeAction = loadYamlFile(".github", "actions", "setup-node-pnpm", "action.yml").parsed;
buildSteps = workflow.jobs?.build?.steps ?? [];
testShardJob = workflow.jobs?.["test-shards"];
contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8");
@@ -72,9 +82,13 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
});
it("pins dependency bootstrap to frozen lockfile", () => {
expect(content).toContain("run: pnpm install --frozen-lockfile");
const jobs = [workflow.jobs?.lint, workflow.jobs?.["test-shards"], workflow.jobs?.build];
for (const job of jobs) {
expect(findCompositeSetupStep(job?.steps ?? [])).toBeDefined();
}
expect(content).not.toContain("run: pnpm install\n");
expect(content).not.toContain("--no-frozen-lockfile");
expect(compositeAction.inputs?.["install-args"]?.default).toBe("--frozen-lockfile");
});
it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => {
@@ -183,9 +197,7 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
expect(
lintSteps.some(
(step: any) =>
step.name === "Install dependencies" &&
typeof step.run === "string" &&
step.run.includes("pnpm install --frozen-lockfile"),
typeof step.uses === "string" && step.uses.includes("./.github/actions/setup-node-pnpm"),
),
).toBe(true);
expect(
@@ -288,8 +300,8 @@ describe("Version & Release workflow (.github/workflows/version.yml)", () => {
it("configures npm registry-url", () => {
const steps = workflow.jobs.release.steps;
const nodeStep = steps.find((s: any) => s.uses?.includes("actions/setup-node"));
expect(nodeStep?.with?.["registry-url"]).toBe("https://registry.npmjs.org");
const compositeStep = findCompositeSetupStep(steps);
expect(compositeStep?.with?.["registry-url"]).toBe("https://registry.npmjs.org");
});
});
@@ -340,6 +352,33 @@ describe("Binary release workflow (.github/workflows/release.yml)", () => {
expect(content).toContain("softprops/action-gh-release");
});
it("uses frozen-lockfile install in every matrix job", () => {
const steps = workflow.jobs["build-binaries"].steps ?? [];
const setupSteps = steps.filter((step: any) => step.uses === "./.github/actions/setup-node-pnpm");
const hasValidCompositeSetup = setupSteps.some((step: any) => {
const installArgs = step.with?.["install-args"];
return installArgs === undefined || String(installArgs).trim() === "--frozen-lockfile";
});
const hasInlineFrozenInstall = steps.some((step: any) =>
typeof step.run === "string" && /\bpnpm install --frozen-lockfile\b/.test(step.run),
);
expect(hasValidCompositeSetup || hasInlineFrozenInstall).toBe(true);
for (const step of setupSteps) {
const installArgs = step.with?.["install-args"];
if (installArgs !== undefined) {
expect(String(installArgs).trim()).toBe("--frozen-lockfile");
}
}
expect(content).not.toMatch(/run:\s*pnpm install\s*(?:\r?\n)/);
expect(content).not.toContain("--no-frozen-lockfile");
expect(content).not.toMatch(/install-args:\s*["']?\s*["']?\s*(?:\r?\n)/);
});
it("references signing scripts", () => {
expect(content).toContain("scripts/sign-macos.sh");
expect(content).toContain("scripts/sign-windows.ps1");
@@ -399,8 +438,10 @@ describe("Test-release workflow (.github/workflows/test-release.yml)", () => {
});
it("uses frozen-lockfile install in every matrix job", () => {
const matches = content.match(/run:\s*pnpm install --frozen-lockfile/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(1);
const steps = workflow.jobs["build-binaries"].steps ?? [];
const compositeStep = findCompositeSetupStep(steps);
expect(compositeStep).toBeDefined();
expect(compositeStep.with?.["install-args"] ?? "--frozen-lockfile").toBe("--frozen-lockfile");
expect(content).not.toContain("run: pnpm install\n");
expect(content).not.toContain("--no-frozen-lockfile");
});

View File

@@ -176,6 +176,7 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
expect(created.details.taskId).toMatch(/^[A-Z]+-\d+$/);
expect(created.details.column).toBe("triage");
expect(created.details.priority).toBe("normal");
const listTool = api.tools.get("fn_task_list")!;
const listed = await listTool.execute("list-1", {}, undefined, undefined, makeCtx(tmpDir));
@@ -186,6 +187,17 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
await store.init();
const persisted = await store.getTask(created.details.taskId);
expect(persisted?.description).toBe("Ship the packed CLI contract");
const urgent = await createTool.execute(
"create-2",
{ description: "Needs urgency", priority: "high" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(urgent.details.priority).toBe("high");
const urgentPersisted = await store.getTask(urgent.details.taskId);
expect(urgentPersisted?.priority).toBe("high");
});
it("runs provisioning tools through the built extension", async () => {
@@ -245,4 +257,23 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
expect(rejected.isError).toBe(true);
expect(rejected.content[0].text).toContain("ephemeral/runtime agent");
});
it("returns explicit error when fn_delegate_task hits task-id collision", async () => {
const agent = await seedAgent(tmpDir, { name: "release-agent" });
const delegateTool = api.tools.get("fn_delegate_task")!;
const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
const result = await delegateTool.execute(
"delegate-collision",
{ agent_id: agent.id, description: "collision task" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
expect(result.details.error).toContain("Task ID already exists: FN-001");
createSpy.mockRestore();
});
});

View File

@@ -269,6 +269,25 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
expect(result.content[0].text).toContain("Fix the login button");
expect(result.content[0].text).toContain("triage");
expect(result.details.column).toBe("triage");
expect(result.details.priority).toBe("normal");
});
it("creates a task with explicit priority", async () => {
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-priority",
{ description: "Urgent task", priority: "urgent" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.priority).toBe("urgent");
expect(result.content[0].text).toContain("Priority: urgent");
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s-priority", { id: result.details.taskId }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.priority).toBe("urgent");
});
it("creates a task with dependencies", async () => {
@@ -958,7 +977,6 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
describe("fn_mission_list", () => {
it("returns formatted list of missions", async () => {
// First create a mission
const createTool = api.tools.get("fn_mission_create")!;
await createTool.execute(
"c1",
@@ -981,6 +999,44 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
expect(result.content[0].text).toContain("Missions");
expect(result.content[0].text).toContain("Summary:");
});
it("includes mission interview drafts by default and exposes them in details", async () => {
const store = new TaskStore(tmpDir);
await store.init();
store.getDatabase().prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, 'mission_interview', 'awaiting_input', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-1", "Draft Mission", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
const listTool = api.tools.get("fn_mission_list")!;
const result = await listTool.execute("call-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("Drafts (1)");
expect(result.content[0].text).toContain("draft-1: Draft Mission (draft · interview awaiting_input)");
expect(result.details.drafts).toEqual([
{
id: "draft-1",
title: "Draft Mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]);
});
it("suppresses mission interview drafts when includeDrafts is false", async () => {
const store = new TaskStore(tmpDir);
await store.init();
store.getDatabase().prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, 'mission_interview', 'error', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-2", "Hidden Draft", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
const listTool = api.tools.get("fn_mission_list")!;
const result = await listTool.execute("call-1", { includeDrafts: false }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).not.toContain("Drafts");
expect(result.details.drafts).toEqual([]);
});
});
describe("fn_mission_show", () => {
@@ -1474,6 +1530,24 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain(ephemeralId);
});
it("returns explicit collision error when fn_task_create hits an existing task id", async () => {
const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-collision",
{ description: "collision task" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
expect(result.details.error).toContain("Task ID already exists: FN-001");
createSpy.mockRestore();
});
it("fn_task_create allows durable engineer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
@@ -1745,6 +1819,35 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.steps[1].status).toBe("in-progress");
});
it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "zero-step execution-failed task",
description: "test",
column: "todo",
});
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step execution-failed task\n\nNo steps yet.\n");
await store.updateTask(task.id, { steps: [] });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: "failed", error: "executor crashed", mergeRetries: 0, steps: [] });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-zero-step-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]);
expect(updated?.mergeRetries).toBe(0);
});
it("keeps merge-failed in-review task (all steps done) in in-review and resets merge state", async () => {
const store = new TaskStore(tmpDir);
await store.init();
@@ -1776,6 +1879,35 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(updated?.error).toBeFalsy();
expect(updated?.mergeRetries).toBe(0);
});
it("keeps zero-step merge-failed in-review task with prior merge attempts in-review and resets merge state", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({
title: "zero-step merge-failed task",
description: "test",
column: "todo",
});
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step merge-failed task\n\nNo steps yet.\n");
await store.updateTask(task.id, { steps: [] });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: "failed", error: "merge conflict", mergeRetries: 2, steps: [] });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-zero-step-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]);
expect(updated?.mergeRetries).toBe(0);
});
});
describe("fn_list_agents", () => {
@@ -1839,6 +1971,20 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
});
describe("research tools", () => {
it.each([
"fn_research_run",
"fn_research_list",
"fn_research_get",
"fn_research_cancel",
"fn_research_retry",
])("%s uses disambiguated cited-research wording", (toolName) => {
const tool = api.tools.get(toolName)!;
expect(tool.description).toMatch(/cited-research pipeline/i);
if (/experiment loop/i.test(tool.description)) {
expect(tool.description).toMatch(/not\s+.*experiment loop/i);
}
});
it("fn_research_run treats builtin as configured when no provider is explicitly set", async () => {
const store = new TaskStore(tmpDir);
await store.init();

View File

@@ -5,7 +5,7 @@ import {
resolveAffectedPackages,
shouldForceFullSuite,
} from "../../../../scripts/test-changed.mjs";
import { parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
import { computeSplitPlan, parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
describe("root test command changed-only planning", () => {
it("uses changed mode when package-only changes are detected", () => {
@@ -78,43 +78,102 @@ describe("CI shard test planner", () => {
);
});
it("deterministically balances weighted packages across shards", () => {
it("deterministically balances weighted packages across shards with virtual dashboard slices", () => {
const weightedPackages = [
{ name: "@fusion/dashboard", testFileCount: 140 },
{ name: "@fusion/engine", testFileCount: 120 },
{ name: "@fusion/core", testFileCount: 60 },
{ name: "@runfusion/fusion", testFileCount: 40 },
{ name: "@fusion/plugin-sdk", testFileCount: 18 },
{ name: "@fusion/mobile", testFileCount: 12 },
{ name: "@fusion/desktop", testFileCount: 8 },
{ name: "@fusion/dashboard-utils", testFileCount: 4 },
{ name: "@fusion/no-tests-yet", testFileCount: 0 },
{ name: "@fusion/dashboard", testFileCount: 505 },
{ name: "@fusion/engine", testFileCount: 90 },
{ name: "@fusion/core", testFileCount: 80 },
{ name: "@runfusion/fusion", testFileCount: 50 },
{ name: "@fusion/plugin-sdk", testFileCount: 30 },
];
const shardAssignments = planShardAssignments(weightedPackages, 3);
expect(shardAssignments).toEqual([
["@fusion/dashboard"],
["@fusion/engine", "@fusion/desktop", "@fusion/dashboard-utils"],
["@fusion/core", "@runfusion/fusion", "@fusion/plugin-sdk", "@fusion/mobile", "@fusion/no-tests-yet"],
]);
expect(selectShardPackages(weightedPackages, 1, 3)).toEqual(shardAssignments[0]);
expect(selectShardPackages(weightedPackages, 2, 3)).toEqual(shardAssignments[1]);
expect(selectShardPackages(weightedPackages, 3, 3)).toEqual(shardAssignments[2]);
const weightsByName = new Map(weightedPackages.map((pkg) => [pkg.name, pkg.testFileCount]));
const shardWeights = shardAssignments.map((shardPackages) =>
shardPackages.reduce((sum, pkgName) => sum + (weightsByName.get(pkgName) ?? 0), 0),
const dashboardSlices = shardAssignments
.flat()
.filter((entry) => entry.name === "@fusion/dashboard" && entry.shardCount === 3);
expect(dashboardSlices).toHaveLength(3);
expect(dashboardSlices.map((entry) => entry.shardIndex).sort()).toEqual([1, 2, 3]);
const shardsContainingDashboard = shardAssignments
.map((entries, index) => ({ entries, index }))
.filter(({ entries }) => entries.some((entry) => entry.name === "@fusion/dashboard"))
.map(({ index }) => index);
expect(shardsContainingDashboard).toEqual([0, 1, 2]);
const computedSplitPlan = computeSplitPlan(weightedPackages, 3);
const byWeight = new Map(computedSplitPlan.map((entry) => [
`${entry.name}:${entry.shardIndex ?? 0}/${entry.shardCount ?? 0}`,
entry.weight,
]));
const shardWeights = shardAssignments.map((entries) =>
entries.reduce(
(sum, entry) =>
sum +
(byWeight.get(`${entry.name}:${entry.shardIndex ?? 0}/${entry.shardCount ?? 0}`) ?? 0),
0,
),
);
const totalWeight = weightedPackages.reduce((sum, pkg) => sum + pkg.testFileCount, 0);
const mean = totalWeight / 3;
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.15);
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.1);
expect(Math.min(...shardWeights)).toBeGreaterThanOrEqual(mean * 0.85);
});
const dashboardShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/dashboard"));
const engineShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/engine"));
expect(dashboardShard).not.toBe(engineShard);
it("leaves packages whole when no single package exceeds the split threshold", () => {
const weightedPackages = [
{ name: "@fusion/engine", testFileCount: 40 },
{ name: "@fusion/core", testFileCount: 40 },
{ name: "@runfusion/fusion", testFileCount: 40 },
];
const shardAssignments = planShardAssignments(weightedPackages, 3, { threshold: 2 });
expect(shardAssignments.flat().every((entry) => entry.shardCount === undefined)).toBe(true);
});
it("splits never co-locate two slices of the same package on the same shard", () => {
const weightedPackages = [
{ name: "@fusion/dashboard", testFileCount: 505 },
{ name: "@fusion/engine", testFileCount: 10 },
{ name: "@fusion/core", testFileCount: 10 },
];
const shardAssignments = planShardAssignments(weightedPackages, 3);
for (const entries of shardAssignments) {
const dashboardEntries = entries.filter((entry) => entry.name === "@fusion/dashboard");
expect(dashboardEntries).toHaveLength(1);
}
});
});
describe("computeSplitPlan", () => {
it("splits oversized package into k slices where k is capped by total", () => {
const result = computeSplitPlan([{ name: "big", testFileCount: 100 }], 3);
expect(result).toEqual([
{ name: "big", weight: 34, shardIndex: 1, shardCount: 3 },
{ name: "big", weight: 34, shardIndex: 2, shardCount: 3 },
{ name: "big", weight: 34, shardIndex: 3, shardCount: 3 },
]);
});
it("returns rewritten list with whole and virtual entries", () => {
const result = computeSplitPlan(
[
{ name: "@fusion/dashboard", testFileCount: 505 },
{ name: "@fusion/core", testFileCount: 60 },
],
3,
);
expect(result).toEqual([
{ name: "@fusion/dashboard", weight: 169, shardIndex: 1, shardCount: 3 },
{ name: "@fusion/dashboard", weight: 169, shardIndex: 2, shardCount: 3 },
{ name: "@fusion/dashboard", weight: 169, shardIndex: 3, shardCount: 3 },
{ name: "@fusion/core", weight: 60 },
]);
});
});

View File

@@ -118,7 +118,7 @@ async function loadCommandHandlers() {
const { runServe } = await import("./commands/serve.js");
const { runDaemon } = await import("./commands/daemon.js");
const { runDesktop } = await import("./commands/desktop.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate, runTaskBranchRecovery } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
@@ -163,6 +163,7 @@ async function loadCommandHandlers() {
runTaskPlan,
runTaskDelete,
runTaskRetry,
runTaskBranchRecovery,
runTaskComment,
runTaskComments,
runTaskSteer,
@@ -246,10 +247,10 @@ Usage:
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
fn dashboard --interactive Start with interactive port selection
fn serve [--port <port>] [--host <host>] [--paused] [--daemon]
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--no-auto-register]
Start Fusion as a headless node (API + engine, no UI)
Use --daemon to enable bearer token authentication
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only]
Auto-registers cwd project on first run (use --no-auto-register to disable)
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--no-auto-register]
Start Fusion daemon (API + engine, auth required)
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
@@ -280,20 +281,22 @@ Usage:
fn task set-node <id> <node-name-or-id> Set a per-task node override
fn task clear-node <id> Clear a per-task node override
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]
Inspect, reclaim, or discard stranded task branches
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task
fn task import <owner/repo> [opts] Import GitHub issues as tasks
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
Create and optionally wait for a research run
Create and optionally wait for a cited-research run (search/fetch/synthesis)
fn research list | ls [--status <status>] [--limit <n>] [--json]
List research runs
fn research show <run-id> [--json] Show research run details
List cited-research runs
fn research show <run-id> [--json] Show cited-research run details
fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]
Export research run results
Export cited-research run results
fn research cancel <run-id> [--json]
Cancel an active research run
Cancel an active cited-research run
fn research retry <run-id> [--json]
Retry a failed/cancelled research run
Retry a failed/cancelled cited-research run
fn mission create [title] [desc] Create a new mission
fn mission list | ls List missions
fn mission show | info <id> Show mission details
@@ -522,6 +525,7 @@ async function main() {
runTaskPlan,
runTaskDelete,
runTaskRetry,
runTaskBranchRecovery,
runTaskComment,
runTaskComments,
runTaskSteer,
@@ -642,7 +646,8 @@ async function main() {
const hostIdx = args.indexOf("--host");
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
const daemon = args.includes("--daemon");
await runServe(port, { paused, interactive, host, daemon });
const noAutoRegister = args.includes("--no-auto-register");
await runServe(port, { paused, interactive, host, daemon, noAutoRegister });
break;
}
@@ -658,7 +663,8 @@ async function main() {
const tokenIdx = args.indexOf("--token");
const token = tokenIdx !== -1 && tokenIdx + 1 < args.length ? args[tokenIdx + 1] : undefined;
const tokenOnly = args.includes("--token-only");
await runDaemon({ port, paused, interactive, host, token, tokenOnly });
const noAutoRegister = args.includes("--no-auto-register");
await runDaemon({ port, paused, interactive, host, token, tokenOnly, noAutoRegister });
break;
}
@@ -1117,6 +1123,20 @@ async function main() {
await runTaskRetry(id, projectName);
break;
}
case "branch-recovery": {
const id = args[2];
if (!id) {
console.error("Usage: fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]");
process.exit(1);
}
const reclaimIdx = args.indexOf("--reclaim");
const discardIdx = args.indexOf("--discard");
const reclaim = reclaimIdx !== -1 && reclaimIdx + 1 < args.length ? args[reclaimIdx + 1] : undefined;
const discard = discardIdx !== -1 && discardIdx + 1 < args.length ? args[discardIdx + 1] : undefined;
const yes = args.includes("--yes");
await runTaskBranchRecovery(id, { reclaim, discard, yes }, projectName);
break;
}
case "pr-create": {
const id = args[2];
if (!id) {
@@ -1206,9 +1226,11 @@ async function main() {
break;
}
case "list":
case "ls":
await runMissionList(projectName);
case "ls": {
const includeDrafts = !args.includes("--no-drafts");
await runMissionList(projectName, { includeDrafts });
break;
}
case "show":
case "info": {
const id = args[2];

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
@@ -123,16 +126,45 @@ const mocks = vi.hoisted(() => {
});
const centralCoreCtor = vi.fn().mockImplementation(() => {
const now = new Date().toISOString();
const projects = [
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: now, updatedAt: now },
];
const instance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
getProjectByPath: vi.fn().mockImplementation((path: string) =>
Promise.resolve(projects.find((project) => project.path === path) ?? null),
),
listProjects: vi.fn().mockResolvedValue([
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
registerProject: vi.fn().mockImplementation(({ name, path, isolationMode }: { name: string; path: string; isolationMode: "in-process" | "child-process" }) => {
const project = {
id: `project-${projects.length + 1}`,
name,
path,
status: "inactive",
isolationMode,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
projects.push(project);
return Promise.resolve(project);
}),
updateProject: vi.fn().mockImplementation((id: string, patch: { status?: string }) => {
const index = projects.findIndex((project) => project.id === id);
if (index >= 0) {
projects[index] = {
...projects[index],
...patch,
updatedAt: new Date().toISOString(),
};
}
return Promise.resolve();
}),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve(projects.find((project) => project.id === id) ?? null),
),
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -591,6 +623,10 @@ vi.mock("../task-lifecycle.js", () => ({
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
}));
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockRejectedValue(new Error("project not initialized")),
}));
const { runDaemon } = await import("../daemon.js");
describe("runDaemon", () => {
@@ -785,6 +821,50 @@ describe("runDaemon", () => {
await triggerSignal("SIGINT");
});
it("auto-registers cwd project when not previously registered", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
try {
await runDaemon({});
const registrationCalls = mocks.centralInstances.flatMap((instance) =>
instance.registerProject.mock.calls,
);
expect(registrationCalls).toContainEqual([
expect.objectContaining({ path: freshCwd, isolationMode: "in-process" }),
]);
const updateCalls = mocks.centralInstances.flatMap((instance) =>
instance.updateProject.mock.calls,
);
expect(updateCalls).toContainEqual([expect.any(String), { status: "active" }]);
expect(process.exit).not.toHaveBeenCalledWith(1);
await triggerSignal("SIGINT");
} finally {
rmSync(freshCwd, { recursive: true, force: true });
}
});
it("--no-auto-register preserves legacy exit behavior", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
try {
await runDaemon({ noAutoRegister: true });
const registrationCalls = mocks.centralInstances.flatMap((instance) =>
instance.registerProject.mock.calls,
);
expect(registrationCalls).toHaveLength(0);
expect(errorSpy).toHaveBeenCalledWith("[daemon] No engine started for the current project — exiting");
expect(process.exit).toHaveBeenCalledWith(1);
} finally {
rmSync(freshCwd, { recursive: true, force: true });
}
});
it("stops engine services during shutdown", async () => {
await runDaemon({});

View File

@@ -748,6 +748,12 @@ function disposeTrackedDashboards(): void {
}
}
const WAIT_FOR_ASYNC_OPTIONS = { timeout: 5000, interval: 10 };
async function waitForAsyncExpectation(assertion: () => void | Promise<void>) {
await vi.waitFor(assertion, WAIT_FOR_ASYNC_OPTIONS);
}
async function runDashboard(...args: Parameters<typeof runDashboardImpl>): ReturnType<typeof runDashboardImpl> {
disposeTrackedDashboards();
const result = await runDashboardImpl(...args);
@@ -822,6 +828,12 @@ beforeEach(() => {
mockExecSync.mockReset();
mockExecSync.mockReturnValue("");
mockExec.mockClear();
mockListen.mockReset();
mockListen.mockImplementation((port: number) => {
const server = createMockServer(port);
process.nextTick(() => server.emit("listening"));
return server;
});
mockStuckCheckNow.mockReset();
mockStuckCheckNow.mockResolvedValue(undefined);
if (updateCacheDir) {
@@ -1121,7 +1133,14 @@ describe("runDashboard — PR-first auto-merge queue", () => {
const { aiMergeTask } = await import("@fusion/engine");
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Task",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
base: "main",
});
});
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Task",
@@ -1270,8 +1289,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
to: "in-review",
});
// Give async handlers time to process
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1296,8 +1314,9 @@ describe("runDashboard — auto-merge pause exclusion", () => {
await runDashboard(0, { open: false });
// Give async handlers time to process
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(aiMergeTask).toHaveBeenCalled();
});
// Only the non-paused task should be enqueued
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
@@ -1328,7 +1347,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1348,7 +1367,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1381,7 +1400,12 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockStore.logEntry).toHaveBeenCalledWith(
"FN-BUFFER",
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
);
});
expect(mockStore.logEntry).toHaveBeenCalledWith(
"FN-BUFFER",
@@ -1415,7 +1439,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1475,9 +1499,9 @@ describe("runDashboard — immediate resume on unpause", () => {
previous: { globalPause: true },
});
await new Promise((r) => setTimeout(r, 50));
expect(resumeOrphaned).toHaveBeenCalled();
await waitForAsyncExpectation(() => {
expect(resumeOrphaned).toHaveBeenCalled();
});
});
it("passes executor recovery callbacks into SelfHealingManager", async () => {
@@ -1527,7 +1551,9 @@ describe("runDashboard — immediate resume on unpause", () => {
previous: { globalPause: true },
});
await new Promise((r) => setTimeout(r, 200));
await waitForAsyncExpectation(() => {
expect(aiMergeTask).toHaveBeenCalledTimes(2);
});
// Both in-review tasks should be enqueued for merge
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
@@ -1575,9 +1601,9 @@ describe("runDashboard — engine pause/unpause cycle", () => {
previous: { enginePaused: true },
});
await new Promise((r) => setTimeout(r, 50));
expect(resumeOrphaned).toHaveBeenCalled();
await waitForAsyncExpectation(() => {
expect(resumeOrphaned).toHaveBeenCalled();
});
});
});
@@ -1614,9 +1640,9 @@ describe("runDashboard — stuck task timeout listener guards", () => {
previous: { taskStuckTimeoutMs: 1_200_000 },
});
await new Promise((r) => setTimeout(r, 50));
expect(mockStuckCheckNow).toHaveBeenCalledTimes(1);
await waitForAsyncExpectation(() => {
expect(mockStuckCheckNow).toHaveBeenCalledTimes(1);
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[stuck-detector] Error during immediate stuck-task check:",
detectorError,
@@ -1651,15 +1677,16 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
it("listens on the requested port when available", async () => {
await runDashboard(4040, { open: false });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
});
// mockListen should have been called with the requested port bound to localhost by default.
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the requested port
// Banner should show the resolved localhost URL from the bound server.
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("http://localhost:4040"),
expect.stringContaining("http://localhost:"),
);
// No warning should be printed
@@ -1697,8 +1724,9 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
await runDashboard(4040, { open: false });
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
});
// Server should have retried with port 0, still bound to localhost.
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
@@ -1736,8 +1764,11 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
await runDashboard(4040, { open: false });
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
`[dashboard] Port 4040 in use, using ${fallbackPort} instead`,
);
});
// Should print warning with both the requested and actual ports
expect(consoleWarnSpy).toHaveBeenCalledWith(
@@ -1789,7 +1820,7 @@ describe("runDashboard — enginePaused (soft pause)", () => {
to: "in-review",
});
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1815,9 +1846,9 @@ describe("runDashboard — enginePaused (soft pause)", () => {
previous: { enginePaused: true },
});
await new Promise((r) => setTimeout(r, 50));
expect(resumeOrphaned).toHaveBeenCalled();
await waitForAsyncExpectation(() => {
expect(resumeOrphaned).toHaveBeenCalled();
});
});
it("sweeps merge queue on engine unpause when autoMerge is enabled", async () => {
@@ -1852,7 +1883,9 @@ describe("runDashboard — enginePaused (soft pause)", () => {
previous: { enginePaused: true },
});
await new Promise((r) => setTimeout(r, 200));
await waitForAsyncExpectation(() => {
expect(aiMergeTask).toHaveBeenCalled();
});
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
(call: any[]) => call[2],
@@ -2025,24 +2058,28 @@ describe("runDashboard — --dev mode", () => {
const { createServer } = await import("@fusion/dashboard");
await runDashboard(4040, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
});
// Server should have been created and listen called (localhost default)
expect(createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the port
// Banner should show the resolved localhost URL from the bound server.
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("http://localhost:4040"),
expect.stringContaining("http://localhost:"),
);
});
it("shows 'AI engine: disabled (dev mode)' in dev mode", async () => {
await runDashboard(0, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("✗ disabled (dev mode)"),
);
});
// Should show disabled message
expect(consoleSpy).toHaveBeenCalledWith(
@@ -2053,8 +2090,7 @@ describe("runDashboard — --dev mode", () => {
it("does NOT show triage/scheduler details in dev mode", async () => {
await runDashboard(0, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
// Should NOT show triage/scheduler details
const triageCall = consoleSpy.mock.calls.find(
@@ -2079,8 +2115,11 @@ describe("runDashboard — --dev mode", () => {
it("shows 'AI engine: ✓ active' when not in dev mode", async () => {
await runDashboard(0, { open: false });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("✓ active"),
);
});
// Should show active message
expect(consoleSpy).toHaveBeenCalledWith(
@@ -2198,8 +2237,12 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
// Wait for retry scheduling
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-RETRY",
expect.objectContaining({ mergeRetries: 1 }),
);
});
// Should have incremented mergeRetries
expect(mockStore.updateTask).toHaveBeenCalledWith(
@@ -2245,7 +2288,7 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
// Exhausted tasks are skipped before enqueue, so they should not be merged again.
expect(aiMergeTask).not.toHaveBeenCalled();
@@ -2278,7 +2321,14 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
const disabledLog = consoleSpy.mock.calls.find(
(call) =>
typeof call[0] === "string" &&
call[0].includes("autoResolveConflicts disabled"),
);
expect(disabledLog).toBeDefined();
});
// Should log that auto-resolve is disabled
const disabledLog = consoleSpy.mock.calls.find(
@@ -2318,7 +2368,12 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-SUCCESS",
expect.objectContaining({ mergeRetries: 0 }),
);
});
// Should clear mergeRetries on success
expect(mockStore.updateTask).toHaveBeenCalledWith(
@@ -2356,7 +2411,16 @@ describe("runDashboard — merge conflict retry logic", () => {
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-BUILD",
expect.objectContaining({
status: null,
mergeRetries: 3,
error: "Build verification failed for FN-BUILD: Dependency sync failed",
}),
);
});
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-BUILD",
@@ -2466,7 +2530,7 @@ describe("runDashboard — lifecycle listener cleanup", () => {
it("engine cleans up its own listeners from the shared store on dispose", async () => {
const { dispose } = await runDashboard(0, { open: false });
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
dispose();

View File

@@ -0,0 +1,133 @@
import { mkdtempSync, existsSync, rmSync, statSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { CentralCore } from "@fusion/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureCwdProjectRegistered } from "../ensure-project-registered.js";
const tempPaths: string[] = [];
function makeTempDir(prefix: string): string {
const path = mkdtempSync(join(tmpdir(), prefix));
tempPaths.push(path);
return path;
}
afterEach(() => {
for (const path of tempPaths.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
describe("ensureCwdProjectRegistered", () => {
it("returns existing registered project without writing files", async () => {
const globalDir = makeTempDir("fn-4266-global-");
const cwd = makeTempDir("fn-4266-project-");
const central = new CentralCore(globalDir);
await central.init();
const existing = await central.registerProject({
name: "existing-project",
path: cwd,
isolationMode: "in-process",
});
const registerSpy = vi.spyOn(central, "registerProject");
const updateSpy = vi.spyOn(central, "updateProject");
const result = await ensureCwdProjectRegistered({
cwd,
central,
logPrefix: "serve",
autoRegister: true,
});
expect(result?.id).toBe(existing.id);
expect(existsSync(join(cwd, ".fusion"))).toBe(false);
expect(registerSpy).not.toHaveBeenCalled();
expect(updateSpy).not.toHaveBeenCalled();
await central.close();
});
it("auto-registers unregistered project when enabled", async () => {
const globalDir = makeTempDir("fn-4266-global-");
const cwd = makeTempDir("fn-4266-project-");
const central = new CentralCore(globalDir);
await central.init();
const registerSpy = vi.spyOn(central, "registerProject");
const updateSpy = vi.spyOn(central, "updateProject");
const result = await ensureCwdProjectRegistered({
cwd,
central,
logPrefix: "serve",
autoRegister: true,
});
expect(result).not.toBeNull();
expect(existsSync(join(cwd, ".fusion"))).toBe(true);
expect(existsSync(join(cwd, ".fusion", "fusion.db"))).toBe(true);
expect(statSync(join(cwd, ".fusion", "fusion.db")).size).toBe(0);
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({
path: cwd,
isolationMode: "in-process",
}),
);
expect(updateSpy).toHaveBeenCalledWith(expect.any(String), { status: "active" });
await central.close();
});
it("returns null and does not write when autoRegister is false", async () => {
const globalDir = makeTempDir("fn-4266-global-");
const cwd = makeTempDir("fn-4266-project-");
const central = new CentralCore(globalDir);
await central.init();
const registerSpy = vi.spyOn(central, "registerProject");
const result = await ensureCwdProjectRegistered({
cwd,
central,
logPrefix: "daemon",
autoRegister: false,
});
expect(result).toBeNull();
expect(existsSync(join(cwd, ".fusion"))).toBe(false);
expect(registerSpy).not.toHaveBeenCalled();
await central.close();
});
it("returns null and logs error when registration throws", async () => {
const globalDir = makeTempDir("fn-4266-global-");
const cwd = makeTempDir("fn-4266-project-");
const central = new CentralCore(globalDir);
await central.init();
vi.spyOn(central, "registerProject").mockRejectedValueOnce(new Error("boom"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await ensureCwdProjectRegistered({
cwd,
central,
logPrefix: "serve",
autoRegister: true,
});
expect(result).toBeNull();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] Failed to auto-register current project: boom"),
);
await central.close();
});
});

View File

@@ -155,13 +155,22 @@ function createMockMissionStore(overrides = {}) {
};
}
function createMockDatabase(drafts: Array<{ id: string; title: string; status: string; updatedAt: string }> = []) {
return {
prepare: vi.fn().mockReturnValue({
all: vi.fn().mockReturnValue(drafts),
}),
};
}
function mockResolvedProjectStore(
missionStore: ReturnType<typeof createMockMissionStore>,
overrides: Partial<{ getTask: ReturnType<typeof vi.fn> }> = {},
overrides: Partial<{ getTask: ReturnType<typeof vi.fn>; getDatabase: ReturnType<typeof createMockDatabase> }> = {},
) {
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => missionStore,
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
getDatabase: () => createMockDatabase(),
...overrides,
} as any);
}
@@ -281,9 +290,7 @@ describe("mission commands", () => {
describe("runMissionList", () => {
it("displays missions in formatted output", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
@@ -313,9 +320,7 @@ describe("mission commands", () => {
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([]),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
@@ -337,6 +342,102 @@ describe("mission commands", () => {
consoleCapture.restore();
}
});
it("shows drafts before mission status sections when present", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([
{
id: "draft-1",
title: "Draft mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch {
// expected
}
const joined = consoleCapture.logs.join("\n");
expect(joined).toContain("◌ Drafts (1)");
expect(joined).toContain("draft-1 Draft mission — (draft · interview awaiting_input)");
expect(joined.indexOf("◌ Drafts (1)")).toBeLessThan(joined.indexOf("● Active (1)"));
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
it("suppresses drafts when includeDrafts is false", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([
{
id: "draft-1",
title: "Draft mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList(undefined, { includeDrafts: false });
} catch {
// expected
}
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
it("omits drafts heading when no drafts exist", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore, {
getDatabase: () => createMockDatabase([]),
});
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch {
// expected
}
expect(consoleCapture.logs.join("\n")).not.toContain("Drafts");
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
});
describe("runMissionShow", () => {

View File

@@ -79,7 +79,7 @@ describe("research commands", () => {
it("creates a run", async () => {
await runResearchCreate({ query: "hello" });
expect(orchestratorMock.createRun).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created research run"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created cited-research run"));
});
it("creates a run when provider is unset by defaulting to builtin", async () => {
@@ -116,7 +116,7 @@ describe("research commands", () => {
it("fails show on missing run", async () => {
researchStoreMock.getRun.mockReturnValue(undefined);
await expect(runResearchShow("RR-404")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Research run not found: RR-404");
expect(errorSpy).toHaveBeenCalledWith("Error: Cited-research run not found: RR-404");
});
it("exports with explicit output path", async () => {

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
@@ -146,23 +149,50 @@ const mocks = vi.hoisted(() => {
});
const centralCoreCtor = vi.fn().mockImplementation(() => {
const now = new Date().toISOString();
const projects = [
{ ...PROJECT_FIXTURES.primary, createdAt: now, updatedAt: now },
{ ...PROJECT_FIXTURES.secondary, createdAt: now, updatedAt: now },
];
const instance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
// Use per-test resolver when available; default to primary project
// Use per-test resolver when available; default to lookup by path
if (getProjectByPathResolver) {
return Promise.resolve(getProjectByPathResolver(cwd));
}
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
return Promise.resolve(projects.find((project) => project.path === cwd) ?? null);
}),
registerProject: vi.fn().mockImplementation(({ name, path, isolationMode }: { name: string; path: string; isolationMode: "in-process" | "child-process" }) => {
const project = {
id: `project-${projects.length + 1}`,
name,
path,
status: "inactive",
isolationMode,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
projects.push(project);
return Promise.resolve(project);
}),
updateProject: vi.fn().mockImplementation((id: string, patch: { status?: string }) => {
const index = projects.findIndex((project) => project.id === id);
if (index >= 0) {
projects[index] = {
...projects[index],
...patch,
updatedAt: new Date().toISOString(),
};
}
return Promise.resolve();
}),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
Promise.resolve(projects.find((project) => project.id === id) ?? null),
),
listProjects: vi.fn().mockResolvedValue([
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ ...PROJECT_FIXTURES.secondary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -656,7 +686,12 @@ vi.mock("../task-lifecycle.js", () => ({
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
}));
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockRejectedValue(new Error("project not initialized")),
}));
const { runServe } = await import("../serve.js");
const ensureProjectRegisteredModule = await import("../ensure-project-registered.js");
describe("runServe", () => {
it("invokes shared startup model sync", async () => {
@@ -1906,20 +1941,53 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
expect(serverOpts2.engine).toBe(originalEngine);
});
it("exits process when cwd cannot be resolved to a registered project", async () => {
// Configure cwd to return null (project not registered)
setupProjectByPath((_cwd) => null);
it("auto-registers cwd project when not previously registered", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "serve-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
const ensureSpy = vi.spyOn(ensureProjectRegisteredModule, "ensureCwdProjectRegistered")
.mockResolvedValue({ ...PROJECT_FIXTURES.primary, path: freshCwd });
try {
await runServe(4040, {});
expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({
cwd: freshCwd,
logPrefix: "serve",
autoRegister: true,
}));
expect(process.exit).not.toHaveBeenCalledWith(1);
await triggerSignal("SIGINT");
} finally {
ensureSpy.mockRestore();
rmSync(freshCwd, { recursive: true, force: true });
}
});
it("--no-auto-register preserves legacy exit behavior", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const ensureSpy = vi.spyOn(ensureProjectRegisteredModule, "ensureCwdProjectRegistered")
.mockResolvedValue(null);
await runServe(4040, {});
try {
await runServe(4040, { noAutoRegister: true });
// runServe should exit when no cwd engine can be started
expect(process.exit).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] No engine started for the current project")
);
errorSpy.mockRestore();
expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({
cwd: freshCwd,
logPrefix: "serve",
autoRegister: false,
}));
expect(process.exit).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] No engine started for the current project")
);
} finally {
ensureSpy.mockRestore();
errorSpy.mockRestore();
rmSync(freshCwd, { recursive: true, force: true });
}
});
it("process.exit is NOT called when cwd project is resolved", async () => {

View File

@@ -14,6 +14,19 @@ vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
}) as any;
execFn[promisify.custom] = (cmd: string, opts?: any) =>
new Promise((resolve) => {
execFn(cmd, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr }));
});
return { exec: execFn };
});
// Mock @fusion/core before importing the module under test
vi.mock("@fusion/core", () => {
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
@@ -45,7 +58,7 @@ vi.mock("@fusion/core", () => {
});
// Mock @fusion/engine
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() }));
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn(), listBranchRecoveryCandidates: vi.fn() }));
// Mock @fusion/dashboard
vi.mock("@fusion/dashboard", () => ({
@@ -83,7 +96,8 @@ vi.mock("../../project-context.js", () => ({
import { createInterface } from "node:readline/promises";
import { TaskStore, CentralCore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import { exec } from "node:child_process";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskBranchRecovery, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import {
getCurrentRepo,
isGhAuthenticated,
@@ -93,7 +107,9 @@ import {
import { GitHubClient } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../../project-context.js";
import { aiMergeTask } from "@fusion/engine";
import { aiMergeTask, listBranchRecoveryCandidates } from "@fusion/engine";
const mockedExec = vi.mocked(exec);
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -2052,6 +2068,153 @@ describe("runTaskRetry", () => {
});
});
describe("runTaskBranchRecovery", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let mockGetTask: ReturnType<typeof vi.fn>;
let mockUpdateTask: ReturnType<typeof vi.fn>;
let mockLogEntry: ReturnType<typeof vi.fn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetTask = vi.fn().mockResolvedValue(makeTask({
id: "FN-001",
branch: "fusion/fn-001",
worktree: "/tmp/fn-001",
executionStartBranch: "main",
status: "failed",
column: "todo",
}));
mockUpdateTask = vi.fn().mockResolvedValue(undefined);
mockLogEntry = vi.fn().mockResolvedValue(undefined);
mockedExec.mockReset();
vi.mocked(listBranchRecoveryCandidates).mockReset();
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: mockGetTask,
updateTask: mockUpdateTask,
logEntry: mockLogEntry,
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("prints branch recovery candidates", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001",
tipSha: "abc123def456",
worktreePath: "/tmp/fn-001",
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
isCanonical: true,
},
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Branch recovery candidates for FN-001");
expect(output).toContain("fusion/fn-001 (canonical)");
expect(output).toContain("abc123def456");
expect(output).toContain("Sibling patch");
});
it("reclaims the selected branch for the next run", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001",
tipSha: "abc123def456",
worktreePath: "/tmp/fn-001",
strandedCommits: [],
isCanonical: true,
},
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001", { reclaim: "fusion/fn-001-2" });
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
branch: "fusion/fn-001-2",
worktree: "/tmp/fn-001-2",
status: null,
error: null,
});
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Branch recovery: reclaimed fusion/fn-001-2",
"bbb222ccc333 @ /tmp/fn-001-2",
);
});
it("refuses discard without explicit confirmation", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await expect(runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Refusing to discard branch recovery state without --yes");
expect(mockedExec).not.toHaveBeenCalled();
});
it("discards the selected branch and worktree when confirmed", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2", yes: true });
expect(mockedExec).toHaveBeenCalledWith(
"git worktree remove '/tmp/fn-001-2' --force",
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
expect.any(Function),
);
expect(mockedExec).toHaveBeenCalledWith(
"git branch -D 'fusion/fn-001-2'",
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
expect.any(Function),
);
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Branch recovery: discarded fusion/fn-001-2",
"bbb222ccc333 @ /tmp/fn-001-2",
);
});
});
// --- Logs Tests ---
describe("runTaskLogs", () => {

View File

@@ -64,6 +64,7 @@ import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, g
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
@@ -167,6 +168,8 @@ export interface DaemonOptions {
interactive?: boolean;
/** Just print/generate token without starting server */
tokenOnly?: boolean;
/** Disable cwd auto-registration and preserve legacy strict behavior */
noAutoRegister?: boolean;
}
export async function runDaemon(opts: DaemonOptions = {}) {
@@ -246,10 +249,6 @@ export async function runDaemon(opts: DaemonOptions = {}) {
try {
sharedCentralCore = new CentralCore();
await sharedCentralCore.init();
const registered = await sharedCentralCore.getProjectByPath(cwd);
if (registered) {
ntfyProjectId = registered.id;
}
} catch {
// Central DB unavailable or project not registered — backward compatible
}
@@ -307,6 +306,16 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
}
if (sharedCentralCore) {
const registered = await ensureCwdProjectRegistered({
cwd,
central: sharedCentralCore,
logPrefix: "daemon",
autoRegister: !opts.noAutoRegister,
});
ntfyProjectId = registered?.id;
}
const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>

View File

@@ -0,0 +1,90 @@
import { exec } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import type { CentralCore, RegisteredProject } from "@fusion/core";
const execAsync = promisify(exec);
export interface EnsureCwdProjectRegisteredOptions {
cwd: string;
central: CentralCore;
logPrefix: string;
autoRegister: boolean;
}
export async function ensureCwdProjectRegistered(
options: EnsureCwdProjectRegisteredOptions,
): Promise<RegisteredProject | null> {
const { cwd, central, logPrefix, autoRegister } = options;
const existing = await central.getProjectByPath(cwd);
if (existing) {
return existing;
}
if (!autoRegister) {
logManualRegistrationHint(logPrefix, cwd);
return null;
}
try {
const fusionDir = join(cwd, ".fusion");
const dbPath = join(fusionDir, "fusion.db");
if (!existsSync(fusionDir)) {
mkdirSync(fusionDir, { recursive: true });
}
if (!existsSync(dbPath)) {
writeFileSync(dbPath, "");
}
const projectName = await detectProjectName(cwd);
const project = await central.registerProject({
name: projectName,
path: cwd,
isolationMode: "in-process",
});
await central.updateProject(project.id, { status: "active" });
console.log(`[${logPrefix}] Auto-registered project "${project.name}" at ${cwd}`);
return project;
} catch (error) {
console.error(
`[${logPrefix}] Failed to auto-register current project: ${error instanceof Error ? error.message : String(error)}`,
);
logManualRegistrationHint(logPrefix, cwd);
return null;
}
}
async function detectProjectName(dir: string): Promise<string> {
if (!existsSync(join(dir, ".git"))) {
return basename(dir) || "my-project";
}
try {
const { stdout: remoteUrl } = await execAsync("git remote get-url origin", {
cwd: dir,
timeout: 10_000,
});
const trimmed = remoteUrl.trim();
if (trimmed) {
const match = trimmed.match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (match) {
return match[2];
}
}
} catch {
// ignore
}
return basename(dir) || "my-project";
}
function logManualRegistrationHint(logPrefix: string, cwd: string): void {
console.error(`[${logPrefix}] Run 'fn init' to register this project, or 'fn project add <name> <path>' (${cwd})`);
}

View File

@@ -93,22 +93,47 @@ export async function runMissionCreate(titleArg?: string, descriptionArg?: strin
console.log();
}
interface RunMissionListOptions {
includeDrafts?: boolean;
}
/**
* List all missions with status summary.
*/
export async function runMissionList(projectName?: string) {
export async function runMissionList(projectName?: string, options: RunMissionListOptions = {}) {
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const includeDrafts = options.includeDrafts ?? true;
const missions = missionStore.listMissions();
const drafts = includeDrafts
? (store.getDatabase()
.prepare(
`SELECT id, title, status, updatedAt
FROM ai_sessions
WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error')
AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`,
)
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
: [];
if (missions.length === 0) {
if (missions.length === 0 && drafts.length === 0) {
console.log("\n No missions yet. Create one with: fn mission create\n");
process.exit(0);
}
console.log();
if (drafts.length > 0) {
console.log(` ◌ Drafts (${drafts.length})`);
for (const draft of drafts) {
console.log(`${draft.id} ${draft.title} — (draft · interview ${draft.status})`);
}
console.log();
}
// Group by status
const byStatus: Record<string, typeof missions> = {};
for (const mission of missions) {

View File

@@ -43,7 +43,6 @@ async function getStore(projectName?: string): Promise<TaskStore> {
function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
if (!providerId || providerId === "builtin") return true;
if (providerId === "none") return false;
if (providerId === "searxng") return Boolean(settings.researchGlobalSearxngUrl);
if (providerId === "brave") return Boolean(settings.researchGlobalBraveApiKey);
if (providerId === "google") return Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx);
@@ -59,7 +58,7 @@ async function getResearchRuntime(store: TaskStore) {
}
const configuredProvider = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
if (configuredProvider !== "builtin" && configuredProvider !== "none" && !hasProviderCredentials(settings, configuredProvider)) {
if (configuredProvider !== "builtin" && !hasProviderCredentials(settings, configuredProvider)) {
throw new Error(`missing-credentials: ${configuredProvider} credentials are missing. Configure Authentication and Research defaults in settings.`);
}
@@ -128,7 +127,7 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
if (options.json) {
jsonOut(run);
} else {
console.log(`Created research run ${runId}.`);
console.log(`Created cited-research run ${runId}.`);
if (run) printRun(run);
}
return;
@@ -180,7 +179,7 @@ export async function runResearchList(options: ResearchListOptions = {}): Promis
}
if (!runs.length) {
console.log("No research runs found.");
console.log("No cited-research runs found.");
return;
}
@@ -196,7 +195,7 @@ export async function runResearchShow(runId: string, options: ResearchCommandOpt
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
if (options.json) {
jsonOut(run);
@@ -219,7 +218,7 @@ export async function runResearchExport(options: ResearchExportOptions): Promise
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(options.runId);
if (!run) throw new Error(`Research run not found: ${options.runId}`);
if (!run) throw new Error(`Cited-research run not found: ${options.runId}`);
const format = (options.format ?? "markdown") as ResearchExportFormat;
if (!RESEARCH_EXPORT_FORMATS.includes(format)) {
@@ -250,7 +249,7 @@ export async function runResearchCancel(runId: string, options: ResearchCommandO
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
throw new Error(`invalid-transition: Run ${runId} cannot be cancelled from status ${run.status}.`);
@@ -275,7 +274,7 @@ export async function runResearchRetry(runId: string, options: ResearchCommandOp
try {
const store = await getStore(options.projectName);
const existing = store.getResearchStore().getRun(runId);
if (!existing) throw new Error(`Research run not found: ${runId}`);
if (!existing) throw new Error(`Cited-research run not found: ${runId}`);
if (existing.status === "retry_exhausted" || existing.lifecycle?.errorCode === "RETRY_EXHAUSTED") {
throw new Error(`retry-exhausted: Run ${runId} has exhausted retry attempts.`);

View File

@@ -65,6 +65,7 @@ import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -223,7 +224,7 @@ function ensureProcessDiagnostics(): void {
export async function runServe(
port: number,
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean } = {},
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean } = {},
) {
serveStartTime = Date.now();
ensureProcessDiagnostics();
@@ -265,10 +266,6 @@ export async function runServe(
try {
sharedCentralCore = new CentralCore();
await sharedCentralCore.init();
const registered = await sharedCentralCore.getProjectByPath(cwd);
if (registered) {
ntfyProjectId = registered.id;
}
} catch {
// Central DB unavailable or project not registered — backward compatible
}
@@ -337,6 +334,16 @@ export async function runServe(
}
}
if (sharedCentralCore) {
const registered = await ensureCwdProjectRegistered({
cwd,
central: sharedCentralCore,
logPrefix: "serve",
autoRegister: !opts.noAutoRegister,
});
ntfyProjectId = registered?.id;
}
const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>

View File

@@ -1,5 +1,7 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
@@ -16,6 +18,7 @@ import {
import { resolveProject, type ProjectContext } from "../project-context.js";
import { findNodeByNameOrId } from "./node.js";
const execAsync = promisify(exec);
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
@@ -161,6 +164,70 @@ async function getProjectPath(projectName?: string): Promise<string> {
return (await getCommandContext(projectName)).projectPath;
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function getCanonicalTaskBranch(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
function formatRecoveryCandidate(candidate: BranchRecoveryCandidate): string[] {
const lines = [
`${candidate.branchName}${candidate.isCanonical ? " (canonical)" : ""}`,
` tip: ${candidate.tipSha}`,
` worktree: ${candidate.worktreePath ?? "(not attached to a worktree)"}`,
];
if (candidate.strandedCommits.length === 0) {
lines.push(" stranded commits: none");
} else {
lines.push(" stranded commits:");
for (const commit of candidate.strandedCommits) {
lines.push(` - ${commit.sha.slice(0, 12)} ${commit.subject}`);
}
}
return lines;
}
async function runGit(projectPath: string, command: string): Promise<string> {
const { stdout } = await execAsync(command, { cwd: projectPath, encoding: "utf-8" });
return stdout.trim();
}
async function resolveBranchRecoveryCandidates(id: string, projectName?: string): Promise<{
store: TaskStore;
projectPath: string;
task: Awaited<ReturnType<TaskStore["getTask"]>>;
canonicalBranch: string;
candidates: BranchRecoveryCandidate[];
}> {
const context = await getCommandContext(projectName);
const task = await context.store.getTask(id);
const canonicalBranch = getCanonicalTaskBranch(task.id);
const candidates = await listBranchRecoveryCandidates({
repoDir: context.projectPath,
branchName: canonicalBranch,
startPoint: task.executionStartBranch ?? undefined,
});
return {
store: context.store,
projectPath: context.projectPath,
task,
canonicalBranch,
candidates,
};
}
async function resolveRecoveryCandidateOrExit(id: string, branch: string, projectName?: string) {
const resolved = await resolveBranchRecoveryCandidates(id, projectName);
const candidate = resolved.candidates.find((entry) => entry.branchName === branch);
if (!candidate) {
console.error(`Error: Branch recovery candidate not found for ${id}: ${branch}`);
process.exit(1);
}
return { ...resolved, candidate };
}
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
const central = new CentralCore();
await central.init();
@@ -830,6 +897,95 @@ export async function runTaskRetry(id: string, projectName?: string) {
console.log();
}
export async function runTaskBranchRecovery(
id: string,
options: { reclaim?: string; discard?: string; yes?: boolean } = {},
projectName?: string,
) {
if (options.reclaim && options.discard) {
console.error("Error: --reclaim and --discard are mutually exclusive");
process.exit(1);
}
if (options.reclaim) {
const { store, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.reclaim, projectName);
await store.updateTask(task.id, {
branch: candidate.branchName,
worktree: candidate.worktreePath,
status: null,
error: null,
});
await store.logEntry(
task.id,
`Branch recovery: reclaimed ${candidate.branchName}`,
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
);
console.log();
console.log(` ✓ Reclaimed ${candidate.branchName} for ${task.id}`);
console.log(` Tip: ${candidate.tipSha}`);
console.log(` Worktree: ${candidate.worktreePath ?? "(none)"}`);
console.log();
return;
}
if (options.discard) {
if (!options.yes) {
console.error("Error: Refusing to discard branch recovery state without --yes");
process.exit(1);
}
const { store, projectPath, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.discard, projectName);
if (candidate.worktreePath) {
await runGit(projectPath, `git worktree remove ${quoteShellArg(candidate.worktreePath)} --force`);
}
await runGit(projectPath, `git branch -D ${quoteShellArg(candidate.branchName)}`);
const patch: Record<string, unknown> = { status: null, error: null };
if (task.branch === candidate.branchName) {
patch.branch = null;
}
if (task.worktree && task.worktree === candidate.worktreePath) {
patch.worktree = null;
}
await store.updateTask(task.id, patch);
await store.logEntry(
task.id,
`Branch recovery: discarded ${candidate.branchName}`,
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
);
console.log();
console.log(` ✓ Discarded ${candidate.branchName} for ${task.id}`);
if (candidate.worktreePath) {
console.log(` Removed worktree: ${candidate.worktreePath}`);
}
console.log(` Deleted branch tip: ${candidate.tipSha}`);
console.log();
return;
}
const { task, candidates, canonicalBranch } = await resolveBranchRecoveryCandidates(id, projectName);
console.log();
console.log(` Branch recovery candidates for ${task.id}`);
console.log(` Canonical branch: ${canonicalBranch}`);
console.log(` Current task branch: ${task.branch ?? "(none)"}`);
console.log(` Current task worktree: ${task.worktree ?? "(none)"}`);
if (candidates.length === 0) {
console.log(" No matching canonical or sibling branches were found.");
console.log();
return;
}
for (const candidate of candidates) {
for (const line of formatRecoveryCandidate(candidate)) {
console.log(line);
}
}
console.log();
}
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
const store = await getStore(projectName);

View File

@@ -8,6 +8,7 @@ import {
validateNodeOverrideChange,
type Task,
type InsightCategory,
type TaskPriority,
type InsightStatus,
type InsightRunStatus,
type InsightRunTrigger,
@@ -19,6 +20,7 @@ import {
canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason,
resolveAgentProvisioningPolicy,
TASK_PRIORITIES,
} from "@fusion/core";
import {
getGhErrorMessage,
@@ -238,9 +240,7 @@ async function getResearchAvailability(store: TaskStore): Promise<{ ok: boolean;
const backend = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
const configured = backend === "builtin"
? true
: backend === "none"
? false
: backend === "searxng"
: backend === "searxng"
? Boolean(settings.researchGlobalSearxngUrl)
: backend === "brave"
? Boolean(settings.researchGlobalBraveApiKey)
@@ -406,6 +406,9 @@ export default function kbExtension(pi: ExtensionAPI) {
description: "Agent ID to assign this task to (e.g. 'agent-abc123')",
}),
),
priority: Type.Optional(
StringEnum([...TASK_PRIORITIES], { description: "Task priority (low, normal, high, urgent)" }) as unknown as TSchema,
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -425,41 +428,55 @@ export default function kbExtension(pi: ExtensionAPI) {
}
}
const task = await store.createTask({
description: params.description.trim(),
dependencies: params.depends,
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
source: { sourceType: "api" },
});
try {
const task = await store.createTask({
description: params.description.trim(),
dependencies: params.depends,
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
priority: params.priority as TaskPriority | undefined,
source: { sourceType: "api" },
});
const label =
task.description.length > 80
? task.description.slice(0, 80) + "…"
: task.description;
const label =
task.description.length > 80
? task.description.slice(0, 80) + "…"
: task.description;
return {
content: [
{
type: "text",
text:
`Created ${task.id}: ${label}\n` +
`Column: triage\n` +
(task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n`
: "") +
(task.assignedAgentId
? `Assigned to: ${task.assignedAgentId}\n`
: "") +
`Path: .fusion/tasks/${task.id}/`,
return {
content: [
{
type: "text",
text:
`Created ${task.id}: ${label}\n` +
`Column: triage\n` +
(task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n`
: "") +
(task.assignedAgentId
? `Assigned to: ${task.assignedAgentId}\n`
: "") +
`Priority: ${task.priority}\n` +
`Path: .fusion/tasks/${task.id}/`,
},
],
details: {
taskId: task.id,
column: task.column,
dependencies: task.dependencies,
assignedAgentId: task.assignedAgentId,
priority: task.priority,
},
],
details: {
taskId: task.id,
column: task.column,
dependencies: task.dependencies,
assignedAgentId: task.assignedAgentId,
},
};
};
} catch (error) {
if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
return {
content: [{ type: "text", text: `ERROR: ${error.message}` }],
isError: true,
details: { error: error.message },
};
}
throw error;
}
},
});
@@ -865,11 +882,15 @@ export default function kbExtension(pi: ExtensionAPI) {
// In-review retry: distinguish between execution failures and merge failures.
if (task.column === 'in-review') {
const hasIncompleteSteps =
task.steps.length > 0 &&
task.steps.some((s: { status: string }) => s.status === "pending" || s.status === "in-progress");
const hasIncompleteSteps = task.steps.some(
(s: { status: string }) => s.status === "pending" || s.status === "in-progress",
);
// FN-4130 / PR #59 follow-up: zero-step review failures with no merge attempts
// (`mergeRetries ?? 0 === 0`) failed during execution, not merge finalization.
const isExecutionFailureInReview =
hasIncompleteSteps || (task.steps.length === 0 && (task.mergeRetries ?? 0) === 0);
if (hasIncompleteSteps) {
if (isExecutionFailureInReview) {
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0 });
await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)");
await store.moveTask(params.id, "todo", { preserveProgress: true });
@@ -1453,7 +1474,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_research_run",
label: "fn: Run Research",
description: "Start a bounded research run and optionally wait for findings.",
description: "Cited-research pipeline: create a bounded search/fetch/synthesis run (not an autonomous experiment loop) and optionally wait for completion.",
parameters: Type.Object({
query: Type.String({ description: "Research query or question" }),
wait_for_completion: Type.Optional(Type.Boolean({ description: "Wait for the run to complete before returning (default: false)" })),
@@ -1517,7 +1538,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_research_list",
label: "fn: List Research Runs",
description: "List recent research runs.",
description: "Cited-research pipeline: list recent search/fetch/synthesis runs (not experiment-loop sessions).",
parameters: Type.Object({
status: Type.Optional(StringEnum([...RESEARCH_RUN_STATUSES], { description: "Filter by run status" }) as unknown as TSchema),
limit: Type.Optional(Type.Number({ description: "Max runs to return (default: 10)" })),
@@ -1541,7 +1562,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_research_get",
label: "fn: Get Research Run",
description: "Get one research run and structured findings.",
description: "Cited-research pipeline: get one run with structured findings and citations (not experiment-loop state).",
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
@@ -1583,7 +1604,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_research_cancel",
label: "fn: Cancel Research Run",
description: "Cancel an in-flight research run. Terminal runs return INVALID_TRANSITION.",
description: "Cited-research pipeline: cancel an in-flight run; terminal runs return INVALID_TRANSITION (does not control experiment loops).",
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
@@ -1645,7 +1666,7 @@ export default function kbExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "fn_research_retry",
label: "fn: Retry Research Run",
description: "Retry a failed research run when lifecycle marks it retryable.",
description: "Cited-research pipeline: retry a failed run when lifecycle marks it retryable (not an autonomous experiment loop retry).",
parameters: Type.Object({ id: Type.String({ description: "Research run ID" }) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
@@ -1999,20 +2020,36 @@ export default function kbExtension(pi: ExtensionAPI) {
promptGuidelines: [
"Use to see all missions and their current status",
"Missions are grouped by status (active, planning, complete, etc.)",
"Drafts represent unfinished mission interview sessions; fn_mission_show does not work on draft IDs because no mission row exists yet",
"Use before fn_mission_show to find a specific mission ID",
],
parameters: Type.Object({}),
parameters: Type.Object({
includeDrafts: Type.Optional(Type.Boolean({ description: "Include in-flight mission interview drafts (default: true)" })),
}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const includeDrafts = params.includeDrafts ?? true;
const missions = missionStore.listMissions();
const drafts = includeDrafts
? (store.getDatabase()
.prepare(
`SELECT id, title, status, updatedAt
FROM ai_sessions
WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error')
AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`,
)
.all() as Array<{ id: string; title: string; status: "generating" | "awaiting_input" | "error"; updatedAt: string }>)
: [];
if (missions.length === 0) {
if (missions.length === 0 && drafts.length === 0) {
return {
content: [{ type: "text", text: "No missions yet." }],
details: { count: 0 },
details: { count: 0, drafts: [] },
};
}
@@ -2027,8 +2064,17 @@ export default function kbExtension(pi: ExtensionAPI) {
const lines: string[] = [];
lines.push(`Missions (${missions.length})`);
lines.push(
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}\n`,
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}`,
);
lines.push("");
if (drafts.length > 0) {
lines.push(`Drafts (${drafts.length})`);
for (const draft of drafts) {
lines.push(`${draft.id}: ${draft.title} (draft · interview ${draft.status})`);
}
lines.push("");
}
for (const mission of missions) {
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : mission.status === "blocked" ? "⚠" : "○";
@@ -2038,7 +2084,11 @@ export default function kbExtension(pi: ExtensionAPI) {
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { count: missions.length, missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })) },
details: {
count: missions.length,
missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })),
drafts: drafts.map((draft) => ({ id: draft.id, title: draft.title, status: draft.status, updatedAt: draft.updatedAt })),
},
};
},
});
@@ -2763,28 +2813,39 @@ export default function kbExtension(pi: ExtensionAPI) {
await agentStore.init();
const agent = await agentStore.getAgent(params.agent_id);
// Create task assigned to the target agent
const store = await getStore(ctx.cwd);
const task = await store.createTask({
description: params.description,
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
source: {
sourceType: "api",
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
},
});
try {
// Create task assigned to the target agent
const store = await getStore(ctx.cwd);
const task = await store.createTask({
description: params.description,
dependencies: params.dependencies,
column: "todo",
assignedAgentId: params.agent_id,
source: {
sourceType: "api",
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
},
});
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
return {
content: [{
type: "text" as const,
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` +
`The task will be picked up by ${agent!.name} on their next heartbeat cycle.`,
}],
details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name },
};
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
return {
content: [{
type: "text" as const,
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` +
`The task will be picked up by ${agent!.name} on their next heartbeat cycle.`,
}],
details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name },
};
} catch (error) {
if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
return {
content: [{ type: "text", text: `ERROR: ${error.message}` }],
isError: true,
details: { error: error.message },
};
}
throw error;
}
},
});

View File

@@ -3,18 +3,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Mocks ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
const { mockExistsSync, mockReadFile, mockValidatePluginManifest } = vi.hoisted(() => ({
const { mockExistsSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } = vi.hoisted(() => ({
mockExistsSync: vi.fn<(path: string) => boolean>(),
mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean }>(),
mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(),
mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(),
mockCopyFile: vi.fn<(src: string, dest: string) => Promise<void>>(),
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(),
}));
vi.mock("node:fs", () => ({
existsSync: mockExistsSync,
statSync: mockStatSync,
}));
vi.mock("node:fs/promises", () => ({
readFile: mockReadFile,
stat: mockFsStat,
copyFile: mockCopyFile,
}));
vi.mock("@fusion/core", () => ({
@@ -194,6 +200,9 @@ async function getResolvedBundledPath(): Promise<string> {
beforeEach(() => {
vi.clearAllMocks();
mockStatSync.mockImplementation(() => ({ isDirectory: () => false }));
mockFsStat.mockImplementation(async () => ({ isDirectory: () => false }));
mockCopyFile.mockResolvedValue();
});
describe("resolvePluginEntryPath", () => {
@@ -216,6 +225,11 @@ describe("resolvePluginEntryPath", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
});
it("returns null when no loadable entry file exists", () => {
mockExistsSync.mockReturnValue(false);
expect(resolvePluginEntryPath("/tmp/plugin")).toBeNull();
});
});
describe("ensureBundledDependencyGraphPluginInstalled", () => {
@@ -276,7 +290,7 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
it("already installed with stale path → updates path to current bundled path", async () => {
const bundledPath = await getResolvedBundledPath();
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph/bundled.js";
vi.clearAllMocks();
const manifest = setupBundleExists();
@@ -344,6 +358,51 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("migrates an existing directory-backed install to the resolved entry file", async () => {
const bundledPath = await getResolvedBundledPath();
const staleDirectoryPath = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
vi.clearAllMocks();
setupBundleExists();
mockStatSync.mockImplementation((path: string) => ({
isDirectory: () => path === staleDirectoryPath,
}));
const store = makePluginStore();
const loader = makePluginLoader();
store._inject(makePlugin({ path: staleDirectoryPath }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ path: bundledPath }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("returns missing-bundle when manifest exists but no loadable entry file exists", async () => {
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist"));
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("missing-bundle");
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("missing bundle (no bundled manifest found) → returns missing-bundle without error", async () => {
setupBundleMissing();
const store = makePluginStore();
@@ -474,4 +533,65 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/bundled.js`);
});
it("loads the real bundled dependency graph plugin and persists a started state", async () => {
const { existsSync, mkdtempSync, statSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
const { cp, mkdir, readFile, rm, stat, copyFile } = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const { buildSync } = await import("esbuild");
const { PluginLoader } = await import("../../../../core/src/plugin-loader.ts");
const { PluginStore } = await import("../../../../core/src/plugin-store.ts");
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
const sourceRoot = fileURLToPath(new URL("../../../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
const stagedRoot = fileURLToPath(new URL("../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
const pluginStateRoot = mkdtempSync(join(tmpdir(), "fn4128-bundled-plugin-"));
await rm(stagedRoot, { recursive: true, force: true });
await mkdir(stagedRoot, { recursive: true });
await cp(join(sourceRoot, "manifest.json"), join(stagedRoot, "manifest.json"));
buildSync({
entryPoints: [join(sourceRoot, "src", "index.ts")],
outfile: join(stagedRoot, "bundled.js"),
bundle: true,
format: "esm",
platform: "node",
alias: {
"@fusion/plugin-sdk": join(repoRoot, "packages", "plugin-sdk", "src", "index.ts"),
},
logLevel: "silent",
});
mockExistsSync.mockImplementation((path: string) => existsSync(path));
mockStatSync.mockImplementation((path: string) => statSync(path));
mockReadFile.mockImplementation((path: string, encoding: string) => readFile(path, encoding as BufferEncoding));
mockFsStat.mockImplementation((path: string) => stat(path));
mockCopyFile.mockImplementation((src: string, dest: string) => copyFile(src, dest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
try {
const pluginStore = new PluginStore(pluginStateRoot, { inMemoryDb: true, centralGlobalDir: pluginStateRoot });
await pluginStore.init();
const taskStore = {
getRootDir: () => repoRoot,
logActivity: vi.fn(),
getPluginStore: () => pluginStore,
} as any;
const loader = new PluginLoader({ pluginStore, taskStore });
const result = await ensureBundledDependencyGraphPluginInstalled(pluginStore, loader);
const storedPlugin = await pluginStore.getPlugin(BUNDLED_PLUGIN_ID);
expect(result).toBe("installed");
expect(storedPlugin.path.endsWith("/fusion-plugin-dependency-graph/bundled.js")).toBe(true);
expect(storedPlugin.state).toBe("started");
expect(storedPlugin.error ?? null).toBeNull();
} finally {
await rm(stagedRoot, { recursive: true, force: true });
await rm(pluginStateRoot, { recursive: true, force: true });
}
}, 20_000);
});

View File

@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -69,9 +69,12 @@ function resolveBundledPluginDir(pluginId: string): string | null {
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
* 2. ./dist/index.js (legacy prebuilt fallback)
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
* 4. fall back to the directory itself
*
* Returns null when the directory exists but none of the loadable entry files
* are present. Callers must treat that as a missing bundle rather than
* persisting a directory path that Node cannot import.
*/
export function resolvePluginEntryPath(pluginDir: string): string {
export function resolvePluginEntryPath(pluginDir: string): string | null {
const candidates = [
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"),
@@ -82,7 +85,15 @@ export function resolvePluginEntryPath(pluginDir: string): string {
return candidate;
}
}
return pluginDir;
return null;
}
function isDirectoryPath(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
export async function ensureBundledPluginInstalled(
@@ -105,16 +116,22 @@ export async function ensureBundledPluginInstalled(
const manifest = await loadManifest(bundledDir);
const entryPath = resolvePluginEntryPath(bundledDir);
if (!entryPath) {
console.warn(`[plugins] Bundled plugin "${pluginId}" is missing a loadable entry file in ${bundledDir}`);
return "missing-bundle";
}
if (existingPlugin) {
const pathChanged = existingPlugin.path !== entryPath;
const existingPathIsDirectory = isDirectoryPath(existingPlugin.path);
const pathChanged = existingPathIsDirectory || existingPlugin.path !== entryPath;
const versionChanged = existingPlugin.version !== manifest.version;
if (!pathChanged && !versionChanged) {
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch {
// best-effort
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
}
}
return "already-installed";
@@ -128,8 +145,8 @@ export async function ensureBundledPluginInstalled(
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch {
// best-effort
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
}
}
@@ -144,8 +161,8 @@ export async function ensureBundledPluginInstalled(
if (plugin.enabled) {
try {
await pluginLoader.loadPlugin(plugin.id);
} catch {
// best-effort
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", plugin.id, err);
}
}