fix(FN-7952): establish PostgreSQL core authority (#2108)

## Summary

Fusion’s core runtime now treats PostgreSQL as the authoritative
metadata store without leaving current CLI, dashboard, desktop, or
engine composition roots uncompilable between stack layers. This is the
99-file foundation for the larger cutover: subsequent PRs migrate the
remaining consumers, plugins, and operator surfaces.

## Design decisions

- Runtime store construction fails closed when an asynchronous
PostgreSQL layer is unavailable; SQLite remains readable only at
explicit migration and identity-recovery boundaries.
- Project ownership is enforced across active, archived, workflow,
mission, analytics, and plugin-schema data.
- The small set of cross-package files in this layer are
compatibility-critical call sites required for a green intermediate
commit, not the complete consumer migration.
- Schema migration 0008 remains assigned to session-advisor state from
current `main`; mission lineage idempotency advances to 0009 so neither
invariant can be skipped.

## Validation

- All affected package typechecks pass: Core, Engine, Dashboard, CLI,
and Desktop.
- `pnpm test:gate` passes: 478 tests across the engine gate, PostgreSQL
core gate, and CLI workflow shape.
- The PR changes exactly 99 files.

## Stack

This is the base PR. Engine/dashboard, CLI/desktop/ops, plugins, and
docs/release follow as stacked PRs, each below 100 changed files.

Related: #2105


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

## Summary by CodeRabbit

* **New Features**
* PostgreSQL is now the standard runtime backend, with embedded
PostgreSQL enabled by default.
* Added project-scoped storage for tasks, archives, chat sessions,
missions, knowledge pages, and operational data.
* Improved archived-task search, filtering, pagination, and restoration.
* Added safer plugin schema initialization with validation and project
isolation.
* Added PostgreSQL-backed workflow, mission, validator, and dashboard
capabilities.

* **Bug Fixes**
  * Improved startup timeout cancellation and resource cleanup.
* Prevented cross-project data access and phantom reservation cleanup
errors.
* Ensured archived tasks remain read-only and asynchronous writes
complete reliably.
  * Retired SQLite opt-out settings with clear startup errors.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-14 22:13:30 -07:00
committed by GitHub
parent e97081fb77
commit 2e4fcfcaea
99 changed files with 7026 additions and 5166 deletions

View File

@@ -544,28 +544,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
try {
/*
FNXC:PluginPostgresSchema 2026-07-14-21:48:
PluginLoader initializes each plugin's schema before publishing that plugin as loaded. Daemon startup must not replay every loaded schema contract as a second batch after loadAllPlugins.
*/
const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
try {
/*
* FNXC:SqliteFinalRemoval 2026-06-25-16:25:
* Skip SQLite-specific plugin schema init in backend mode (PostgreSQL
* uses Drizzle migrations for schema management).
*/
if (store.isBackendMode()) {
console.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)");
} else {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
}
} catch (err) {
console.error(
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
);
}
}
} catch (err) {
console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
@@ -941,14 +925,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
centralCore = null;
}
}
let localNodeId: string | undefined;
try {
if (centralCore) {
const nodes = await centralCore.listNodes();
const localNode = nodes.find((node) => node.type === "local");
if (localNode) {
localNodeId = localNode.id;
await centralCore.updateNode(localNode.id, { status: "online" });
}
}
@@ -993,13 +974,32 @@ export async function runDaemon(opts: DaemonOptions = {}) {
if (shuttingDown) return;
shuttingDown = true;
// Stop all project engines uniformly
/*
FNXC:PostgresResourceLifecycle 2026-07-14-21:48:
CentralCore adopts an engine TaskStore layer but retains ownership of its original embedded backend lifecycle. Persist the local-node offline state while the adopted pool is live, then stop engine-owned stores, and only then close CentralCore so its retained backend lifecycle cannot terminate PostgreSQL under a live engine.
*/
if (centralCore) {
try {
await centralCore.markLocalNodeOffline();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[daemon] Failed to set local node offline: ${message}`);
}
}
// Stop all project engines uniformly; their runtimes own their TaskStores.
if (hybridExecutor) {
await hybridExecutor.shutdown();
}
await engineManager.stopAll();
/*
FNXC:PostgresResourceLifecycle 2026-07-14-22:07:
Preserve the command-level TaskStore close barrier before CentralCore releases its retained backend. Runtime shutdown normally closes this store first; the idempotent explicit close also covers partial-start and test-owned runtimes.
*/
await store.close();
// Stop peer exchange service
if (peerExchangeService) {
try {
@@ -1010,15 +1010,6 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
}
if (centralCore && localNodeId) {
try {
await centralCore.updateNode(localNodeId, { status: "offline" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[daemon] Failed to set local node offline: ${message}`);
}
}
if (centralCore) {
await centralCore.close().catch(() => {
// best-effort
@@ -1032,7 +1023,6 @@ export async function runDaemon(opts: DaemonOptions = {}) {
// best-effort
}
store.close();
process.exit(signal ? (SIGNAL_EXIT_CODES[signal] ?? 128) : 0);
};

View File

@@ -6,7 +6,7 @@ import { stat, readdir, readFile as fsReadFile } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
TaskStore,
type TaskStore,
AutomationStore,
CentralCore,
AgentStore,
@@ -773,6 +773,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// (they're assigned after initialization, but the variables exist from the start).
// prefer-const disabled: callbacks close over these identifiers before the
// single assignment below, which requires `let` even though no reassignment occurs.
// eslint-disable-next-line prefer-const
let store: TaskStore | undefined;
// eslint-disable-next-line prefer-const
let agentStore: AgentStore | undefined;
@@ -877,28 +878,25 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// FNXC:BackendFlip 2026-06-26-14:40:
// Consult the startup factory to boot a PostgreSQL-backed TaskStore. Post
// default-flip: the factory boots embedded PG by default when DATABASE_URL
// is unset, external PG when DATABASE_URL is set, and returns null only
// when the operator opted out via FUSION_NO_EMBEDDED_PG=1 (legacy SQLite
// path). When it returns null, the legacy SQLite path runs unchanged. The
// is unset and external PG when DATABASE_URL is set. The
// backend shutdown handle is captured so the dashboard teardown path can
// release the pool / stop an embedded cluster; it is invoked via the
// existing store.close() (which closes the AsyncDataLayer) plus the
// dashboardBackendShutdown
// registered below for embedded-cluster teardown.
let dashboardBackendShutdown: (() => Promise<void>) | undefined;
const dashboardBackendBoot = await createTaskStoreForBackend({ rootDir: cwd });
if (dashboardBackendBoot) {
store = dashboardBackendBoot.taskStore;
dashboardBackendShutdown = dashboardBackendBoot.shutdown;
} else {
store = new TaskStore(cwd);
}
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Dashboard runtime storage is
// PostgreSQL-only; factory failure is surfaced instead of creating a dead store.
store = dashboardBackendBoot.taskStore;
const dashboardBackendShutdown = dashboardBackendBoot.shutdown;
const dashboardLayer = store.getAsyncLayer();
if (!dashboardLayer) throw new Error("Dashboard runtime requires the project PostgreSQL AsyncDataLayer");
// FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:05:
// Propagate the backend mode (asyncLayer) from the resolved TaskStore so
// AutomationStore does not construct a SQLite file under PostgreSQL. The
// `?? undefined` coerces `AsyncDataLayer | null` to the optional option
// shape used by the other satellite stores.
const automationStore = new AutomationStore(cwd, { asyncLayer: store.getAsyncLayer() ?? undefined });
const automationStore = new AutomationStore(cwd, { asyncLayer: dashboardLayer });
// CentralCore.init() is independent of store inits — start it early so it
// overlaps with plugin loading and extension resolution instead of running
@@ -916,7 +914,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// kanban board and all dashboard UI flows.
const centralCoreInitPromise = !noEngine
? (async () => {
const core = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() ?? undefined });
const core = new CentralCore(undefined, { asyncLayer: dashboardLayer });
try { await core.init(); } catch { /* non-fatal — fallback defaults */ }
return core;
})()
@@ -936,13 +934,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
};
// TaskStore / AutomationStore / PluginStore / AgentStore all open the SAME
// .fusion/fusion.db file and run addColumnIfMissing migrations (a TOCTOU
// `hasColumn` → ALTER pattern with no per-process lock). node:sqlite's
// DatabaseSync is synchronous, so Promise.all on these gives no real
// parallelism anyway — explicit sequencing keeps the schema-migration race
// from triggering if any init() body ever introduces an `await` between
// hasColumn and ALTER TABLE.
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Initialize the PostgreSQL-backed
// store and satellite adapters in dependency order so each receives the live
// AsyncDataLayer before watchers and engines begin dispatching work.
await phaseTime("store.init", () => store.init());
await phaseTime("automationStore.init", () => automationStore.init());
const pluginStore = store.getPluginStore();
@@ -956,7 +950,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// any getter touches `this.db`. Mirrors the AutomationStore fix on line ~893
// (VAL-CROSS-008 dashboard boot on embedded PostgreSQL). The `?? undefined`
// coerces `AsyncDataLayer | null` to the optional option shape.
agentStore = new AgentStore({ rootDir: store.getFusionDir(), asyncLayer: store.getAsyncLayer() ?? undefined });
agentStore = new AgentStore({ rootDir: store.getFusionDir(), asyncLayer: dashboardLayer });
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.initializingAgentStore);
await phaseTime("agentStore.init", () => agentStore!.init());
// store.watch() is filesystem-watcher setup — no DB schema work, safe to
@@ -1004,13 +998,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
projectStore = store;
} else {
const boot = await createTaskStoreForBackend({ rootDir: projectPath });
if (boot) {
projectStore = boot.taskStore;
projectStoreShutdowns.set(projectPath, boot.shutdown);
} else {
projectStore = new TaskStore(projectPath);
await projectStore.init();
}
projectStore = boot.taskStore;
projectStoreShutdowns.set(projectPath, boot.shutdown);
}
projectStores.set(projectPath, projectStore);
return projectStore;
@@ -1175,7 +1164,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
event: string | symbol;
handler: (...args: any[]) => void;
}> = [];
const disposeCallbacks: Array<() => void> = [];
const disposeCallbacks: Array<() => Promise<void> | void> = [];
let disposed = false;
let shutdownInProgress = false;
@@ -1451,21 +1440,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
try {
/*
* FNXC:SqliteFinalRemoval 2026-06-25-16:25:
* Skip SQLite-specific plugin schema init in backend mode (PostgreSQL
* uses Drizzle migrations for schema management).
*/
if (store.isBackendMode()) {
logSink.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)");
} else {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
}
/* FNXC:PluginPostgresSchema 2026-07-14-17:30: Dashboard startup materializes runtime-loaded plugin schemas through the backend-aware TaskStore contract instead of skipping PostgreSQL hooks. */
await store.runPluginSchemaInits(schemaHooks);
} catch (err) {
logSink.log(
`Schema initialization failed: ${err instanceof Error ? err.message : err}`,
"plugins",
);
throw err;
}
}
} catch (err) {
@@ -1924,7 +1906,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
})
: undefined;
function dispose(): void {
async function disposeAsync(): Promise<void> {
if (disposed) return;
disposed = true;
@@ -1945,29 +1927,37 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// after q" regression. We are exiting; drop teardown output instead.
// FUSION_DEBUG_SHUTDOWN still surfaces per-step timing on stderr.
logSink.silence();
void tui.stop();
await tui.stop();
}
for (const { target, event, handler } of handlers) {
target.off(event, handler);
}
handlers.length = 0;
for (const callback of disposeCallbacks.splice(0)) {
callback();
/* FNXC:PostgresDashboardLifecycle 2026-07-14-19:10: Teardown runs in reverse ownership order and is fully awaited before process.exit, so engines stop before their shared PostgreSQL backend and no pool shutdown is fire-and-forget. */
for (const callback of disposeCallbacks.splice(0).reverse()) {
try {
await callback();
} catch (error) {
logSink.warn(`Dashboard dispose callback failed: ${error instanceof Error ? error.message : String(error)}`, "dashboard");
}
}
}
// FNXC:RuntimeStartupWiring 2026-06-24-10:20:
// Register the backend shutdown (release PG pool / stop embedded cluster)
// so it runs during dispose(). store.close() already closes the
// AsyncDataLayer pool; this adds embedded-cluster teardown.
if (dashboardBackendShutdown) {
disposeCallbacks.push(() => {
void dashboardBackendShutdown!().catch(() => undefined);
});
}
disposeCallbacks.push(() => {
void closeProjectStores();
const dispose = (): void => {
void disposeAsync();
};
/*
FNXC:PostgresDashboardLifecycle 2026-07-14-22:07:
Dispose secondary stores first, explicitly close the cwd TaskStore so its watcher and timers stop, then invoke the startup factory shutdown that releases the remaining backend resources. The exported dispose path must await every stage.
*/
disposeCallbacks.push(async () => {
await closeProjectStores();
await store?.close();
if (dashboardBackendShutdown) {
await dashboardBackendShutdown().catch(() => undefined);
}
});
// ── createServer: deferred until engine is conditionally started ────
@@ -2297,7 +2287,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
await logShutdownDiagnostics(signal);
dispose();
await disposeAsync();
stopDiagnosticInterval();
// Tear down user-project dev-server children (and their process groups)
@@ -2331,8 +2321,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
closeCentralCoreBestEffort(centralCoreForEngine, `shutdown (${signal})`),
);
await timeShutdownStep("closeProjectStores", () => closeProjectStores());
store.close();
process.exit(shutdownExitCode);
};
/*
@@ -2370,7 +2358,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// instance for peer exchange and mDNS discovery.
//
try {
centralCoreForMesh = new CentralCore(undefined, { asyncLayer: store.getAsyncLayer() ?? undefined });
centralCoreForMesh = new CentralCore(undefined, { asyncLayer: dashboardLayer });
await centralCoreForMesh.init();
peerExchangeService = new PeerExchangeService(centralCoreForMesh);
@@ -2635,7 +2623,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
await logShutdownDiagnostics(signal);
dispose();
await disposeAsync();
stopDiagnosticInterval();
if (triggerScheduler) triggerScheduler.stop();
if (heartbeatMonitorImpl) heartbeatMonitorImpl.stop();
@@ -2665,8 +2653,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
);
}
await timeShutdownStep("closeProjectStores", () => closeProjectStores());
store.close();
process.exit(shutdownExitCode);
};
// FNXC:SystemPanel 2026-07-12-11:00: System panel restart binding for

View File

@@ -287,27 +287,29 @@ export async function runServe(
* the TaskStore) as the externalTaskStore for the cwd project's engine so
* the connection pool is shared — no second embedded PG instance is started.
*/
let centralBootResult: { taskStore: import("@fusion/core").TaskStore; asyncLayer: import("@fusion/core").AsyncDataLayer; shutdown: () => Promise<void> } | null = null;
const { createTaskStoreForBackend } = await import("@fusion/core");
/*
* FNXC:PostgresFinalCutover 2026-07-14-17:20:
* Serve must share one successfully booted PostgreSQL layer between CentralCore and the cwd engine. A backend boot error is fatal; constructing a layerless CentralCore would make project discovery appear empty and split control-plane state.
*/
const centralBootResult = await createTaskStoreForBackend({ rootDir: cwd });
let centralBackendShutdownPromise: Promise<void> | undefined;
const shutdownCentralBackendOnce = (): Promise<void> => {
centralBackendShutdownPromise ??= centralBootResult.shutdown();
return centralBackendShutdownPromise;
};
sharedCentralCore = new CentralCore(undefined, { asyncLayer: centralBootResult.asyncLayer });
try {
const { createTaskStoreForBackend } = await import("@fusion/core");
centralBootResult = await createTaskStoreForBackend({ rootDir: cwd });
if (centralBootResult) {
sharedCentralCore = new CentralCore(undefined, { asyncLayer: centralBootResult.asyncLayer });
} else {
sharedCentralCore = new CentralCore();
}
await sharedCentralCore.init();
} catch {
if (!sharedCentralCore) {
sharedCentralCore = new CentralCore();
try {
await sharedCentralCore.init();
} catch {
// Non-fatal — engine uses fallback defaults
}
}
} catch (error) {
/* FNXC:PostgresServeLifecycle 2026-07-14-18:03: A failed shared CentralCore boot occurs before serve installs signal teardown, so release the sole shared TaskStore pool and embedded lifecycle here. */
await shutdownCentralBackendOnce().catch(() => undefined);
throw error;
}
let startupEngineManager: ProjectEngineManager | undefined;
try {
// ── ProjectEngineManager: uniform engine lifecycle for all projects ──
//
// Every registered project gets an identical ProjectEngine with the
@@ -363,24 +365,13 @@ export async function runServe(
}
};
if (!sharedCentralCore) {
sharedCentralCore = new CentralCore();
try {
await sharedCentralCore.init();
} catch {
// Non-fatal — engine uses fallback defaults
}
}
if (sharedCentralCore) {
const registered = await ensureCwdProjectRegistered({
cwd,
central: sharedCentralCore,
logPrefix: "serve",
autoRegister: !opts.noAutoRegister,
});
ntfyProjectId = registered?.id;
}
const registered = await ensureCwdProjectRegistered({
cwd,
central: sharedCentralCore,
logPrefix: "serve",
autoRegister: !opts.noAutoRegister,
});
ntfyProjectId = registered?.id;
try {
registerGithubTrackingHook?.();
@@ -391,7 +382,7 @@ export async function runServe(
const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url);
const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion;
const engineManager = new ProjectEngineManager(sharedCentralCore, {
const engineManager = startupEngineManager = new ProjectEngineManager(sharedCentralCore, {
cliPackageVersion,
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId, pool) =>
@@ -404,9 +395,8 @@ export async function runServe(
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
// FNXC:SqliteFinalRemoval 2026-06-26-11:15: share the central boot's TaskStore
// as the externalTaskStore so the cwd engine reuses the same connection pool
// (no second embedded PG). When centralBootResult is null (legacy mode), the
// engine creates its own TaskStore via createTaskStoreForBackend as before.
...(centralBootResult ? { externalTaskStore: centralBootResult.taskStore } : {}),
// (no second embedded PG).
externalTaskStore: centralBootResult.taskStore,
});
// Start engines for all registered projects eagerly
@@ -620,36 +610,35 @@ export async function runServe(
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
/*
FNXC:PluginPostgresSchema 2026-07-14-21:48:
Optional plugin module-load failures remain nonfatal for serve compatibility. A schema initialization failure from a loaded plugin is instead a fatal storage-integrity error and must escape startup rather than being swallowed by the module-load catch.
*/
let pluginsLoaded = false;
try {
const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
try {
/*
* FNXC:SqliteFinalRemoval 2026-06-25-16:25:
* In backend mode (PostgreSQL), plugin schema inits are handled by the
* Drizzle schema applier at startup, not the SQLite Database class.
* Skip the SQLite-specific runPluginSchemaInits path in backend mode.
*/
if (store.isBackendMode()) {
console.log("[plugins] Schema initialization skipped — backend mode (PostgreSQL Drizzle migrations)");
} else {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
}
} catch (err) {
console.error(
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
);
}
}
pluginsLoaded = true;
} catch (err) {
console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
);
}
if (pluginsLoaded) {
const schemaHooks = pluginLoader.getPluginSchemaInitHooks?.() ?? [];
if (schemaHooks.length > 0) {
try {
await store.runPluginSchemaInits(schemaHooks);
} catch (err) {
console.error(
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
);
throw err;
}
}
}
// Get subsystems from the primary engine for the HTTP layer
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
@@ -1066,15 +1055,6 @@ export async function runServe(
// If it wasn't initialized successfully, create a new one for node registration.
//
let centralCore: CentralCore | null = sharedCentralCore;
// sharedCentralCore was already init'd; if null, try again for node registration
if (!centralCore) {
try {
centralCore = new CentralCore();
await centralCore.init();
} catch {
centralCore = null;
}
}
let localNodeId: string | undefined;
try {
@@ -1121,7 +1101,7 @@ export async function runServe(
}
console.log();
let shuttingDown = false;
let shutdownPromise: Promise<void> | undefined;
/*
FNXC:DaemonSignalExit 2026-07-10-14:00:
@@ -1134,9 +1114,6 @@ export async function runServe(
const SIGNAL_EXIT_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143 };
const shutdown = async (signal?: NodeJS.Signals) => {
if (shuttingDown) return;
shuttingDown = true;
// Log active handles at shutdown for diagnostics
const handleTypes: Record<string, number> = {};
try {
@@ -1155,12 +1132,14 @@ export async function runServe(
// Ignore errors getting handle types
}
if (hybridExecutor) {
await hybridExecutor.shutdown();
}
if (hybridExecutor) await hybridExecutor.shutdown().catch((error) => {
console.warn(`[serve] Hybrid executor shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
});
// Stop all project engines uniformly
await engineManager.stopAll();
await engineManager.stopAll().catch((error) => {
console.warn(`[serve] Engine shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
});
// Stop peer exchange service
if (peerExchangeService) {
@@ -1202,16 +1181,36 @@ export async function runServe(
// best-effort
}
stopDiagnosticInterval();
store.close();
try {
stopDiagnosticInterval();
} catch (error) {
console.warn(`[serve] Diagnostic teardown failed: ${error instanceof Error ? error.message : String(error)}`);
}
/*
* FNXC:PostgresServeLifecycle 2026-07-14-18:08:
* The serve bootstrap owns the TaskStore pool and any embedded PostgreSQL
* process because its runtime receives that store externally. Release the
* complete boot result after every engine and CentralCore user has stopped.
*/
await shutdownCentralBackendOnce().catch((error) => {
console.warn(`[serve] PostgreSQL shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
});
process.exit(signal ? (SIGNAL_EXIT_CODES[signal] ?? 128) : 0);
};
/* FNXC:PostgresServeLifecycle 2026-07-14-19:10: Every shutdown request observes the same promise so repeated signals cannot duplicate pool/postmaster teardown and asynchronous failures are never left as unhandled rejections. */
const requestShutdown = (signal?: NodeJS.Signals): void => {
shutdownPromise ??= shutdown(signal);
void shutdownPromise.catch((error) => {
console.error(`[serve] Shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
});
};
process.on("SIGINT", () => {
void shutdown("SIGINT");
requestShutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
requestShutdown("SIGTERM");
});
// Ignore SIGHUP so the server survives SSH session disconnects.
@@ -1221,4 +1220,11 @@ export async function runServe(
process.on("SIGHUP", () => {
console.log("[serve] Received SIGHUP (terminal disconnected) — ignoring");
});
} catch (error) {
/* FNXC:PostgresServeLifecycle 2026-07-14-19:10: Any startup failure after the shared PostgreSQL boot must unwind partially-started engines and CentralCore before releasing the sole backend owner exactly once. */
await startupEngineManager?.stopAll().catch(() => undefined);
await sharedCentralCore?.close().catch(() => undefined);
await shutdownCentralBackendOnce().catch(() => undefined);
throw error;
}
}