feat: faster dashboard and serve startup (#2132)

## Summary

Speeds up **time-to-HTTP-ready** for `fn dashboard` and `fn serve` after
the PostgreSQL cutover without reintroducing the historical 3s
cwd-engine race that degraded webhooks.

- **Dashboard store share (serve parity):** inject the factory-booted
`TaskStore` as `externalTaskStore` so cwd `ensureEngine` does not open a
second pool; share only when store root matches project working
directory (multi-project safe).
- **Serve multi-project:** stop awaiting `startAll()` before listen;
await only the primary engine; background the rest + reconciliation.
- **Defer non-route-critical engine work:** ordered OAuth (refresh →
monitor), automation schedule syncs, and auto-merge **enqueue** after
the engine handle is returnable.
- **Critical-path merge status clear:** still clear stale
`merging`/`merging-pr` before ready so manual merge is not blocked after
crash.
- **Serve `--paused`:** apply `enginePaused` before
`ensureEngine`/`startAll` (dashboard ordering).
- **Stop safety:** generation counter so deferred tails cannot resume
after `stop()` clears `shuttingDown`.
- **Phase timing:** shared `phaseTime` helper, factory substep logs,
serve time-to-listen.

Plan: `docs/plans/2026-07-14-001-feat-faster-startup-plan.md`

## Test plan

- [x] `packages/engine` — `project-engine-manager.test.ts` (path-matched
external store)
- [x] `packages/engine` — `project-engine-deferred-startup.test.ts`
(status clear, OAuth order, stop generation)
- [x] `packages/cli` — `startup-phase.test.ts`
- [x] `packages/cli` — `serve.test.ts` (60 tests, including `--paused`)
- [ ] Local: warm `fn dashboard` / `fn serve` and compare `startup phase
*` / `time-to-listen` logs
- [ ] `pnpm smoke:boot` (real serve `/api/health` on ephemeral port)

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

* **Performance**
* Improved dashboard and serve startup times, including faster
time-to-listen and time-to-ready.
* Moved non-essential background initialization off the critical startup
path.
  * Parallelized dashboard service initialization where possible.

* **Reliability**
  * Improved multi-project startup handling and project selection.
  * Prevented cross-project task-store sharing.
  * Added safer shutdown behavior for partially completed startup.

* **Diagnostics**
* Added startup phase timing logs to help identify performance
bottlenecks.

* **Tests**
* Expanded coverage for deferred startup, shutdown, project isolation,
and startup timing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-15 14:01:08 -07:00
committed by GitHub
parent 883f38d68f
commit 05151a25db
12 changed files with 1334 additions and 289 deletions

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from "vitest";
import { phaseTime } from "../startup-phase.js";
describe("phaseTime", () => {
it("logs duration on success", async () => {
const log = vi.fn();
const result = await phaseTime("demo", async () => {
await new Promise((r) => setTimeout(r, 5));
return 42;
}, log, "test");
expect(result).toBe(42);
expect(log).toHaveBeenCalledOnce();
expect(log.mock.calls[0][0]).toMatch(/^startup phase demo: \d+ms$/);
expect(log.mock.calls[0][1]).toBe("test");
});
it("logs duration when the phase throws", async () => {
const log = vi.fn();
await expect(
phaseTime("boom", async () => {
throw new Error("nope");
}, log),
).rejects.toThrow("nope");
expect(log).toHaveBeenCalledOnce();
expect(log.mock.calls[0][0]).toMatch(/^startup phase boom: \d+ms$/);
});
});

View File

@@ -382,6 +382,10 @@ const mocks = vi.hoisted(() => {
return pluginLoader;
});
const pluginRunner = {
getRuntimeById: vi.fn(),
};
const authStorage = {
getApiKey: vi.fn().mockResolvedValue(undefined),
reload: vi.fn(),
@@ -536,6 +540,11 @@ const mocks = vi.hoisted(() => {
at: new Date().toISOString(),
provider: null,
})),
/*
FNXC:FasterStartup 2026-07-15-12:41:
Keep the serve startup fixture aligned with the HTTP host contract: createServer receives the engine PluginRunner so model routes can resolve runtime-backed providers while startup remains non-blocking.
*/
getPluginRunner: vi.fn(() => pluginRunner),
startRemoteTunnel: vi.fn(async () => remoteStatus),
stopRemoteTunnel: vi.fn(async () => ({ ...remoteStatus, state: "stopped" as const, provider: null, pid: null, url: null })),
onMerge: vi.fn().mockResolvedValue(undefined),
@@ -681,17 +690,48 @@ vi.mock("@fusion/engine", async (importOriginal) => {
ProjectEngine: mocks.projectEngineCtor,
ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) {
const engines = new Map<string, any>();
const starting = new Map<string, Promise<any>>();
/*
FNXC:FasterStartup 2026-07-15-00:20:
Serve no longer awaits startAll before primary ensureEngine. The mock must
create-on-ensure (matching real ProjectEngineManager) so primary resolution
works when background startAll has not finished yet.
*/
const ensureEngine = async (id: string) => {
const existing = engines.get(id);
if (existing) return existing;
const pending = starting.get(id);
if (pending) return pending;
const promise = (async () => {
// Prefer listProjects path (authoritative registry fixture) over getProject,
// which some multi-project suite stubs invent as `/repo/${id}`.
const listed = await centralCore.listProjects();
const fromList = listed.find((p: { id: string }) => p.id === id);
const project = fromList ?? (await centralCore.getProject(id));
const engine = mocks.projectEngineCtor(
{
projectId: id,
workingDirectory: project?.path ?? `/tmp/${id}`,
isolationMode: "in-process",
maxConcurrent: 4,
maxWorktrees: 10,
},
centralCore,
{ ...options, projectId: id },
);
await engine.start();
engines.set(id, engine);
starting.delete(id);
return engine;
})();
starting.set(id, promise);
return promise;
};
return {
startAll: vi.fn(async () => {
const projects = await centralCore.listProjects();
for (const project of projects) {
const engine = mocks.projectEngineCtor(
{ projectId: project.id, workingDirectory: project.path, isolationMode: "in-process", maxConcurrent: 4, maxWorktrees: 10 },
centralCore,
{ ...options, projectId: project.id },
);
await engine.start();
engines.set(project.id, engine);
await ensureEngine(project.id);
}
}),
// Track which engine is used to verify correct cwd/default routing
@@ -701,11 +741,12 @@ vi.mock("@fusion/engine", async (importOriginal) => {
}),
getAllEngines: vi.fn(() => engines),
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
has: vi.fn((id: string) => engines.has(id)),
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
has: vi.fn((id: string) => engines.has(id) || starting.has(id)),
ensureEngine: vi.fn(async (id: string) => ensureEngine(id)),
stopAll: vi.fn(async () => {
for (const engine of engines.values()) await engine.stop();
engines.clear();
starting.clear();
}),
onProjectAccessed: vi.fn(),
startReconciliation: vi.fn(),
@@ -1202,7 +1243,7 @@ describe("runServe — Plugin wiring", () => {
expect(serverOpts).toHaveProperty("pluginStore");
expect(serverOpts).toHaveProperty("pluginLoader");
expect(serverOpts).toHaveProperty("pluginRunner");
expect(serverOpts.pluginRunner).toBe(serverOpts.pluginLoader);
expect(serverOpts.pluginRunner).toBe(mocks.projectEngineInstances[0].getPluginRunner());
await triggerSignal("SIGINT");
});

View File

@@ -108,6 +108,7 @@ import { registerCustomProviders, reregisterCustomProviders } from "./custom-pro
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
import { phaseTime } from "../startup-phase.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -885,7 +886,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// existing store.close() (which closes the AsyncDataLayer) plus the
// dashboardBackendShutdown
// registered below for embedded-cluster teardown.
const dashboardBackendBoot = await createTaskStoreForBackend({ rootDir: cwd });
/*
FNXC:FasterStartup 2026-07-14-23:55:
Phase timing is permanent and cheap. Factory wall time (embedded PG, schema,
optional SQLite migrate) is the first large bucket after the PostgreSQL cutover.
*/
const logPhase = (message: string, scope = "dashboard") => logSink.log(message, scope);
const dashboardBackendBoot = await phaseTime(
"backend.factory",
() => createTaskStoreForBackend({ rootDir: cwd }),
logPhase,
);
// 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;
@@ -926,42 +937,27 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
})()
: undefined;
// Phase timing instrumentation — each step logs its wall-clock duration so
// we can see at-a-glance which startup phase is the actual bottleneck.
// Cheap enough (microsecond reads, one log per phase) to leave on
// permanently; lands in the dashboard log buffer and can be diffed across
// restarts to spot regressions.
const phaseTime = async <T>(label: string, fn: () => Promise<T> | T): Promise<T> => {
const t0 = Date.now();
try {
return await fn();
} finally {
logSink.log(`startup phase ${label}: ${Date.now() - t0}ms`, "dashboard");
}
};
// 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());
// store and satellite adapters so each receives the live AsyncDataLayer before
// watchers and engines begin dispatching work.
/*
FNXC:FasterStartup 2026-07-14-23:55:
After store.init(), automation / plugin / agent satellite inits are independent
of each other and run in parallel. watch() still follows so filesystem events
do not race half-initialized satellites.
*/
await phaseTime("store.init", () => store.init(), logPhase);
const pluginStore = store.getPluginStore();
await phaseTime("pluginStore.init", () => pluginStore.init());
// FNXC:PhysicalDeleteSqliteClass 2026-06-26-15:10:
// Propagate the backend mode (asyncLayer) from the resolved TaskStore so
// AgentStore does not construct a SQLite file under PostgreSQL. Without
// this, AgentStore falls into the legacy SQLite path in backend mode and
// throws "SQLite Database is not available in backend mode" the first time
// 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: dashboardLayer });
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.initializingAgentStore);
await phaseTime("agentStore.init", () => agentStore!.init());
await phaseTime("satellites.init", () => Promise.all([
automationStore.init(),
pluginStore.init(),
agentStore!.init(),
]), logPhase);
// store.watch() is filesystem-watcher setup — no DB schema work, safe to
// overlap with anything coming after.
await phaseTime("store.watch", () => store.watch());
await phaseTime("store.watch", () => store.watch(), logPhase);
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.startingAgents);
// Set up database health check for diagnostics
@@ -1667,64 +1663,50 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
agentDir,
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
});
const resolvedPaths = await phaseTime("packageManager.resolve", () => packageManager!.resolve());
const resolvedPaths = await phaseTime("packageManager.resolve", () => packageManager!.resolve(), logPhase);
const packageExtensionPaths = resolvedPaths.extensions
.filter((r) => r.enabled)
.map((r) => r.path);
const claudeCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveClaudeCliExtensionPaths(globalSettings);
setCachedClaudeCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] pi-claude-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useClaudeCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedClaudeCliResolution(null);
return [];
/*
FNXC:FasterStartup 2026-07-14-23:55:
Claude / Droid / Llama extension toggles all read the same global settings
document. One getSettings() replaces three sequential store reads on the
dashboard extension critical path.
*/
let claudeCliPaths: string[] = [];
let droidCliPaths: string[] = [];
let llamaCppPaths: string[] = [];
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const claude = resolveClaudeCliExtensionPaths(globalSettings);
setCachedClaudeCliResolution(claude.resolution);
if (claude.warning) {
console.warn(`[extensions] pi-claude-cli: ${claude.warning}`);
}
})();
claudeCliPaths = claude.paths;
const droidCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveDroidCliExtensionPaths(globalSettings);
setCachedDroidCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] droid-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useDroidCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedDroidCliResolution(null);
return [];
const droid = resolveDroidCliExtensionPaths(globalSettings);
setCachedDroidCliResolution(droid.resolution);
if (droid.warning) {
console.warn(`[extensions] droid-cli: ${droid.warning}`);
}
})();
droidCliPaths = droid.paths;
const llamaCppPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] llama-cpp: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useLlamaCpp setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedLlamaCppResolution(null);
return [];
const llama = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(llama.resolution);
if (llama.warning) {
console.warn(`[extensions] llama-cpp: ${llama.warning}`);
}
})();
llamaCppPaths = llama.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate CLI extension settings: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedClaudeCliResolution(null);
setCachedDroidCliResolution(null);
setCachedLlamaCppResolution(null);
}
// Always inject the cli's own extension (`@runfusion/fusion`) so its
// `fn_*` tools register globally even when the user hasn't run
@@ -1752,7 +1734,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
));
), logPhase);
for (const { path, error } of extensionsResult.errors) {
logSink.log(`Failed to load ${path}: ${error}`, "extensions");
@@ -1984,7 +1966,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
//
const githubClient = new GitHubClient();
const centralCoreForEngine = await phaseTime("centralCore.init (await)", () => centralCoreInitPromise!);
const centralCoreForEngine = await phaseTime("centralCore.init (await)", () => centralCoreInitPromise!, logPhase);
try {
registerGithubTrackingHook?.();
@@ -1995,6 +1977,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const resolvedCliPackageVersion = getCliPackageVersion(import.meta.url);
const cliPackageVersion = isUnresolvedCliPackageVersion(resolvedCliPackageVersion) ? undefined : resolvedCliPackageVersion;
/*
FNXC:FasterStartup 2026-07-14-23:55:
Serve parity: inject the dashboard-booted TaskStore so ensureEngine(cwd)
reuses the same PostgreSQL pool instead of factory-booting a second store.
ProjectEngineManager only applies this store when the project's working
directory matches the store root (multi-project safety).
*/
const engineManager = new ProjectEngineManager(centralCoreForEngine, {
cliPackageVersion,
getMergeStrategy,
@@ -2005,6 +1994,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
prNodeGithubOps: createPrNodeGithubOps(githubClient),
prReconcileGithubOps: createPrReconcileGithubOps(githubClient),
getTaskMergeBlocker,
externalTaskStore: store,
});
// Start engines for all registered projects in the background. The
@@ -2069,7 +2059,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
logSink.warn(`Failed to start peer exchange service: ${message}`, "dashboard");
}
})(),
]));
]), logPhase);
logSink.log(
`hybrid executor gate: enabled=${hybridGate.enabled} reason=${hybridGate.reason}`,
@@ -2084,7 +2074,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const x = new HybridExecutor(centralCoreForEngine);
await x.initialize();
return x;
});
}, logPhase);
hybridExecutor = he;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -2109,13 +2099,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// duplicate-runtime issue that previously made this 7s+ is gone (see
// hybrid-executor-gate change), so warmup typically runs in ~3-5s with
// engineManager.startAll() already in flight in parallel.
//
// FNXC:FasterStartup 2026-07-14-23:55: Do not reintroduce Promise.race with
// a deadline. Defer non-route-critical work inside ProjectEngine.start instead.
const cwdEngine = cwdRegistered
? await phaseTime("engine: ensureEngine(cwd)", () =>
engineManager.ensureEngine(cwdRegistered.id).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to warm cwd project engine: ${message}`, "engine");
return undefined;
}),
? await phaseTime(
"engine: ensureEngine(cwd)",
() =>
engineManager.ensureEngine(cwdRegistered.id).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to warm cwd project engine: ${message}`, "engine");
return undefined;
}),
logPhase,
)
: undefined;
@@ -2138,7 +2134,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Ensure plugin loading has completed before pluginLoader is handed off
// to createServer — routes derived from getPluginRoutes() rely on it.
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise);
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise, logPhase);
// ── CLI Agent Executor: hub resolver + session transport ─────────────
//
@@ -2479,7 +2475,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Ensure plugin loading has completed before pluginLoader is handed off
// to createServer — routes derived from getPluginRoutes() rely on it.
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise);
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise, logPhase);
// UI-only mode: no engine, pass individual proxy objects to createServer.
//

View File

@@ -82,6 +82,7 @@ import { registerCustomProviders, reregisterCustomProviders } from "./custom-pro
import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledGrokRuntimePluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
import { phaseTime } from "../startup-phase.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -292,11 +293,20 @@ export async function runServe(
/*
* 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.
*
* FNXC:FasterStartup 2026-07-14-23:55:
* Phase labels mirror dashboard so time-to-listen can be compared across surfaces.
*/
const centralBootResult = await createTaskStoreForBackend({ rootDir: cwd });
const serveStartedAt = Date.now();
const logPhase = (message: string, scope = "serve") => console.log(`[${scope}] ${message}`);
const centralBootResult = await phaseTime(
"backend.factory",
() => createTaskStoreForBackend({ rootDir: cwd }),
logPhase,
);
/*
FNXC:MergeQueue 2026-07-15-11:40:
Share the serve TaskStore with the host pi extension so agent fn_* tools reuse the engine pool (no dual-boot).
FNXC:FasterStartup 2026-07-15-12:38:
Preserve both the timed backend-factory phase and the host-store injection during rebases. The serve command must report its critical-path duration without allowing host fn_* tools to boot a second TaskStore pool.
*/
setHostTaskStore(cwd, centralBootResult.taskStore);
let centralBackendShutdownPromise: Promise<void> | undefined;
@@ -306,7 +316,7 @@ export async function runServe(
};
sharedCentralCore = new CentralCore(undefined, { asyncLayer: centralBootResult.asyncLayer });
try {
await sharedCentralCore.init();
await phaseTime("centralCore.init", () => sharedCentralCore!.init(), logPhase);
} 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);
@@ -402,11 +412,35 @@ export async function runServe(
// 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).
// FNXC:FasterStartup 2026-07-14-23:55: Manager only injects this store when
// the project's working directory matches the store root (multi-project safe).
externalTaskStore: centralBootResult.taskStore,
});
// Start engines for all registered projects eagerly
await engineManager.startAll();
/*
FNXC:FasterStartup 2026-07-15-00:20:
Apply --paused before any ensureEngine/startAll so recovery, merge enqueue,
and deferred OAuth side effects observe enginePaused on the shared factory
store (cwd path). Idempotent re-apply on the primary store after ensure.
Matches dashboard's pause-before-engine ordering.
*/
if (opts.paused) {
await centralBootResult.taskStore.updateSettings({ enginePaused: true });
console.log("[engine] Starting in paused mode — automation disabled");
}
/*
FNXC:FasterStartup 2026-07-14-23:55:
Do not await startAll() before listen — multi-project count must not gate
HTTP readiness. Warm the primary project (cwd / flag / default) fully so
createServer receives a live engine; other projects continue via startAll
+ reconciliation in the background.
*/
void engineManager.startAll().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[serve] Background startAll failed: ${message}`);
});
engineManager.startReconciliation();
let hybridExecutor: HybridExecutor | null = null;
const hybridGate = await shouldUseHybridExecutor(sharedCentralCore);
@@ -435,14 +469,6 @@ export async function runServe(
}
})();
// Start background reconciliation to detect and start engines for projects
// registered after startup (without requiring headless node API access).
// This ensures project task execution starts from backend runtime alone.
// The onProjectFirstAccessed callback in createServer remains as a fast-path
// fallback for immediate engine startup on project access, but it is NOT
// required for correctness — reconciliation handles all cases.
engineManager.startReconciliation();
// ── PeerExchangeService: gossip protocol for mesh peer discovery ──────
//
// Periodically exchanges peer information with connected remote nodes
@@ -460,52 +486,61 @@ export async function runServe(
}
}
const startedEngines = [...engineManager.getAllEngines().values()];
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
const resolvePrimaryEngine = async (): Promise<{
engine: (typeof startedEngines)[number];
engine: import("@fusion/engine").ProjectEngine;
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
} | null> => {
const ensure = async (projectId: string, source: "cli-flag" | "default-setting" | "cwd" | "fallback") => {
const engine = await phaseTime(
`engine: ensureEngine(${source})`,
() => engineManager.ensureEngine(projectId),
logPhase,
);
return { engine, source };
};
if (opts.project) {
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
if (byId) {
return { engine: byId, source: "cli-flag" };
const byIdProject = projects.find((project) => project.id === opts.project);
if (byIdProject) {
return ensure(byIdProject.id, "cli-flag");
}
const projectMatch = projects.find((project) => project.name === opts.project);
if (projectMatch) {
const byName = engineManager.getEngine(projectMatch.id);
if (byName) {
return { engine: byName, source: "cli-flag" };
}
return ensure(projectMatch.id, "cli-flag");
}
console.error(`[serve] --project "${opts.project}" did not match any started engine`);
console.error(`[serve] --project "${opts.project}" did not match any registered project`);
process.exit(1);
return null;
}
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
if (defaultProjectId) {
const defaultEngine = engineManager.getEngine(defaultProjectId);
if (defaultEngine) {
return { engine: defaultEngine, source: "default-setting" };
try {
return await ensure(defaultProjectId, "default-setting");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[serve] defaultProjectId ${defaultProjectId} failed to start — falling through: ${message}`);
}
console.warn(`[serve] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
}
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (cwdEngine) {
return { engine: cwdEngine, source: "cwd" };
if (ntfyProjectId) {
try {
return await ensure(ntfyProjectId, "cwd");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[serve] cwd project engine failed to start — falling through: ${message}`);
}
}
const fallback = startedEngines[0];
if (!fallback) {
const fallbackProject = projects[0];
if (!fallbackProject) {
return null;
}
return { engine: fallback, source: "fallback" };
return ensure(fallbackProject.id, "fallback");
};
const primarySelection = await resolvePrimaryEngine();
@@ -534,9 +569,10 @@ export async function runServe(
// Set up database health check for diagnostics
setServeDbHealthCheck(() => store.healthCheck());
// Re-apply pause on the primary store when it is not the factory/cwd share
// (non-cwd --project / fallback). No-op when already set on the shared store.
if (opts.paused) {
await store.updateSettings({ enginePaused: true });
console.log("[engine] Starting in paused mode — automation disabled");
}
// ── PluginStore: plugin installation management ─────────────────────
@@ -1017,6 +1053,7 @@ export async function runServe(
});
const actualPort = (server.address() as AddressInfo).port;
logPhase(`startup phase time-to-listen: ${Date.now() - serveStartedAt}ms`);
/*
FNXC:CustomProviders 2026-06-30-00:00:

View File

@@ -0,0 +1,29 @@
/**
* Shared startup phase timing for CLI surfaces (dashboard, serve).
*
* FNXC:FasterStartup 2026-07-14-23:55:
* Operators and developers need wall-clock labels for each boot phase so
* time-to-listen regressions are attributable. Dashboard already had an
* inline phaseTime helper; serve and factory/engine paths need the same
* cheap pattern without inventing a separate metrics product.
*/
export type StartupPhaseLogger = (message: string, scope?: string) => void;
/**
* Time an async or sync startup phase and log `startup phase <label>: Nms`.
* Always logs in `finally` so failures still surface their duration.
*/
export async function phaseTime<T>(
label: string,
fn: () => Promise<T> | T,
log: StartupPhaseLogger,
scope = "startup",
): Promise<T> {
const t0 = Date.now();
try {
return await fn();
} finally {
log(`startup phase ${label}: ${Date.now() - t0}ms`, scope);
}
}