FN-7740: fix store-leak and no-retry-on-lock gaps in research/settings-import/agent-export/git/project CLI commands

Close leaked TaskStore/AgentStore handles and retry lock errors across fn research, settings import, agent export, git, and project CLI commands.

- fn research create/list/show/export/cancel/retry now close their resolved store on every exit path except the intentional fire-and-forget --wait-for-completion-less create, which stays open to avoid truncating the background run
- fn settings import retries importSettings through a momentary database-is-locked window and closes the store before every process.exit()
- fn agent export closes both the project TaskStore and the AgentStore it opens on every exit path, including the no-agents-to-export guard
- fn git status/fetch/pull/push and fn agent export switch to a path-only project resolution helper so no cached, never-closed TaskStore is left behind for these read/write-nothing-to-board commands
- fn project list/show compute per-project task counts against an uncached TaskStore that is now closed after every call, and the count read retries a momentary lock instead of silently reporting zero tasks
- Adds dedicated lock-retry regression tests for each touched command plus a changeset documenting the fix

Files changed:
 .changeset/fn-7740-cli-cmd-lock-retry.md           |   7 +
 docs/cli-reference.md                              |  28 +++
 .../__tests__/agent-export-lock-retry.test.ts      | 122 ++++++++++
 .../src/commands/__tests__/git-lock-retry.test.ts  | 129 +++++++++++
 packages/cli/src/commands/__tests__/git.test.ts    |  12 +
 .../commands/__tests__/project-lock-retry.test.ts  | 195 ++++++++++++++++
 .../cli/src/commands/__tests__/project.test.ts     |   8 +
 .../commands/__tests__/research-lock-retry.test.ts | 248 ++++++++++++++++++++
 .../cli/src/commands/__tests__/research.test.ts    |  16 +-
 .../__tests__/settings-import-lock-retry.test.ts   | 170 ++++++++++++++
 .../src/commands/__tests__/settings-import.test.ts |  34 +++
 packages/cli/src/commands/agent-export.ts          |  67 ++++--
 packages/cli/src/commands/git.ts                   |  18 +-
 packages/cli/src/commands/project.ts               |  40 +++-
 packages/cli/src/commands/research.ts              | 254 ++++++++++++++-------
 packages/cli/src/commands/settings-import.ts       |  61 ++++-
 16 files changed, 1284 insertions(+), 125 deletions(-)

Fusion-Task-Id: FN-7740

Fusion-Task-Lineage: 5c572332-b81c-4e16-9748-ce8639f53ff3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 12:02:15 -07:00
parent 5304af8d0e
commit 4726af673a
16 changed files with 1287 additions and 128 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: CLI research/settings-import/agent-export/git/project commands close board stores promptly and retry a locked database.
category: fix
dev: Applies the FN-7731/FN-7738/FN-7704 CLI resolveProjectPathOnly + closeProjectStore/asLocalProjectContext + retryOnLock pattern to packages/cli/src/commands/research.ts, settings-import.ts, agent-export.ts, git.ts, and project.ts; path-only callers stop leaking the cached resolveProject TaskStore, getTaskCounts closes its per-project store, agent export closes its AgentStore, and importSettings/createExport retry FUSION_CLI_LOCK_RETRY_MS. The research non-wait fire-and-forget run path is intentionally exempted so it is not truncated; GlobalSettingsStore is file-backed and left unchanged.

View File

@@ -626,6 +626,34 @@ global-scope settings live in the file-backed `GlobalSettingsStore`
with no close and no lock-retry. All of the above honor the same
`FUSION_CLI_LOCK_RETRY_MS` deadline override.
The same class of fix (FN-7740) also covers `fn research *`
(`create`/`list`/`show`/`export`/`cancel`/`retry`), `fn settings import`,
`fn agent export`, `fn git *` (`status`/`fetch`/`pull`/`push`), and
`fn project *` (`list`/`add`/`remove`/`show`/`set-default`/`detect`):
- `fn git *` and `fn agent export` never write the board, so they are
**teardown-only** — the resolved project path is now obtained via the
path-only helper (no cached, never-closed `TaskStore` left behind), and
`fn agent export` additionally closes the `AgentStore` it opens on every
exit path (success and the no-agents-to-export guard).
- `fn project list`/`show` compute per-project task counts against an
uncached `TaskStore` per registered project; that store is now closed
after every call (so `fn project list` no longer leaks one store per
registered project), and the count read retries a momentary
`database is locked` instead of silently reporting zero tasks.
- `fn settings import`'s `importSettings` write and `fn research create`
(settings read) / `fn research export`'s `createExport` write retry
through a momentary `database is locked` — subject to the same
`FUSION_CLI_LOCK_RETRY_MS` deadline override as `fn task`/`fn branch-group`/
`fn pr` — and the resolved store is closed BEFORE every `process.exit()`
call (a pending `finally` does not run after `process.exit()`).
- `fn research create` without `--wait-for-completion` is the ONE
intentionally-long-lived exception in the CLI: the research run continues
in the background against the same store after the command returns, so
that store is deliberately NOT closed on this path (closing it would
truncate the in-flight run). Every other `fn research *` path, including
`--wait-for-completion`, closes its store on exit.
### Execution and status
```bash

View File

@@ -0,0 +1,122 @@
/**
* FNXC:CliAgentControl 2026-07-09-00:00:
* Regression coverage for FN-7740's `agent-export.ts` fix: `getProjectPath`
* must resolve the project path WITHOUT leaking the cached `TaskStore`
* `resolveProject()` constructs internally (path-only leak, mirrors
* `git.ts`), AND `runAgentExport` must close the `AgentStore` it opens on
* EVERY exit path — the success return AND the no-agents `process.exit(1)`
* guard. Export is a read (no board writes) so there is no `retryOnLock`
* surface here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TaskStore as TaskStoreType, ProjectContext } from "@fusion/core";
const mockResolveProject = vi.fn();
// See git-lock-retry.test.ts FNXC header for why this is a full replacement
// mock (not a partial `importActual` spread) — the real
// `resolveProjectPathOnly` calls `resolveProject` through the module's own
// closure, bypassing any partial override.
vi.mock("../../project-context.js", () => ({
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
resolveProjectPathOnly: async (...args: unknown[]) => {
const context = await mockResolveProject(...args);
try {
await context.store.close();
} catch {
// best-effort
}
return context.projectPath;
},
}));
describe("fn agent export — store-leak reproduction (FN-7740)", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = mkdtempSync(join(tmpdir(), "fn-agent-export-lock-retry-test-"));
vi.clearAllMocks();
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
vi.restoreAllMocks();
});
it("closes both the path-only TaskStore and the AgentStore when no agents exist (guard exit path)", async () => {
const { TaskStore, AgentStore } = await import("@fusion/core");
const store = new TaskStore(tmpDir) as TaskStoreType;
await store.init();
const taskStoreCloseSpy = vi.spyOn(store, "close");
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectPath: tmpDir,
projectName: "demo",
isRegistered: true,
store,
} satisfies ProjectContext);
const agentStoreCloseSpy = vi.spyOn(AgentStore.prototype, "close");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { runAgentExport } = await import("../agent-export.js");
await expect(runAgentExport(join(tmpDir, "out"), { project: "demo" })).rejects.toThrow("process.exit:1");
expect(taskStoreCloseSpy).toHaveBeenCalled();
expect(agentStoreCloseSpy).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith("No agents found to export");
await store.close().catch(() => {});
exitSpy.mockRestore();
errorSpy.mockRestore();
});
it("closes both stores on the success return path when agents exist", async () => {
const { TaskStore, AgentStore } = await import("@fusion/core");
const store = new TaskStore(tmpDir) as TaskStoreType;
await store.init();
const taskStoreCloseSpy = vi.spyOn(store, "close");
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectPath: tmpDir,
projectName: "demo",
isRegistered: true,
store,
} satisfies ProjectContext);
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
await agentStore.createAgent({
name: "Solo",
role: "executor",
title: "Solo Agent",
metadata: { description: "test agent", skills: [] },
instructionsText: "Do the thing.",
});
agentStore.close();
const agentStoreCloseSpy = vi.spyOn(AgentStore.prototype, "close");
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { runAgentExport } = await import("../agent-export.js");
await runAgentExport(join(tmpDir, "out"), { project: "demo" });
expect(taskStoreCloseSpy).toHaveBeenCalled();
expect(agentStoreCloseSpy).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Agents exported: 1"));
await store.close().catch(() => {});
logSpy.mockRestore();
});
});

View File

@@ -0,0 +1,129 @@
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* Regression coverage for FN-7740's `git.ts` fix: `resolveGitCwd` must
* resolve the project path WITHOUT leaking the `TaskStore` that
* `resolveProject()` constructs internally (`git` commands never touch the
* board DB at all — this is a pure path-only-caller leak, no lock-retry
* surface). Proves the original symptom (a cached, never-closed `TaskStore`
* left in `storeCache` after a return-normally `git` command) is gone by
* driving the REAL `resolveProjectPathOnly`/`closeProjectStore` helpers
* (only `resolveProject` itself is stubbed, to avoid touching the real
* central registry / `~/.fusion` under test) against a REAL `TaskStore`
* and asserting `.close()` is invoked.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TaskStore as TaskStoreType, ProjectContext } from "@fusion/core";
const mockResolveProject = vi.fn();
// Full replacement mock (not a partial `importActual` spread): the real
// `resolveProjectPathOnly` calls `resolveProject` through the SAME module's
// internal closure, not through the exported binding, so overriding only
// `resolveProject` via a partial spread would silently keep calling the
// REAL `resolveProject` (which hits the real central registry / global
// dir resolution — forbidden under VITEST without an explicit temp dir).
// Provide local implementations of `resolveProjectPathOnly`/
// `closeProjectStore` that mirror the real close-then-evict semantics
// against the SAME `mockResolveProject`, so this test still exercises the
// real store-close call this fix depends on.
vi.mock("../../project-context.js", () => ({
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
resolveProjectPathOnly: async (...args: unknown[]) => {
const context = await mockResolveProject(...args);
try {
await context.store.close();
} catch {
// best-effort
}
return context.projectPath;
},
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
const { promisify } = await import("node:util");
const execFn: typeof vi.fn = vi.fn((_cmd: string, opts: object | undefined, cb: (err: Error | null, stdout: string, stderr: string) => void) => {
const callback = typeof opts === "function" ? opts : cb;
if (callback === undefined) return;
callback(new Error("not a git repo"), "", "");
});
execFn[promisify.custom] = () => Promise.reject(new Error("not a git repo"));
return { ...actual, exec: execFn };
});
describe("fn git — store-leak reproduction (FN-7740)", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = mkdtempSync(join(tmpdir(), "fn-git-lock-retry-test-"));
vi.clearAllMocks();
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
vi.restoreAllMocks();
});
it("closes the resolved TaskStore even though git commands never use context.store (path-only leak class)", async () => {
const { TaskStore } = await import("@fusion/core");
const store = new TaskStore(tmpDir) as TaskStoreType;
await store.init();
const closeSpy = vi.spyOn(store, "close");
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectPath: tmpDir,
projectName: "demo",
isRegistered: true,
store,
} satisfies ProjectContext);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { runGitStatus } = await import("../git.js");
// No `.git` directory in `tmpDir` — `runGitStatus` resolves the project
// path (constructing+closing the store via `resolveProjectPathOnly`)
// BEFORE the "Not a git repository" guard exits, so the store-close
// assertion holds regardless of the git-repo outcome.
await expect(runGitStatus("demo-project")).rejects.toThrow("process.exit:1");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(closeSpy).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith("Error: Not a git repository");
await store.close().catch(() => {});
exitSpy.mockRestore();
errorSpy.mockRestore();
});
it("does not leak a store on the no-project-flag / CWD-fallback branch when resolution fails", async () => {
mockResolveProject.mockRejectedValue(new Error("No fusion project found"));
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmpDir);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { runGitStatus } = await import("../git.js");
// No store is ever constructed on this branch (resolution failed before
// any `TaskStore` was built) — nothing to leak, and the command still
// fails cleanly with a non-zero exit once it discovers `tmpDir` is not
// a git repo.
await expect(runGitStatus()).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Not a git repository");
cwdSpy.mockRestore();
exitSpy.mockRestore();
errorSpy.mockRestore();
});
});

View File

@@ -46,8 +46,20 @@ vi.mock("node:readline/promises", () => ({
})),
}));
// FN-7740: `resolveGitCwd` now calls `resolveProjectPathOnly` (which
// resolves via `resolveProject` internally and closes+evicts the store) so
// the mock delegates to the same mocked `resolveProject` implementation the
// tests already configure, keeping existing assertions on `resolveProject`
// call args meaningful (see project memory: once a command imports
// `closeProjectStore`/`asLocalProjectContext`-adjacent helpers, tests that
// only mocked `resolveProject` must stub the newly-imported exports too).
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
resolveProjectPathOnly: vi.fn(async (projectName?: string) => {
const { resolveProject: resolveProjectMock } = await import("../../project-context.js");
const context = await (resolveProjectMock as ReturnType<typeof vi.fn>)(projectName);
return context.projectPath;
}),
}));
import { createInterface } from "node:readline/promises";

View File

@@ -0,0 +1,195 @@
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* Regression coverage for FN-7740's `project.ts` fix: `getTaskCounts`
* builds an UNCACHED `new TaskStore(projectPath)` per call and previously
* never closed it — `runProjectList` calls it once per registered project
* in a `Promise.all` map, so an N-project registry leaked N never-closed
* stores. Proves (1) every constructed `getTaskCounts` store is closed,
* including across MULTIPLE registered projects in one `runProjectList`
* call, (2) the `runProjectAdd` interactive-init store is closed, and (3)
* `listTasks` retries a momentary `database is locked` instead of silently
* masquerading as "zero tasks" via the outer soft-catch.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
const originalMockImplementation = mock.mockImplementation.bind(mock);
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
return nextImpl(...args);
};
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
if (impl) {
mock.mockImplementation(impl);
}
return mock;
}
const { taskStoreInstances, mockListProjects, mockGetProjectHealth, mockGetSettings, isSqliteLockErrorMock } = vi.hoisted(() => ({
taskStoreInstances: [] as Array<{ path: string; init: ReturnType<typeof import("vitest").vi.fn>; listTasks: ReturnType<typeof import("vitest").vi.fn>; close: ReturnType<typeof import("vitest").vi.fn> }>,
mockListProjects: vi.fn(),
mockGetProjectHealth: vi.fn(),
mockGetSettings: vi.fn(),
isSqliteLockErrorMock: vi.fn((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return /database is locked|SQLITE_BUSY/i.test(message);
}),
}));
vi.mock("@fusion/core", () => ({
CentralCore: makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
listProjects: mockListProjects,
getProjectHealth: mockGetProjectHealth,
getProject: vi.fn(),
})),
GlobalSettingsStore: makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings: mockGetSettings,
})),
TaskStore: makeConstructibleMock((path: string) => {
const instance = {
path,
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
close: vi.fn().mockResolvedValue(undefined),
};
taskStoreInstances.push(instance);
return instance;
}),
countRunningAgentTasks: () => 0,
ensureMemoryFileWithBackend: vi.fn(),
readProjectIdentity: vi.fn().mockReturnValue(undefined),
writeProjectIdentity: vi.fn(),
isSqliteLockError: isSqliteLockErrorMock,
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",
todo: "To Do",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
},
}));
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(() => ({
question: vi.fn().mockResolvedValue("y"),
close: vi.fn(),
})),
}));
vi.mock("../../project-context.js", () => ({
formatProjectLine: vi.fn((project: { name: string }, isDefault: boolean) => `${isDefault ? "* " : " "}${project.name}`),
detectProjectFromCwd: vi.fn(),
setDefaultProject: vi.fn(),
}));
import { runProjectList } from "../project.js";
describe("fn project — store-leak reproduction (FN-7740)", () => {
beforeEach(() => {
vi.clearAllMocks();
taskStoreInstances.length = 0;
mockGetSettings.mockResolvedValue({});
mockGetProjectHealth.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("closes zero stores when no projects are registered", async () => {
mockListProjects.mockResolvedValue([]);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runProjectList({ json: true });
expect(taskStoreInstances).toHaveLength(0);
logSpy.mockRestore();
});
it("closes EVERY per-project TaskStore getTaskCounts constructs, across multiple registered projects", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-a", name: "alpha", path: "/projects/alpha", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
{ id: "proj-b", name: "beta", path: "/projects/beta", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
{ id: "proj-c", name: "gamma", path: "/projects/gamma", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
]);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runProjectList({ json: true });
// getTaskCounts constructs an uncached TaskStore per project
expect(taskStoreInstances).toHaveLength(3);
for (const instance of taskStoreInstances) {
expect(instance.close).toHaveBeenCalled();
}
logSpy.mockRestore();
});
it("closes the store even when a project's listTasks throws (outer soft-catch still fires, but the store is not leaked)", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-bad", name: "unreadable", path: "/projects/unreadable", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
]);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runProjectList({ json: true });
expect(taskStoreInstances).toHaveLength(1);
// Simulate the failure via the instance itself: reconfigure listTasks to
// reject on next call and re-run to prove close still fires.
taskStoreInstances[0]!.listTasks.mockRejectedValueOnce(new Error("not-a-project"));
await runProjectList({ json: true });
const secondInstance = taskStoreInstances[1]!;
expect(secondInstance.close).toHaveBeenCalled();
logSpy.mockRestore();
});
it("retries listTasks through a transient database-is-locked error instead of silently reporting zero tasks", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-a", name: "alpha", path: "/projects/alpha", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
]);
const lockError = Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" });
const realTasks = [{ id: "FN-1", column: "todo" }, { id: "FN-2", column: "in-progress" }];
// Configure the FIRST constructed store's listTasks to fail twice with a
// lock error, then succeed — proving retryOnLock rides out the lock
// instead of the outer soft-catch silently reporting empty counts.
const originalPush = taskStoreInstances.push.bind(taskStoreInstances);
taskStoreInstances.push = ((instance: (typeof taskStoreInstances)[number]) => {
instance.listTasks
.mockRejectedValueOnce(lockError)
.mockRejectedValueOnce(lockError)
.mockResolvedValueOnce(realTasks);
return originalPush(instance);
}) as typeof taskStoreInstances.push;
process.env.FUSION_CLI_LOCK_RETRY_MS = "5000";
vi.useFakeTimers();
let jsonOutput = "";
const logSpy = vi.spyOn(console, "log").mockImplementation((msg: string) => {
jsonOutput += msg;
});
try {
const promise = runProjectList({ json: true });
for (let i = 0; i < 10 && taskStoreInstances[0]?.listTasks.mock.calls.length !== 3; i++) {
await vi.advanceTimersByTimeAsync(1_000);
}
await promise;
} finally {
vi.useRealTimers();
delete process.env.FUSION_CLI_LOCK_RETRY_MS;
}
expect(taskStoreInstances[0]!.listTasks).toHaveBeenCalledTimes(3);
expect(taskStoreInstances[0]!.close).toHaveBeenCalled();
// The real (non-empty) task counts made it through — proving the retry
// succeeded rather than the outer catch masking the lock as zero tasks.
expect(jsonOutput).toContain('"todo": 1');
expect(jsonOutput).toContain('"in-progress": 1');
logSpy.mockRestore();
}, 20_000);
});

View File

@@ -40,6 +40,7 @@ const mockGetSettings = vi.fn();
const mockGlobalInit = vi.fn();
const mockTaskStoreInit = vi.fn();
const mockTaskStoreListTasks = vi.fn();
const mockTaskStoreClose = vi.fn();
const mockEnsureMemoryFileWithBackend = vi.fn();
// Mock @fusion/core
@@ -63,7 +64,13 @@ vi.mock("@fusion/core", () => ({
TaskStore: makeConstructibleMock(() => ({
init: mockTaskStoreInit,
listTasks: mockTaskStoreListTasks,
close: mockTaskStoreClose,
})),
// FN-7740: `getTaskCounts`/`runProjectAdd`'s interactive-init store now
// close via `store.close()` and `listTasks` is wrapped in `retryOnLock`
// (which imports `isSqliteLockError` from @fusion/core) — stub it per
// project memory's mocked-module pitfall.
isSqliteLockError: vi.fn(() => false),
countRunningAgentTasks: (tasks: Array<{ column: string; status?: string; paused?: boolean }>) => tasks.filter((task) => (
task.column === "in-progress" ||
(task.column === "triage" && task.status === "planning" && !task.paused) ||
@@ -118,6 +125,7 @@ describe("project commands", () => {
mockGetProjectHealth.mockResolvedValue(undefined);
mockTaskStoreInit.mockResolvedValue(undefined);
mockTaskStoreListTasks.mockResolvedValue([]);
mockTaskStoreClose.mockResolvedValue(undefined);
});
afterEach(() => {

View File

@@ -0,0 +1,248 @@
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* Regression coverage for FN-7740's `research.ts` fix: every `runResearch*`
* command must close its resolved `TaskStore` on every exit path
* (success/not-found/`handleError`), retry `getSettings()`/`createExport`
* through a momentary `database is locked`, and — critically — the
* `runResearchCreate` non-`waitForCompletion` fire-and-forget branch must
* be exempt from the close discipline (closing it would truncate an
* in-flight background run). Uses a mocked `TaskStore`/orchestrator (per
* FN-5048 — no real long waits) with `retryOnLock`'s real bounded-backoff
* implementation exercised end to end via fake timers.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
runResearchCancel,
runResearchCreate,
runResearchExport,
runResearchList,
runResearchRetry,
runResearchShow,
} from "../research.js";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
const originalMockImplementation = mock.mockImplementation.bind(mock);
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
return nextImpl(...args);
};
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
if (impl) {
mock.mockImplementation(impl);
}
return mock;
}
const mockRun = {
id: "RR-001",
query: "test query",
topic: "test query",
status: "running",
sources: [],
events: [],
tags: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
results: { summary: "done", findings: [], citations: [] },
};
const researchStoreMock = {
getRun: vi.fn(() => mockRun),
listRuns: vi.fn(() => [mockRun]),
createExport: vi.fn(),
};
const { storeMock, orchestratorMock, resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => {
const researchStore = {
getRun: vi.fn(),
listRuns: vi.fn(),
createExport: vi.fn(),
};
return {
storeMock: {
init: vi.fn(),
close: vi.fn(async () => undefined),
getSettings: vi.fn(async () => ({ researchSettings: { enabled: true }, researchGlobalWebSearchProvider: "tavily", researchGlobalTavilyApiKey: "x" })),
getResearchStore: vi.fn(() => researchStore),
},
orchestratorMock: {
createRun: vi.fn(() => "RR-002"),
startRun: vi.fn(async () => ({ id: "RR-002", status: "running" })),
cancelRun: vi.fn(() => true),
retryRun: vi.fn(() => "RR-003"),
},
resolveResearchSettingsMock: vi.fn(() => ({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } })),
providerRegistryMock: makeConstructibleMock(function () { return { getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) }; }),
writeFileMock: vi.fn(async () => undefined),
};
});
vi.mock("@fusion/core", () => ({
TaskStore: makeConstructibleMock(() => storeMock),
resolveResearchSettings: resolveResearchSettingsMock,
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
isSqliteLockError: (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return /database is locked|SQLITE_BUSY/i.test(message);
},
}));
vi.mock("@fusion/engine", () => ({
ResearchProviderRegistry: providerRegistryMock,
ResearchStepRunner: vi.fn(),
ResearchOrchestrator: makeConstructibleMock(() => orchestratorMock),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(async () => undefined),
resolveProjectPathOnly: vi.fn(async () => undefined),
}));
vi.mock("node:fs/promises", () => ({ writeFile: writeFileMock }));
describe("research commands — leak/lock reproduction (FN-7740)", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const originalExit = process.exit;
const originalRetryMs = process.env.FUSION_CLI_LOCK_RETRY_MS;
const researchStore = researchStoreMock;
beforeEach(() => {
vi.clearAllMocks();
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
storeMock.getSettings.mockResolvedValue({ researchSettings: { enabled: true }, researchGlobalWebSearchProvider: "tavily", researchGlobalTavilyApiKey: "x" });
storeMock.getResearchStore.mockReturnValue(researchStore);
resolveResearchSettingsMock.mockReturnValue({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } });
providerRegistryMock.mockImplementation(function () { return { getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) }; });
researchStore.getRun.mockReturnValue(mockRun);
researchStore.listRuns.mockReturnValue([mockRun]);
researchStore.createExport.mockReturnValue(undefined);
orchestratorMock.retryRun.mockReturnValue("RR-003");
orchestratorMock.startRun.mockResolvedValue({ ...mockRun, id: "RR-002", status: "running" });
});
afterEach(() => {
process.exit = originalExit;
if (originalRetryMs === undefined) {
delete process.env.FUSION_CLI_LOCK_RETRY_MS;
} else {
process.env.FUSION_CLI_LOCK_RETRY_MS = originalRetryMs;
}
});
it("closes the store on the runResearchList success path", async () => {
await runResearchList({ json: true });
expect(storeMock.close).toHaveBeenCalled();
});
it("closes the store on the runResearchShow success path", async () => {
await runResearchShow("RR-001");
expect(storeMock.close).toHaveBeenCalled();
});
it("closes the store on a not-found error path (handleError → process.exit(1))", async () => {
researchStore.getRun.mockReturnValue(undefined);
await expect(runResearchShow("RR-404")).rejects.toThrow("process.exit:1");
expect(storeMock.close).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith("Error: Cited-research run not found: RR-404");
});
it("closes the store on the runResearchExport success path", async () => {
await runResearchExport({ runId: "RR-001", format: "json", output: "./out.json" });
expect(storeMock.close).toHaveBeenCalled();
});
it("closes the store on the runResearchCancel success path", async () => {
await runResearchCancel("RR-001", { json: true });
expect(storeMock.close).toHaveBeenCalled();
});
it("closes the store on the runResearchRetry success path", async () => {
researchStore.getRun.mockImplementation((id: string) => (id === "RR-003" ? { ...mockRun, id: "RR-003", status: "queued" } : { ...mockRun, status: "failed" }));
await runResearchRetry("RR-001", { json: true });
expect(storeMock.close).toHaveBeenCalled();
});
it("closes the store on runResearchCreate's waitForCompletion path (fully awaited)", async () => {
orchestratorMock.startRun.mockResolvedValue({ ...mockRun, id: "RR-002", status: "completed" });
await runResearchCreate({ query: "hello", waitForCompletion: true, maxWaitMs: 1_000 });
expect(storeMock.close).toHaveBeenCalled();
});
it("does NOT close the store on runResearchCreate's non-wait fire-and-forget path (intentionally-long-lived exemption)", async () => {
// The background run continues to read/write the same store via
// `orchestrator.startRun` after this call returns — closing it here
// would truncate an in-flight run. This is the ONE deliberately
// exempted branch in the whole FN-7740 audit.
await runResearchCreate({ query: "hello" });
expect(storeMock.close).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created cited-research run"));
});
it("retries getSettings through a transient database-is-locked error and succeeds once it clears", async () => {
const lockError = Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" });
storeMock.getSettings
.mockRejectedValueOnce(lockError)
.mockRejectedValueOnce(lockError)
.mockResolvedValueOnce({ researchSettings: { enabled: true }, researchGlobalWebSearchProvider: "tavily", researchGlobalTavilyApiKey: "x" });
process.env.FUSION_CLI_LOCK_RETRY_MS = "5000";
vi.useFakeTimers();
try {
const promise = runResearchCreate({ query: "hello" });
for (let i = 0; i < 10 && storeMock.getSettings.mock.calls.length < 3; i++) {
await vi.advanceTimersByTimeAsync(1_000);
}
await promise;
} finally {
vi.useRealTimers();
}
expect(storeMock.getSettings).toHaveBeenCalledTimes(3);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created cited-research run"));
});
it("fails fast with a clear non-zero-exit error when the lock never clears within the bound (getSettings)", async () => {
const lockError = Object.assign(new Error("SQLITE_BUSY: database is locked"), { code: "SQLITE_BUSY" });
storeMock.getSettings.mockRejectedValue(lockError);
process.env.FUSION_CLI_LOCK_RETRY_MS = "500";
vi.useFakeTimers();
try {
const promise = runResearchCreate({ query: "hello" });
const assertion = expect(promise).rejects.toThrow("process.exit:1");
await vi.advanceTimersByTimeAsync(3_000);
await assertion;
} finally {
vi.useRealTimers();
}
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("board database stayed locked"));
expect(storeMock.close).toHaveBeenCalled();
});
it("retries createExport through a transient database-is-locked error and succeeds once it clears", async () => {
const lockError = Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" });
researchStore.createExport
.mockImplementationOnce(() => { throw lockError; })
.mockImplementationOnce(() => { throw lockError; })
.mockImplementationOnce(() => undefined);
process.env.FUSION_CLI_LOCK_RETRY_MS = "5000";
vi.useFakeTimers();
try {
const promise = runResearchExport({ runId: "RR-001", format: "json", output: "./out.json" });
for (let i = 0; i < 10 && researchStore.createExport.mock.calls.length < 3; i++) {
await vi.advanceTimersByTimeAsync(1_000);
}
await promise;
} finally {
vi.useRealTimers();
}
expect(researchStore.createExport).toHaveBeenCalledTimes(3);
expect(storeMock.close).toHaveBeenCalled();
});
});

View File

@@ -37,6 +37,7 @@ const researchStoreMock = {
const storeMock = {
init: vi.fn(),
close: vi.fn(async () => undefined),
getSettings: vi.fn(async () => ({ researchSettings: { enabled: true }, researchGlobalWebSearchProvider: "tavily", researchGlobalTavilyApiKey: "x" })),
getResearchStore: vi.fn(() => researchStoreMock),
};
@@ -54,11 +55,17 @@ const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.
writeFileMock: vi.fn(async () => undefined),
}));
// FN-7740: `research.ts` now imports `retryOnLock` (which imports
// `isSqliteLockError` from @fusion/core), so a fully-mocked @fusion/core
// module must stub it (project memory pitfall). `storeMock.close` above
// backs the new close-on-every-exit-path discipline (except the documented
// non-wait fire-and-forget branch in `runResearchCreate`).
vi.mock("@fusion/core", () => ({
TaskStore: makeConstructibleMock(() => storeMock),
resolveResearchSettings: resolveResearchSettingsMock,
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
isSqliteLockError: vi.fn(() => false),
}));
vi.mock("@fusion/engine", () => ({
@@ -67,7 +74,14 @@ vi.mock("@fusion/engine", () => ({
ResearchOrchestrator: makeConstructibleMock(() => orchestratorMock),
}));
vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(async () => undefined) }));
// FN-7740: `getStore` now resolves a name→path via `resolveProjectPathOnly`
// instead of using `resolveProject`'s `.store` directly — stub both exports
// (none of these tests pass `projectName`, so `resolveProjectPathOnly` is
// unused at runtime here, but it must exist on the mock module).
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(async () => undefined),
resolveProjectPathOnly: vi.fn(async () => undefined),
}));
vi.mock("node:fs/promises", () => ({ writeFile: writeFileMock }));
describe("research commands", () => {

View File

@@ -0,0 +1,170 @@
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* Regression coverage for FN-7740's `settings-import.ts` fix:
* `runSettingsImport` must close its uncached `TaskStore` BEFORE every
* `process.exit()` call (per project memory: a pending `finally` never
* runs after `process.exit()`), and retry the `importSettings` board
* mutation through a momentary `database is locked` instead of failing the
* import outright. Uses a mocked `TaskStore`/`importSettings` (per FN-5048
* — no real long waits / real SQLite I/O entangled with fake timers) with
* `retryOnLock`'s real bounded-backoff implementation exercised end to end.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync } from "node:fs";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
const originalMockImplementation = mock.mockImplementation.bind(mock);
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
return nextImpl(...args);
};
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
if (impl) {
mock.mockImplementation(impl);
}
return mock;
}
const { mockStoreInit, mockStoreClose, mockImportSettings } = vi.hoisted(() => ({
mockStoreInit: vi.fn().mockResolvedValue(undefined),
mockStoreClose: vi.fn().mockResolvedValue(undefined),
mockImportSettings: vi.fn(),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: makeConstructibleMock(() => ({
init: mockStoreInit,
close: mockStoreClose,
})),
importSettings: mockImportSettings,
readExportFile: vi.fn(),
validateImportData: vi.fn(),
isSqliteLockError: (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return /database is locked|SQLITE_BUSY/i.test(message);
},
}));
vi.mock("../../project-context.js", () => ({
resolveProjectPathOnly: vi.fn(async () => undefined),
asLocalProjectContext: (store: unknown) => ({
projectId: "cwd",
projectPath: "cwd",
projectName: "cwd",
isRegistered: false,
store,
}),
closeProjectStore: async (context: { store: { close: () => Promise<void> } }) => {
await context.store.close().catch(() => {});
},
}));
import { runSettingsImport } from "../settings-import.js";
import { readExportFile, validateImportData } from "@fusion/core";
describe("fn settings import — leak/lock reproduction (FN-7740)", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
if ((code ?? 0) === 0) return undefined as never;
throw new Error(`process.exit:${code ?? 0}`);
});
const originalRetryMs = process.env.FUSION_CLI_LOCK_RETRY_MS;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readExportFile).mockResolvedValue({
version: 1,
exportedAt: "2026-07-09T00:00:00.000Z",
global: { ntfyEnabled: true },
project: {},
} as any);
vi.mocked(validateImportData).mockReturnValue([]);
mockImportSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 0 });
});
afterEach(() => {
if (originalRetryMs === undefined) {
delete process.env.FUSION_CLI_LOCK_RETRY_MS;
} else {
process.env.FUSION_CLI_LOCK_RETRY_MS = originalRetryMs;
}
vi.clearAllMocks();
});
it("closes the uncached TaskStore on a successful import (process.exit(0))", async () => {
await runSettingsImport("./settings.json", { yes: true });
expect(mockStoreClose).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(" ✓ Settings imported successfully");
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("closes the uncached TaskStore before process.exit(1) on a not-found file (no retry-looping)", async () => {
vi.mocked(existsSync).mockReturnValue(false);
await expect(runSettingsImport("./missing.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(mockStoreClose).toHaveBeenCalled();
expect(mockImportSettings).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("File not found"));
});
it("retries importSettings through a transient database-is-locked error and succeeds once it clears", async () => {
const lockError = Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" });
mockImportSettings
.mockRejectedValueOnce(lockError)
.mockRejectedValueOnce(lockError)
.mockResolvedValueOnce({ success: true, globalCount: 1, projectCount: 0 });
process.env.FUSION_CLI_LOCK_RETRY_MS = "5000";
vi.useFakeTimers();
try {
const promise = runSettingsImport("./settings.json", { yes: true });
for (let i = 0; i < 10 && mockImportSettings.mock.calls.length < 3; i++) {
await vi.advanceTimersByTimeAsync(1_000);
}
await promise;
} finally {
vi.useRealTimers();
}
expect(mockImportSettings).toHaveBeenCalledTimes(3);
expect(mockStoreClose).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(" ✓ Settings imported successfully");
});
it("fails fast with a clear non-zero-exit error (and closes the store) when the lock never clears within the bound", async () => {
const lockError = Object.assign(new Error("SQLITE_BUSY: database is locked"), { code: "SQLITE_BUSY" });
mockImportSettings.mockRejectedValue(lockError);
process.env.FUSION_CLI_LOCK_RETRY_MS = "500";
vi.useFakeTimers();
try {
const promise = runSettingsImport("./settings.json", { yes: true });
const assertion = expect(promise).rejects.toThrow("process.exit:1");
await vi.advanceTimersByTimeAsync(3_000);
await assertion;
} finally {
vi.useRealTimers();
}
expect(mockStoreClose).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("board database stayed locked"));
});
it("propagates a non-lock importSettings failure immediately without retrying", async () => {
mockImportSettings.mockResolvedValue({ success: false, globalCount: 0, projectCount: 0, error: "schema mismatch" });
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(mockImportSettings).toHaveBeenCalledTimes(1);
expect(mockStoreClose).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith("Error: Import failed: schema mismatch");
});
});

View File

@@ -19,22 +19,56 @@ function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T)
}
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
const mockStoreClose = vi.fn().mockResolvedValue(undefined);
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
}));
// FN-7740: `runSettingsImport` now imports `retryOnLock` (which imports
// `isSqliteLockError` from @fusion/core) and closes its store via
// `asLocalProjectContext`/`closeProjectStore`. Per project memory, a fully
// mocked `@fusion/core` module must stub `isSqliteLockError` once any
// command under test transitively imports `lock-retry.js`, and the mocked
// `TaskStore` needs a `close()` so the close-before-exit path is exercised.
vi.mock("@fusion/core", () => ({
TaskStore: makeConstructibleMock(() => ({
init: mockStoreInit,
close: mockStoreClose,
})),
importSettings: vi.fn(),
readExportFile: vi.fn(),
validateImportData: vi.fn(),
isSqliteLockError: vi.fn(() => false),
}));
// FN-7740: `runSettingsImport` now resolves the name→path via
// `resolveProjectPathOnly` (path-only) instead of using `resolveProject`'s
// `.store` directly, and wraps its own uncached store via
// `asLocalProjectContext`/`closeProjectStore` before every `process.exit()`.
// Stub all four so existing assertions on `resolveProject` call args keep
// working (see project memory on this test-mock pitfall).
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
resolveProjectPathOnly: vi.fn(async (projectName?: string) => {
const { resolveProject: resolveProjectMock } = await import("../../project-context.js");
const context = await (resolveProjectMock as ReturnType<typeof vi.fn>)(projectName);
return context.projectPath;
}),
asLocalProjectContext: vi.fn((store: unknown) => ({
projectId: "cwd",
projectPath: "cwd",
projectName: "cwd",
isRegistered: false,
store,
})),
closeProjectStore: vi.fn(async (context: { store: { close?: () => Promise<void> } }) => {
try {
await context.store.close?.();
} catch {
// best-effort
}
}),
}));
import { runSettingsImport } from "../settings-import.js";

View File

@@ -11,26 +11,46 @@ import { resolve } from "node:path";
import { AgentStore, exportAgentsToDirectory } from "@fusion/core";
import { resolveProject } from "../project-context.js";
import { resolveProjectPathOnly } from "../project-context.js";
/**
* Get the project path for agent operations.
* Falls back to process.cwd() if no project is specified.
* FNXC:CliAgentControl 2026-07-09-00:00:
* FN-7740 audit finding: `getProjectPath` only ever needs the resolved
* `projectPath` — it never uses `context.store`. The prior `resolveProject`
* call still constructed (and, for registered/CWD-detected projects,
* cached) a `TaskStore` that was never closed, leaking a SQLite/WAL handle
* that keeps the CLI event loop alive after export finishes. Use
* `resolveProjectPathOnly` (FN-7731/FN-7738), which closes+evicts the store
* it constructs internally.
*/
async function getProjectPath(projectName?: string): Promise<string> {
if (projectName) {
const context = await resolveProject(projectName);
return context.projectPath;
return resolveProjectPathOnly(projectName);
}
try {
const context = await resolveProject(undefined);
return context.projectPath;
return await resolveProjectPathOnly(undefined);
} catch {
return process.cwd();
}
}
/**
* FNXC:CliAgentControl 2026-07-09-00:00:
* Mirrors `agent.ts`'s private `closeAgentStoreSafely` (FN-7704) — kept as
* a tiny local copy here per FN-7740 File Scope (do NOT edit `agent.ts`,
* and do NOT fork the `TaskStore` retry/teardown logic; this only closes
* the `AgentStore` this file itself opens). Best-effort: an already-closed
* store must never throw here.
*/
function closeAgentStoreSafely(agentStore: AgentStore): void {
try {
agentStore.close();
} catch {
// Best-effort teardown — never let a close failure block exit.
}
}
function printSummary(result: {
outputDir: string;
agentsExported: number;
@@ -70,21 +90,26 @@ export async function runAgentExport(
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
await agentStore.init();
const allAgents = await agentStore.listAgents();
const filterIds = options?.agentIds?.filter((id) => id.trim().length > 0);
const agents = filterIds && filterIds.length > 0
? allAgents.filter((agent) => filterIds.includes(agent.id))
: allAgents;
try {
const allAgents = await agentStore.listAgents();
const filterIds = options?.agentIds?.filter((id) => id.trim().length > 0);
const agents = filterIds && filterIds.length > 0
? allAgents.filter((agent) => filterIds.includes(agent.id))
: allAgents;
if (agents.length === 0) {
console.error("No agents found to export");
process.exit(1);
if (agents.length === 0) {
console.error("No agents found to export");
closeAgentStoreSafely(agentStore);
process.exit(1);
}
const result = await exportAgentsToDirectory(agents, resolve(outputDir), {
companyName: options?.companyName,
companySlug: options?.companySlug,
});
printSummary(result);
} finally {
closeAgentStoreSafely(agentStore);
}
const result = await exportAgentsToDirectory(agents, resolve(outputDir), {
companyName: options?.companyName,
companySlug: options?.companySlug,
});
printSummary(result);
}

View File

@@ -3,15 +3,27 @@ import { promisify } from "node:util";
const execAsync = promisify(exec);
import { createInterface } from "node:readline/promises";
import { resolveProject } from "../project-context.js";
import { resolveProjectPathOnly } from "../project-context.js";
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* FN-7740 audit finding: `git` commands never touch the board DB — they only
* need the resolved `projectPath` for the `execAsync` `cwd`. The prior
* `resolveProject(...).projectPath` call still constructed (and, for
* registered/CWD-detected projects, cached) a `TaskStore` that was never
* closed, leaking a SQLite/WAL handle that keeps the CLI event loop alive
* after the command's real work (a subprocess `git` call) is done. Use
* `resolveProjectPathOnly` (FN-7731/FN-7738), which resolves the path AND
* closes+evicts the store it constructs internally. No board access here →
* no `retryOnLock` needed.
*/
async function resolveGitCwd(projectName?: string): Promise<string> {
if (projectName) {
return (await resolveProject(projectName)).projectPath;
return resolveProjectPathOnly(projectName);
}
try {
return (await resolveProject(undefined)).projectPath;
return await resolveProjectPathOnly(undefined);
} catch {
return process.cwd();
}

View File

@@ -30,6 +30,7 @@ import { existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { detectProjectFromCwd, setDefaultProject } from "../project-context.js";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
import { retryOnLock } from "../lock-retry.js";
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
@@ -120,13 +121,28 @@ function formatLastActivity(timestamp?: string | null): string {
}
/**
* Get task counts by column for a project.
* FNXC:CliBoardMutation 2026-07-09-00:00:
* FN-7740 audit finding: `getTaskCounts` builds an UNCACHED `new
* TaskStore(projectPath)` per call and never closed it. `runProjectList`
* calls this once per registered project in a `Promise.all` map, so an
* N-project registry leaked N never-closed SQLite/WAL handles — each one
* keeping the CLI event loop alive after `fn project list`'s real work was
* done. Add an explicit `finally { store.close() }` so every call tears its
* store down regardless of success/failure, keeping the existing outer
* soft-catch (a genuinely unreadable project still reports empty counts
* rather than failing the whole `list`/`show`). `listTasks` is additionally
* wrapped in `retryOnLock` (FN-7731) so a momentary `database is locked`
* from an active engine/agent writer does not silently masquerade as "zero
* tasks" via the outer catch — it rides out the lock instead.
*/
async function getTaskCounts(projectPath: string): Promise<TaskCountSummary> {
const store = new TaskStore(projectPath);
try {
const store = new TaskStore(projectPath);
await store.init();
const tasks = await store.listTasks({ slim: true });
const tasks = await retryOnLock(
() => store.listTasks({ slim: true }),
{ id: projectPath, action: "count tasks" },
);
const counts: Record<string, number> = {};
for (const col of COLUMNS) {
@@ -137,8 +153,15 @@ async function getTaskCounts(projectPath: string): Promise<TaskCountSummary> {
}
return { byColumn: counts, runningAgentCount: countRunningAgentTasks(tasks) };
} catch {
// Return empty counts if we can't read the project
// Return empty counts if we can't read the project (not-found, corrupt
// store, or lock-retry exhaustion — all fail soft here by design).
return { byColumn: {}, runningAgentCount: 0 };
} finally {
try {
await store.close();
} catch {
// Best-effort: an already-closed/never-initialized store must not throw.
}
}
}
@@ -316,6 +339,15 @@ export async function runProjectAdd(
// Initialize the project
const store = new TaskStore(absolutePath);
await store.init();
// FNXC:CliBoardMutation 2026-07-09-00:00: FN-7740 — this init-only
// store was never closed, leaking a handle for the rest of the
// process lifetime. Close it right after init; nothing downstream
// in this handler uses it.
try {
await store.close();
} catch {
// Best-effort.
}
console.log(` ✓ Initialized fn at ${absolutePath}`);
} else {
console.log("\n Cancelled. Run `fn init` to initialize a project first.\n");

View File

@@ -10,7 +10,48 @@ import {
type ResearchRun,
} from "@fusion/core";
import { ResearchOrchestrator, ResearchProviderRegistry, ResearchStepRunner } from "@fusion/engine";
import { resolveProject } from "../project-context.js";
import { resolveProjectPathOnly } from "../project-context.js";
import { retryOnLock } from "../lock-retry.js";
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* FN-7740 audit finding: `getStore` resolved a name→path via a cached
* `resolveProject(projectName)` call it never used `.store` from (path-only
* leak), THEN always built a second, UNCACHED `new TaskStore(...)` that IS
* the store actually used. NONE of `runResearchList`/`Show`/`Export`/
* `Cancel`/`Retry` (or `runResearchCreate`'s `waitForCompletion` path)
* closed either store on any exit path (success `return` or `handleError`
* → `process.exit(1)`), leaking a SQLite/WAL handle that keeps the CLI
* event loop alive after the command's work is done. Fixed by resolving the
* name→path via `resolveProjectPathOnly` (closes+evicts the cached store
* internally) and having every caller close the uncached `getStore` store
* on every exit path via a local `withStore` helper — EXCEPT
* `runResearchCreate`'s non-`waitForCompletion` fire-and-forget branch,
* which is intentionally exempted (see the FNXC comment at that call site):
* `orchestrator.startRun(runId, query)` is not awaited and the background
* run continues to read/write the SAME store via `store.getResearchStore()`
* after this function returns — closing it there would truncate an
* in-flight run. Discrete board/settings reads that gate run-critical
* decisions (`getSettings()` in `getResearchRuntime`) and the `createExport`
* write are wrapped in `retryOnLock` so a momentary `database is locked`
* from an active engine/agent writer is retried instead of failing the
* command outright.
*/
async function withResolvedStore<T>(
projectName: string | undefined,
fn: (store: TaskStore) => Promise<T>,
): Promise<T> {
const store = await getStore(projectName);
try {
return await fn(store);
} finally {
try {
await store.close();
} catch {
// Best-effort: an already-closed store must not throw here.
}
}
}
interface ResearchCommandOptions {
projectName?: string;
@@ -35,8 +76,8 @@ interface ResearchExportOptions extends ResearchCommandOptions {
}
async function getStore(projectName?: string): Promise<TaskStore> {
const project = projectName ? await resolveProject(projectName) : undefined;
const store = new TaskStore(project?.projectPath ?? process.cwd());
const projectPath = projectName ? await resolveProjectPathOnly(projectName) : undefined;
const store = new TaskStore(projectPath ?? process.cwd());
await store.init();
return store;
}
@@ -51,7 +92,7 @@ function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSetti
}
async function getResearchRuntime(store: TaskStore) {
const settings = await store.getSettings();
const settings = await retryOnLock(() => store.getSettings(), { id: "research", action: "read research settings" });
const resolved = resolveResearchSettings(settings);
if (!resolved.enabled) {
throw new Error("feature-disabled: Research is disabled in settings.");
@@ -107,8 +148,35 @@ function handleError(error: unknown): never {
}
export async function runResearchCreate(options: ResearchCreateOptions): Promise<void> {
/*
* FNXC:CliBoardMutation 2026-07-09-00:00:
* Closes the store explicitly BEFORE every exit point rather than via a
* try/finally wrapping `handleError` — per project memory, `process.exit()`
* does NOT run pending `finally` blocks in production (only a *mocked*
* `process.exit` in tests throws, which would misleadingly make a
* `finally` after `handleError` appear to work under test but not for
* real). EVERY exit point below closes the store explicitly first,
* EXCEPT the fire-and-forget non-wait branch (judgment call (a), Step 1
* audit): `orchestrator.startRun(runId, query)` is not awaited and the
* `ResearchOrchestrator` keeps reading/writing THIS SAME store for the
* rest of the background run's lifecycle after this function returns —
* closing it there would truncate an in-flight run. `createRun` has
* already persisted the initial run row synchronously, so nothing is
* lost if the CLI process exits on its own right after; this is the ONE
* deliberately-exempted branch in the whole FN-7740 audit.
*/
let store: TaskStore | undefined;
const closeStore = async (): Promise<void> => {
if (!store) return;
try {
await store.close();
} catch {
// Best-effort.
}
};
try {
const store = await getStore(options.projectName);
store = await getStore(options.projectName);
const { orchestrator, settings, resolved, availableProviderTypes } = await getResearchRuntime(store);
const runId = orchestrator.createRun({
@@ -123,6 +191,8 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
const runPromise = orchestrator.startRun(runId, options.query);
if (!options.waitForCompletion) {
// Intentionally-long-lived branch — do NOT close `store` here (see
// the function-level FNXC comment above).
const run = store.getResearchStore().getRun(runId);
if (options.json) {
jsonOut(run);
@@ -137,7 +207,7 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
const completed = await Promise.race([
runPromise,
new Promise<ResearchRun>((resolveRun) => setTimeout(() => {
const latest = store.getResearchStore().getRun(runId);
const latest = store!.getResearchStore().getRun(runId);
resolveRun(latest ?? ({
id: runId,
query: options.query,
@@ -151,41 +221,47 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
}, maxWaitMs)),
]);
// `waitForCompletion` fully awaited (or timed out on) the run above, so
// unlike the fire-and-forget branch, it is safe to close here.
await closeStore();
if (options.json) {
jsonOut(completed);
} else {
printRun(completed);
}
} catch (error) {
await closeStore();
handleError(error);
}
}
export async function runResearchList(options: ResearchListOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
if (options.status && !RESEARCH_RUN_STATUSES.includes(options.status as ResearchRunStatus)) {
throw new Error(`Invalid status: ${options.status}`);
}
await withResolvedStore(options.projectName, async (store) => {
if (options.status && !RESEARCH_RUN_STATUSES.includes(options.status as ResearchRunStatus)) {
throw new Error(`Invalid status: ${options.status}`);
}
const runs = store.getResearchStore().listRuns({
status: options.status as ResearchRunStatus | undefined,
limit: options.limit ? Math.max(1, options.limit) : 20,
const runs = store.getResearchStore().listRuns({
status: options.status as ResearchRunStatus | undefined,
limit: options.limit ? Math.max(1, options.limit) : 20,
});
if (options.json) {
jsonOut({ runs });
return;
}
if (!runs.length) {
console.log("No cited-research runs found.");
return;
}
for (const run of runs) {
console.log(`${run.id} [${run.status}] ${run.query}`);
}
});
if (options.json) {
jsonOut({ runs });
return;
}
if (!runs.length) {
console.log("No cited-research runs found.");
return;
}
for (const run of runs) {
console.log(`${run.id} [${run.status}] ${run.query}`);
}
} catch (error) {
handleError(error);
}
@@ -193,15 +269,16 @@ export async function runResearchList(options: ResearchListOptions = {}): Promis
export async function runResearchShow(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
await withResolvedStore(options.projectName, async (store) => {
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
if (options.json) {
jsonOut(run);
return;
}
printRun(run);
if (options.json) {
jsonOut(run);
return;
}
printRun(run);
});
} catch (error) {
handleError(error);
}
@@ -216,30 +293,34 @@ function renderMarkdown(run: ResearchRun): string {
export async function runResearchExport(options: ResearchExportOptions): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(options.runId);
if (!run) throw new Error(`Cited-research run not found: ${options.runId}`);
await withResolvedStore(options.projectName, async (store) => {
const run = store.getResearchStore().getRun(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)) {
throw new Error(`Unsupported export format: ${format}`);
}
const format = (options.format ?? "markdown") as ResearchExportFormat;
if (!RESEARCH_EXPORT_FORMATS.includes(format)) {
throw new Error(`Unsupported export format: ${format}`);
}
const content = format === "json" ? JSON.stringify(run, null, 2) : renderMarkdown(run);
const ext = format === "json" ? "json" : "md";
const outputPath = options.output
? resolve(options.output)
: join(process.cwd(), `research-${run.id.toLowerCase()}.${ext}`);
const content = format === "json" ? JSON.stringify(run, null, 2) : renderMarkdown(run);
const ext = format === "json" ? "json" : "md";
const outputPath = options.output
? resolve(options.output)
: join(process.cwd(), `research-${run.id.toLowerCase()}.${ext}`);
await writeFile(outputPath, content, "utf8");
store.getResearchStore().createExport(run.id, format, content);
await writeFile(outputPath, content, "utf8");
await retryOnLock(
async () => store.getResearchStore().createExport(run.id, format, content),
{ id: run.id, action: "export research run" },
);
if (options.json) {
jsonOut({ runId: run.id, format, outputPath, bytes: Buffer.byteLength(content, "utf8") });
return;
}
if (options.json) {
jsonOut({ runId: run.id, format, outputPath, bytes: Buffer.byteLength(content, "utf8") });
return;
}
console.log(`Exported ${run.id} (${format}) to ${outputPath}`);
console.log(`Exported ${run.id} (${format}) to ${outputPath}`);
});
} catch (error) {
handleError(error);
}
@@ -247,24 +328,25 @@ export async function runResearchExport(options: ResearchExportOptions): Promise
export async function runResearchCancel(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Cited-research run not found: ${runId}`);
await withResolvedStore(options.projectName, async (store) => {
const run = store.getResearchStore().getRun(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}.`);
}
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
throw new Error(`invalid-transition: Run ${runId} cannot be cancelled from status ${run.status}.`);
}
const { orchestrator } = await getResearchRuntime(store);
const cancelled = orchestrator.cancelRun(runId);
const { orchestrator } = await getResearchRuntime(store);
const cancelled = orchestrator.cancelRun(runId);
if (options.json) {
jsonOut({ cancelled, run });
return;
}
if (options.json) {
jsonOut({ cancelled, run });
return;
}
console.log(cancelled ? `Cancellation requested for ${runId}.` : `Run ${runId} is not active.`);
printRun(run);
console.log(cancelled ? `Cancellation requested for ${runId}.` : `Run ${runId} is not active.`);
printRun(run);
});
} catch (error) {
handleError(error);
}
@@ -272,28 +354,32 @@ export async function runResearchCancel(runId: string, options: ResearchCommandO
export async function runResearchRetry(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const existing = store.getResearchStore().getRun(runId);
if (!existing) throw new Error(`Cited-research run not found: ${runId}`);
await withResolvedStore(options.projectName, async (store) => {
const existing = store.getResearchStore().getRun(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.`);
}
if (existing.lifecycle?.retryable === false) {
throw new Error(`non-retryable-provider-error: Run ${runId} is marked non-retryable.`);
}
if (existing.status === "retry_exhausted" || existing.lifecycle?.errorCode === "RETRY_EXHAUSTED") {
throw new Error(`retry-exhausted: Run ${runId} has exhausted retry attempts.`);
}
if (existing.lifecycle?.retryable === false) {
throw new Error(`non-retryable-provider-error: Run ${runId} is marked non-retryable.`);
}
const { orchestrator } = await getResearchRuntime(store);
const newRunId = orchestrator.retryRun(runId);
const run = store.getResearchStore().getRun(newRunId);
// `retryRun` only creates a new run row (does not call `startRun`), so
// unlike `runResearchCreate`'s fire-and-forget branch there is no
// background execution in flight here — safe to close the store below.
const { orchestrator } = await getResearchRuntime(store);
const newRunId = orchestrator.retryRun(runId);
const run = store.getResearchStore().getRun(newRunId);
if (options.json) {
jsonOut({ retryOf: runId, run });
return;
}
if (options.json) {
jsonOut({ retryOf: runId, run });
return;
}
console.log(`Created retry run ${newRunId} from ${runId}.`);
if (run) printRun(run);
console.log(`Created retry run ${newRunId} from ${runId}.`);
if (run) printRun(run);
});
} catch (error) {
handleError(error);
}

View File

@@ -1,7 +1,29 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { resolveProject } from "../project-context.js";
import { resolveProjectPathOnly, asLocalProjectContext, closeProjectStore } from "../project-context.js";
import { retryOnLock } from "../lock-retry.js";
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* FN-7740 audit finding: `runSettingsImport` resolved a name→path via a
* cached `resolveProject(projectName)` call it never used `.store` from
* (path-only leak), THEN built a second, UNCACHED `new TaskStore(...)` that
* IS the store actually used — and closed neither. Every exit path here is
* `process.exit(0/1)`, so this specific file did not previously hang the
* event loop (per project memory: `process.exit()` terminates regardless of
* open handles), but the leaked handles are still a correctness/discipline
* gap and, per MEMORY, a pending `finally` never runs after `process.exit()`
* — so teardown must happen explicitly BEFORE every exit call, not via
* `finally`. Fixed by: `resolveProjectPathOnly` for the name→path
* resolution (closes+evicts the cached store internally); wrapping the
* uncached store in `asLocalProjectContext` + an `exitWithStore`-style
* closure (mirrors `branch-group.ts`/`agent.ts`) that closes it BEFORE
* every `process.exit()` call; and wrapping the `importSettings` board
* mutation in `retryOnLock` so a momentary `database is locked` from an
* active engine/agent writer is retried instead of failing the import
* outright.
*/
/**
* Run settings import command.
@@ -23,18 +45,25 @@ export async function runSettingsImport(
} = {}
): Promise<void> {
const scope = options.scope ?? "both";
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
const projectPath = options.projectName ? await resolveProjectPathOnly(options.projectName) : undefined;
const store = new TaskStore(project?.projectPath ?? process.cwd());
const store = new TaskStore(projectPath ?? process.cwd());
await store.init();
const storeContext = asLocalProjectContext(store);
const merge = options.merge ?? true;
const skipConfirm = options.yes ?? false;
const exitWithStore = async (code: number): Promise<never> => {
await closeProjectStore(storeContext);
return process.exit(code);
};
try {
const resolvedPath = resolve(filePath);
if (!existsSync(resolvedPath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
await exitWithStore(1);
return;
}
let importData;
@@ -42,7 +71,8 @@ export async function runSettingsImport(
importData = await readExportFile(resolvedPath);
} catch (err) {
console.error(`Error: Failed to read import file: ${(err as Error).message}`);
process.exit(1);
await exitWithStore(1);
return;
}
const validationErrors = validateImportData(importData);
@@ -51,7 +81,8 @@ export async function runSettingsImport(
for (const error of validationErrors) {
console.error(` - ${error}`);
}
process.exit(1);
await exitWithStore(1);
return;
}
const summary: string[] = [];
@@ -76,7 +107,8 @@ export async function runSettingsImport(
if (summary.length === 0) {
console.error("Error: No settings to import in the specified scope");
process.exit(1);
await exitWithStore(1);
return;
}
console.log();
@@ -93,14 +125,19 @@ export async function runSettingsImport(
if (!skipConfirm) {
console.log(" Use --yes to confirm this import operation");
console.log();
process.exit(1);
await exitWithStore(1);
return;
}
const result = await importSettings(store, importData, { scope, merge });
const result = await retryOnLock(
() => importSettings(store, importData, { scope, merge }),
{ id: projectPath ?? process.cwd(), action: "import settings" },
);
if (!result.success) {
console.error(`Error: Import failed: ${result.error}`);
process.exit(1);
await exitWithStore(1);
return;
}
console.log(` ✓ Settings imported successfully`);
@@ -115,9 +152,9 @@ export async function runSettingsImport(
}
console.log();
process.exit(0);
await exitWithStore(0);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
await exitWithStore(1);
}
}