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:
29
packages/cli/src/__tests__/startup-phase.test.ts
Normal file
29
packages/cli/src/__tests__/startup-phase.test.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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:
|
||||
|
||||
29
packages/cli/src/startup-phase.ts
Normal file
29
packages/cli/src/startup-phase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user