fix(FN-7952): require PostgreSQL in CLI and desktop (#2110)

## Summary

CLI commands, daemon/dashboard startup, packaged desktop startup, and
live-data maintenance scripts now share the mandatory PostgreSQL
lifecycle. Operators no longer risk a command silently reading or
writing a disconnected SQLite shadow when PostgreSQL setup fails.

## Design decisions

- Every startup owner retains and awaits its PostgreSQL shutdown
callback, including partial-startup failure paths.
- CLI project context and lock-retry flows resolve through asynchronous
project stores.
- Maintenance scripts use the shared backend helper; explicit database
migration/inspection remains the only CLI surface allowed to read legacy
SQLite sources.

## Validation

- CLI and Desktop typechecks pass on the stacked branch.
- `pnpm test:gate` passes all 478 gate tests.
- This PR changes 54 files.

## Stack

- Depends on #2109, which depends on #2108.
- Bundled plugins and docs/release follow in later PRs.

Related: #2105


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* PostgreSQL is now the authoritative store for structured project and
task metadata.
* Projects can be recognized and initialized using
`.fusion/project.json`, without creating a legacy SQLite database.
  * CLI commands now retry transient PostgreSQL contention errors.

* **Bug Fixes**
* Improved cleanup when commands complete, fail, or run in the
background, preventing lingering resources.
  * Improved desktop, server, and session shutdown reliability.

* **Documentation**
* Updated storage and standalone binary guidance to reflect PostgreSQL
and legacy SQLite compatibility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-14 23:18:55 -07:00
committed by GitHub
parent 7261083781
commit 97172fdcf2
60 changed files with 1961 additions and 924 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Ensure PostgreSQL-backed CLI commands release project resources before exiting.
category: fix
dev: Rebases the CLI cutover onto the merged core/runtime stack and makes factory shutdown ownership explicit.

View File

@@ -200,18 +200,18 @@ If all resolution methods fail, terminal creation gracefully returns `null`, whi
**Cross-compilation:** Native assets are staged per-platform during build. When cross-compiling, only the target platform's assets are included. PTY functionality requires running on a platform with matching native assets.
### Known Bun `node:sqlite` Limitation
### Legacy migration `node:sqlite` compatibility
Bun-compiled standalone binaries may encounter a `No such built-in module: node:sqlite` error at startup. This happens because Bun's compiler does not include the full `node:sqlite` built-in module in all compilation targets.
Bun-compiled standalone binaries may encounter a `No such built-in module: node:sqlite` error when a command explicitly inspects or imports a retained legacy SQLite database. This happens because Bun's compiler does not include the full `node:sqlite` built-in module in all compilation targets.
**Impact:** When this error occurs, the binary exits immediately. This affects any command that initializes the SQLite-backed task store, including `dashboard`, `task list`, and `task create`. Commands that don't need the store (like `--help`) continue to work.
**Impact:** PostgreSQL is the mandatory runtime store, so ordinary dashboard/task operation does not use SQLite as a fallback. The error blocks only the legacy migration/identity-validation seam that requested SQLite access; commands that do not inspect a legacy source continue normally.
**Detection:** The startup validation test suite treats this specific error as an expected limitation — it is not misinterpreted as a generic dashboard startup failure. The test probe distinguishes between:
| Outcome | Behavior |
|---------|----------|
| Startup banner detected | Full test proceeds (PTY endpoint verification) |
| `node:sqlite` error in output | Test skips cleanly (known Bun limitation) |
| `node:sqlite` error in an explicit legacy-migration probe | Test skips cleanly (known Bun limitation) |
| Other early exit | Test fails with diagnostic output |
Only the exact `node:sqlite` built-in module error is handled specially. Any other exit or crash during startup is treated as a real regression and fails the test with full process output for debugging.
Only the exact `node:sqlite` built-in module error from a legacy-migration probe is handled specially. Any other exit or crash during startup is treated as a real regression and fails the test with full process output for debugging.

View File

@@ -132,14 +132,16 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
```
.fusion/
├── fusion.db # SQLite database (WAL mode)
├── project.json # Canonical local project identity
└── tasks/
└── FN-001/
├── PROMPT.md # Task specification
├── agent.log # Execution logs
├── agent-log.jsonl # File-backed execution logs
└── attachments/ # File attachments
```
Structured runtime metadata is authoritative in PostgreSQL. A retained `fusion.db` is migration input only.
## Dashboard Features
- Real-time kanban board with drag-and-drop

View File

@@ -2,14 +2,14 @@
## Database Architecture
Fusion uses a hybrid storage architecture: structured metadata in SQLite, large blobs on the filesystem.
Fusion uses a hybrid storage architecture: structured metadata in PostgreSQL, with large blobs and compatibility artifacts on the filesystem.
**Project database:** `.fusion/fusion.db` (SQLite with WAL mode)
**Project identity:** `.fusion/project.json` (the data rows keyed by this identity live in PostgreSQL)
**Filesystem blobs:**
```
.fusion/
├── fusion.db # SQLite database (WAL mode)
├── project.json # Canonical local project identity
├── config.json # Board config + workflow steps
└── tasks/
└── FN-001/
@@ -20,7 +20,7 @@ Fusion uses a hybrid storage architecture: structured metadata in SQLite, large
└── data.json
```
## Task Metadata (in SQLite)
## Task Metadata (in PostgreSQL)
Key fields stored in the `tasks` table:
@@ -152,7 +152,7 @@ User-level settings at `~/.fusion/settings.json`:
## Central Database (Multi-Project)
For multi-project setups: `~/.fusion/fusion-central.db`
For multi-project setups, the PostgreSQL `central` schema stores:
- Project registry
- Unified activity feed
- Global concurrency management

View File

@@ -22,6 +22,7 @@ const previewPlan = vi.fn();
const finalize = vi.fn();
const init = vi.fn();
const getExperimentSessionStore = vi.fn(() => ({}));
const backendShutdown = vi.fn(async () => undefined);
const mockErrors = vi.hoisted(() => ({
CherryPickConflictError: class extends Error {
@@ -30,9 +31,11 @@ const mockErrors = vi.hoisted(() => ({
commit = "abc";
stderr = "conflict";
},
closeProjectStore: vi.fn(async () => undefined),
}));
vi.mock("@fusion/core", () => ({
createTaskStoreForBackend: vi.fn(async () => ({ taskStore: { init, getExperimentSessionStore }, shutdown: backendShutdown })),
TaskStore: makeConstructibleMock(() => ({ init, getExperimentSessionStore })),
}));
@@ -47,6 +50,17 @@ vi.mock("@fusion/engine", () => ({
ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickConflictError,
}));
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn(async () => ({
projectId: "proj-1",
projectName: "demo",
projectPath: "/tmp/demo",
isRegistered: true,
store: {},
})),
closeProjectStore: mockErrors.closeProjectStore,
}));
import { runExperimentFinalize } from "../commands/experiment-finalize.js";
describe("runExperimentFinalize", () => {
@@ -66,6 +80,7 @@ describe("runExperimentFinalize", () => {
expect(previewPlan).toHaveBeenCalledWith({ sessionId: "EXP-1", integrationBranch: undefined });
expect(finalize).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalled();
expect(backendShutdown).toHaveBeenCalledTimes(1);
});
it("plan-file loads override and passes to finalize", async () => {
@@ -111,4 +126,25 @@ describe("runExperimentFinalize", () => {
exitSpy.mockRestore();
});
it("closes the resolver-owned project on success", async () => {
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runExperimentFinalize({ sessionId: "EXP-1", projectName: "demo", dryRun: true });
expect(mockErrors.closeProjectStore).toHaveBeenCalledTimes(1);
logSpy.mockRestore();
});
it("closes the resolver-owned project before backend startup fails", async () => {
const { createTaskStoreForBackend } = await import("@fusion/core");
vi.mocked(createTaskStoreForBackend).mockRejectedValueOnce(new Error("startup failed"));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
await expect(runExperimentFinalize({ sessionId: "EXP-1", projectName: "demo" })).rejects.toThrow("exit:1");
expect(mockErrors.closeProjectStore).toHaveBeenCalledTimes(1);
exitSpy.mockRestore();
});
});

View File

@@ -21,7 +21,7 @@ vi.mock("../commands/task.js", () => ({
runTaskPlan: vi.fn(),
}));
import { resolveTaskListFormatter } from "../extension.js";
import { __setCachedStoreForTesting, closeCachedStores, resolveTaskListFormatter } from "../extension.js";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
@@ -51,6 +51,42 @@ const h = createPgExtensionHarness("fn-extension");
function makeCtx(cwd: string): ToolExecuteContext {
return { cwd };
}
describe("fn pi extension session lifecycle", () => {
afterEach(async () => {
await closeCachedStores();
});
it("keeps session_shutdown pending until factory-owned cache teardown finishes", async () => {
/*
FNXC:PostgresCliLifecycle 2026-07-14-22:38:
Pi awaits the promise returned by session_shutdown. Keep that promise pending until the cached startup-factory owner finishes so PostgreSQL resources cannot outlive the extension session.
*/
let releaseShutdown!: () => void;
const backendShutdown = vi.fn(() => new Promise<void>((resolve) => {
releaseShutdown = resolve;
}));
__setCachedStoreForTesting("/owned-extension-store", {} as TaskStore, backendShutdown);
const events = new Map<string, () => Promise<void>>();
const api = createMockApi();
api.on = ((event: string, handler: () => Promise<void>) => {
events.set(event, handler);
}) as MockApi["on"];
registerExtension(api);
const shutdownPromise = events.get("session_shutdown")?.();
expect(shutdownPromise).toBeDefined();
let settled = false;
void shutdownPromise?.then(() => { settled = true; });
await vi.waitFor(() => expect(backendShutdown).toHaveBeenCalledTimes(1));
await Promise.resolve();
expect(settled).toBe(false);
releaseShutdown();
await expect(shutdownPromise).resolves.toBeUndefined();
});
});
interface ToolMeta {
description?: string;
promptGuidelines?: string[];

View File

@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTaskStoreForBackend } from "@fusion/core";
const lifecycle = vi.hoisted(() => {
const events: string[] = [];
const project = { id: "proj-1", name: "demo", path: "/repo", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
const layer = {};
const store = {
getAsyncLayer: vi.fn(() => layer),
close: vi.fn(async () => { events.push("store-close"); }),
};
const centralClose = vi.fn(async () => { events.push("central-close"); });
const backendShutdown = vi.fn(async () => {
events.push("backend-shutdown");
await store.close();
});
return { events, project, layer, store, centralClose, backendShutdown };
});
vi.mock("@fusion/core", () => ({
CentralCore: class {
init = vi.fn(async () => undefined);
close = lifecycle.centralClose;
getProject = vi.fn(async (id: string) => id === lifecycle.project.id ? lifecycle.project : undefined);
listProjects = vi.fn(async () => [lifecycle.project]);
getProjectByPath = vi.fn(async () => lifecycle.project);
},
GlobalSettingsStore: class {
init = vi.fn(async () => undefined);
getSettings = vi.fn(async () => ({}));
updateSettings = vi.fn(async () => undefined);
},
createTaskStoreForBackend: vi.fn(async () => ({
taskStore: lifecycle.store,
asyncLayer: lifecycle.layer,
backend: { mode: "embedded" },
shutdown: lifecycle.backendShutdown,
})),
hasProjectIdentity: vi.fn(() => true),
isValidSqliteDatabaseFile: vi.fn(() => false),
}));
import { clearStoreCache, closeProjectStore, resolveAgentStoreBase, resolveProject } from "../project-context.js";
describe("project-context PostgreSQL ownership", () => {
afterEach(async () => {
await clearStoreCache();
lifecycle.events.length = 0;
vi.clearAllMocks();
});
it("keeps the CentralCore-owned postmaster alive until the returned store is closed", async () => {
const context = await resolveProject("proj-1", "/repo");
expect(lifecycle.centralClose).not.toHaveBeenCalled();
expect(context.store.getAsyncLayer()).toBe(lifecycle.layer);
await closeProjectStore(context);
await closeProjectStore(context);
expect(lifecycle.backendShutdown).toHaveBeenCalledTimes(1);
expect(lifecycle.centralClose).toHaveBeenCalledTimes(1);
expect(lifecycle.events).toEqual(["backend-shutdown", "store-close", "central-close"]);
});
it("surfaces resolution failures instead of returning a null agent layer", async () => {
await expect(resolveAgentStoreBase("missing")).rejects.toThrow("not found");
});
/*
FNXC:PostgresCliLifecycle 2026-07-14-22:55:
A rejected owner shutdown must not suppress CentralCore teardown or permanently mark the store closed. The same context remains retryable and is evicted only after every retained owner closes successfully.
*/
it("attempts every owner and retries after a rejected shutdown", async () => {
const retryStore = {
getAsyncLayer: vi.fn(() => lifecycle.layer),
close: vi.fn(async () => undefined),
};
const retryShutdown = vi.fn()
.mockRejectedValueOnce(new Error("pool close failed"))
.mockImplementationOnce(async () => {
lifecycle.events.push("backend-shutdown-retry");
await retryStore.close();
});
vi.mocked(createTaskStoreForBackend).mockResolvedValueOnce({
taskStore: retryStore,
asyncLayer: lifecycle.layer,
backend: { mode: "embedded" },
shutdown: retryShutdown,
} as never);
const context = await resolveProject("proj-1", "/repo");
await expect(closeProjectStore(context)).rejects.toThrow("pool close failed");
expect(lifecycle.centralClose).toHaveBeenCalledTimes(1);
await closeProjectStore(context);
expect(retryShutdown).toHaveBeenCalledTimes(2);
expect(lifecycle.centralClose).toHaveBeenCalledTimes(2);
await closeProjectStore(context);
expect(retryShutdown).toHaveBeenCalledTimes(2);
});
});

View File

@@ -13,6 +13,7 @@ import {
detectProjectFromCwd,
formatProjectLine,
getStoreForProject,
closeProjectStore,
clearStoreCache,
} from "../project-context.js";
import { CentralCore, GlobalSettingsStore, type RegisteredProject } from "@fusion/core";
@@ -54,7 +55,7 @@ describe("project-context", () => {
} catch {
// Ignore close errors
}
clearStoreCache();
await clearStoreCache();
// Filesystem cleanup last
try {
@@ -97,6 +98,19 @@ describe("project-context", () => {
expect(found?.name).toBe("legacy-project");
});
it("detects an unregistered project.json marker without fusion.db", async () => {
const projectPath = join(tempDir, "postgres-project");
mkdirSync(join(projectPath, ".fusion"), { recursive: true });
writeFileSync(join(projectPath, ".fusion", "project.json"), JSON.stringify({
id: "proj_1234567890abcdef",
createdAt: "2026-07-14T00:00:00.000Z",
}));
const found = await detectProjectFromCwd(projectPath, central);
expect(found).toMatchObject({ path: resolve(projectPath), name: "postgres-project" });
});
it("should not inherit an unregistered parent project from a nested cwd", async () => {
const projectPath = createMockProject("legacy-project");
const nestedDir = join(projectPath, "src", "components");
@@ -224,7 +238,7 @@ pgDescribe("project-context (PostgreSQL-backed CentralCore)", () => {
} catch {
// Ignore close errors
}
clearStoreCache();
await clearStoreCache();
try {
rmSync(tempDir, { recursive: true, force: true });
rmSync(homeDir, { recursive: true, force: true });
@@ -285,7 +299,11 @@ pgDescribe("project-context (PostgreSQL-backed CentralCore)", () => {
expect(context.projectPath).toBe(resolve(projectPath));
expect(context.projectName).toBe("legacy-project");
expect(context.isRegistered).toBe(false);
await context.store.close();
/*
FNXC:PostgresCliLifecycle 2026-07-14-22:25:
A resolved ProjectContext owns both its factory-backed TaskStore and the CentralCore retained during resolution. Tests and commands must close that aggregate through closeProjectStore so the central PostgreSQL pool cannot outlive the context and block database teardown.
*/
await closeProjectStore(context);
} finally {
if (prevDatabaseUrl === undefined) {
delete process.env.DATABASE_URL;

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { existsSync, statSync } from "node:fs";
import { TaskStore } from "@fusion/core";
import { TaskStore, createTaskStoreForBackend } from "@fusion/core";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
@@ -17,8 +17,9 @@ function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T)
return mock;
}
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
const { mockIsValidSqliteDatabaseFile, mockHasProjectIdentity } = vi.hoisted(() => ({
mockIsValidSqliteDatabaseFile: vi.fn(),
mockHasProjectIdentity: vi.fn(),
}));
// Mock fs module
@@ -29,6 +30,10 @@ vi.mock("node:fs", () => ({
// Mock @fusion/core
vi.mock("@fusion/core", async () => {
const TaskStoreMock = makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
}));
return {
CentralCore: class MockCentralCore {
init = vi.fn().mockResolvedValue(undefined);
@@ -56,9 +61,14 @@ vi.mock("@fusion/core", async () => {
},
isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) =>
mockIsValidSqliteDatabaseFile(...args),
TaskStore: makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
hasProjectIdentity: (...args: Parameters<typeof mockHasProjectIdentity>) =>
mockHasProjectIdentity(...args),
TaskStore: TaskStoreMock,
createTaskStoreForBackend: vi.fn(async () => ({
taskStore: new TaskStoreMock(),
asyncLayer: {},
backend: { mode: "embedded" },
shutdown: vi.fn().mockResolvedValue(undefined),
})),
readProjectIdentity: vi.fn().mockReturnValue(undefined),
writeProjectIdentity: vi.fn(),
@@ -97,6 +107,9 @@ const {
suggestProjectName,
resolveAbsolutePath,
formatLastActivity,
getProjectsWithStatus,
getProjectTaskCounts,
resolveProjectStore,
resetProjectResolution,
} = projectResolver;
@@ -105,6 +118,7 @@ describe("Project Resolver", () => {
vi.clearAllMocks();
resetProjectResolution();
mockIsValidSqliteDatabaseFile.mockReturnValue(false);
mockHasProjectIdentity.mockReturnValue(false);
vi.mocked(TaskStore).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
@@ -116,6 +130,13 @@ describe("Project Resolver", () => {
});
describe("findKbDir", () => {
it("finds a PostgreSQL-era project identity marker without SQLite", () => {
mockHasProjectIdentity.mockImplementation((path) => String(path) === "/project/.fusion");
expect(findKbDir("/project/src")).toBe("/project");
expect(mockIsValidSqliteDatabaseFile).not.toHaveBeenCalledWith("/project/.fusion/fusion.db");
});
it("should find .fusion directory in current path", () => {
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
@@ -142,6 +163,172 @@ describe("Project Resolver", () => {
});
});
describe("PostgreSQL project status reads", () => {
const project = {
id: "proj_1234567890abcdef",
name: "alpha",
path: "/projects/alpha",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("boots and releases an owned PostgreSQL store for aggregate status", async () => {
const shutdown = vi.fn().mockResolvedValue(undefined);
const listTasks = vi.fn().mockResolvedValue([{ column: "todo" }, { column: "done" }]);
const central = await getCentralCore();
vi.mocked(central.listProjects).mockResolvedValue([project] as never);
vi.mocked(createTaskStoreForBackend).mockResolvedValueOnce({
taskStore: { listTasks } as never,
shutdown,
} as never);
const result = await getProjectsWithStatus();
expect(result).toEqual([{ project, runtimeStatus: "not_started", taskCount: 2 }]);
expect(createTaskStoreForBackend).toHaveBeenCalledWith({ rootDir: project.path, projectId: project.id });
expect(shutdown).toHaveBeenCalledOnce();
});
it("releases aggregate status backends when task reads reject", async () => {
const shutdown = vi.fn().mockResolvedValue(undefined);
const central = await getCentralCore();
vi.mocked(central.listProjects).mockResolvedValue([project] as never);
vi.mocked(createTaskStoreForBackend).mockResolvedValueOnce({
taskStore: { listTasks: vi.fn().mockRejectedValue(new Error("read failed")) } as never,
shutdown,
} as never);
await expect(getProjectsWithStatus()).resolves.toEqual([
{ project, runtimeStatus: "not_started", taskCount: 0 },
]);
expect(shutdown).toHaveBeenCalledOnce();
});
it("bounds aggregate PostgreSQL backend fan-out and preserves project order", async () => {
const projects = Array.from({ length: 7 }, (_, index) => ({
...project,
id: `proj_${index}`,
name: `project-${index}`,
path: `/projects/${index}`,
}));
const central = await getCentralCore();
vi.mocked(central.listProjects).mockResolvedValue(projects as never);
let active = 0;
let peak = 0;
vi.mocked(createTaskStoreForBackend).mockImplementation(async ({ projectId }) => {
active += 1;
peak = Math.max(peak, active);
return {
taskStore: { listTasks: vi.fn().mockResolvedValue([{ column: projectId }]) },
shutdown: vi.fn(async () => { active -= 1; }),
} as never;
});
const result = await getProjectsWithStatus();
expect(peak).toBeLessThanOrEqual(4);
expect(active).toBe(0);
expect(result.map(({ project: item }) => item.id)).toEqual(projects.map((item) => item.id));
});
it("boots and releases an owned PostgreSQL store for one-shot column counts", async () => {
const shutdown = vi.fn().mockResolvedValue(undefined);
const listTasks = vi.fn().mockResolvedValue([{ column: "todo" }, { column: "todo" }, { column: "done" }]);
const central = await getCentralCore();
vi.mocked(central.getProject).mockResolvedValue(project as never);
vi.mocked(createTaskStoreForBackend).mockResolvedValueOnce({
taskStore: { listTasks } as never,
shutdown,
} as never);
await expect(getProjectTaskCounts(project.id)).resolves.toEqual({ todo: 2, done: 1 });
expect(createTaskStoreForBackend).toHaveBeenCalledWith({ rootDir: project.path, projectId: project.id });
expect(shutdown).toHaveBeenCalledOnce();
});
it("releases one-shot column-count backends when task reads reject", async () => {
const shutdown = vi.fn().mockResolvedValue(undefined);
const central = await getCentralCore();
vi.mocked(central.getProject).mockResolvedValue(project as never);
vi.mocked(createTaskStoreForBackend).mockResolvedValueOnce({
taskStore: { listTasks: vi.fn().mockRejectedValue(new Error("read failed")) } as never,
shutdown,
} as never);
await expect(getProjectTaskCounts(project.id)).rejects.toThrow("read failed");
expect(shutdown).toHaveBeenCalledOnce();
});
it("releases an owner-resolved command store exactly once", async () => {
/*
* FNXC:PostgresProjectResolverLifecycle 2026-07-14-22:20:
* Short-lived commands release their factory store explicitly; later module cleanup and repeated close calls must not invoke the same backend shutdown again.
*/
const shutdown = vi.fn().mockResolvedValue(undefined);
const taskStore = { getMissionStore: vi.fn() };
const central = await getCentralCore();
vi.mocked(central.listProjects).mockResolvedValue([project] as never);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(createTaskStoreForBackend).mockResolvedValueOnce({
taskStore,
shutdown,
} as never);
const owner = await resolveProjectStore({ project: project.name });
expect(owner.store).toBe(taskStore);
await owner.close();
await owner.close();
await cleanupProjectResolution();
expect(shutdown).toHaveBeenCalledOnce();
});
});
describe("mission command store ownership", () => {
it("awaits owner cleanup before a successful command exit", async () => {
const order: string[] = [];
const close = vi.fn(async () => {
order.push("close");
});
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
order.push(`exit:${code}`);
throw new Error(`exit:${code}`);
}) as never);
vi.spyOn(projectResolver, "resolveProjectStore").mockResolvedValue({
store: {
getMissionStore: () => ({ listMissions: vi.fn().mockResolvedValue([]) }),
} as never,
close,
});
const { runMissionList } = await import("../commands/mission.js");
await expect(runMissionList("alpha", { includeDrafts: false })).rejects.toThrow("exit:0");
expect(close).toHaveBeenCalledOnce();
expect(exit).toHaveBeenCalledWith(0);
expect(order).toEqual(["close", "exit:0"]);
});
it("awaits owner cleanup when mission work rejects", async () => {
const close = vi.fn().mockResolvedValue(undefined);
vi.spyOn(projectResolver, "resolveProjectStore").mockResolvedValue({
store: {
getMissionStore: () => ({
listMissions: vi.fn().mockRejectedValue(new Error("mission read failed")),
}),
} as never,
close,
});
const { runMissionList } = await import("../commands/mission.js");
await expect(runMissionList("alpha", { includeDrafts: false })).rejects.toThrow("mission read failed");
expect(close).toHaveBeenCalledOnce();
});
});
describe("isKbProject", () => {
it("should return true if fusion.db is a valid SQLite database", () => {
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
@@ -212,7 +399,7 @@ describe("Project Resolver", () => {
expect(resolved.projectId).toBe("proj_123");
expect(resolved.name).toBe("alpha");
expect(resolved.directory).toBe("/workspace/alpha");
expect(resolved.store.init).toHaveBeenCalledOnce();
expect(resolved.store).toBeDefined();
});
it("should throw NOT_FOUND if --project project not found", async () => {

View File

@@ -9,7 +9,7 @@ const mockResolveProject = vi.fn();
vi.mock("../../project-context.js", () => ({
// FNXC:PostgresCutover 2026-07-10: branch agent commands resolve their AgentStore base (rootDir + asyncLayer) via this helper.
resolveAgentStoreBase: vi.fn(async () => ({ rootDir: process.cwd(), asyncLayer: null })),
resolveAgentStoreBase: vi.fn(async () => ({ rootDir: process.cwd(), asyncLayer: {}, cleanup: vi.fn(async () => undefined) })),
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
}));

View File

@@ -4,6 +4,15 @@ import { execSync } from "node:child_process";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
vi.mock("../../project-context.js", () => ({
resolveAgentStoreBase: vi.fn(async () => ({
rootDir: process.cwd(),
asyncLayer: {} as never,
cleanup: vi.fn(async () => undefined),
})),
}));
import { runAgentImport } from "../agent-import.js";
function makeAgentManifest(options: {

View File

@@ -29,7 +29,7 @@ const { mockGetAgent, mockUpdateAgentState, mockInit, mockClose, mockResolveProj
mockInit: vi.fn().mockResolvedValue(undefined),
mockClose: vi.fn(),
mockResolveProjectPathOnly: vi.fn().mockResolvedValue("/tmp/test-project"),
mockResolveAgentStoreBase: vi.fn(async () => ({ rootDir: "/tmp/test-project", asyncLayer: null })),
mockResolveAgentStoreBase: vi.fn(async () => ({ rootDir: "/tmp/test-project", asyncLayer: {}, cleanup: vi.fn(async () => undefined) })),
}));
// AgentStore mock — vi.fn() with mockImplementation works with `new` in vitest.

View File

@@ -99,6 +99,13 @@ const mocks = vi.hoisted(() => {
getPluginStore: vi.fn(() => pluginStore),
close: vi.fn(),
};
const backendShutdown = vi.fn(async () => undefined);
const createTaskStoreForBackend = vi.fn(async () => ({
taskStore: store,
asyncLayer: {},
backend: { mode: "embedded" },
shutdown: backendShutdown,
}));
const project = { id: "project-1", name: "Repo", path: "/repo", status: "active" };
const centralCore = {
init: vi.fn().mockResolvedValue(undefined),
@@ -140,6 +147,8 @@ const mocks = vi.hoisted(() => {
state,
createMockChild,
store,
backendShutdown,
createTaskStoreForBackend,
server,
app,
spawn,
@@ -174,6 +183,7 @@ vi.mock("node:fs", () => ({
vi.mock("@fusion/core", () => ({
TaskStore: mocks.taskStoreCtor,
CentralCore: mocks.centralCoreCtor,
createTaskStoreForBackend: mocks.createTaskStoreForBackend,
// FNXC:PluginSubsystem 2026-07-08-00:00: desktop.ts imports PluginLoader
// from @fusion/core and constructs it for the embedded dashboard server.
PluginLoader: vi.fn(),
@@ -258,7 +268,7 @@ describe("runDesktop", () => {
expect.arrayContaining(["--filter", "@fusion/desktop", "build"]),
expect.anything(),
);
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
expect(mocks.createTaskStoreForBackend).toHaveBeenCalledWith({ rootDir: "/repo" });
expect(mocks.store.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
expect(mocks.ensureCwdProjectRegistered).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/repo", central: mocks.centralCore, autoRegister: true }),
@@ -414,7 +424,8 @@ describe("runDesktop", () => {
expect(mocks.server.close).toHaveBeenCalledTimes(1);
expect(mocks.engineManager.stopAll).toHaveBeenCalledTimes(1);
expect(mocks.store.close).toHaveBeenCalledTimes(1);
expect(mocks.store.close).not.toHaveBeenCalled();
expect(mocks.backendShutdown).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(7);
});
@@ -427,7 +438,18 @@ describe("runDesktop", () => {
expect(mocks.state.electronChild.kill).toHaveBeenCalledWith("SIGTERM");
expect(mocks.server.close).toHaveBeenCalledTimes(1);
expect(mocks.engineManager.stopAll).toHaveBeenCalledTimes(1);
expect(mocks.store.close).toHaveBeenCalledTimes(1);
expect(mocks.store.close).not.toHaveBeenCalled();
expect(mocks.backendShutdown).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(0);
});
it("still releases the PostgreSQL owner when an earlier desktop cleanup fails", async () => {
mocks.centralCore.close.mockRejectedValueOnce(new Error("central cleanup failed"));
await runDesktop();
mocks.state.electronChild.emit("exit", 0);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.backendShutdown).toHaveBeenCalledTimes(1);
});
});

View File

@@ -72,7 +72,8 @@ describe("ensureCwdProjectRegistered", () => {
expect(result).not.toBeNull();
expect(existsSync(join(cwd, ".git"))).toBe(true);
expect(existsSync(join(cwd, ".fusion"))).toBe(true);
expect(existsSync(join(cwd, ".fusion", "fusion.db"))).toBe(true);
expect(existsSync(join(cwd, ".fusion", "project.json"))).toBe(true);
expect(existsSync(join(cwd, ".fusion", "fusion.db"))).toBe(false);
expect(ensureSpy).toHaveBeenCalledWith(
expect.objectContaining({
path: cwd,

View File

@@ -94,23 +94,23 @@ describe("init command", () => {
mockCentralClose.mockResolvedValue(undefined);
mockGetProjectByPath.mockResolvedValue(undefined);
mockRegisterProject.mockResolvedValue({
id: "proj_test",
id: "proj_1234567890abcdef",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "initializing",
createdAt: "",
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "",
});
mockEnsureProjectForPath.mockResolvedValue({
outcome: "registered",
project: {
id: "proj_test",
id: "proj_1234567890abcdef",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "initializing",
createdAt: "",
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "",
},
});
@@ -153,14 +153,39 @@ describe("init command", () => {
expect(existsSync(fusionDir)).toBe(true);
});
it("should create fusion.db when initializing", async () => {
it("should create project.json without creating fusion.db when initializing", async () => {
const markerPath = join(tempProjectDir, ".fusion", "project.json");
const dbPath = join(tempProjectDir, ".fusion", "fusion.db");
expect(existsSync(dbPath)).toBe(false);
expect(existsSync(markerPath)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(dbPath)).toBe(true);
expect(statSync(dbPath).size).toBeGreaterThan(0);
expect(existsSync(markerPath)).toBe(true);
expect(statSync(markerPath).size).toBeGreaterThan(0);
expect(existsSync(dbPath)).toBe(false);
});
it("should repair a missing project identity for an existing central registration", async () => {
const markerPath = join(tempProjectDir, ".fusion", "project.json");
mkdirSync(join(tempProjectDir, ".fusion"), { recursive: true });
mockGetProjectByPath.mockResolvedValueOnce({
id: "proj_1234567890abcdef",
name: "registered-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "active",
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
});
mockEnsureProjectForPath.mockClear();
await runInit({ path: tempProjectDir });
expect(JSON.parse(readFileSync(markerPath, "utf8"))).toMatchObject({
id: "proj_1234567890abcdef",
createdAt: "2026-07-14T00:00:00.000Z",
});
expect(mockEnsureProjectForPath).not.toHaveBeenCalled();
});
it("should reject existing invalid fusion.db files", async () => {
@@ -186,7 +211,7 @@ describe("init command", () => {
// First init
await runInit({ path: tempProjectDir });
mockGetProjectByPath.mockResolvedValue({
id: "proj_test",
id: "proj_1234567890abcdef",
name: "registered-project",
path: tempProjectDir,
isolationMode: "in-process",
@@ -234,7 +259,8 @@ describe("init command", () => {
await runInit({ path: tempProjectDir });
expect(existsSync(fusionDir)).toBe(true);
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(true);
expect(existsSync(join(fusionDir, "project.json"))).toBe(true);
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(false);
});
it("should add local storage directories to .gitignore when it doesn't exist", async () => {
@@ -322,7 +348,8 @@ describe("init command", () => {
console.warn = originalWarn;
}
expect(existsSync(join(tempProjectDir, ".fusion", "fusion.db"))).toBe(true);
expect(existsSync(join(tempProjectDir, ".fusion", "project.json"))).toBe(true);
expect(existsSync(join(tempProjectDir, ".fusion", "fusion.db"))).toBe(false);
expect(warnings.some((warning) => warning.includes("Could not install bundled Fusion skill for Claude"))).toBe(true);
expect(existsSync(join(tempHomeDir, ".codex", "skills", "fusion", "SKILL.md"))).toBe(true);
expect(existsSync(join(tempHomeDir, ".gemini", "skills", "fusion", "SKILL.md"))).toBe(true);
@@ -409,12 +436,12 @@ describe("init command", () => {
outcome: "registered",
gitRepository: "initialized",
project: {
id: "proj_test",
id: "proj_1234567890abcdef",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
status: "initializing",
createdAt: "",
createdAt: "2026-07-14T00:00:00.000Z",
updatedAt: "",
},
});

View File

@@ -46,13 +46,11 @@ vi.mock("@fusion/core", () => {
// ── Mock project-context ─────────────────────────────────────────────
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({
projectId: "test-project",
projectPath: "/tmp/test-project",
projectName: "test-project",
isRegistered: true,
store: {},
vi.mock("../../project-context.js", () => ({
resolveAgentStoreBase: vi.fn().mockResolvedValue({
rootDir: "/tmp/test-project",
asyncLayer: {},
cleanup: vi.fn(async () => undefined),
}),
}));

View File

@@ -25,8 +25,15 @@ function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T)
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> }>,
const { taskStoreInstances, makeTaskStore, mockListProjects, mockGetProjectHealth, mockGetSettings, isSqliteLockErrorMock } = vi.hoisted(() => {
const instances: Array<{ path: string; init: ReturnType<typeof vi.fn>; listTasks: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> }> = [];
return {
taskStoreInstances: instances,
makeTaskStore: (path: string) => {
const instance = { path, init: vi.fn().mockResolvedValue(undefined), listTasks: vi.fn().mockResolvedValue([]), close: vi.fn().mockResolvedValue(undefined) };
instances.push(instance);
return instance;
},
mockListProjects: vi.fn(),
mockGetProjectHealth: vi.fn(),
mockGetSettings: vi.fn(),
@@ -34,11 +41,18 @@ const { taskStoreInstances, mockListProjects, mockGetProjectHealth, mockGetSetti
const message = error instanceof Error ? error.message : String(error);
return /database is locked|SQLITE_BUSY/i.test(message);
}),
}));
};
});
vi.mock("@fusion/core", () => ({
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
createTaskStoreForBackend: vi.fn(async () => null),
// FNXC:PostgresCutover 2026-07-14-23:02: The mock mirrors the mandatory backend owner: shutdown closes the exact TaskStore it returns so lifecycle tests expose direct-close and double-close regressions.
createTaskStoreForBackend: vi.fn(async ({ rootDir }: { rootDir: string }) => {
const taskStore = makeTaskStore(rootDir);
return {
taskStore,
shutdown: vi.fn(async () => taskStore.close()),
};
}),
CentralCore: makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
@@ -50,20 +64,13 @@ vi.mock("@fusion/core", () => ({
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;
}),
TaskStore: makeConstructibleMock(makeTaskStore),
countRunningAgentTasks: () => 0,
ensureMemoryFileWithBackend: vi.fn(),
readProjectIdentity: vi.fn().mockReturnValue(undefined),
writeProjectIdentity: vi.fn(),
hasProjectIdentity: vi.fn(() => false),
isValidSqliteDatabaseFile: vi.fn(() => false),
isSqliteLockError: isSqliteLockErrorMock,
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {

View File

@@ -4,9 +4,8 @@
* 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
* `runResearchCreate` non-`waitForCompletion` branch must persist queued work,
* avoid in-process execution, and complete normal backend shutdown. 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.
*/
@@ -50,6 +49,7 @@ const researchStoreMock = Object.assign(Object.create(MockResearchStore.prototyp
getRun: vi.fn(() => mockRun),
listRuns: vi.fn(() => [mockRun]),
createExport: vi.fn(),
updateRun: vi.fn(),
});
const { storeMock, orchestratorMock, resolveResearchSettingsMock, providerRegistryMock, writeFileMock, MockResearchStore } = vi.hoisted(() => {
@@ -60,6 +60,7 @@ const { storeMock, orchestratorMock, resolveResearchSettingsMock, providerRegist
getRun: vi.fn(),
listRuns: vi.fn(),
createExport: vi.fn(),
updateRun: vi.fn(),
});
return {
storeMock: {
@@ -83,7 +84,7 @@ const { storeMock, orchestratorMock, resolveResearchSettingsMock, providerRegist
vi.mock("@fusion/core", () => ({
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path; getSyncResearchStore needs the ResearchStore class for its instanceof gate.
createTaskStoreForBackend: vi.fn(async () => null),
createTaskStoreForBackend: vi.fn(async () => ({ taskStore: storeMock, shutdown: async () => storeMock.close() })),
ResearchStore: MockResearchStore,
TaskStore: makeConstructibleMock(() => storeMock),
resolveResearchSettings: resolveResearchSettingsMock,
@@ -178,16 +179,22 @@ describe("research commands — leak/lock reproduction (FN-7740)", () => {
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.
it("persists a queued non-wait run without starting it and completes shutdown", async () => {
await runResearchCreate({ query: "hello" });
expect(storeMock.close).not.toHaveBeenCalled();
expect(researchStore.updateRun).toHaveBeenCalledWith("RR-002", { query: "hello" });
expect(orchestratorMock.startRun).not.toHaveBeenCalled();
expect(storeMock.close).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created cited-research run"));
});
it("starts and awaits the run only for waitForCompletion", async () => {
orchestratorMock.startRun.mockResolvedValue({ ...mockRun, id: "RR-002", status: "completed" });
await runResearchCreate({ query: "hello", waitForCompletion: true, maxWaitMs: 1_000 });
expect(researchStore.updateRun).toHaveBeenCalledWith("RR-002", { query: "hello" });
expect(orchestratorMock.startRun).toHaveBeenCalledWith("RR-002", "hello");
expect(storeMock.close).toHaveBeenCalledTimes(1);
});
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
@@ -226,7 +233,7 @@ describe("research commands — leak/lock reproduction (FN-7740)", () => {
vi.useRealTimers();
}
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("board database stayed locked"));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("board database stayed contended"));
expect(storeMock.close).toHaveBeenCalled();
});

View File

@@ -39,6 +39,7 @@ const researchStoreMock = Object.assign(Object.create(MockResearchStore.prototyp
getRun: vi.fn(() => mockRun),
listRuns: vi.fn(() => [mockRun]),
createExport: vi.fn(),
updateRun: vi.fn(),
});
const storeMock = {
@@ -64,13 +65,13 @@ const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.
// 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`).
// backs the close-on-every-exit-path discipline, including queued non-wait
// creation in `runResearchCreate`.
vi.mock("@fusion/core", () => ({
TaskStore: makeConstructibleMock(() => storeMock),
// FNXC:PostgresCutover 2026-07-10: getStore() consults the PG startup factory
// first; null routes the test through the legacy `new TaskStore` mock path.
createTaskStoreForBackend: vi.fn(async () => null),
createTaskStoreForBackend: vi.fn(async () => ({ taskStore: storeMock, shutdown: async () => storeMock.close() })),
ResearchStore: MockResearchStore,
resolveResearchSettings: resolveResearchSettingsMock,
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
@@ -132,6 +133,9 @@ describe("research commands", () => {
it("creates a run", async () => {
await runResearchCreate({ query: "hello" });
expect(orchestratorMock.createRun).toHaveBeenCalled();
expect(researchStoreMock.updateRun).toHaveBeenCalledWith("RR-002", { query: "hello" });
expect(orchestratorMock.startRun).not.toHaveBeenCalled();
expect(storeMock.close).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created cited-research run"));
});

View File

@@ -95,6 +95,7 @@ const mocks = vi.hoisted(() => {
const pluginLoaderInstances: any[] = [];
const projectEngineInstances: any[] = [];
const listenCalls: ListenCall[] = [];
const backendShutdowns: Array<ReturnType<typeof vi.fn>> = [];
const globalSettingsGetSettings = vi.fn().mockResolvedValue({});
function createTaskStoreMock(projectId = "") {
@@ -157,6 +158,23 @@ const mocks = vi.hoisted(() => {
return store;
});
/*
* FNXC:PostgresServeLifecycle 2026-07-14-22:20:
* Serve's authoritative TaskStore is initialized by createTaskStoreForBackend and then injected into ProjectEngineManager. The test factory must model that single owner instead of leaving the engine mock to construct and initialize a second store.
*/
const createTaskStoreForBackendMock = vi.fn(async ({ rootDir }: { rootDir: string }) => {
const taskStore = taskStoreCtor(rootDir);
await taskStore.init();
const shutdown = vi.fn().mockResolvedValue(undefined);
backendShutdowns.push(shutdown);
return {
taskStore,
asyncLayer: {},
backend: { mode: "embedded" },
shutdown,
};
});
const automationStoreCtor = vi.fn().mockImplementation(function () {
const automationStore = {
init: vi.fn().mockResolvedValue(undefined),
@@ -423,8 +441,13 @@ const mocks = vi.hoisted(() => {
pruning: { applied: false },
});
const projectEngineCtor = vi.fn().mockImplementation(function (runtimeConfig: { workingDirectory: string }, _centralCore: unknown, options: { onInsightRunProcessed?: unknown }) {
const store = taskStoreCtor(runtimeConfig.workingDirectory);
const projectEngineCtor = vi.fn().mockImplementation(function (
runtimeConfig: { workingDirectory: string },
_centralCore: unknown,
options: { onInsightRunProcessed?: unknown; externalTaskStore?: ReturnType<typeof createTaskStoreMock> },
) {
const store = options.externalTaskStore ?? taskStoreCtor(runtimeConfig.workingDirectory);
const ownsStoreInitialization = options.externalTaskStore === undefined;
const automationStore = automationStoreCtor(runtimeConfig.workingDirectory);
const agentStore = agentStoreCtor();
const semaphore = agentSemaphoreCtor();
@@ -454,7 +477,7 @@ const mocks = vi.hoisted(() => {
const engine = {
start: vi.fn(async () => {
await store.init();
if (ownsStoreInitialization) await store.init();
await automationStore.init();
await agentStore.init();
const settings = await store.getSettings();
@@ -538,7 +561,9 @@ const mocks = vi.hoisted(() => {
pluginLoaderInstances,
projectEngineInstances,
listenCalls,
backendShutdowns,
taskStoreCtor,
createTaskStoreForBackendMock,
automationStoreCtor,
agentStoreCtor,
centralCoreCtor,
@@ -584,6 +609,7 @@ const mocks = vi.hoisted(() => {
pluginLoaderInstances.length = 0;
projectEngineInstances.length = 0;
listenCalls.length = 0;
backendShutdowns.length = 0;
syncInsightExtractionAutomationMock.mockReset();
syncInsightExtractionAutomationMock.mockResolvedValue(undefined);
processAndAuditInsightExtractionMock.mockClear();
@@ -603,6 +629,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: mocks.taskStoreCtor,
createTaskStoreForBackend: mocks.createTaskStoreForBackendMock,
AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor,
CentralCore: mocks.centralCoreCtor,
@@ -903,7 +930,8 @@ describe("runServe", () => {
it("initializes stores, starts engine services, and creates a headless server", async () => {
await runServe(4040, {});
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
expect(mocks.createTaskStoreForBackendMock).toHaveBeenCalledWith({ rootDir: "/repo" });
expect(mocks.taskStoreCtor).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].init).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].watch).toHaveBeenCalledTimes(1);
expect(mocks.automationStoreCtor).toHaveBeenCalledWith("/repo");
@@ -1020,7 +1048,8 @@ describe("runServe", () => {
expect(mocks.cronRunnerInstances[0].stop).toHaveBeenCalledTimes(1);
expect(mocks.notifierInstances[0].stop).toHaveBeenCalledTimes(1);
expect(listenCall.server.close).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].close).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].close).not.toHaveBeenCalled();
expect(mocks.backendShutdowns[0]).toHaveBeenCalledTimes(1);
});
it("enables HybridExecutor when env override is set and shuts it down before engine stop", async () => {
@@ -1154,6 +1183,15 @@ describe("runServe — Plugin wiring", () => {
await triggerSignal("SIGINT");
});
it("shuts down the single shared PostgreSQL boot on graceful serve shutdown", async () => {
await runServe(4040, {});
expect(mocks.backendShutdowns).toHaveLength(1);
await triggerSignal("SIGINT");
expect(mocks.backendShutdowns[0]).toHaveBeenCalledTimes(1);
});
it("passes pluginStore, pluginLoader, and pluginRunner to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");

View File

@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core";
import { resolveProject } from "../../project-context.js";
import { createTaskStoreForBackend, exportSettings, generateExportFilename } from "@fusion/core";
import { closeProjectStore, resolveProject } from "../../project-context.js";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
@@ -20,12 +20,14 @@ function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T)
}
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
const mockBackendShutdown = vi.fn().mockResolvedValue(undefined);
vi.mock("node:fs/promises", () => ({
writeFile: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
createTaskStoreForBackend: vi.fn(async () => ({ taskStore: { init: mockStoreInit }, shutdown: mockBackendShutdown })),
TaskStore: makeConstructibleMock(() => ({
init: mockStoreInit,
})),
@@ -35,6 +37,7 @@ vi.mock("@fusion/core", () => ({
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
closeProjectStore: vi.fn(async () => undefined),
}));
import { runSettingsExport } from "../settings-export.js";
@@ -83,6 +86,7 @@ describe("runSettingsExport", () => {
expect(logSpy).toHaveBeenCalledWith(` ✓ Settings exported to ${expectedPath}`);
expect(logSpy).toHaveBeenCalledWith(" Exported: 2 global setting(s), 2 project setting(s)");
expect(exitSpy).toHaveBeenCalledWith(0);
expect(mockBackendShutdown).toHaveBeenCalledTimes(1);
});
it("exports successfully with a custom --output path", async () => {
@@ -155,7 +159,17 @@ describe("runSettingsExport", () => {
await runSettingsExport({ projectName: "alpha" });
expect(resolveProject).toHaveBeenCalledWith("alpha");
expect(TaskStore).toHaveBeenCalledWith("/tmp/demo");
expect(mockStoreInit).toHaveBeenCalledOnce();
expect(closeProjectStore).toHaveBeenCalledWith(expect.objectContaining({ projectId: "proj-1" }));
expect(createTaskStoreForBackend).toHaveBeenCalledWith({ rootDir: "/tmp/demo" });
});
it("closes the resolver-owned project before backend startup fails", async () => {
vi.mocked(createTaskStoreForBackend).mockRejectedValueOnce(new Error("startup failed"));
await expect(runSettingsExport({ projectName: "alpha" })).rejects.toThrow("startup failed");
expect(closeProjectStore).toHaveBeenCalledTimes(1);
expect(vi.mocked(closeProjectStore).mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(createTaskStoreForBackend).mock.invocationCallOrder[0]);
});
});

View File

@@ -37,7 +37,10 @@ vi.mock("node:fs", () => ({
vi.mock("@fusion/core", () => ({
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
createTaskStoreForBackend: vi.fn(async () => null),
createTaskStoreForBackend: vi.fn(async () => ({
taskStore: { init: mockStoreInit, close: mockStoreClose },
shutdown: vi.fn(async () => mockStoreClose()),
})),
TaskStore: makeConstructibleMock(() => ({
init: mockStoreInit,
close: mockStoreClose,
@@ -159,7 +162,7 @@ describe("fn settings import — leak/lock reproduction (FN-7740)", () => {
}
expect(mockStoreClose).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("board database stayed locked"));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("board database stayed contended"));
});
it("propagates a non-lock importSettings failure immediately without retrying", async () => {

View File

@@ -1,6 +1,6 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { existsSync } from "node:fs";
import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { createTaskStoreForBackend, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { resolveProject } from "../../project-context.js";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
@@ -33,7 +33,10 @@ vi.mock("node:fs", () => ({
// `TaskStore` needs a `close()` so the close-before-exit path is exercised.
vi.mock("@fusion/core", () => ({
// FNXC:PostgresCutover 2026-07-10: PG startup factory consulted before legacy TaskStore; null keeps the legacy mock path.
createTaskStoreForBackend: vi.fn(async () => null),
createTaskStoreForBackend: vi.fn(async () => ({
taskStore: { init: mockStoreInit, close: mockStoreClose },
shutdown: vi.fn(async () => mockStoreClose()),
})),
TaskStore: makeConstructibleMock(() => ({
init: mockStoreInit,
close: mockStoreClose,
@@ -239,7 +242,7 @@ describe("runSettingsImport", () => {
await runSettingsImport("./settings.json", { projectName: "alpha", yes: true });
expect(resolveProject).toHaveBeenCalledWith("alpha");
expect(TaskStore).toHaveBeenCalledWith("/tmp/demo");
expect(mockStoreInit).toHaveBeenCalledOnce();
expect(createTaskStoreForBackend).toHaveBeenCalledWith({ rootDir: "/tmp/demo" });
expect(mockStoreInit).not.toHaveBeenCalled();
});
});

View File

@@ -25,6 +25,20 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { retryOnLock, LockRetryExhaustedError, DEFAULT_CLI_LOCK_RETRY_MS } from "../../lock-retry.js";
describe("retryOnLock", () => {
it("retries PostgreSQL serialization failures", async () => {
vi.useFakeTimers();
try {
const op = vi.fn()
.mockRejectedValueOnce(Object.assign(new Error("could not serialize access"), { code: "40001" }))
.mockResolvedValue("ok");
const pending = retryOnLock(op, { id: "FN-PG", action: "move task" }, 1_000);
await vi.runAllTimersAsync();
await expect(pending).resolves.toBe("ok");
expect(op).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("returns immediately on first-try success (no added latency)", async () => {
const op = vi.fn().mockResolvedValue("ok");
const result = await retryOnLock(op, { id: "FN-1", action: "read task" });

View File

@@ -67,11 +67,11 @@ export async function runAgentExport(
// FNXC:PostgresCutover 2026-07-04: construct AgentStore in backend mode by
// borrowing the asyncLayer from the resolved project store (SQLite runtime
// removed under VAL-REMOVAL-005), mirroring extension.ts getAgentStore.
const { rootDir, asyncLayer } = await resolveAgentStoreBase(options?.project);
const agentStore = new AgentStore({ rootDir: rootDir + "/.fusion", asyncLayer: asyncLayer ?? undefined });
await agentStore.init();
const base = await resolveAgentStoreBase(options?.project);
const agentStore = new AgentStore({ rootDir: base.rootDir + "/.fusion", asyncLayer: base.asyncLayer });
try {
await agentStore.init();
const allAgents = await agentStore.listAgents();
const filterIds = options?.agentIds?.filter((id) => id.trim().length > 0);
const agents = filterIds && filterIds.length > 0
@@ -81,6 +81,7 @@ export async function runAgentExport(
if (agents.length === 0) {
console.error("No agents found to export");
closeAgentStoreSafely(agentStore);
await base.cleanup();
process.exit(1);
}
@@ -92,5 +93,6 @@ export async function runAgentExport(
printSummary(result);
} finally {
closeAgentStoreSafely(agentStore);
await base.cleanup();
}
}

View File

@@ -230,9 +230,37 @@ export async function runAgentImport(
// FNXC:PostgresCutover 2026-07-04: construct AgentStore in backend mode by
// borrowing the asyncLayer from the resolved project store (SQLite runtime
// removed under VAL-REMOVAL-005), mirroring extension.ts getAgentStore.
const { rootDir: projectPath, asyncLayer } = await resolveAgentStoreBase(options?.project);
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion", asyncLayer: asyncLayer ?? undefined });
await agentStore.init();
const base = await resolveAgentStoreBase(options?.project);
const projectPath = base.rootDir;
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion", asyncLayer: base.asyncLayer });
let cleanupPromise: Promise<void> | undefined;
let exitRequested = false;
const cleanup = (): Promise<void> => {
cleanupPromise ??= (async () => {
const failures: unknown[] = [];
try {
agentStore.close();
} catch (error) {
failures.push(error);
}
try {
await base.cleanup();
} catch (error) {
failures.push(error);
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw new AggregateError(failures, "Failed to close agent import resources");
})();
return cleanupPromise;
};
const exitWithCleanup = async (code: number): Promise<never> => {
exitRequested = true;
/* FNXC:PostgresCliLifecycle 2026-07-14-22:55: Import parse failures retain their established exit code even when teardown fails, while cleanup still attempts both AgentStore and borrowed PostgreSQL owners. */
await cleanup().catch((error) => console.error(`Cleanup failed: ${error instanceof Error ? error.message : String(error)}`));
return process.exit(code);
};
try {
await agentStore.init();
const existingAgents = await agentStore.listAgents();
const existingNames = new Set(existingAgents.map((a) => a.name));
@@ -308,16 +336,16 @@ export async function runAgentImport(
} catch (err) {
if (err instanceof AgentCompaniesParseError) {
console.error(`Parse error: ${err.message}`);
process.exit(1);
return await exitWithCleanup(1);
}
if (err instanceof Error && err.message === UNSUPPORTED_FORMAT_MESSAGE) {
console.error(err.message);
process.exit(1);
return await exitWithCleanup(1);
}
console.error(`Error reading source: ${(err as Error).message}`);
process.exit(1);
return await exitWithCleanup(1);
}
if (result.created.length === 0 && result.skipped.length === 0 && result.errors.length === 0) {
@@ -381,5 +409,9 @@ export async function runAgentImport(
? await importSkillsToProject(projectPath, skills, companySlug, false)
: undefined;
printSummary(companyName, agentCount, teamCount, created, result.skipped, errors, false, skillResult);
printSummary(companyName, agentCount, teamCount, created, result.skipped, errors, false, skillResult);
} finally {
if (exitRequested) await cleanup().catch(() => undefined);
else await cleanup();
}
}

View File

@@ -9,11 +9,17 @@ import { resolveAgentStoreBase } from "../project-context.js";
* the resolved project store so AgentStore runs in backend mode (the SQLite
* runtime was removed under VAL-REMOVAL-005), mirroring extension.ts getAgentStore.
*/
async function createAgentStore(projectName?: string): Promise<AgentStore> {
const { rootDir, asyncLayer } = await resolveAgentStoreBase(projectName);
const agentStore = new AgentStore({ rootDir: rootDir + "/.fusion", asyncLayer: asyncLayer ?? undefined });
await agentStore.init();
return agentStore;
async function createAgentStore(projectName?: string): Promise<{ store: AgentStore; cleanup: () => Promise<void> }> {
const base = await resolveAgentStoreBase(projectName);
const agentStore = new AgentStore({ rootDir: base.rootDir + "/.fusion", asyncLayer: base.asyncLayer });
try {
await agentStore.init();
return { store: agentStore, cleanup: base.cleanup };
} catch (error) {
closeAgentStoreSafely(agentStore);
await base.cleanup();
throw error;
}
}
/**
@@ -117,10 +123,12 @@ async function withBoundedTimeout<T>(
* assigned to a task. Skills are resolved by `buildSessionSkillContext`.
*/
export async function runAgentStop(id: string, projectName?: string): Promise<void> {
const agentStore = await createAgentStore(projectName);
const owned = await createAgentStore(projectName);
const agentStore = owned.store;
function exitWithStore(code: number): never {
async function exitWithStore(code: number): Promise<never> {
closeAgentStoreSafely(agentStore);
await owned.cleanup();
return process.exit(code);
}
@@ -128,7 +136,7 @@ export async function runAgentStop(id: string, projectName?: string): Promise<vo
const agent = await agentStore.getAgent(id);
if (!agent) {
console.error(`Agent ${id} not found`);
exitWithStore(1);
return await exitWithStore(1);
}
// Already paused — nothing to do
@@ -136,7 +144,6 @@ export async function runAgentStop(id: string, projectName?: string): Promise<vo
console.log();
console.log(` Agent ${id} is already paused`);
console.log();
closeAgentStoreSafely(agentStore);
return;
}
@@ -144,23 +151,22 @@ export async function runAgentStop(id: string, projectName?: string): Promise<vo
const validTargets = AGENT_VALID_TRANSITIONS[agent.state as AgentState];
if (!validTargets || !validTargets.includes("paused")) {
console.error(`Cannot stop agent ${id} — current state '${agent.state}' cannot transition to 'paused'`);
exitWithStore(1);
return await exitWithStore(1);
}
try {
await withBoundedTimeout(() => agentStore.updateAgentState(id, "paused"), { id, action: "stop (transition to paused)" });
} catch (err) {
console.error(`Failed to stop agent ${id}: ${err instanceof Error ? err.message : String(err)}`);
exitWithStore(1);
return await exitWithStore(1);
}
console.log();
console.log(` ✓ Agent ${id} stopped`);
console.log();
} finally {
closeAgentStoreSafely(agentStore);
} catch (err) {
closeAgentStoreSafely(agentStore);
throw err;
await owned.cleanup();
}
}
@@ -169,10 +175,12 @@ export async function runAgentStop(id: string, projectName?: string): Promise<vo
* Transitions state from paused to active.
*/
export async function runAgentStart(id: string, projectName?: string): Promise<void> {
const agentStore = await createAgentStore(projectName);
const owned = await createAgentStore(projectName);
const agentStore = owned.store;
function exitWithStore(code: number): never {
async function exitWithStore(code: number): Promise<never> {
closeAgentStoreSafely(agentStore);
await owned.cleanup();
return process.exit(code);
}
@@ -180,7 +188,7 @@ export async function runAgentStart(id: string, projectName?: string): Promise<v
const agent = await agentStore.getAgent(id);
if (!agent) {
console.error(`Agent ${id} not found`);
exitWithStore(1);
return await exitWithStore(1);
}
// Already active/running — nothing to do
@@ -188,7 +196,6 @@ export async function runAgentStart(id: string, projectName?: string): Promise<v
console.log();
console.log(` Agent ${id} is already running (${agent.state})`);
console.log();
closeAgentStoreSafely(agentStore);
return;
}
@@ -196,22 +203,21 @@ export async function runAgentStart(id: string, projectName?: string): Promise<v
const validTargets = AGENT_VALID_TRANSITIONS[agent.state as AgentState];
if (!validTargets || !validTargets.includes("active")) {
console.error(`Cannot start agent ${id} — current state '${agent.state}' cannot transition to 'active'`);
exitWithStore(1);
return await exitWithStore(1);
}
try {
await withBoundedTimeout(() => agentStore.updateAgentState(id, "active"), { id, action: "start (transition to active)" });
} catch (err) {
console.error(`Failed to start agent ${id}: ${err instanceof Error ? err.message : String(err)}`);
exitWithStore(1);
return await exitWithStore(1);
}
console.log();
console.log(` ✓ Agent ${id} started`);
console.log();
} finally {
closeAgentStoreSafely(agentStore);
} catch (err) {
closeAgentStoreSafely(agentStore);
throw err;
await owned.cleanup();
}
}

View File

@@ -24,11 +24,27 @@ Borrow the PostgreSQL AsyncDataLayer from the resolved project store so the
chat AgentStore runs in backend mode (the SQLite runtime was removed under
VAL-REMOVAL-005), mirroring agent.ts/extension.ts createAgentStore.
*/
async function createAgentStore(projectName?: string): Promise<AgentStore> {
const { rootDir, asyncLayer } = await resolveAgentStoreBase(projectName);
const store = new AgentStore({ rootDir: `${rootDir}/.fusion`, asyncLayer: asyncLayer ?? undefined });
await store.init();
return store;
async function createAgentStore(projectName?: string): Promise<{ store: AgentStore; cleanup: () => Promise<void> }> {
const base = await resolveAgentStoreBase(projectName);
const store = new AgentStore({ rootDir: `${base.rootDir}/.fusion`, asyncLayer: base.asyncLayer });
try {
await store.init();
return { store, cleanup: base.cleanup };
} catch (error) {
const failures: unknown[] = [error];
try {
store.close();
} catch (cleanupError) {
failures.push(cleanupError);
}
try {
await base.cleanup();
} catch (cleanupError) {
failures.push(cleanupError);
}
if (failures.length === 1) throw error;
throw new AggregateError(failures, "AgentStore initialization and cleanup failed");
}
}
function parsePollMs(options: ChatInteractiveOptions): number {
@@ -101,29 +117,33 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
const input = options.input ?? process.stdin;
const pollIntervalMs = parsePollMs(options);
const agentStore = await createAgentStore(options.project);
const agent = await agentStore.getAgent(agentId);
if (!agent) {
console.error(`Agent ${agentId} not found`);
return 1;
}
const ownedAgentStore = await createAgentStore(options.project);
const agentStore = ownedAgentStore.store;
let messageOwner: Awaited<ReturnType<typeof createMessageStore>> | undefined;
let commandFailure: unknown;
try {
const agent = await agentStore.getAgent(agentId);
if (!agent) {
console.error(`Agent ${agentId} not found`);
return 1;
}
const { store: messageStore, db } = await createMessageStore(options.project);
const printedIds = new Set<string>();
messageOwner = await createMessageStore(options.project);
const messageStore = messageOwner.store;
const printedIds = new Set<string>();
const conversation = await messageStore.getConversation(
const conversation = await messageStore.getConversation(
{ id: CLI_USER_ID, type: "user" },
{ id: agentId, type: "agent" },
);
const tail = conversation.slice(-HISTORY_LIMIT);
for (const message of tail) printedIds.add(message.id);
const tail = conversation.slice(-HISTORY_LIMIT);
for (const message of tail) printedIds.add(message.id);
output.write(`Chat with Agent ${agentId} — type /exit or Ctrl-C to quit, /help for commands\n`);
output.write("Replies appear when this project's engine is running (fn dashboard or fn serve).\n");
printConversationTail(output, tail);
output.write(`Chat with Agent ${agentId} — type /exit or Ctrl-C to quit, /help for commands\n`);
output.write("Replies appear when this project's engine is running (fn dashboard or fn serve).\n");
printConversationTail(output, tail);
const runOnce = options.once === true;
try {
const runOnce = options.once === true;
if (runOnce) {
const content = await readSingleMessage(input, output, options.nonInteractive);
if (!content.trim()) return 0;
@@ -217,8 +237,34 @@ export async function runChatInteractive(agentId: string, options: ChatInteracti
rl.close();
await poller;
return 0;
} catch (error) {
commandFailure = error;
throw error;
} finally {
db.close();
/* FNXC:PostgresCliLifecycle 2026-07-14-22:55: Chat owns three independently-failing resources. Always attempt AgentStore, message database, and borrowed project teardown; report all cleanup failures without discarding an earlier command failure. */
const cleanupFailures: unknown[] = [];
try {
agentStore.close();
} catch (error) {
cleanupFailures.push(error);
}
try {
await messageOwner?.db.close();
} catch (error) {
cleanupFailures.push(error);
}
try {
await ownedAgentStore.cleanup();
} catch (error) {
cleanupFailures.push(error);
}
if (cleanupFailures.length > 0) {
// eslint-disable-next-line no-unsafe-finally -- cleanup must aggregate with, rather than silently lose, the active command failure.
throw new AggregateError(
commandFailure === undefined ? cleanupFailures : [commandFailure, ...cleanupFailures],
"Chat command cleanup failed",
);
}
}
}

View File

@@ -250,7 +250,8 @@ export async function runDbMigrate(
if (!registeredProjectId) {
const centralSource = presentSources.find((source) => source.pgSchema === "central");
if (centralSource) {
const legacyCentral = new DatabaseSync(centralSource.sqlitePath);
// FNXC:LegacySqliteBoundary 2026-07-14-18:42: project ownership discovery reads the operator-selected migration source without modifying it.
const legacyCentral = new DatabaseSync(centralSource.sqlitePath, { readOnly: true });
try {
registeredProjectId = (legacyCentral
.prepare("SELECT id FROM projects WHERE path = ? LIMIT 1")

View File

@@ -6,7 +6,7 @@ import type { AddressInfo } from "node:net";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import * as os from "node:os";
import { CentralCore, PluginLoader, TaskStore, createTaskStoreForBackend } from "@fusion/core";
import { CentralCore, PluginLoader, createTaskStoreForBackend, type TaskStore } from "@fusion/core";
import { createServer } from "@fusion/dashboard";
import { ProjectEngineManager } from "@fusion/engine";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
@@ -28,20 +28,17 @@ interface DashboardRuntime {
engineManager?: ProjectEngineManager;
centralCore?: CentralCore;
/** Releases the PostgreSQL backend pool / embedded cluster (backend mode only). */
backendShutdown?: () => Promise<void>;
backendShutdown: () => Promise<void>;
}
async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: boolean): Promise<DashboardRuntime> {
// FNXC:PostgresCutover 2026-07-04: boot the PostgreSQL backend via the startup
// FNXC:PostgresCutover 2026-07-04-00:00: boot the PostgreSQL backend via the startup
// factory (embedded by default, external via DATABASE_URL), mirroring dashboard.ts.
// The factory returns null only on the FUSION_NO_EMBEDDED_PG=1 opt-out, in which
// case the legacy SQLite TaskStore is constructed (init() is still required).
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Desktop startup has no SQLite
// fallback; an obsolete backend opt-out fails explicitly in the factory.
const boot = await createTaskStoreForBackend({ rootDir });
let backendShutdown: (() => Promise<void>) | undefined;
const store: TaskStore = boot ? boot.taskStore : new TaskStore(rootDir);
if (boot) {
backendShutdown = boot.shutdown;
}
const backendShutdown = boot.shutdown;
const store: TaskStore = boot.taskStore;
let server: import("node:http").Server | null = null;
let engineManager: ProjectEngineManager | undefined;
let centralCore: CentralCore | undefined;
@@ -123,25 +120,28 @@ async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: b
backendShutdown,
};
} catch (error) {
if (server) {
await new Promise<void>((resolve) => server?.close(() => resolve()));
try {
if (server) await new Promise<void>((resolve) => server?.close(() => resolve()));
} finally {
await engineManager?.stopAll().catch(() => undefined);
await centralCore?.close?.().catch(() => undefined);
/* FNXC:PostgresDesktopLifecycle 2026-07-14-19:10: The startup-factory shutdown is the sole TaskStore/pool/postmaster owner and must run even when earlier server, engine, or CentralCore cleanup fails. */
await backendShutdown().catch(() => undefined);
}
await engineManager?.stopAll().catch(() => undefined);
await centralCore?.close?.().catch(() => undefined);
store.close();
await backendShutdown?.().catch(() => undefined);
throw error;
}
}
async function closeDashboardRuntime(runtime: DashboardRuntime): Promise<void> {
await new Promise<void>((resolve) => {
runtime.server.close(() => resolve());
});
await runtime.engineManager?.stopAll().catch(() => undefined);
await runtime.centralCore?.close?.().catch(() => undefined);
runtime.store.close();
await runtime.backendShutdown?.().catch(() => undefined);
try {
await new Promise<void>((resolve) => {
runtime.server.close(() => resolve());
});
} finally {
await runtime.engineManager?.stopAll().catch(() => undefined);
await runtime.centralCore?.close?.().catch(() => undefined);
await runtime.backendShutdown().catch(() => undefined);
}
}
function resolveElectronBinary(): string {

View File

@@ -1,5 +1,5 @@
import { exec } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync } from "node:fs";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import {
@@ -54,18 +54,15 @@ export async function ensureCwdProjectRegistered(
try {
const fusionDir = join(cwd, ".fusion");
const dbPath = join(fusionDir, "fusion.db");
if (!existsSync(fusionDir)) {
mkdirSync(fusionDir, { recursive: true });
}
if (!existsSync(dbPath)) {
writeFileSync(dbPath, "");
}
const projectName = await detectProjectName(cwd);
const identity: ProjectIdentity | null = existsSync(dbPath) ? readProjectIdentity(fusionDir) : null;
// FNXC:ProjectIdentityMarker 2026-07-14-17:20: Auto-registration writes
// project.json and reads fusion.db only through the legacy identity migrator.
const identity: ProjectIdentity | null = readProjectIdentity(fusionDir);
const ensured = await central.ensureProjectForPath({
path: cwd,
@@ -79,7 +76,7 @@ export async function ensureCwdProjectRegistered(
if (ensured.outcome === "reattached") {
console.log(
`[${logPrefix}] Recovered project identity ${project.id} from ${dbPath} (central had no row)`,
`[${logPrefix}] Recovered project identity ${project.id} from ${fusionDir} (central had no row)`,
);
} else if (ensured.outcome === "registered") {
console.log(`[${logPrefix}] Auto-registered project "${project.name}" at ${cwd}`);

View File

@@ -1,6 +1,6 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { TaskStore, createTaskStoreForBackend } from "@fusion/core";
import { createTaskStoreForBackend } from "@fusion/core";
import {
defaultGitOps,
ExperimentFinalizeBranchExistsError,
@@ -12,7 +12,7 @@ import {
ExperimentFinalizeStateError,
type FinalizePlanOverride,
} from "@fusion/engine";
import { resolveProject } from "../project-context.js";
import { closeProjectStore, resolveProject, type ProjectContext } from "../project-context.js";
interface ExperimentFinalizeOptions {
sessionId: string;
@@ -50,7 +50,11 @@ async function parsePlanOverride(path: string): Promise<FinalizePlanOverride> {
return JSON.parse(content) as FinalizePlanOverride;
}
function exitWithError(error: unknown): never {
async function exitWithError(error: unknown, shutdown?: () => Promise<void>): Promise<never> {
/* FNXC:PostgresCliLifecycle 2026-07-14-22:55: Experiment-finalize must attempt backend teardown before exit without allowing a shutdown rejection to replace its established typed error code. */
await shutdown?.().catch((cleanupError) => {
console.error(`Cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
});
if (error instanceof ExperimentFinalizeCherryPickConflictError) {
console.error(`Error: ${error.message}`);
console.error(JSON.stringify({ groupId: error.groupId, commit: error.commit, stderr: error.stderr }));
@@ -66,17 +70,27 @@ function exitWithError(error: unknown): never {
}
export async function runExperimentFinalize(options: ExperimentFinalizeOptions): Promise<void> {
let backendShutdown: (() => Promise<void>) | undefined;
const shutdownBackend = async (): Promise<void> => {
const shutdown = backendShutdown;
backendShutdown = undefined;
await shutdown?.();
};
try {
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
const projectRoot = project?.projectPath ?? process.cwd();
// FNXC:PostgresCutover 2026-07-04: boot the PostgreSQL backend via the startup
// factory instead of a legacy SQLite TaskStore whose runtime was removed
// (VAL-REMOVAL-005). Falls back to legacy only on FUSION_NO_EMBEDDED_PG=1.
const boot = await createTaskStoreForBackend({ rootDir: projectRoot });
const taskStore: TaskStore = boot ? boot.taskStore : new TaskStore(projectRoot);
if (!boot) {
await taskStore.init();
let project: ProjectContext | undefined;
let projectRoot = process.cwd();
try {
project = options.projectName ? await resolveProject(options.projectName) : undefined;
projectRoot = project?.projectPath ?? projectRoot;
} finally {
/* FNXC:PostgresCliLifecycle 2026-07-14-21:20: Experiment finalization boots its own backend after path resolution, so always close and evict the resolver-owned project context before parsing or Git work can succeed or fail. */
if (project) await closeProjectStore(project);
}
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Experiment finalization has one
// authoritative PostgreSQL store path; the startup factory is non-nullable.
const boot = await createTaskStoreForBackend({ rootDir: projectRoot });
backendShutdown = boot.shutdown;
const taskStore = boot.taskStore;
const sessionStore = taskStore.getExperimentSessionStore();
const service = new ExperimentFinalizeService({
store: sessionStore,
@@ -95,6 +109,7 @@ export async function runExperimentFinalize(options: ExperimentFinalizeOptions):
} else {
printPlan(plan);
}
await shutdownBackend();
return;
}
@@ -107,6 +122,7 @@ export async function runExperimentFinalize(options: ExperimentFinalizeOptions):
if (options.json) {
printJson({ result });
await shutdownBackend();
return;
}
@@ -114,6 +130,7 @@ export async function runExperimentFinalize(options: ExperimentFinalizeOptions):
for (const branch of result.branches) {
console.log(`- ${branch.name} (${branch.tipCommit})`);
}
await shutdownBackend();
} catch (error) {
if (
error instanceof ExperimentFinalizeStateError
@@ -123,8 +140,8 @@ export async function runExperimentFinalize(options: ExperimentFinalizeOptions):
|| error instanceof ExperimentFinalizeBranchExistsError
|| error instanceof ExperimentFinalizeCherryPickConflictError
) {
exitWithError(error);
await exitWithError(error, shutdownBackend);
}
exitWithError(error);
await exitWithError(error, shutdownBackend);
}
}

View File

@@ -2,7 +2,7 @@
* Init command for fn CLI.
*
* Initializes a new fn project in the current directory by:
* 1. Creating the .fusion/ directory with fusion.db
* 1. Creating the .fusion/ directory and PostgreSQL-neutral project identity
* 2. Registering the project in the central database
*
* Idempotent: if already initialized, reports success without recreating.
@@ -18,6 +18,7 @@ import {
GitRepositoryInitializationError,
QMD_INSTALL_COMMAND,
isQmdAvailable,
hasProjectIdentity,
isValidSqliteDatabaseFile,
readProjectIdentity,
writeProjectIdentity,
@@ -51,9 +52,14 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
const dbPath = join(fusionDir, "fusion.db");
const hasDbPath = existsSync(dbPath);
const hasValidDb = hasDbPath && isValidSqliteDatabaseFile(dbPath);
/*
FNXC:ProjectIdentityMarker 2026-07-14-17:20:
`fn init` treats `.fusion/project.json` as the durable local project marker. A valid fusion.db remains detectable only to migrate projects created before the PostgreSQL cutover; new initialization never creates a SQLite file.
*/
const hasIdentity = hasProjectIdentity(fusionDir);
// Check if already initialized
if (existsSync(fusionDir) && hasDbPath && hasValidDb) {
if (existsSync(fusionDir) && (hasIdentity || hasValidDb)) {
// Check if registered in central DB
const central = new CentralCore();
await central.init();
@@ -87,7 +93,7 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
return;
}
if (existsSync(fusionDir) && hasDbPath && !hasValidDb) {
if (existsSync(fusionDir) && !hasIdentity && hasDbPath && !hasValidDb) {
throw new Error(
`Existing database at ${dbPath} is not a valid SQLite database. ` +
"Restore it from .fusion/backups or move it aside before re-running fn init.",
@@ -115,13 +121,6 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
await addLocalStorageToGitignore(cwd);
await warnIfQmdMissing();
// Create fusion.db (empty SQLite file)
if (!existsSync(dbPath)) {
// A zero-byte bootstrap file is a valid SQLite starting point.
writeFileSync(dbPath, "");
console.log(` ✓ Created fusion.db`);
}
const bundledSkillInstall = installBundledFusionSkill();
logBundledSkillInstallResults(bundledSkillInstall.results);
@@ -133,6 +132,18 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
// Check if already registered
const existing = await central.getProjectByPath(cwd);
if (existing) {
/*
FNXC:ProjectIdentityMarker 2026-07-14-22:25:
A project already registered in PostgreSQL can still reach this branch when its local `.fusion/project.json` marker is missing. Repair the marker before returning so subsequent startup and init checks use the same durable identity as a newly registered project.
*/
try {
writeProjectIdentity(fusionDir, {
id: existing.id,
createdAt: existing.createdAt,
});
} catch (identityError) {
console.warn(` ⚠ Could not persist project identity: ${identityError instanceof Error ? identityError.message : String(identityError)}`);
}
console.log(` ✓ Already registered in central database`);
maybeInstallClaudeSkillForNewProject(cwd);
console.log(`\n✓ Project "${projectName}" is ready!`);
@@ -144,7 +155,7 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
return;
}
const identity = existsSync(dbPath) ? readProjectIdentity(fusionDir) : null;
const identity = readProjectIdentity(fusionDir);
const ensured = await central.ensureProjectForPath({
path: cwd,
identity: identity ?? undefined,

View File

@@ -1,4 +1,4 @@
import { MessageStore, createDatabase } from "@fusion/core";
import { MessageStore } from "@fusion/core";
import type { ParticipantType } from "@fusion/core";
import { resolveAgentStoreBase } from "../project-context.js";
@@ -8,23 +8,15 @@ import { resolveAgentStoreBase } from "../project-context.js";
*
* FNXC:PostgresCutover 2026-07-05-12:00:
* Borrow the PostgreSQL AsyncDataLayer from the resolved project store so the
* MessageStore runs in backend mode (the sync SQLite Database runtime was
* removed under VAL-REMOVAL-005). The legacy createDatabase path survives only
* for the FUSION_NO_EMBEDDED_PG=1 opt-out where no asyncLayer exists. The
* backend-mode `db` handle is a no-op closer: the AsyncDataLayer pool is owned
* MessageStore runs in backend mode. The returned cleanup handle is a no-op:
* the AsyncDataLayer pool is owned
* by the resolved project store, not by this command.
*/
export async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: { close: () => void } }> {
const { rootDir, asyncLayer } = await resolveAgentStoreBase(projectName);
if (asyncLayer) {
const store = new MessageStore(null, { asyncLayer });
return { store, db: { close: () => {} } };
}
const fusionDir = rootDir + "/.fusion";
const db = createDatabase(fusionDir);
db.init();
const store = new MessageStore(db);
return { store, db };
export async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: { close: () => Promise<void> } }> {
const { asyncLayer, cleanup } = await resolveAgentStoreBase(projectName);
/* FNXC:PostgresCliMessages 2026-07-14-18:24: CLI messaging always uses the resolved project's authoritative PostgreSQL layer; the removed SQLite opt-out must not create a second local store. */
const store = new MessageStore(null, { asyncLayer });
return { store, db: { close: cleanup } };
}
/** User ID for CLI-originated messages */
@@ -59,7 +51,7 @@ export async function runMessageInbox(projectName?: string): Promise<void> {
console.log();
}
} finally {
db.close();
await db.close();
}
}
@@ -90,7 +82,7 @@ export async function runMessageOutbox(projectName?: string): Promise<void> {
console.log();
}
} finally {
db.close();
await db.close();
}
}
@@ -114,7 +106,7 @@ export async function runMessageSend(toId: string, content: string, projectName?
console.log(` To: Agent ${toId}`);
console.log();
} finally {
db.close();
await db.close();
}
}
@@ -128,6 +120,7 @@ export async function runMessageRead(id: string, projectName?: string): Promise<
if (!message) {
console.error(`Message ${id} not found`);
await db.close();
process.exit(1);
}
@@ -150,7 +143,7 @@ export async function runMessageRead(id: string, projectName?: string): Promise<
console.log(` ${message.content}`);
console.log();
} finally {
db.close();
await db.close();
}
}
@@ -166,7 +159,7 @@ export async function runMessageDelete(id: string, projectName?: string): Promis
console.log(` ✓ Message ${id} deleted`);
console.log();
} finally {
db.close();
await db.close();
}
}
@@ -199,7 +192,7 @@ export async function runAgentMailbox(agentId: string, projectName?: string): Pr
console.log();
}
} finally {
db.close();
await db.close();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -130,9 +130,13 @@ export async function maybeAutoLaunchOnboarding(deps: MaybeAutoLaunchDeps): Prom
const pathExists = deps.pathExists ?? existsSync;
const centralDbPath = deps.centralDbPath ?? getDefaultCentralDbPath();
const cwd = deps.cwd ?? process.cwd();
const projectDbPath = join(cwd, ".fusion", "fusion.db");
const projectMarkerPath = join(cwd, ".fusion", "project.json");
const legacyProjectDbPath = join(cwd, ".fusion", "fusion.db");
centralDbExists = pathExists(centralDbPath);
projectInitialized = deps.projectInitialized ?? pathExists(projectDbPath);
// FNXC:ProjectIdentityMarker 2026-07-14-17:20: Onboarding probes the new
// marker first and recognizes fusion.db only as a pre-cutover project signal.
projectInitialized = deps.projectInitialized
?? (pathExists(projectMarkerPath) || pathExists(legacyProjectDbPath));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[onboard-autolaunch] central DB probe failed; skipping auto-launch: ${message}`);

View File

@@ -13,8 +13,9 @@
import {
CentralCore,
GlobalSettingsStore,
TaskStore,
createTaskStoreForBackend,
hasProjectIdentity,
isValidSqliteDatabaseFile,
ensureMemoryFileWithBackend,
type RegisteredProject,
type IsolationMode,
@@ -144,19 +145,11 @@ async function getTaskCounts(projectPath: string): Promise<TaskCountSummary> {
// the surrounding try/catch swallowed it so project info/list silently showed
// zero tasks. The boot result's shutdown() releases the connection pool (and
// any embedded PostgreSQL process) so a one-shot CLI read leaks nothing.
let store: TaskStore | undefined;
let backendShutdown: (() => Promise<void>) | undefined;
try {
const boot = await createTaskStoreForBackend({ rootDir: projectPath });
if (boot) {
store = boot.taskStore;
backendShutdown = boot.shutdown;
} else {
// Legacy SQLite opt-out (FUSION_NO_EMBEDDED_PG=1): byte-identical legacy path.
store = new TaskStore(projectPath);
await store.init();
}
const resolvedStore = store;
backendShutdown = boot.shutdown;
const resolvedStore = boot.taskStore;
const tasks = await retryOnLock(
() => resolvedStore.listTasks({ slim: true }),
{ id: projectPath, action: "count tasks" },
@@ -175,11 +168,6 @@ async function getTaskCounts(projectPath: string): Promise<TaskCountSummary> {
// 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.
}
if (backendShutdown) {
await backendShutdown().catch(() => undefined);
}
@@ -350,8 +338,9 @@ export async function runProjectAdd(
}
// Check for .fusion directory
const kbDbPath = resolve(absolutePath, ".fusion", "fusion.db");
if (!existsSync(kbDbPath) && !options.force) {
const fusionDir = resolve(absolutePath, ".fusion");
const legacyDbPath = resolve(fusionDir, "fusion.db");
if (!hasProjectIdentity(fusionDir) && !isValidSqliteDatabaseFile(legacyDbPath) && !options.force) {
console.log(`\n No fn project found at ${formatDisplayPath(absolutePath)}`);
const init = await rl.question(" Initialize fn here first? [Y/n] ");
rl.close();
@@ -362,18 +351,12 @@ export async function runProjectAdd(
// startup factory; bare `new TaskStore` throws in backend mode. The
// boot shutdown releases the pool for this one-shot init.
const boot = await createTaskStoreForBackend({ rootDir: absolutePath });
const store = boot ? boot.taskStore : 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.
await boot.taskStore.init();
} finally {
/* FNXC:PostgresCliLifecycle 2026-07-14-22:55: The startup factory shutdown is the sole owner for one-shot project initialization. It must run when init rejects and must not be paired with a second direct store.close that races the same pool. */
await boot.shutdown().catch(() => undefined);
}
if (boot) await boot.shutdown().catch(() => {});
console.log(` ✓ Initialized fn at ${absolutePath}`);
} else {
console.log("\n Cancelled. Run `fn init` to initialize a project first.\n");
@@ -417,8 +400,9 @@ export async function runProjectAdd(
}
// Check for .fusion directory
const kbDbPath = resolve(absolutePath, ".fusion", "fusion.db");
if (!existsSync(kbDbPath) && !options.force) {
const fusionDir = resolve(absolutePath, ".fusion");
const legacyDbPath = resolve(fusionDir, "fusion.db");
if (!hasProjectIdentity(fusionDir) && !isValidSqliteDatabaseFile(legacyDbPath) && !options.force) {
console.error(`\n ✗ No fn project found at ${formatDisplayPath(absolutePath)}`);
console.error(" Run `fn init` first to initialize the project.\n");
process.exit(1);
@@ -439,7 +423,7 @@ export async function runProjectAdd(
process.exit(1);
}
const identity = existsSync(kbDbPath) ? readProjectIdentity(join(absolutePath, ".fusion")) : null;
const identity = readProjectIdentity(fusionDir);
const ensured = await central.ensureProjectForPath({
path: absolutePath,
identity: identity ?? undefined,

View File

@@ -4,7 +4,7 @@ import {
RESEARCH_EXPORT_FORMATS,
RESEARCH_RUN_STATUSES,
ResearchRunStatus,
TaskStore,
type TaskStore,
createTaskStoreForBackend,
resolveResearchSettings,
type ResearchExportFormat,
@@ -25,14 +25,9 @@ import { retryOnLock } from "../lock-retry.js";
* → `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
* internally) and retaining the startup factory shutdown owner for every
* caller. The non-wait create path persists a queued run and shuts down its
* backend normally; the durable engine dispatcher executes that work. 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
@@ -42,15 +37,11 @@ async function withResolvedStore<T>(
projectName: string | undefined,
fn: (store: TaskStore) => Promise<T>,
): Promise<T> {
const store = await getStore(projectName);
const owned = await getStore(projectName);
try {
return await fn(store);
return await fn(owned.store);
} finally {
try {
await store.close();
} catch {
// Best-effort: an already-closed store must not throw here.
}
await owned.shutdown();
}
}
@@ -80,19 +71,19 @@ interface ResearchExportOptions extends ResearchCommandOptions {
FNXC:ResearchCliPostgres 2026-07-13-22:38:
Research CLI execution, lifecycle commands, and exports must use the TaskStore-selected backend. Both ResearchStore and AsyncResearchStore expose the same API; callers await every operation so PostgreSQL promises and legacy synchronous returns preserve identical operator behavior.
*/
async function getStore(projectName?: string): Promise<TaskStore> {
interface OwnedResearchStore {
store: TaskStore;
shutdown: () => Promise<void>;
}
async function getStore(projectName?: string): Promise<OwnedResearchStore> {
const projectPath = projectName ? await resolveProjectPathOnly(projectName) : undefined;
const rootDir = projectPath ?? process.cwd();
// FNXC:PostgresCutover 2026-07-04: boot the PostgreSQL backend via the startup
// factory instead of a legacy SQLite TaskStore whose runtime was removed
// (VAL-REMOVAL-005). Falls back to legacy only on FUSION_NO_EMBEDDED_PG=1.
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Research always borrows the
// non-null PostgreSQL TaskStore returned by the startup factory.
const boot = await createTaskStoreForBackend({ rootDir });
if (boot) {
return boot.taskStore;
}
const store = new TaskStore(rootDir);
await store.init();
return store;
/* FNXC:PostgresCliLifecycle 2026-07-14-19:10: Research commands retain the full startup owner, not only TaskStore, because embedded PostgreSQL teardown belongs to BackendBootResult.shutdown. */
return { store: boot.taskStore, shutdown: boot.shutdown };
}
function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
@@ -168,28 +159,22 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
* 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.
* real). EVERY exit point below invokes the startup factory's shutdown owner
* before returning or exiting; only the explicit wait path starts and drains
* in-process orchestrator work.
*/
let owned: OwnedResearchStore | undefined;
let store: TaskStore | undefined;
const closeStore = async (): Promise<void> => {
if (!store) return;
try {
await store.close();
} catch {
// Best-effort.
}
if (!owned) return;
const current = owned;
owned = undefined;
await current.shutdown();
};
try {
store = await getStore(options.projectName);
owned = await getStore(options.projectName);
store = owned.store;
const { orchestrator, settings, resolved, availableProviderTypes } = await getResearchRuntime(store);
const runId = await orchestrator.createRun({
@@ -202,11 +187,14 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
stepTimeoutMs: resolved.limits.requestTimeoutMs,
});
const runPromise = orchestrator.startRun(runId, options.query);
await store.getResearchStore().updateRun(runId, { query: options.query });
if (!options.waitForCompletion) {
// Intentionally-long-lived branch — do NOT close `store` here (see
// the function-level FNXC comment above).
/*
FNXC:ResearchCliDurableDispatch 2026-07-14-22:54:
A non-wait CLI invocation persists a query-bearing queued run and exits after normal backend shutdown. It must not start in-process work or retain PostgreSQL ownership; the durable engine ResearchRunDispatcher owns queued execution after the short-lived CLI process exits.
*/
const run = await store.getResearchStore().getRun(runId);
await closeStore();
if (options.json) {
jsonOut(run);
} else {
@@ -216,6 +204,7 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
return;
}
const runPromise = orchestrator.startRun(runId, options.query);
const maxWaitMs = Math.max(1_000, Math.min(options.maxWaitMs ?? 90_000, resolved.limits.maxDurationMs));
const fallbackRun = (): ResearchRun => ({
id: runId,
@@ -254,8 +243,7 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise
completed = (await store.getResearchStore().getRun(runId)) ?? completed;
}
// The run either completed or was cancelled and drained above, so unlike
// the fire-and-forget branch it is safe to close here.
// The wait-path run either completed or was cancelled and drained above.
await closeStore();
if (options.json) {

View File

@@ -1,7 +1,7 @@
import { writeFile } from "node:fs/promises";
import { resolve, join } from "node:path";
import { TaskStore, createTaskStoreForBackend, exportSettings, generateExportFilename } from "@fusion/core";
import { resolveProject } from "../project-context.js";
import { createTaskStoreForBackend, exportSettings, generateExportFilename } from "@fusion/core";
import { closeProjectStore, resolveProject, type ProjectContext } from "../project-context.js";
/**
* Run settings export command.
@@ -17,22 +17,34 @@ export async function runSettingsExport(options: {
projectName?: string;
} = {}): Promise<void> {
const scope = options.scope ?? "both";
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
// FNXC:PostgresCutover 2026-07-04: boot the PostgreSQL backend via the startup
// factory instead of a legacy SQLite TaskStore whose runtime was removed
// (VAL-REMOVAL-005). Falls back to legacy only on FUSION_NO_EMBEDDED_PG=1.
const rootDir = project?.projectPath ?? process.cwd();
const boot = await createTaskStoreForBackend({ rootDir });
let store: TaskStore;
if (boot) {
store = boot.taskStore;
} else {
store = new TaskStore(rootDir);
await store.init();
let project: ProjectContext | undefined;
let rootDir = process.cwd();
try {
project = options.projectName ? await resolveProject(options.projectName) : undefined;
rootDir = project?.projectPath ?? rootDir;
} finally {
/* FNXC:PostgresCliLifecycle 2026-07-14-21:20: Settings export uses a separate backend boot, so a project context opened only to resolve its root must be closed and evicted before export begins, including when later startup or export work fails. */
if (project) await closeProjectStore(project);
}
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Settings export always uses the
// PostgreSQL startup factory; the removed SQLite opt-out has no runtime path.
const boot = await createTaskStoreForBackend({ rootDir });
const store = boot.taskStore;
const outputPath = options.output;
/* FNXC:PostgresCliLifecycle 2026-07-14-19:10: A one-shot settings export must release the startup factory owner before process.exit; store.close alone cannot stop an embedded PostgreSQL cluster. */
let backendShutdown: (() => Promise<void>) | undefined = boot.shutdown;
const exitWithBackend = async (code: number): Promise<never> => {
const shutdown = backendShutdown;
backendShutdown = undefined;
/* FNXC:PostgresCliLifecycle 2026-07-14-22:55: Settings export preserves its success/failure exit code after making an awaited shutdown attempt; teardown rejection is diagnostic, not a second export result. */
await shutdown?.().catch((cleanupError) => {
console.error(`Cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
});
return process.exit(code);
};
try {
const exportData = await exportSettings(store, { scope });
@@ -73,9 +85,9 @@ export async function runSettingsExport(options: {
}
console.log();
process.exit(0);
await exitWithBackend(0);
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
await exitWithBackend(1);
}
}

View File

@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { TaskStore, createTaskStoreForBackend, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { resolveProjectPathOnly, asLocalProjectContext, closeProjectStore } from "../project-context.js";
import { createTaskStoreForBackend, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { resolveProjectPathOnly } from "../project-context.js";
import { retryOnLock } from "../lock-retry.js";
/**
@@ -47,24 +47,20 @@ export async function runSettingsImport(
const scope = options.scope ?? "both";
const projectPath = options.projectName ? await resolveProjectPathOnly(options.projectName) : undefined;
// FNXC:PostgresCutover 2026-07-04: boot the PostgreSQL backend via the startup
// factory instead of a legacy SQLite TaskStore whose runtime was removed
// (VAL-REMOVAL-005). Falls back to legacy only on FUSION_NO_EMBEDDED_PG=1.
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Settings import always uses the
// PostgreSQL startup factory; a disabled embedded backend now fails explicitly.
const rootDir = projectPath ?? process.cwd();
const boot = await createTaskStoreForBackend({ rootDir });
let store: TaskStore;
if (boot) {
store = boot.taskStore;
} else {
store = new TaskStore(rootDir);
await store.init();
}
const storeContext = asLocalProjectContext(store);
const store = boot.taskStore;
const merge = options.merge ?? true;
const skipConfirm = options.yes ?? false;
let backendShutdown: (() => Promise<void>) | undefined = boot.shutdown;
const exitWithStore = async (code: number): Promise<never> => {
await closeProjectStore(storeContext);
/* FNXC:PostgresCliLifecycle 2026-07-14-19:10: This direct startup-factory boot is not registered in project-context's ownership map, so its own shutdown handle is the sole correct teardown boundary. */
const shutdown = backendShutdown;
backendShutdown = undefined;
await shutdown?.();
return process.exit(code);
};

View File

@@ -1,8 +1,8 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { TaskStore } from "@fusion/core";
import { createTaskStoreForBackend, type TaskStore } from "@fusion/core";
import { validateWorkflowIrDryRun } from "@fusion/engine";
import { getStore } from "../project-resolver.js";
import { cleanupProjectResolution, getStore } from "../project-resolver.js";
export interface RunWorkflowValidateOptions {
workflowId?: string;
@@ -11,14 +11,21 @@ export interface RunWorkflowValidateOptions {
json?: boolean;
}
async function resolveStore(projectName?: string): Promise<TaskStore> {
interface OwnedWorkflowStore {
store: TaskStore;
shutdown: () => Promise<void>;
}
async function resolveStore(projectName?: string): Promise<OwnedWorkflowStore> {
try {
return await getStore({ project: projectName });
const store = await getStore({ project: projectName });
return { store, shutdown: cleanupProjectResolution };
} catch (error) {
if (projectName) throw error;
const store = new TaskStore(process.cwd());
await store.init();
return store;
// FNXC:PostgresFinalCutover 2026-07-14-17:20: An unregistered CWD still
// validates workflows against PostgreSQL; it must not revive TaskStore's removed SQLite runtime.
const boot = await createTaskStoreForBackend({ rootDir: process.cwd() });
return { store: boot.taskStore, shutdown: boot.shutdown };
}
}
@@ -40,9 +47,18 @@ export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Pro
process.exit(2);
}
let store: TaskStore | undefined;
let owned: OwnedWorkflowStore | undefined;
try {
store = await resolveStore(opts.projectName);
owned = await resolveStore(opts.projectName);
const store = owned.store;
/* FNXC:PostgresCliLifecycle 2026-07-14-19:10: Workflow validation must await the exact startup owner before any process exit; a finally block is insufficient because process.exit skips pending cleanup. */
const exitWithStore = async (payload: unknown | undefined, code: number): Promise<never> => {
if (payload !== undefined) console.log(JSON.stringify(payload, null, 2));
const current = owned;
owned = undefined;
await current!.shutdown();
return process.exit(code);
};
let ir: unknown;
if (opts.file) {
const filePath = resolve(opts.file);
@@ -50,31 +66,33 @@ export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Pro
ir = JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
const message = `Failed to read or parse workflow IR file '${opts.file}': ${error instanceof Error ? error.message : String(error)}`;
if (opts.json) printJsonAndExit({ valid: false, error: message }, 2);
if (opts.json) return await exitWithStore({ valid: false, error: message }, 2);
console.error(message);
process.exit(2);
return await exitWithStore(undefined, 2);
}
} else {
const def = await store.getWorkflowDefinition(workflowId!);
if (!def) {
const message = `Workflow '${workflowId}' not found`;
if (opts.json) printJsonAndExit({ valid: false, error: message }, 2);
if (opts.json) return await exitWithStore({ valid: false, error: message }, 2);
console.error(message);
process.exit(2);
return await exitWithStore(undefined, 2);
}
ir = def.ir;
}
const result = await validateWorkflowIrDryRun(store, ir, false);
if (opts.json) printJsonAndExit(result.valid ? { valid: true } : { valid: false, errors: result.errors }, result.valid ? 0 : 1);
if (opts.json) return await exitWithStore(result.valid ? { valid: true } : { valid: false, errors: result.errors }, result.valid ? 0 : 1);
if (result.valid) {
console.log("✓ Workflow IR is valid. No workflow was created or mutated.");
process.exit(0);
return await exitWithStore(undefined, 0);
}
console.error("✗ Workflow IR is invalid:");
for (const error of result.errors) console.error(` - ${error.message}`);
process.exit(1);
return await exitWithStore(undefined, 1);
} finally {
await store?.close?.().catch(() => {});
const current = owned;
owned = undefined;
await current?.shutdown().catch(() => undefined);
}
}

View File

@@ -3,7 +3,7 @@ import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import * as fusionCore from "@fusion/core";
import {
TaskStore,
type TaskStore,
createTaskStoreForBackend,
drizzleSql,
AgentStore,
@@ -241,16 +241,10 @@ async function getStore(cwd: string): Promise<TaskStore> {
if (existing) return existing.store;
const boot = await createTaskStoreForBackend({ rootDir: projectRoot });
if (boot) {
storeCache.set(projectRoot, { store: boot.taskStore, shutdown: boot.shutdown });
return boot.taskStore;
}
// Legacy SQLite opt-out (FUSION_NO_EMBEDDED_PG=1). createTaskStoreForBackend
// returns null only in that case; the store still needs an explicit init().
const store = new TaskStore(projectRoot);
await store.init();
storeCache.set(projectRoot, { store });
return store;
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Agent tools cache only the
// PostgreSQL factory result; the removed SQLite opt-out is an explicit error.
storeCache.set(projectRoot, { store: boot.taskStore, shutdown: boot.shutdown });
return boot.taskStore;
}
/**
@@ -258,9 +252,15 @@ async function getStore(cwd: string): Promise<TaskStore> {
* one isolated PostgreSQL database with the agent tools without re-booting the
* backend per tool call. The entry carries no shutdown hook; tests own the
* injected store's lifecycle (the shared PG harness tears it down in afterAll).
* An optional shutdown owner supports lifecycle tests that must prove the host
* awaits an internally-owned factory result.
*/
export function __setCachedStoreForTesting(projectRoot: string, store: TaskStore): void {
storeCache.set(projectRoot, { store, external: true });
export function __setCachedStoreForTesting(
projectRoot: string,
store: TaskStore,
shutdown?: () => Promise<void>,
): void {
storeCache.set(projectRoot, shutdown ? { store, shutdown } : { store, external: true });
}
/** @internal Exposed so tests and the extension shutdown hook can close cached stores deterministically; not a public CLI API contract. */
@@ -290,13 +290,18 @@ export async function closeCachedStores(): Promise<void> {
function getFusionDir(cwd: string): string {
return join(resolveProjectRoot(cwd), ".fusion");
}
function requireProjectLayer(store: TaskStore, consumer: string) {
const layer = store.getAsyncLayer();
if (!layer) throw new Error(`${consumer} requires the project PostgreSQL AsyncDataLayer`);
return layer;
}
/*
FNXC:PostgresCutover 2026-07-04-00:00:
Agent tools must construct AgentStore in backend mode so agent data lives in PostgreSQL, not the removed SQLite runtime (VAL-REMOVAL-005). The asyncLayer is borrowed from the project's cached TaskStore (same connection pool), mirroring the TaskStore backend injection. The returned store is NOT pre-initialized — callers keep their existing `await agentStore.init()` (idempotent mkdir in backend mode).
*/
async function getAgentStore(cwd: string): Promise<AgentStore> {
const projectStore = await getStore(cwd);
return new AgentStore({ rootDir: getFusionDir(cwd), asyncLayer: projectStore.getAsyncLayer() ?? undefined });
return new AgentStore({ rootDir: getFusionDir(cwd), asyncLayer: requireProjectLayer(projectStore, "CLI AgentStore") });
}
function emitSecretAudit(
@@ -2397,8 +2402,8 @@ export default function kbExtension(pi: ExtensionAPI) {
if (decision.policy === "prompt") {
const cliLayer = store.getAsyncLayer();
const approvalStore = new ApprovalRequestStore(cliLayer ? null : store.getDatabase(), { asyncLayer: cliLayer });
const cliLayer = requireProjectLayer(store, "CLI secret approval store");
const approvalStore = new ApprovalRequestStore(null, { asyncLayer: cliLayer });
const dedupeKey = `secret-read:${resolvedScope}:${params.key}:${fnCtx.agentId ?? "unknown"}`;
const existing = await approvalStore.findLatestByDedupeKey({ requesterActorId: fnCtx.agentId ?? "user", taskId: fnCtx.taskId, dedupeKey });
const request = existing && existing.status === "pending"
@@ -3085,22 +3090,9 @@ export default function kbExtension(pi: ExtensionAPI) {
};
let drafts: MissionInterviewDraft[] = [];
if (includeDrafts) {
if (store.isBackendMode()) {
drafts = await store.getAsyncLayer()!.db.execute<MissionInterviewDraft>(
drizzleSql`SELECT id, title, status, updated_at AS "updatedAt" FROM project.ai_sessions WHERE type = 'mission_interview' AND status IN ('generating', 'awaiting_input', 'error', 'complete') AND COALESCE(archived, 0) = 0 ORDER BY updated_at DESC`,
);
} else {
drafts = store.getDatabase()
.prepare(
`SELECT id, title, status, updatedAt
FROM ai_sessions
WHERE type = 'mission_interview'
AND status IN ('generating', 'awaiting_input', 'error', 'complete')
AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`,
)
.all() as MissionInterviewDraft[];
}
drafts = await requireProjectLayer(store, "CLI mission drafts").db.execute<MissionInterviewDraft>(
drizzleSql`SELECT id, title, status, updated_at AS "updatedAt" FROM project.ai_sessions WHERE type = 'mission_interview' AND status IN ('generating', 'awaiting_input', 'error', 'complete') AND COALESCE(archived, 0) = 0 ORDER BY updated_at DESC`,
);
}
if (missions.length === 0 && drafts.length === 0) {
@@ -4492,8 +4484,8 @@ export default function kbExtension(pi: ExtensionAPI) {
}
if (policy.decision === "require-approval") {
const cliLayer2 = store.getAsyncLayer();
const approvalStore = new ApprovalRequestStore(cliLayer2 ? null : store.getDatabase(), { asyncLayer: cliLayer2 });
const cliLayer2 = requireProjectLayer(store, "CLI agent-create approval store");
const approvalStore = new ApprovalRequestStore(null, { asyncLayer: cliLayer2 });
const request = await approvalStore.create({
requester: { actorId: "user", actorType: "user", actorName: "CLI User" },
targetAction: { category: "agent_provisioning", action: "create", summary: `Create agent ${params.name} (${params.role})`, resourceType: "agent", resourceId: "", context: { tool: "fn_agent_create", params } },
@@ -4872,8 +4864,8 @@ export default function kbExtension(pi: ExtensionAPI) {
});
if (policy.decision === "require-approval") {
const cliLayer3 = store.getAsyncLayer();
const approvalStore = new ApprovalRequestStore(cliLayer3 ? null : store.getDatabase(), { asyncLayer: cliLayer3 });
const cliLayer3 = requireProjectLayer(store, "CLI agent-delete approval store");
const approvalStore = new ApprovalRequestStore(null, { asyncLayer: cliLayer3 });
const request = await approvalStore.create({
requester: { actorId: "user", actorType: "user", actorName: "CLI User" },
targetAction: { category: "agent_provisioning", action: "delete", summary: `Delete agent ${params.agent_id}`, resourceType: "agent", resourceId: params.agent_id, context: { tool: "fn_agent_delete", params } },
@@ -5574,6 +5566,10 @@ export default function kbExtension(pi: ExtensionAPI) {
dashboardProcess = null;
dashboardPort = null;
}
void closeCachedStores();
/*
FNXC:PostgresCliLifecycle 2026-07-14-22:38:
The session shutdown handler's returned promise is the host's teardown barrier. Await cache cleanup so every factory-owned PostgreSQL pool or embedded process is stopped before the host considers the extension session closed.
*/
await closeCachedStores();
});
}

View File

@@ -2,21 +2,13 @@
* FNXC:CliBoardMutation 2026-07-09-00:00:
* `fn task show`/`fn task move` (FN-7731, upstream #1976) open a `TaskStore`
* and call `getTask`/`moveTask` exactly once. If the engine or another agent
* holds a SQLite writer lock on `.fusion/fusion.db` at that instant, the
* call surfaces a raw `database is locked` error (or appears to hang until
* the DB layer's own bounded `busy_timeout`/lock-recovery window in
* packages/core/src/db.ts — DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 5s plus a short
* lock-recovery retry — finally gives up). That DB-level bound already
* prevents an unbounded hang at the SQLite layer, but it does not retry
* across separate `better-sqlite3`/node:sqlite statement calls, so a single
* unlucky read/write at the CLI surface still fails outright even though
* the lock typically clears within a second or two of normal engine
* activity.
* can collide with another PostgreSQL transaction. Serialization failures,
* deadlocks, and lock-not-available errors are transient and should receive a
* bounded command-level retry rather than surfacing immediately.
*
* This module adds a CLI-level retry ABOVE that bound: it retries a thunk
* only when the error is classified as a SQLite lock error (reusing
* `@fusion/core`'s `isSqliteLockError`, the same classifier the DB layer's
* own `runWithLockRecovery` uses, so CLI and DB lock detection never drift),
* only when the error is classified as PostgreSQL contention. The legacy
* SQLite classifier remains accepted for isolated compatibility tests,
* with exponential backoff capped by a total wall-clock deadline. Non-lock
* errors (not-found, invalid column, etc.) propagate immediately — they are
* never retried. On deadline exhaustion the command fails fast with a
@@ -75,6 +67,16 @@ export interface RetryOnLockContext {
action: string;
}
function isRetryableDatabaseContention(error: unknown): boolean {
if (isSqliteLockError(error)) return true;
if (!error || typeof error !== "object") return false;
const candidate = error as { code?: unknown; message?: unknown };
// PostgreSQL: serialization_failure, deadlock_detected, lock_not_available.
if (candidate.code === "40001" || candidate.code === "40P01" || candidate.code === "55P03") return true;
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
return message.includes("could not serialize access") || message.includes("deadlock detected") || message.includes("lock not available");
}
/**
* Run `operation`, retrying with bounded exponential backoff ONLY when the
* thrown error is a SQLite lock error (`isSqliteLockError`). Any other
@@ -94,7 +96,7 @@ export async function retryOnLock<T>(
try {
return await operation();
} catch (error) {
if (!isSqliteLockError(error)) {
if (!isRetryableDatabaseContention(error)) {
throw error;
}
@@ -102,7 +104,7 @@ export async function retryOnLock<T>(
if (now >= deadline) {
throw new LockRetryExhaustedError(
`Timed out after ${totalMs}ms waiting to ${context.action} for ${context.id}: ` +
`the board database stayed locked (the engine or another agent is writing). ` +
`the board database stayed contended (the engine or another agent is writing). ` +
`Retry the command, or raise the bound via FUSION_CLI_LOCK_RETRY_MS.`,
error,
);

View File

@@ -5,7 +5,7 @@
* for operating on tasks across multiple registered projects.
*/
import { TaskStore, createTaskStoreForBackend, type AsyncDataLayer, type RegisteredProject, CentralCore, GlobalSettingsStore, isValidSqliteDatabaseFile } from "@fusion/core";
import { createTaskStoreForBackend, type AsyncDataLayer, type RegisteredProject, type TaskStore, CentralCore, GlobalSettingsStore, hasProjectIdentity, isValidSqliteDatabaseFile } from "@fusion/core";
import { resolve, dirname, basename } from "node:path";
/** Project context for CLI operations */
@@ -24,6 +24,52 @@ export interface ProjectContext {
/** Cache of TaskStore instances by project ID to avoid re-initialization */
const storeCache = new Map<string, TaskStore>();
interface ProjectStoreOwner {
backendShutdown: () => Promise<void>;
central?: CentralCore;
closePromise?: Promise<void>;
}
const storeOwners = new WeakMap<TaskStore, ProjectStoreOwner>();
const closedProjectStores = new WeakSet<TaskStore>();
async function closeOwnedProjectStore(store: TaskStore): Promise<void> {
if (closedProjectStores.has(store)) return;
const owner = storeOwners.get(store);
if (!owner) {
await store.close();
closedProjectStores.add(store);
return;
}
if (!owner.closePromise) {
/*
FNXC:PostgresCliLifecycle 2026-07-14-19:10:
A layerless CentralCore can own the embedded postmaster that a subsequently-created project TaskStore only observes. Teardown must attempt both retained owners even if the first rejects. Failed cleanup remains retryable; only a completely successful attempt evicts ownership and marks the store closed.
*/
owner.closePromise = (async () => {
const failures: unknown[] = [];
try {
await owner.backendShutdown();
} catch (error) {
failures.push(error);
}
try {
await owner.central?.close();
} catch (error) {
failures.push(error);
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw new AggregateError(failures, "Failed to close project PostgreSQL owners");
})();
}
try {
await owner.closePromise;
storeOwners.delete(store);
closedProjectStores.add(store);
} catch (error) {
owner.closePromise = undefined;
throw error;
}
}
/**
* Resolve a project from explicit name flag, default project, or CWD detection.
@@ -31,7 +77,8 @@ const storeCache = new Map<string, TaskStore>();
* Resolution order:
* 1. If `projectNameFlag` provided: look up by name (case-insensitive) or ID (exact)
* 2. Else if default project set in global settings: use that project
* 3. Else: auto-detect from CWD by finding nearest `.fusion/fusion.db`
* 3. Else: auto-detect from CWD using `.fusion/project.json` (or a legacy
* SQLite database only as migration input)
*
* @param projectNameFlag - Optional explicit project name/ID from --project flag
* @param cwd - Current working directory for CWD detection (default: process.cwd())
@@ -45,6 +92,7 @@ export async function resolveProject(
): Promise<ProjectContext> {
const central = new CentralCore(globalDir);
await central.init();
let centralRetained = false;
try {
let project: RegisteredProject | undefined;
@@ -88,6 +136,12 @@ export async function resolveProject(
// For unregistered projects, use the path as the project ID
const projectId = isRegistered ? detected.id : detected.path;
const owner = storeOwners.get(store);
if (owner && !owner.central) {
owner.central = central;
centralRetained = true;
}
return {
projectId,
projectPath: detected.path,
@@ -98,6 +152,11 @@ export async function resolveProject(
}
const store = await getStoreForProject(project.id, project.path, globalDir);
const owner = storeOwners.get(store);
if (owner && !owner.central) {
owner.central = central;
centralRetained = true;
}
return {
projectId: project.id,
@@ -107,7 +166,7 @@ export async function resolveProject(
store,
};
} finally {
await central.close();
if (!centralRetained) await central.close();
}
}
@@ -170,7 +229,7 @@ export async function clearDefaultProject(globalDir?: string): Promise<void> {
/**
* Detect a project from the current working directory by walking up
* the directory tree looking for `.fusion/fusion.db`.
* the directory tree looking for a PostgreSQL-era project identity marker.
*
* @param cwd - Starting directory (typically process.cwd())
* @param central - Initialized CentralCore instance
@@ -185,9 +244,11 @@ export async function detectProjectFromCwd(
// Walk up the directory tree
while (true) {
// Check for fn database
const kbPath = resolve(currentDir, ".fusion", "fusion.db");
if (isValidSqliteDatabaseFile(kbPath)) {
// FNXC:ProjectIdentityMarker 2026-07-14-17:20: CWD discovery is marker-first;
// an openable fusion.db remains recognized only to migrate older projects.
const fusionDir = resolve(currentDir, ".fusion");
const legacyDbPath = resolve(fusionDir, "fusion.db");
if (hasProjectIdentity(fusionDir) || isValidSqliteDatabaseFile(legacyDbPath)) {
// Found a fn project - check if it's registered
const project = await central.getProjectByPath(currentDir);
if (project) {
@@ -271,8 +332,12 @@ export async function getStoreForProject(
/**
* Clear the store cache. Useful for testing or memory management.
*/
export function clearStoreCache(): void {
export async function clearStoreCache(): Promise<void> {
const stores = [...storeCache.values()];
storeCache.clear();
await Promise.allSettled(stores.map(async (store) => {
await closeOwnedProjectStore(store);
}));
}
export async function createLocalStore(
@@ -281,20 +346,15 @@ export async function createLocalStore(
): Promise<TaskStore> {
// FNXC:PostgresCutover 2026-07-04: route through createTaskStoreForBackend so
// standalone CLI commands (and resolveProject().store) boot PostgreSQL instead
// of the removed SQLite runtime. The factory returns null only on the
// FUSION_NO_EMBEDDED_PG=1 opt-out; in that case fall back to the legacy
// TaskStore, which still needs an explicit init().
// of the removed SQLite runtime.
// FNXC:PostgresCutover 2026-07-05-12:00: exported so CLI command
// catch-fallbacks (task/pr/backup/memory-backup/branch-group/mcp) boot their
// cwd-rooted store through the same factory instead of constructing a legacy
// SQLite TaskStore directly (its runtime throws in backend mode).
const boot = await createTaskStoreForBackend({ rootDir: projectPath, globalSettingsDir });
if (boot) {
return boot.taskStore;
}
const store = new TaskStore(projectPath, globalSettingsDir);
await store.init();
return store;
/* FNXC:PostgresCliLifecycle 2026-07-14-18:07: CLI project contexts return only TaskStore, so retain the factory owner handle in a WeakMap and release it whenever closeProjectStore closes that context. */
storeOwners.set(boot.taskStore, { backendShutdown: boot.shutdown });
return boot.taskStore;
}
/**
@@ -336,18 +396,24 @@ export async function getStore(
* must construct AgentStore in backend mode so agent data lives in PostgreSQL,
* not the removed SQLite runtime (VAL-REMOVAL-005). The asyncLayer is borrowed
* from the resolved project's TaskStore (same connection pool), mirroring the
* extension.ts getAgentStore injection. When no project resolves (unregistered
* cwd) the layer is null and AgentStore falls back to its layerless path.
* extension.ts getAgentStore injection. Resolution and PostgreSQL failures are
* surfaced to the command; a layerless SQLite AgentStore is never constructed.
*/
export async function resolveAgentStoreBase(
projectName?: string,
): Promise<{ rootDir: string; asyncLayer: AsyncDataLayer | null }> {
try {
const context = await resolveProject(projectName);
return { rootDir: context.projectPath, asyncLayer: context.store.getAsyncLayer() };
} catch {
return { rootDir: process.cwd(), asyncLayer: null };
): Promise<{ rootDir: string; asyncLayer: AsyncDataLayer; cleanup: () => Promise<void> }> {
/* FNXC:PostgresCliLifecycle 2026-07-14-19:10: Agent, message, and chat commands must surface project/PostgreSQL resolution failures and borrow a non-null layer only while retaining an explicit asynchronous owner cleanup. */
const context = await resolveProject(projectName);
const asyncLayer = context.store.getAsyncLayer();
if (!asyncLayer) {
await closeProjectStore(context);
throw new Error(`PostgreSQL AsyncDataLayer unavailable for ${context.projectPath}`);
}
return {
rootDir: context.projectPath,
asyncLayer,
cleanup: () => closeProjectStore(context),
};
}
/**
@@ -366,12 +432,7 @@ export async function resolveAgentStoreBase(
* another in-process caller already holds/closed the same cached instance.
*/
export async function closeProjectStore(context: ProjectContext): Promise<void> {
try {
await context.store.close();
} catch {
// Best-effort: an already-closed store (or one closed by a concurrent
// in-process caller) must not throw here.
}
await closeOwnedProjectStore(context.store);
if (storeCache.get(context.projectId) === context.store) {
storeCache.delete(context.projectId);
}

View File

@@ -14,13 +14,14 @@ import { createInterface } from "node:readline/promises";
import {
CentralCore,
createTaskStoreForBackend,
hasProjectIdentity,
isValidSqliteDatabaseFile,
readProjectIdentity,
writeProjectIdentity,
detectWorkspaceRepos,
saveWorkspaceConfig,
suggestTaskPrefix,
TaskStore,
type TaskStore,
type RegisteredProject,
} from "@fusion/core";
import { ProjectManager } from "@fusion/engine";
@@ -28,6 +29,7 @@ import { ProjectManager } from "@fusion/engine";
// Singleton instances for reuse across commands
let centralCoreInstance: CentralCore | null = null;
let projectManagerInstance: ProjectManager | null = null;
const resolvedProjectStores = new Map<TaskStore, () => Promise<void>>();
/**
* Error thrown when project resolution fails with actionable context.
@@ -117,8 +119,10 @@ export function findKbDir(startPath: string): string | null {
// Safety limit to prevent infinite loops
for (let i = 0; i < 100; i++) {
const dbPath = resolve(current, ".fusion", "fusion.db");
if (isValidSqliteDatabaseFile(dbPath)) {
const fusionDir = resolve(current, ".fusion");
// FNXC:ProjectIdentityMarker 2026-07-14-17:20: Project discovery uses the
// PG-neutral marker, with SQLite recognition retained only for migration.
if (hasProjectIdentity(fusionDir) || isValidSqliteDatabaseFile(resolve(fusionDir, "fusion.db"))) {
return current;
}
@@ -426,18 +430,13 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
* FNXC:PostgresCutover 2026-07-04: boot a project TaskStore through the
* PostgreSQL startup factory (embedded by default, external via DATABASE_URL)
* instead of a legacy SQLite TaskStore whose runtime was removed under
* VAL-REMOVAL-005. Returns the legacy store only on the FUSION_NO_EMBEDDED_PG=1
* opt-out. Shared by createResolvedProject and the onboarding init paths so
* VAL-REMOVAL-005. Shared by createResolvedProject and the onboarding init paths so
* every `fn project`/`fn init` store construction stays backend-first.
*/
async function createProjectStore(rootDir: string): Promise<TaskStore> {
const boot = await createTaskStoreForBackend({ rootDir });
if (boot) {
return boot.taskStore;
}
const store = new TaskStore(rootDir);
await store.init();
return store;
resolvedProjectStores.set(boot.taskStore, boot.shutdown);
return boot.taskStore;
}
/**
@@ -476,6 +475,11 @@ async function createResolvedProject(project: RegisteredProject): Promise<Resolv
* Call this on CLI exit to close database connections.
*/
export async function cleanupProjectResolution(): Promise<void> {
/* FNXC:PostgresProjectResolverLifecycle 2026-07-14-18:08: ResolvedProject exposes only its TaskStore, so module cleanup must retain and release every startup-factory ownership handle on signals and normal explicit cleanup. */
for (const [store, shutdown] of resolvedProjectStores) {
resolvedProjectStores.delete(store);
await shutdown().catch(() => undefined);
}
if (projectManagerInstance) {
// ProjectManager doesn't have a close method, but we should stop all runtimes
try {
@@ -552,7 +556,9 @@ export async function isProjectNameTaken(
* Validate that a path contains an initialized fn project (.fusion/ directory exists).
*/
export function isKbProject(path: string): boolean {
return isValidSqliteDatabaseFile(resolve(path, ".fusion", "fusion.db"));
const fusionDir = resolve(path, ".fusion");
return hasProjectIdentity(fusionDir)
|| isValidSqliteDatabaseFile(resolve(fusionDir, "fusion.db"));
}
/**
@@ -976,29 +982,46 @@ export async function getProjectsWithStatus(): Promise<
const projects = await central.listProjects();
const results = await Promise.all(
projects.map(async (project) => {
/*
* FNXC:PostgresProjectStatus 2026-07-14-23:02:
* Each status read owns a short-lived PostgreSQL pool. Bound fan-out so a large registry cannot open one pool per project simultaneously, while retaining input order and per-project soft failure semantics.
*/
const results = new Array<{
project: RegisteredProject;
runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started";
taskCount: number;
}>(projects.length);
let nextProjectIndex = 0;
const workerCount = Math.min(4, projects.length);
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextProjectIndex < projects.length) {
const projectIndex = nextProjectIndex++;
const project = projects[projectIndex];
const runtime = pm.getRuntime(project.id);
const runtimeStatus = runtime?.getStatus() ?? "not_started";
// Get task count from store
let taskCount = 0;
let shutdown: (() => Promise<void>) | undefined;
try {
const store = new (await import("@fusion/core")).TaskStore(project.path);
await store.init();
const tasks = await store.listTasks({ slim: true });
/* FNXC:PostgresProjectStatus 2026-07-14-18:42:
* Status aggregation must use the mandatory PostgreSQL startup factory.
* A bare TaskStore entered the removed SQLite fallback and also leaked
* its store because this read-only helper never retained ownership.
*/
const boot = await createTaskStoreForBackend({ rootDir: project.path, projectId: project.id });
shutdown = boot.shutdown;
const tasks = await boot.taskStore.listTasks({ slim: true });
taskCount = tasks.length;
} catch {
// If we can't read tasks, just report 0
} finally {
await shutdown?.().catch(() => undefined);
}
return { project, runtimeStatus, taskCount } as {
project: RegisteredProject;
runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started";
taskCount: number;
};
})
);
results[projectIndex] = { project, runtimeStatus, taskCount };
}
}));
return results;
}
@@ -1030,27 +1053,31 @@ export async function getProjectTaskCounts(
projectId: string,
store?: TaskStore
): Promise<Record<string, number>> {
const taskStore =
store ??
(await (async () => {
const central = await getCentralCore();
const project = await central.getProject(projectId);
if (!project) return undefined;
const s = new (await import("@fusion/core")).TaskStore(project.path);
await s.init();
return s;
})());
if (!taskStore) return {};
const tasks = await taskStore.listTasks({ slim: true });
const counts: Record<string, number> = {};
for (const task of tasks) {
counts[task.column] = (counts[task.column] || 0) + 1;
let shutdown: (() => Promise<void>) | undefined;
let taskStore = store;
if (!taskStore) {
const central = await getCentralCore();
const project = await central.getProject(projectId);
if (!project) return {};
/* FNXC:PostgresProjectStatus 2026-07-14-18:42:
* One-shot task counts own a PostgreSQL backend for exactly this read.
* Never construct the public TaskStore without an AsyncDataLayer.
*/
const boot = await createTaskStoreForBackend({ rootDir: project.path, projectId });
taskStore = boot.taskStore;
shutdown = boot.shutdown;
}
return counts;
try {
const tasks = await taskStore.listTasks({ slim: true });
const counts: Record<string, number> = {};
for (const task of tasks) {
counts[task.column] = (counts[task.column] || 0) + 1;
}
return counts;
} finally {
await shutdown?.().catch(() => undefined);
}
}
/**
@@ -1106,6 +1133,42 @@ export async function getStore(options?: { project?: string; cwd?: string }): Pr
return resolved.store;
}
/** A factory-owned project store whose backend lifecycle has one awaited release path. */
export interface ResolvedProjectStoreOwner {
readonly store: TaskStore;
close(): Promise<void>;
}
/**
* Resolve a project store together with the startup-factory ownership handle.
* Short-lived commands must prefer this over getStore() so success, errors, and
* requested CLI exits can await backend shutdown before returning control.
*/
export async function resolveProjectStore(
options?: { project?: string; cwd?: string },
): Promise<ResolvedProjectStoreOwner> {
/*
FNXC:PostgresProjectResolverLifecycle 2026-07-14-22:20:
One-shot CLI commands need an owner-aware handle for factory-created TaskStores. Releasing the handle removes it from module cleanup and awaits the exact startup-factory shutdown once, so commands do not depend on asynchronous process-exit hooks to flush PostgreSQL resources.
*/
const resolved = await resolveProject({
project: options?.project,
cwd: options?.cwd,
interactive: true,
});
let closed = false;
return {
store: resolved.store,
async close(): Promise<void> {
if (closed) return;
closed = true;
const shutdown = resolvedProjectStores.get(resolved.store);
resolvedProjectStores.delete(resolved.store);
await shutdown?.().catch(() => undefined);
},
};
}
// Export getStore as default for backward compatibility
export { getStore as default };

View File

@@ -92,6 +92,11 @@ const quarantinedCliTests: string[] = [
FN-7530 resolved the FN-7447 entry: RESCUE-by-split, not delete. The single dist-barrel recompilation test (unchanged assertions) moved to packages/cli/src/__tests__/extension-dist-barrel.test.ts; extension.test.ts is back in the default lane and its ~68 stable tests run again. The isolated file still stays quarantined here under its OWN fresh entry, because the root cause is loaded-lane CPU contention during vi.resetModules()/vi.importActual(dist barrel)/dynamic import() -- a property of that operation under 4-shard CI, not of file layout -- so splitting the file does not by itself make it safe to re-admit, and with only one test in the file there is no second call site to amortize a module-top-level rescue against. No testTimeout widening, retries, or worker/concurrency changes were made. Mirrors scripts/lib/test-quarantine.json; this isolated file's own 14-day deletion clock is due 2026-07-18.
*/
"src/__tests__/extension-dist-barrel.test.ts",
/*
FNXC:CliTests 2026-07-14-23:05:
project-context.test.ts lost its embedded PostgreSQL postmaster and timed out only in a combined focused lane, then passed 12/12 in isolation. Quarantine the loaded-lane cluster interference on sight instead of widening hooks, retrying, or weakening lifecycle assertions; mirrored in scripts/lib/test-quarantine.json.
*/
"src/__tests__/project-context.test.ts",
];
export default defineConfig({

View File

@@ -132,7 +132,8 @@ describe("LocalRuntimeManager", () => {
watch: vi.fn(async () => undefined),
close: vi.fn(),
getPluginStore: vi.fn(() => engineMocks.pluginStoreInstance),
getDatabase: vi.fn(() => ({ runPluginSchemaInits: engineMocks.runPluginSchemaInits })),
runPluginSchemaInits: engineMocks.runPluginSchemaInits,
getAsyncLayer: vi.fn(() => ({ projectId: "project-1" } as never)),
};
beforeEach(() => {
@@ -467,6 +468,8 @@ describe("LocalRuntimeManager", () => {
await manager.startLocal();
expect(engineMocks.CentralCore).toHaveBeenCalledWith(undefined, { asyncLayer: store.getAsyncLayer() });
expect(engineMocks.seedDashboardProviders).toHaveBeenCalledWith(
expect.objectContaining({ authStorage: expect.anything(), modelRegistry: expect.anything() }),
);
@@ -495,6 +498,9 @@ describe("LocalRuntimeManager", () => {
return server as unknown as Server;
}),
});
engineMocks.pluginLoaderInstance.getPluginSchemaInitHooks.mockReturnValueOnce([
{ pluginId: "fusion-plugin-even-realities-glasses", hook: vi.fn() },
]);
const manager = new LocalRuntimeManager({
rootDir: "/repo",
@@ -509,6 +515,10 @@ describe("LocalRuntimeManager", () => {
expect.objectContaining({ pluginStore: engineMocks.pluginStoreInstance, taskStore: expect.anything() }),
);
expect(engineMocks.pluginLoaderInstance.loadAllPlugins).toHaveBeenCalledTimes(1);
/* FNXC:DesktopPluginSchema 2026-07-14-17:50: Desktop schema initialization goes through TaskStore and therefore cannot reach backend getDatabase(). */
expect(engineMocks.runPluginSchemaInits).toHaveBeenCalledWith([
expect.objectContaining({ pluginId: "fusion-plugin-even-realities-glasses" }),
]);
expect(engineMocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({

View File

@@ -41,7 +41,6 @@ const mocks = vi.hoisted(() => {
getPluginSchemaInitHooks: vi.fn(() => []),
};
const runPluginSchemaInits = vi.fn(async () => undefined);
const database = { runPluginSchemaInits };
const PluginLoader = vi.fn(function () {
return pluginLoaderInstance;
});
@@ -59,8 +58,10 @@ const mocks = vi.hoisted(() => {
watch: vi.fn(async () => undefined),
close: vi.fn(),
getPluginStore: vi.fn(() => pluginStoreInstance),
getDatabase: vi.fn(() => database),
runPluginSchemaInits,
getAsyncLayer: vi.fn(() => ({ projectId: "project-1" } as never)),
};
const backendShutdown = vi.fn(async () => store.close());
const centralCore = {
init: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
@@ -88,7 +89,7 @@ const mocks = vi.hoisted(() => {
watch = store.watch;
close = store.close;
getPluginStore = store.getPluginStore;
getDatabase = store.getDatabase;
getAsyncLayer = store.getAsyncLayer;
}
const server = Object.assign(new SimpleEmitter(), {
@@ -121,6 +122,8 @@ const mocks = vi.hoisted(() => {
return {
TaskStore,
createTaskStoreForBackend: vi.fn(async () => ({ taskStore: store, shutdown: backendShutdown })),
backendShutdown,
CentralCore,
PluginLoader,
ProjectEngineManager,
@@ -143,6 +146,7 @@ const mocks = vi.hoisted(() => {
vi.mock("@fusion/core", () => ({
TaskStore: mocks.TaskStore,
createTaskStoreForBackend: mocks.createTaskStoreForBackend,
CentralCore: mocks.CentralCore,
PluginLoader: mocks.PluginLoader,
ensureBundledPluginInstalled: mocks.ensureBundledPluginInstalled,
@@ -175,6 +179,7 @@ describe("DesktopLocalServerManager", () => {
expect(manager.getPort()).toBe(4545);
expect(manager.getState().status).toBe("ready");
expect(mocks.engineManager.startAll).toHaveBeenCalledTimes(1);
expect(mocks.CentralCore).toHaveBeenCalledWith(undefined, { asyncLayer: mocks.store.getAsyncLayer() });
// No auto-registration of the runtime root; the primary engine is the first existing project.
expect(mocks.centralCore.registerProject).not.toHaveBeenCalled();
expect(mocks.engineManager.ensureEngine).toHaveBeenCalledWith("project-1");
@@ -298,6 +303,9 @@ describe("DesktopLocalServerManager", () => {
it("wires PluginStore + PluginLoader into createServer (FN-7623)", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
mocks.pluginLoaderInstance.getPluginSchemaInitHooks.mockReturnValueOnce([
{ pluginId: "fusion-plugin-even-realities-glasses", hook: vi.fn() },
]);
await manager.start();
@@ -307,6 +315,9 @@ describe("DesktopLocalServerManager", () => {
expect.objectContaining({ pluginStore: mocks.pluginStoreInstance, taskStore: expect.anything() }),
);
expect(mocks.pluginLoaderInstance.loadAllPlugins).toHaveBeenCalledTimes(1);
expect(mocks.runPluginSchemaInits).toHaveBeenCalledWith([
expect.objectContaining({ pluginId: "fusion-plugin-even-realities-glasses" }),
]);
expect(mocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({

View File

@@ -9,19 +9,8 @@ const fusionAliases = {
"@fusion/engine": resolve(__dirname, "../engine/src/index.ts"),
};
/*
FNXC:DesktopTestQuarantine 2026-06-25-14:15:
The SQLite-to-PostgreSQL cutover (feature quarantine-sqlite-internals-tests, retry session)
quarantines local-server.test.ts: the desktop local-server now imports createTaskStoreForBackend
from @fusion/core but the test's @fusion/core mock does not expose it
([vitest] No "createTaskStoreForBackend" export is defined on the "@fusion/core" mock).
Confirmed failing on clean baseline (stash + rerun, 1 failed | 23 passed). Quarantined on sight
per AGENTS.md so verify:workspace goes green. Rescue requires updating the mock to expose
createTaskStoreForBackend. Mirrored in scripts/lib/test-quarantine.json.
*/
const quarantinedDesktopTests: string[] = [
"src/__tests__/local-server.test.ts",
];
/* FNXC:DesktopTestQuarantine 2026-07-14-19:10: Restore local-server.test.ts after fixing its startup-factory mock. The suite exercises real desktop startup, rollback, provider, plugin, and PostgreSQL ownership regressions, and no quarantine ledger entry remains. */
const quarantinedDesktopTests: string[] = [];
export default defineConfig({
resolve: {

View File

@@ -6,10 +6,10 @@ import path from "node:path";
import { tsImport } from "tsx/esm/api";
import { composeTransitionEvidence } from "../backfill-fn-4441-transition-evidence.mjs";
async function loadTaskStore() {
const moduleUrl = new globalThis.URL("../../packages/core/src/store.ts", import.meta.url).href;
async function loadBackendFactory() {
const moduleUrl = new globalThis.URL("../../packages/core/src/postgres/startup-factory.ts", import.meta.url).href;
const mod = await tsImport(moduleUrl, import.meta.url);
return mod.TaskStore;
return mod.createTaskStoreForBackend;
}
test("composeTransitionEvidence includes required evidence fields", () => {
@@ -50,9 +50,14 @@ test("composeTransitionEvidence includes required evidence fields", () => {
});
test("TaskStore upsertTaskDocument increments revision and round-trips latest content", async () => {
const TaskStore = await loadTaskStore();
const createTaskStoreForBackend = await loadBackendFactory();
const projectRoot = mkdtempSync(path.join(os.tmpdir(), "fn-4441-transition-evidence-"));
const store = new TaskStore(projectRoot, undefined, { inMemoryDb: true });
/* FNXC:PostgresOperationalScriptTests 2026-07-14-18:44: Script integration coverage must exercise the same authoritative PostgreSQL bootstrap as the backfill instead of constructing the removed in-memory SQLite store. */
const boot = await createTaskStoreForBackend({
rootDir: projectRoot,
embeddedDataDir: path.join(projectRoot, ".embedded-pg"),
});
const store = boot.taskStore;
try {
await store.createTaskWithReservedId({ description: "seed" }, { taskId: "FN-4441" });
@@ -75,7 +80,7 @@ test("TaskStore upsertTaskDocument increments revision and round-trips latest co
assert.equal(doc?.revision, 2);
assert.equal(doc?.content, "second");
} finally {
await store.close();
await boot.shutdown();
rmSync(projectRoot, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import test from "node:test";
import { hasLocalProjectMigrationInput } from "../lib/start-local-project.mjs";
test("local startup recognizes project identity and legacy migration input", async (t) => {
const root = await mkdtemp(join(tmpdir(), "fusion-start-local-project-"));
t.after(() => rm(root, { recursive: true, force: true }));
await mkdir(join(root, ".fusion"));
assert.equal(hasLocalProjectMigrationInput(root), false);
await writeFile(join(root, ".fusion", "fusion.db"), "");
assert.equal(hasLocalProjectMigrationInput(root), true);
await rm(join(root, ".fusion", "fusion.db"));
const db = new DatabaseSync(join(root, ".fusion", "fusion.db"));
db.exec("CREATE TABLE migration_input (id INTEGER PRIMARY KEY)");
db.close();
assert.equal(hasLocalProjectMigrationInput(root), true);
await rm(join(root, ".fusion", "fusion.db"));
await writeFile(join(root, ".fusion", "fusion.db"), "malformed legacy input");
assert.equal(hasLocalProjectMigrationInput(root), false);
await rm(join(root, ".fusion", "fusion.db"));
await mkdir(join(root, ".fusion", "fusion.db"));
assert.equal(hasLocalProjectMigrationInput(root), false);
await rm(join(root, ".fusion", "fusion.db"), { recursive: true });
await writeFile(join(root, ".fusion", "project.json"), "{}");
assert.equal(hasLocalProjectMigrationInput(root), true);
});

View File

@@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { tsImport } from "tsx/esm/api";
import { openBackend } from "./lib/backend-db.mjs";
export function composeTransitionEvidence({
mergeRetries,
@@ -48,15 +48,10 @@ function getBranchDisposition() {
return "not present locally or on origin refs";
}
async function loadTaskStore() {
const moduleUrl = new globalThis.URL("../packages/core/src/store.ts", import.meta.url).href;
const mod = await tsImport(moduleUrl, import.meta.url);
return mod.TaskStore;
}
export async function runBackfill() {
const TaskStore = await loadTaskStore();
const store = new TaskStore(process.cwd());
/* FNXC:PostgresOperationalScripts 2026-07-14-18:20: Evidence backfills must write through the authoritative PostgreSQL TaskStore and close its backend lifecycle. */
const backend = await openBackend(process.cwd());
const store = backend.store;
try {
const targetTask = await store.getTask("FN-4441");
const preResolution = await store.getTaskDocument("FN-4450", "resolution");
@@ -116,7 +111,7 @@ export async function runBackfill() {
return { writeResult, readBack };
} finally {
await store.close();
await backend.shutdown();
}
}

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { openBackend } from "./lib/backend-db.mjs";
function createSummary() {
return { total_input: 0, total_cached: 0, total_cache_write: 0, total_output: 0, n_tasks: 0, hit_ratio: 0 };
@@ -54,24 +55,33 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
const asJson = argv.includes("--json");
const projectDir = process.cwd();
const { taskStore, agentStore, isEphemeralAgent } = deps.stores ?? (await (async () => {
const { TaskStore, AgentStore, isEphemeralAgent } = await import("../packages/core/dist/index.js");
const store = new TaskStore(projectDir);
await store.init();
const aStore = new AgentStore({ rootDir: store.getFusionDir() });
await aStore.init();
return { taskStore: store, agentStore: aStore, isEphemeralAgent };
})());
let backend;
try {
const { taskStore, agentStore, isEphemeralAgent } = deps.stores ?? (await (async () => {
backend = await openBackend(projectDir);
const { AgentStore, isEphemeralAgent } = backend.core;
const aStore = new AgentStore({
rootDir: backend.store.getFusionDir(),
taskStore: backend.store,
asyncLayer: backend.asyncLayer,
});
await aStore.init();
return { taskStore: backend.store, agentStore: aStore, isEphemeralAgent };
})());
const result = await collectCacheStats({ taskStore, agentStore, isEphemeralAgent });
if (asJson) {
console.log(JSON.stringify(result, null, 2));
/* FNXC:PostgresOperationalScripts 2026-07-14-18:18: Operator reports must read the authoritative PostgreSQL store and release embedded backend ownership after collection. */
const result = await collectCacheStats({ taskStore, agentStore, isEphemeralAgent });
if (asJson) {
console.log(JSON.stringify(result, null, 2));
return 0;
}
printTable("Cache stats by role", result.byRole);
printTable("Cache stats by permanent agent", result.byAgent);
return 0;
} finally {
await backend?.shutdown();
}
printTable("Cache stats by role", result.byRole);
printTable("Cache stats by permanent agent", result.byAgent);
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@@ -63,18 +63,12 @@ async function importCore() {
* - `shutdown` — releases the pool and stops an embedded cluster this boot
* started. Always call it in `finally`.
*
* Throws when the factory opts out (FUSION_NO_EMBEDDED_PG=1): these scripts
* must never fall back to the removed SQLite runtime.
* Throws when PostgreSQL cannot start. These scripts must never fall back to
* the removed SQLite runtime.
*/
export async function openBackend(rootDir = process.cwd()) {
const core = await importCore();
const boot = await core.createTaskStoreForBackend({ rootDir });
if (!boot) {
throw new Error(
"PostgreSQL backend unavailable (FUSION_NO_EMBEDDED_PG=1 opt-out is set). " +
"This script requires the PostgreSQL backend; the SQLite runtime was removed.",
);
}
const asyncLayer = boot.taskStore.getAsyncLayer();
if (!asyncLayer) {
await boot.shutdown().catch(() => {});

View File

@@ -0,0 +1,35 @@
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
/**
* FNXC:LocalStartupPostgresMigration 2026-07-14-22:25:
* Local startup recognizes both the PostgreSQL-era identity marker and a valid legacy SQLite database. Legacy input must pass the canonical read-only SQLite probe so malformed paths do not suppress initialization; an intentional zero-byte bootstrap file remains valid migration input.
*/
export function hasLocalProjectMigrationInput(rootDir) {
return existsSync(resolve(rootDir, ".fusion/project.json"))
|| isValidLegacySqliteInput(resolve(rootDir, ".fusion/fusion.db"));
}
function isValidLegacySqliteInput(dbPath) {
if (!existsSync(dbPath)) return false;
try {
const stats = statSync(dbPath);
if (!stats.isFile()) return false;
if (stats.size === 0) return true;
} catch {
return false;
}
let db = null;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
db.prepare("PRAGMA schema_version").get();
return true;
} catch {
return false;
} finally {
db?.close();
}
}

View File

@@ -185,6 +185,11 @@
"file": "packages/dashboard/src/__tests__/routes-system.test.ts",
"reason": "PostgreSQL maintainability verification on 2026-07-14 observed the CPU sampling assertion near line 500 receive 10 when 30 was expected during the local file-scoped `pnpm --filter @fusion/dashboard exec vitest run packages/dashboard/src/__tests__/routes-system.test.ts` run. Archived local-run evidence: https://github.com/Runfusion/Fusion/pull/2109#discussion_r3584473782. The unrelated sampling result is timing/load-sensitive, so quarantine the file on sight instead of changing its timeout, retries, or assertion. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-07-14"
},
{
"file": "packages/cli/src/__tests__/project-context.test.ts",
"reason": "PR #2110 feedback verification on 2026-07-14 observed the embedded PostgreSQL postmaster exit unexpectedly in a combined file-scoped CLI run, followed by ECONNREFUSED, 10s hook timeouts, and the subprocess guard; the unchanged suite passed 12/12 immediately in isolation. Archived local-run evidence: https://github.com/Runfusion/Fusion/pull/2110#issuecomment-4977406152. The loaded-lane embedded-cluster interference is timing/resource-sensitive, so quarantine the file on sight instead of widening timeouts, adding retries, or weakening assertions. Mirrored in packages/cli/vitest.config.ts quarantinedCliTests.",
"quarantinedAt": "2026-07-14"
}
]
}

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import path from "node:path";
import { openBackend } from "./lib/backend-db.mjs";
import process from "node:process";
export const TASK_ID = "FN-3909";
@@ -163,16 +164,15 @@ export async function runReconciliation({ store, projectRoot, dryRun = true } =
export async function main(argv = process.argv.slice(2), deps = {}) {
const dryRun = !argv.includes("--apply");
const projectRoot = path.resolve(readFlagValue(argv, "--project-root") ?? process.cwd());
const store = deps.store ?? (await (async () => {
const { TaskStore } = await import("../packages/core/dist/index.js");
const taskStore = new TaskStore(projectRoot);
await taskStore.init();
return taskStore;
})());
const result = await runReconciliation({ store, projectRoot, dryRun });
console.log(JSON.stringify(result, null, 2));
return result;
const backend = deps.store ? undefined : await openBackend(projectRoot);
try {
/* FNXC:PostgresOperationalScripts 2026-07-14-18:18: Historical reconciliation utilities must mutate the live PostgreSQL task store, never a stale local SQLite file. */
const result = await runReconciliation({ store: deps.store ?? backend.store, projectRoot, dryRun });
console.log(JSON.stringify(result, null, 2));
return result;
} finally {
await backend?.shutdown();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node
import process from "node:process";
import { openBackend } from "./lib/backend-db.mjs";
const DEFAULT_NOTE = "FN-4000 reconciliation: cleared stale transient failure state using TaskStore done-normalization so database and task JSON remain synchronized.";
@@ -64,20 +65,21 @@ function readFlagValue(argv, flag) {
export async function main(argv = process.argv.slice(2), deps = {}) {
const dryRun = !argv.includes("--apply");
const projectDir = readFlagValue(argv, "--project-dir") ?? process.cwd();
const store = deps.store ?? (await (async () => {
const { TaskStore } = await import("../packages/core/dist/index.js");
const taskStore = new TaskStore(projectDir);
await taskStore.init();
return taskStore;
})());
const backend = deps.store ? undefined : await openBackend(projectDir);
const store = deps.store ?? backend.store;
const noteByTaskId = {
"FN-3990": "FN-4000 reconciliation: cleared stale failed-state metadata after shipped lineage work landed in b89471aa5 and dashboard/doc follow-through completed in FN-3998.",
};
const result = await runReconciliation({ store, dryRun, noteByTaskId });
console.log(JSON.stringify({ dryRun, ...result }, null, 2));
return 0;
try {
/* FNXC:PostgresOperationalScripts 2026-07-14-18:18: Consistency reconciliation must inspect and repair the authoritative PostgreSQL rows. */
const result = await runReconciliation({ store, dryRun, noteByTaskId });
console.log(JSON.stringify({ dryRun, ...result }, null, 2));
return 0;
} finally {
await backend?.shutdown();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import process from "node:process";
import { openBackend } from "./lib/backend-db.mjs";
export const RESTORATIONS = [
{ id: "FN-3794", canonicalSha: "7d20a348d82320bc57310169aaa2d3b3f0d5a946" },
@@ -133,16 +134,15 @@ export async function runRestoration({ store, git, restorations = RESTORATIONS,
export async function main(argv = process.argv.slice(2), deps = {}) {
const dryRun = !argv.includes("--apply");
const git = deps.git ?? createGitHelpers(process.cwd());
let store = deps.store;
if (!store) {
const { TaskStore } = await import("../packages/core/dist/index.js");
store = new TaskStore(process.cwd());
await store.init();
const backend = deps.store ? undefined : await openBackend(process.cwd());
try {
/* FNXC:PostgresOperationalScripts 2026-07-14-18:18: Merge-SHA restoration targets the authoritative PostgreSQL task history. */
const output = await runRestoration({ store: deps.store ?? backend.store, git, dryRun });
console.log(JSON.stringify(output.results, null, 2));
return output.hadValidationErrors ? 1 : 0;
} finally {
await backend?.shutdown();
}
const output = await runRestoration({ store, git, dryRun });
console.log(JSON.stringify(output.results, null, 2));
return output.hadValidationErrors ? 1 : 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@@ -14,6 +14,7 @@ import { existsSync, readFileSync } from "node:fs";
import net from "node:net";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { hasLocalProjectMigrationInput } from "./lib/start-local-project.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
@@ -205,8 +206,9 @@ function projectNameFromPackage() {
}
function ensureProjectInitialized() {
if (existsSync(resolve(repoRoot, ".fusion/fusion.db"))) {
ok("Project database exists");
if (hasLocalProjectMigrationInput(repoRoot)) {
/* FNXC:LocalStartupPostgresMigration 2026-07-14-21:20: A pre-cutover `.fusion/fusion.db` is valid migration input even without the newer project identity marker; local startup must preserve it for project registration instead of classifying the repository as uninitialized. */
ok("Project marker or legacy migration input exists");
return;
}