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:
7
.changeset/fn-7740-cli-cmd-lock-retry.md
Normal file
7
.changeset/fn-7740-cli-cmd-lock-retry.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
129
packages/cli/src/commands/__tests__/git-lock-retry.test.ts
Normal file
129
packages/cli/src/commands/__tests__/git-lock-retry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
195
packages/cli/src/commands/__tests__/project-lock-retry.test.ts
Normal file
195
packages/cli/src/commands/__tests__/project-lock-retry.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
248
packages/cli/src/commands/__tests__/research-lock-retry.test.ts
Normal file
248
packages/cli/src/commands/__tests__/research-lock-retry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user