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,7 @@
---
"@runfusion/fusion": patch
---
summary: Speed up dashboard and serve startup by sharing the PostgreSQL store and deferring non-route work.
category: performance
dev: Dashboard injects externalTaskStore for cwd engine (serve parity); multi-project engines only share when working directories match. ProjectEngine defers notifiers/OAuth (refresh-before-monitor), automation syncs, and merge sweep. Serve no longer awaits startAll before listen. Phase timing logs on both surfaces.

View File

@@ -0,0 +1,447 @@
---
title: "feat: Faster dashboard and serve startup"
status: completed
date: 2026-07-14
completed: 2026-07-15
pr: https://github.com/Runfusion/Fusion/pull/2132
type: feat
origin: conversation (startup path analysis on feature/faster-startup)
---
# feat: Faster dashboard and serve startup
## Summary
Shorten **time-to-HTTP-ready / TUI-usable** for `fn dashboard` and `fn serve` by eliminating dual TaskStore boots, moving non-route-critical engine work off the pre-listen path, parallelizing independent satellite inits, and extending phase timing — without reintroducing the historical 3s cwd-engine race that degraded webhooks, and without weakening PostgreSQL migration integrity.
## Problem Frame
After the PostgreSQL cutover, local startup is dominated by:
1. **Backend boot** — embedded PG (cold `initdb` / warm `pg_ctl`) plus schema baseline and optional SQLite→PG auto-migration.
2. **Dual store construction on dashboard** — dashboard already boots a PostgreSQL `TaskStore`, then `ensureEngine(cwd)` boots a **second** factory path because `externalTaskStore` is not wired (serve already shares one store).
3. **Serial engine bring-up** — `ProjectEngine.start()` awaits notifiers, OAuth refresh/monitor, automation syncs, and `startupMergeSweep` before returning, which blocks `createServer` / listen on the dashboard critical path.
4. **Extension resolution** — `packageManager.resolve()` is called out in-code as a slow dashboard phase; serve blocks on the same walk before listen.
5. **Incomplete phase metrics** — dashboard logs coarse `startup phase *` labels; serve and engine internals lack matching substep timing, so regressions are hard to attribute.
Primary success metric: **time from process start to HTTP listening (and TUI `setReady`)** on warm multi-project and single-project boots. Full orchestration readiness may lag slightly if deferred work remains fail-soft and does not leave route closures unbound.
## Requirements
- R1. `fn dashboard` with engine on reuses a single PostgreSQL-backed `TaskStore` (and connection pool) for the cwd project HTTP layer and cwd engine — parity with serve’s `externalTaskStore` wiring.
- R2. Multi-project engines must not receive a cwd-bound store for a different project root; store injection is cwd/path-matched (or projectId-matched), not “one store for every registered project.”
- R3. `createServer` always receives a live cwd `options.engine` when engine mode is on — no timed race / partial-undefined engine that unbinds webhook, automation, mission, or routine routes.
- R4. PostgreSQL schema baseline and first-boot SQLite auto-migration remain pre-first-write, single-owner, and fatal on verification failure.
- R5. Non-route-critical engine startup work (merge sweep, notification/OAuth stack, automation schedule syncs) may complete after the engine object is returned / after HTTP listen, with fail-soft logging and no silent permanent skip.
- R6. OAuth refresh-before-expiry-monitor order is preserved whenever both run (avoids false “token expired” ntfy on restart).
- R7. Plugin module load may remain pre-`createServer` while plugin routes depend on a loaded `pluginLoader`; schema init failures stay integrity-critical (serve fatal; dashboard must not leave a half-bound plugin schema silently).
- R8. Operators and developers can see per-phase wall times for backend factory substeps, engine blocks, and total time-to-listen on both dashboard and serve (extend existing `phaseTime` style; optional EL lag via `FUSION_TRACE_EL_LAG`).
- R9. Boot smoke and thin merge gate remain green: real `fn serve` → `/api/health` on ephemeral port, clean SIGTERM; no binding/killing port 4040.
- R10. Regression tests prove single factory boot for dashboard engine-on cwd path and that deferred work still eventually runs; tests stay file-scoped and free of real-network / slow full-suite habits (FN-5048).
## Scope Boundaries
### In scope
- `fn dashboard` and `fn serve` process startup critical path.
- `ProjectEngine` / `InProcessRuntime` pre-return awaits that are not required for route closures.
- Satellite store init ordering on dashboard after shared layer boot.
- Phase timing instrumentation and lightweight extension-path improvements (cache/defer only where chat/provider readiness is not blocked incorrectly).
### Deferred to Follow-Up Work
- Desktop embedded runtime dual-store parity (`packages/cli/src/commands/desktop.ts`, Electron local-runtime) — same seams, not primary.
- Embedded PG keep-alive daemon / always-on external `DATABASE_URL` as the default operator workflow (document as operational tip; no product redesign here).
- Full CLI bundle code-split / 14MB `bin.js` parse cost.
- Deep SQLite→PG migration throughput rewrite (bulk copy algorithms) beyond ensuring progress remains visible.
- Capturing the 3s webhook race as a dedicated `docs/solutions/` entry (recommended after land via ce-compound).
### Out of scope
- Re-enabling HybridExecutor for ordinary local multi-project (prior ~7s duplicate runtime cost).
- Changing engine singleton lock semantics (`has` vs `hasRunningEngine`).
- Dashboard query/index load work already covered by `docs/performance/dashboard-load.md`.
- Product UX redesign of TUI loading copy beyond accurate phase status.
## Assumptions
- Confirmed primary metric is **time-to-HTTP-ready / TUI usable**, not full orchestration quiet-state.
- Surfaces: **dashboard + serve**; desktop only if a unit can reuse the same API without expanding risk.
- Aggressiveness includes **backgrounding non-route-critical engine work** after a correct engine handle exists, not only store-sharing.
## Key Technical Decisions
1. **Share store via existing `externalTaskStore` seam, not a new abstraction.** Serve already proves the pattern: factory once → CentralCore `asyncLayer` → `ProjectEngineManager({ externalTaskStore })` → single shutdown. Dashboard adopts the same seam.
2. **Prefer cwd-scoped injection over manager-global blind share.** Manager today forwards one `externalTaskStore` to every engine in `buildEngineOptions`. For multi-project correctness under project-partitioned PG, inject only when the engine’s working directory / projectId matches the booted store’s root (cwd-only override on `ensureEngine`, or clear/undefined for other projects so they factory-boot their own bound store). Do not leave multi-project engines silently writing through the cwd partition.
3. **Engine handle before `createServer` remains a hard invariant.** Defer work **inside** `ProjectEngine.start()` / post-`start()` background phases so `options.engine` is non-null and route closures bind real subsystem getters. Do **not** reintroduce `Promise.race(ensureEngine, 3s)`.
4. **Deferral allowlist (post engine object, preferably post-listen for pure side effects):**
- `startupMergeSweep` (self-healing / periodic merge retry already cover related cases; accept brief stale `merging*` window)
- NotificationService + OAuth refresh/monitor/validity + NtfyNotifier (preserve refresh→monitor order inside the deferred chain)
- Automation schedule sync helpers (CronRunner can start; syncs fill in with degraded→ready health)
- Already allowed: custom provider `/models` refresh, mDNS (needs bound port), Claude skill FS backfill, runtime startup recovery sequence
5. **Keep pre-listen:** PostgreSQL factory + migration, store init/watch (or equivalent ownership), plugin load required for `getPluginRoutes()`, cwd `ensureEngine` completion for engine mode, HybridExecutor only when gate enables multi-node.
6. **Serve: stop awaiting `startAll()` before listen.** Match dashboard: `void startAll()` + reconciliation + await only primary/cwd engine needed for `createServer`’s primary store/engine. Multi-project engines warm in background.
7. **Instrumentation first-class but cheap.** Shared helper (or duplicated minimal `phaseTime`) for serve; add factory substeps (`embedded.start`, `schema.baseline`, `sqlite.migrate?`, `taskStore.construct`) and engine blocks (`runtime.start`, `notifiers`, `automations`, `mergeSweep`) behind normal log lines — not a new metrics product.
8. **Tests stay mock-first.** Assert factory call count / `externalTaskStore` wiring in CLI unit tests (extend serve/dashboard tests). Use `pnpm smoke:boot` / `verify:fast` for real process proof; avoid new real embedded-PG cold boots in the CLI vitest suite.
## High-Level Technical Design
### Target boot topology (cwd project)
```mermaid
flowchart TD
A[bin / command load] --> B[createTaskStoreForBackend once]
B --> C[CentralCore + asyncLayer]
B --> D[Dashboard satellite inits parallel-safe]
C --> E[ProjectEngineManager externalTaskStore for cwd only]
D --> E
E --> F[await ensureEngine cwd]
F --> G[runtime.start core path]
G --> H[return engine handle]
H --> I[createServer with engine]
I --> J[listen + TUI ready]
H --> K[background: notifiers OAuth order]
H --> L[background: automation syncs]
H --> M[background: merge sweep]
J --> N[background: custom providers mDNS skills]
```
### Critical-path vs deferred
| Phase | Pre-listen / pre-engine-return | Deferred |
|-------|--------------------------------|----------|
| Embedded PG + schema + migrate | Yes | Never |
| TaskStore + CentralCore layer | Yes | Never |
| Satellite inits (agent/plugin/automation for HTTP) | Yes (parallelize) | — |
| Plugin load for routes | Yes | Module-only defer only with route readiness redesign (out of scope) |
| `InProcessRuntime` scheduler/executor/self-healing construct | Yes | Recovery sequence already deferred |
| Notifiers / OAuth stack | No (after handle) | Yes |
| Automation sync ×4 | No | Yes |
| `startupMergeSweep` | No | Yes |
| `packageManager.resolve` | Prefer overlap; cache if safe | Non-essential providers post-listen |
| Non-cwd `startAll` engines | Background | Yes |
### Ownership rules when sharing store
- **One** `createTaskStoreForBackend` for cwd on dashboard engine-on path.
- Engine `InProcessRuntime` takes `externalTaskStore` and skips factory + does not own `backendShutdown`.
- Process teardown: engines stop → then factory `shutdown()` once (serve pattern).
- Dashboard continues to pass the **same** store instance into `createServer(store, …)` so HTTP and engine see one writer.
## Sequenced Delivery (phases)
Land as dependency-ordered commits/PRs. Each phase is independently shippable and measurable.
| Phase | Units | Outcome | Risk |
|-------|-------|---------|------|
| **P0 — Measure & share store** | U1, U2 | Factory substep + serve timing; dashboard single store for cwd engine | Low |
| **P1 — Serve multi-project non-block** | U3 | Serve listens without awaiting every project engine | Medium |
| **P2 — Defer non-route-critical engine work** | U4 | Shorter `ensureEngine` / `ProjectEngine.start` critical path | Medium–High |
| **P3 — Parallel satellite inits** | U5 | Shorter dashboard pre-engine path | Low |
| **P4 — Extension path** | U6 | Reduce `packageManager.resolve` wall time / blocking | Medium |
| **P5 — Verification & docs** | U7 | Boot smoke, gate, FNXC, optional solutions capture | Low |
Do not start P2 until P0 is green: store sharing is the largest structural win and simplifies measuring engine-internal deferrals.
---
## Implementation Units
### U1. Extend startup phase instrumentation (dashboard factory + serve + engine hooks)
**Goal:** Make time-to-listen and bottleneck attribution first-class on both surfaces so later units can be validated with numbers, not guesswork.
**Requirements:** R8, R9
**Dependencies:** None
**Files:**
- `packages/cli/src/commands/dashboard.ts` (extend labels / wrap factory)
- `packages/cli/src/commands/serve.ts` (add phase logging)
- `packages/core/src/postgres/startup-factory.ts` (optional substep logs)
- `packages/engine/src/project-engine.ts` and/or `packages/engine/src/runtimes/in-process-runtime.ts` (block timings)
- `packages/cli/src/commands/__tests__/dashboard.test.ts` or a small new helper test if extracting `phaseTime`
- Prefer extracting a tiny shared helper under `packages/cli/src/` only if both commands can import without cycles
**Approach:**
- Keep cheap wall-clock logs (`startup phase <label>: Nms`).
- Cover at least: `backend.factory` (and if easy: embedded start, schema, migrate, construct), `engine.ensureEngine`, total time-to-listen on serve (mirror dashboard `startupDurationMs`).
- Engine: log durations for `runtime.start`, `notifiers+oauth`, `automations`, `mergeSweep` even before deferral so before/after is comparable.
**Patterns to follow:** Existing `phaseTime` in `dashboard.ts`; `FUSION_TRACE_EL_LAG` for optional EL stalls.
**Test scenarios:**
- Happy path: helper or command test that a phase label is emitted around a stubbed async step (mock logger/logSink).
- Edge: phase logs still emit when the wrapped function throws (use `finally`).
- Test expectation if pure log wiring without extract: cover via existing dashboard/serve tests that still boot under mocks without asserting every label.
**Verification:** Manual or scripted dashboard/serve log shows labeled phases; no behavior change.
---
### U2. Dashboard cwd TaskStore sharing (`externalTaskStore` serve parity)
**Goal:** One factory boot and one connection pool for dashboard HTTP + cwd engine.
**Requirements:** R1, R2, R3, R4, R10
**Dependencies:** U1 helpful but not hard-required
**Files:**
- `packages/cli/src/commands/dashboard.ts` — pass `externalTaskStore: store` (or cwd-only ensure override); align shutdown with `dashboardBackendShutdown`
- `packages/engine/src/project-engine-manager.ts` — if needed, support **cwd/project-matched** injection so non-cwd engines do not inherit the wrong store
- `packages/cli/src/commands/__tests__/dashboard.test.ts` — assert single `createTaskStoreForBackend` (or equivalent) when engine on; assert manager receives external store for cwd
- `packages/cli/src/commands/__tests__/serve.test.ts` — keep/extend as regression for serve single-owner pattern
- FNXC comments on ownership / multi-project matching
**Approach:**
- Mirror serve’s `ProjectEngineManager(..., { externalTaskStore: boot.taskStore })` for the dashboard-owned store.
- Fix multi-project hazard: either document that the shared store is multi-tenant-safe for all projects under current PG binding **only if proven**, or inject store only for the matching projectId/path (preferred if binding is per-store).
- Ensure `store.init()` / `watch()` ownership remains single: engine must not re-init/close the shared store destructively; teardown order matches serve (engines → backend shutdown once).
- `--no-engine` path unchanged (no manager share required).
**Patterns to follow:** `serve.ts` externalTaskStore + `serve.test.ts` single-owner factory mock; `InProcessRuntime` external branch.
**Test scenarios:**
- Happy path: dashboard engine-on path constructs factory once for cwd; engine receives external store; `createServer` still gets engine + same store.
- Multi-project: second registered project engine does **not** use cwd store when roots differ (assert factory called for other project or explicit projectId bind — depending on implementation choice).
- Error path: factory failure still aborts boot; no orphan engine without shutdown.
- Integration with mocks: engine `ensureEngine(cwd)` does not call `createTaskStoreForBackend` again for cwd when share is wired.
- `--no-engine`: factory still once; no engine share requirements.
**Verification:** Unit tests green; optional local dashboard log shows shorter `engine: ensureEngine(cwd)` and no second embedded “starting” for same data dir from a second owner path; `pnpm smoke:boot` still green.
---
### U3. Serve: non-blocking multi-project `startAll`
**Goal:** `fn serve` becomes HTTP-ready after primary/cwd engine is ready, not after every registered project engine finishes.
**Requirements:** R3, R5, R8, R9
**Dependencies:** U1 recommended; U2 independent
**Files:**
- `packages/cli/src/commands/serve.ts` — `void engineManager.startAll()` (or await only primary), keep reconciliation; resolve primary store/engine for `createServer`
- `packages/cli/src/commands/__tests__/serve.test.ts`
- FNXC: serve readiness vs multi-project engine warmup
**Approach:**
- Align with dashboard: background `startAll`, await only the engine whose store backs `createServer`.
- Preserve hybrid executor gate behavior; do not enable HybridExecutor for local-only multi-project.
- Health endpoint must still report engine availability correctly for external singleton ownership (`hasRunningEngine`).
**Test scenarios:**
- Happy path: serve listen path does not await slow secondary project engines (mock second `ensureEngine` delayed; listen proceeds after primary).
- Edge: zero projects / cwd unregistered — fail-soft or existing serve registration behavior preserved.
- Error path: primary engine failure still fails boot (or existing policy); secondary failure is logged, not fatal to listen.
- Regression: single-project serve still one factory + externalTaskStore.
**Verification:** Serve unit tests; boot smoke.
---
### U4. Defer non-route-critical `ProjectEngine` startup work
**Goal:** Shrink wall time of `ProjectEngine.start()` / `ensureEngine` by moving merge sweep, notifier/OAuth stack, and automation syncs off the return-critical path while keeping route-bound engine subsystems constructed.
**Requirements:** R3, R5, R6, R10
**Dependencies:** U2 (preferred so measurements isolate engine internals)
**Files:**
- `packages/engine/src/project-engine.ts` — split critical vs deferred phases; readiness/health signals if needed
- `packages/engine/src/__tests__/project-engine.test.ts` (and related soft-delete/merge tests that use `skipNotifier`)
- Possibly notification/oauth unit tests if start timing changes
- FNXC comments documenting deferral allowlist and OAuth order
**Approach:**
- After `runtime.start()` and wiring required for routes (PR monitor config, settings listeners, auto-merge wiring as required), return-capable engine may complete deferred chain via `void this.startDeferredSubsystems().catch(...)`.
- Deferred chain order for OAuth: **refresh scheduler start → expiry monitor → validity logger** (same relative order as today).
- `startupMergeSweep`: background; document stale merging status window; keep unconditional stale status clear eventually.
- Automation: prefer `cronRunner.start()` then background the four `sync*` calls; keep degraded health messaging if sync fails.
- Do **not** defer plugin schema integrity or TaskStore factory/migration.
- Do **not** use a wall-clock race on `ensureEngine` from dashboard/serve.
**Execution note:** Characterization-first for `ProjectEngine.start` ordering tests — capture what routes need from `options.engine` at construction time before moving awaits.
**Patterns to follow:** Existing deferred `resumeStartupRecoverySequence` in `InProcessRuntime`; post-listen custom provider refresh; `skipNotifier` test pattern for isolating notifier-free starts.
**Test scenarios:**
- Happy path: `start()` resolves before deferred notifiers complete (mock delayed OAuth start); engine methods used by routes remain defined.
- OAuth order: when deferred chain runs, refresh is invoked before expiry monitor check (spy call order).
- Merge sweep: deferred sweep still clears stale `merging` / `merging-pr` on in-review tasks.
- Automation: sync failures leave degraded health but do not reject `start()`.
- Error path: deferred chain failure logs and does not crash process / does not leave unhandled rejection.
- Regression: `skipNotifier: true` tests still pass; auto-merge wiring still present for task:moved paths after start.
**Verification:** Engine unit tests; dashboard phase log for `ensureEngine` drops vs baseline; boot smoke.
---
### U5. Parallelize independent dashboard satellite inits
**Goal:** Reduce serial `store.init → automation → plugin → agent → watch` wall time where dependencies allow.
**Requirements:** R1, R8
**Dependencies:** U2 recommended (shared store already init’d once)
**Files:**
- `packages/cli/src/commands/dashboard.ts`
- `packages/cli/src/commands/__tests__/dashboard.test.ts` if ordering assertions exist
- FNXC on init dependency order
**Approach:**
- After core `store.init()` (and layer available): `Promise.all` for independent `automationStore.init`, `pluginStore.init`, `agentStore.init` if safe.
- `store.watch()` can overlap with extension resolution / plugin loading where event races are acceptable (today watch is early; keep correctness if TUI/subscriptions need watch before events).
- Do not parallelize with factory/migration.
**Test scenarios:**
- Happy path: dashboard boot under mocks still initializes all satellites.
- Failure path: one satellite init rejection still surfaces (no swallowed partial boot unless existing fail-soft policy).
**Verification:** Phase logs show overlapping satellite durations; unit tests green.
---
### U6. Extension / packageManager critical-path reduction
**Goal:** Cut dashboard/serve wall time spent in `packageManager.resolve` and extension discovery without breaking provider registration for chat.
**Requirements:** R5, R8
**Dependencies:** U1 (measure first); can ship after P0–P2
**Files:**
- `packages/cli/src/commands/dashboard.ts`
- `packages/cli/src/commands/serve.ts`
- `packages/cli/src/commands/claude-cli-extension.ts` / droid / llama path resolvers (single settings read)
- Optional small cache module under `packages/cli/src/`
- Tests colocated under `packages/cli/src/commands/__tests__/`
**Approach:**
- Collapse triple `getGlobalSettings()` for Claude/Droid/Llama into one read.
- Overlap `packageManager.resolve` with work that does not depend on its result (already partly true for plugins).
- Optional: fingerprint cache of resolved extension paths (agentDir + settings mtime/hash) with safe invalidation.
- Defer only provider registration that is already safe post-listen (custom providers already post-listen); do not defer self-extension `fn_*` tools required for agent sessions if chat can start immediately.
**Test scenarios:**
- Happy path: extensions still load; self-extension path still set via `setHostExtensionPaths`.
- Cache (if implemented): second resolve with unchanged fingerprint skips full walk; settings change busts cache.
- Failure path: resolve failure still fails soft as today (dashboard creates empty extension runtime).
**Verification:** Phase log `packageManager.resolve` reduced on warm restart; unit tests for cache invalidation if added.
---
### U7. Gate verification, changesets, and operator-facing notes
**Goal:** Prove the sequenced work does not break boot smoke or published CLI contracts; document measurement how-to.
**Requirements:** R9, R10
**Dependencies:** U2–U6 as landed
**Files:**
- `.changeset/*.md` when `@runfusion/fusion` user-visible performance/behavior changes (category `performance`)
- Optional short note in `docs/cli-reference.md` or `docs/getting-started.md` on reading startup phase logs / preferring `DATABASE_URL` for faster warm ops (only if product-facing)
- FNXC updates kept current
**Approach:**
- Run file-scoped vitest for touched packages + `pnpm smoke:boot` / `pnpm verify:fast`.
- Changeset body: labeled `summary` / `category: performance` / optional `dev`.
- Do not quarantine flakes by widening timeouts.
**Test scenarios:**
- Boot smoke: CLI help + serve health + clean shutdown.
- Gate-shaped: no new dependence on port 4040.
**Verification:** `pnpm verify:fast` and scoped tests green on the branch.
---
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Dual writers / split HTTP vs engine store | U2 single store; single shutdown; tests on factory call count |
| Multi-project wrong partition via global externalTaskStore | U2 matching rule; tests for second project |
| Webhook/route degradation if engine undefined | Hard invariant: await cwd engine; no 3s race |
| Stale merging status if sweep deferred | Background sweep ASAP after start; self-healing / retry still run |
| False OAuth expiry ntfy | Preserve refresh→monitor order in deferred chain |
| Plugin routes empty if load deferred naively | Keep plugin load pre-createServer in this plan |
| Serve multi-project race on first request to cold project | `onProjectFirstAccessed` + reconciliation already exist |
| Measurement noise | U1 before claiming wins |
**Dependencies:** PostgreSQL embedded binaries available for default boot smoke; existing serve single-store pattern.
## System-Wide Impact
- **Operators:** Faster dashboard/serve start; logs gain more phase lines.
- **Multi-project:** Background engine warmup for non-cwd projects on serve may mean first touch is slightly colder — acceptable under existing on-access ensure.
- **Health banner:** Must keep `hasRunningEngine` semantics when another process owns the lock.
- **Published CLI:** performance changeset if user-visible.
## Open Questions (implementation-time)
- Exact multi-tenant safety of a single TaskStore instance across projectIds under current RLS/bind model — resolve in U2 by reading `TaskStore` bind behavior; default to path/projectId-matched injection if ambiguous.
- Which `createServer` closures require notifier/cron **instances** at construction vs lazy getters — inventory during U4 characterization.
## Success Metrics
- Warm single-project dashboard: measurable drop in `engine: ensureEngine(cwd)` and total `startupDurationMs` after U2+U4.
- Serve: time-to-listen no longer scales linearly with registered project count after U3.
- Zero dual `createTaskStoreForBackend` for same cwd on dashboard engine-on path after U2.
- Boot smoke remains green; no increase in false OAuth expiry notifications.
## Alternative Approaches Considered
| Approach | Why not chosen |
|----------|----------------|
| Race `ensureEngine` with deadline, createServer with optional engine | Previously shipped and regressed webhooks/automations; hard ban |
| “UI shell first” without engine handle; rebind routes later | Requires large `createServer` redesign; out of scope |
| Always require external `DATABASE_URL` | Good ops tip; not a code fix for default embedded path |
| Full min-engine / full-engine process split | Higher complexity; defer after P0–P2 wins measured |
## Sources & Research
- Local: `packages/cli/src/commands/dashboard.ts`, `serve.ts`, `packages/core/src/postgres/startup-factory.ts`, `embedded-lifecycle.ts`, `packages/engine/src/project-engine.ts`, `project-engine-manager.ts`, `runtimes/in-process-runtime.ts`, `packages/dashboard/src/server.ts`
- Tests: `packages/cli/src/commands/__tests__/serve.test.ts`, `dashboard.test.ts`
- Learnings: `docs/solutions/integration-issues/engine-already-running-is-not-no-engine.md`, `docs/solutions/logic-errors/terminal-bootstrap-list-serialized-before-auto-create.md`, `docs/solutions/architecture-patterns/thin-trusted-merge-gate.md`, in-code FNXC on 3s race and CustomProviders post-listen
- Prior perf note (query-level only): `docs/performance/dashboard-load.md`
- External research: skipped — strong in-repo serve parity pattern and documented readiness constraints
## Execution Posture
- U2/U3/U4: characterization-first around store ownership and engine start ordering before moving awaits.
- Prefer file-scoped vitest + `pnpm smoke:boot` / `verify:fast` over full-suite.
- FNXC comments required for ownership, deferral allowlist, and multi-project injection rules.

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);
}
}

View File

@@ -422,9 +422,17 @@ export async function createTaskStoreForBackend(
}
const rootDir = options.rootDir ?? "";
/*
FNXC:FasterStartup 2026-07-14-23:55:
Factory substep timings feed CLI phase logs so operators can separate embedded
PG start + schema work from engine bring-up. Cheap permanent diagnostics.
*/
const factoryT0 = Date.now();
let boot: SchemaBackendBootResult;
try {
const schemaT0 = Date.now();
boot = await bootSchemaBackend(options);
log.log(`startup phase backend.schemaBackend: ${Date.now() - schemaT0}ms`);
} catch (err) {
throw new Error(
`startup-factory: failed to initialize PostgreSQL schema backend: ${err instanceof Error ? err.message : String(err)}`,
@@ -721,6 +729,7 @@ export async function createTaskStoreForBackend(
*/
let taskStore: TaskStore;
try {
const constructT0 = Date.now();
if (options.projectId && !options.rootDir) {
taskStore = await TaskStore.getOrCreateForProject(
options.projectId,
@@ -734,6 +743,7 @@ export async function createTaskStoreForBackend(
});
await taskStore.init();
}
log.log(`startup phase backend.taskStore.construct: ${Date.now() - constructT0}ms`);
} catch (err) {
await asyncLayer.close().catch(() => undefined);
if (embeddedLifecycle) {
@@ -745,6 +755,7 @@ export async function createTaskStoreForBackend(
}`,
);
}
log.log(`startup phase backend.factory.total: ${Date.now() - factoryT0}ms`);
/*
FNXC:PluginPostgresContract 2026-07-14-18:32:

View File

@@ -0,0 +1,223 @@
/**
* FNXC:FasterStartup 2026-07-14-23:55 / 2026-07-15-00:20:
* ProjectEngine.start returns before notifiers/OAuth/merge enqueue finish, but
* OAuth refresh must still start before the expiry monitor when deferred work runs.
* Stale merging/merging-pr statuses clear on the critical path.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const oauthRefreshStart = vi.fn(async () => undefined);
const oauthExpiryStart = vi.fn(async () => undefined);
const notificationStart = vi.fn(async () => undefined);
const notifierStart = vi.fn(async () => undefined);
const oauthValidityStart = vi.fn(async () => undefined);
const updateTask = vi.fn(async () => undefined);
const listTasks = vi.fn(async () => [] as Array<{ id: string; column: string; status: string | null }>);
vi.mock("../notification/index.js", () => ({
NotificationService: vi.fn().mockImplementation(function () {
return { start: notificationStart, stop: vi.fn() };
}),
OAuthAlertStateStore: vi.fn().mockImplementation(function () {
return {};
}),
OAuthExpiryMonitor: vi.fn().mockImplementation(function () {
return { start: oauthExpiryStart, stop: vi.fn() };
}),
OAuthRefreshScheduler: vi.fn().mockImplementation(function () {
return { start: oauthRefreshStart, stop: vi.fn() };
}),
OAuthValidityLogger: vi.fn().mockImplementation(function () {
return { start: oauthValidityStart, stop: vi.fn() };
}),
}));
vi.mock("../notifier.js", () => ({
NtfyNotifier: vi.fn().mockImplementation(function () {
return { start: notifierStart, stop: vi.fn(), notifyGridlock: vi.fn() };
}),
}));
vi.mock("../auth-storage.js", () => ({
createFusionAuthStorage: vi.fn(() => ({})),
getFusionOAuthAlertStatePath: () => "/tmp/fusion-oauth-alert-state-test",
}));
vi.mock("../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: vi.fn(() => ({
getSettings: vi.fn(async () => ({})),
getAsyncLayer: vi.fn(() => ({})),
listTasks,
updateTask,
on: vi.fn(),
off: vi.fn(),
})),
getMessageStore: vi.fn(() => undefined),
getAgentStore: vi.fn(() => undefined),
getPluginRunner: vi.fn(() => undefined),
configurePrMonitoring: vi.fn(),
getExecutor: vi.fn(() => undefined),
};
}),
}));
vi.mock("../gridlock-detector.js", () => ({
GridlockDetector: vi.fn().mockImplementation(function () {
return { start: vi.fn(), stop: vi.fn() };
}),
}));
vi.mock("../cron-runner.js", () => ({
CronRunner: vi.fn().mockImplementation(function () {
return { start: vi.fn(), stop: vi.fn() };
}),
createAiPromptExecutor: vi.fn(async () => undefined),
}));
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return {
...actual,
AutomationStore: vi.fn().mockImplementation(function () {
return { init: vi.fn(async () => undefined) };
}),
};
});
vi.mock("../pr-monitor.js", () => ({
PrMonitor: vi.fn().mockImplementation(function () {
return { onNewComments: vi.fn(), start: vi.fn(), stop: vi.fn() };
}),
}));
vi.mock("../pr-comment-handler.js", () => ({
PrCommentHandler: vi.fn().mockImplementation(function () {
return { handleNewComments: vi.fn(), createFollowUpTask: vi.fn() };
}),
}));
vi.mock("../pr-reconcile.js", () => ({
PrReconciler: vi.fn().mockImplementation(function () {
return { start: vi.fn(), stop: vi.fn() };
}),
}));
vi.mock("../planner-overseer.js", () => ({
PlannerOverseerMonitor: vi.fn().mockImplementation(function () {
return {};
}),
resolveExecutorStuckAfterMs: vi.fn(() => 60_000),
}));
vi.mock("../planner-recovery-controller.js", () => ({
PlannerRecoveryController: vi.fn().mockImplementation(function () {
return {};
}),
}));
vi.mock("../postgres-migration-notice.js", () => ({
deliverPostgresMigrationNoticeIfNeeded: vi.fn(async () => undefined),
deliverPostgresMigrationCompleteNoticeIfNeeded: vi.fn(async () => undefined),
}));
vi.mock("../merger.js", () => ({
sweepStaleAutostashes: vi.fn(async () => 0),
VerificationError: class VerificationError extends Error {},
}));
import { ProjectEngine } from "../project-engine.js";
function makeEngine(projectId: string, skipNotifier: boolean) {
return new ProjectEngine(
{
projectId,
workingDirectory: `/tmp/${projectId}`,
isolationMode: "in-process",
maxConcurrent: 1,
maxWorktrees: 1,
},
{ on: vi.fn(), off: vi.fn() } as any,
{ skipNotifier, projectId },
);
}
describe("ProjectEngine deferred startup", () => {
beforeEach(() => {
vi.clearAllMocks();
listTasks.mockResolvedValue([]);
});
it("clears stale merging statuses during start critical path", async () => {
listTasks.mockResolvedValue([
{ id: "FN-1", column: "in-review", status: "merging" },
{ id: "FN-2", column: "in-review", status: "merging-pr" },
{ id: "FN-3", column: "in-review", status: null },
]);
const engine = makeEngine("proj_stale", true);
await engine.start();
expect(updateTask).toHaveBeenCalledWith("FN-1", { status: null });
expect(updateTask).toHaveBeenCalledWith("FN-2", { status: null });
expect(updateTask).not.toHaveBeenCalledWith("FN-3", expect.anything());
});
it("returns from start before OAuth refresh completes, then runs refresh before expiry monitor", async () => {
let resolveRefresh!: () => void;
const refreshGate = new Promise<void>((resolve) => {
resolveRefresh = resolve;
});
oauthRefreshStart.mockImplementation(async () => {
await refreshGate;
});
const engine = makeEngine("proj_deferred", false);
const startPromise = engine.start();
await expect(startPromise).resolves.toBeUndefined();
// Deferred work is scheduled after start resolves; wait for refresh to be entered.
await vi.waitFor(() => {
expect(oauthRefreshStart).toHaveBeenCalled();
});
// Expiry monitor must not start until refresh finishes
expect(oauthExpiryStart).not.toHaveBeenCalled();
resolveRefresh();
await vi.waitFor(() => {
expect(oauthExpiryStart).toHaveBeenCalledTimes(1);
});
expect(notificationStart).toHaveBeenCalled();
expect(oauthRefreshStart.mock.invocationCallOrder[0]).toBeLessThan(
oauthExpiryStart.mock.invocationCallOrder[0]!,
);
});
it("stop sets shuttingDown so deferred work exits without throwing", async () => {
let resolveRefresh!: () => void;
oauthRefreshStart.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveRefresh = resolve;
}),
);
const engine = makeEngine("proj_stop", false);
await engine.start();
await vi.waitFor(() => {
expect(oauthRefreshStart).toHaveBeenCalled();
});
await engine.stop();
resolveRefresh();
// Deferred chain should observe shuttingDown and not reject after stop.
await new Promise((r) => setTimeout(r, 30));
expect(oauthExpiryStart).not.toHaveBeenCalled();
});
});

View File

@@ -110,6 +110,45 @@ describe("ProjectEngineManager", () => {
);
});
it("injects externalTaskStore only when project working directory matches store root", async () => {
const sharedStore = {
getRootDir: () => "/mapped/proj_aaa",
} as any;
const manager = new ProjectEngineManager(centralCore, {
externalTaskStore: sharedStore,
});
await manager.ensureEngine("proj_aaa");
expect(ProjectEngine).toHaveBeenLastCalledWith(
expect.objectContaining({ workingDirectory: "/mapped/proj_aaa" }),
centralCore,
expect.objectContaining({ externalTaskStore: sharedStore }),
);
await manager.ensureEngine("proj_bbb");
expect(ProjectEngine).toHaveBeenLastCalledWith(
expect.objectContaining({ workingDirectory: "/mapped/proj_bbb" }),
centralCore,
expect.not.objectContaining({ externalTaskStore: sharedStore }),
);
});
it("shares externalTaskStore when roots differ only by trailing slash", async () => {
const sharedStore = {
getRootDir: () => "/mapped/proj_aaa/",
} as any;
const manager = new ProjectEngineManager(centralCore, {
externalTaskStore: sharedStore,
});
await manager.ensureEngine("proj_aaa");
expect(ProjectEngine).toHaveBeenLastCalledWith(
expect.objectContaining({ workingDirectory: "/mapped/proj_aaa" }),
centralCore,
expect.objectContaining({ externalTaskStore: sharedStore }),
);
});
it("returns existing engine on repeated calls", async () => {
const manager = new ProjectEngineManager(centralCore);
const engine1 = await manager.ensureEngine("proj_aaa");

View File

@@ -13,6 +13,8 @@
* - Graceful shutdown of all engines via `stopAll()`
*/
import { realpathSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
import type {
CentralCore,
TaskStore,
@@ -47,8 +49,16 @@ export interface EngineManagerOptions {
prReconcileGithubOps?: ProjectEngineOptions["prReconcileGithubOps"];
getTaskMergeBlocker?: ProjectEngineOptions["getTaskMergeBlocker"];
onInsightRunProcessed?: ProjectEngineOptions["onInsightRunProcessed"];
// FNXC:SqliteFinalRemoval 2026-06-26-11:20: shared TaskStore from the central
// backend boot so all engines reuse one connection pool (no second embedded PG).
/**
* FNXC:SqliteFinalRemoval 2026-06-26-11:20: shared TaskStore from the central
* backend boot so engines reuse one connection pool (no second embedded PG).
*
* FNXC:FasterStartup 2026-07-14-23:55:
* Inject only for the project whose resolved working directory matches this
* store's rootDir. Multi-project engines must factory-boot (or receive) their
* own bound store — a cwd-partitioned TaskStore must never back a different
* project root. Callers may still pass per-call overrides via ensureEngine.
*/
externalTaskStore?: ProjectEngineOptions["externalTaskStore"];
}
@@ -469,7 +479,7 @@ export class ProjectEngineManager {
}
const runtimeConfig = await this.buildRuntimeConfig(project);
const engineOptions = this.buildEngineOptions(project, overrides);
const engineOptions = this.buildEngineOptions(project, runtimeConfig.workingDirectory, overrides);
// Acquire the per-machine singleton guard before spinning up any engine
// subsystems. This prevents two fusion processes from running engines for
@@ -548,8 +558,21 @@ export class ProjectEngineManager {
private buildEngineOptions(
project: RegisteredProject,
workingDirectory: string,
overrides?: Partial<ProjectEngineOptions>,
): ProjectEngineOptions {
/*
FNXC:FasterStartup 2026-07-14-23:55 / 2026-07-15-00:40:
Share the CLI-booted TaskStore only when the engine's working directory is
the same project root as the store. Compare realpath when available so a
symlinked CLI cwd and a registry-canonical path still share one pool
(Greptile: path.resolve alone double-boots symlink aliases).
*/
const sharedStore = this.options.externalTaskStore;
const shareForThisProject = Boolean(
sharedStore
&& sameProjectRoot(sharedStore.getRootDir(), workingDirectory),
);
return {
projectId: project.id,
cliPackageVersion: this.options.cliPackageVersion,
@@ -561,11 +584,25 @@ export class ProjectEngineManager {
prReconcileGithubOps: this.options.prReconcileGithubOps,
getTaskMergeBlocker: this.options.getTaskMergeBlocker,
onInsightRunProcessed: this.options.onInsightRunProcessed,
// FNXC:SqliteFinalRemoval 2026-06-26-11:20: forward the shared external
// TaskStore so engines reuse the central boot's connection pool instead
// of starting a second embedded PostgreSQL on the same data dir.
...(this.options.externalTaskStore ? { externalTaskStore: this.options.externalTaskStore } : {}),
...(shareForThisProject && sharedStore ? { externalTaskStore: sharedStore } : {}),
...overrides,
};
}
}
/**
* FNXC:FasterStartup 2026-07-15-00:40:
* Path identity for externalTaskStore matching: resolve then realpath so
* symlinked project roots compare equal to their canonical registry path.
*/
function sameProjectRoot(a: string, b: string): boolean {
const normalize = (p: string): string => {
const resolved = pathResolve(p);
try {
return realpathSync(resolved);
} catch {
return resolved;
}
};
return normalize(a) === normalize(b);
}

View File

@@ -497,6 +497,13 @@ export class ProjectEngine {
// would let a second caller overwrite the first, stranding its promise.
private manualMergeResolvers = new Map<string, Array<MergeResolver>>();
private shuttingDown = false;
/**
* FNXC:FasterStartup 2026-07-15-00:20:
* stop() clears shuttingDown so the engine can restart, which would otherwise
* let in-flight deferred startup work resume after stop. Bump this generation
* on stop (and capture it when scheduling deferred work) so post-stop tails abort.
*/
private startupGeneration = 0;
private addMergeResolver(taskId: string, r: MergeResolver): void {
const list = this.manualMergeResolvers.get(taskId);
@@ -880,10 +887,21 @@ export class ProjectEngine {
this.prReconciler.start();
}
// 3. Initialize notification services (unless caller manages them externally)
/*
FNXC:FasterStartup 2026-07-14-23:55:
Route-critical path constructs AutomationStore/CronRunner and wires merge
listeners so createServer closures bind real subsystems. Notifiers, OAuth
(refresh-before-monitor order preserved), automation schedule syncs, and
startupMergeSweep run in the background so ensureEngine returns sooner.
Do not reintroduce a timed race that hands createServer an undefined engine.
*/
const engineStartT0 = Date.now();
// 3. Construct notification services (start deferred — see startDeferredStartupWork)
let deferredAgentNameResolver: ((agentId: string) => Promise<string | null>) | undefined;
if (!this.options.skipNotifier) {
const agentStore = this.runtime.getAgentStore();
const agentNameResolver = agentStore
deferredAgentNameResolver = agentStore
? async (agentId: string): Promise<string | null> => {
const agent = await agentStore.getAgent(agentId);
const name = typeof agent?.name === "string" ? agent.name.trim() : "";
@@ -895,59 +913,20 @@ export class ProjectEngine {
projectId: this.options.projectId,
ntfyBaseUrl: this.options.ntfyBaseUrl,
messageStore: this.runtime.getMessageStore(),
agentNameResolver,
agentNameResolver: deferredAgentNameResolver,
});
await this.notificationService.start();
const authStorage = createFusionAuthStorage();
this.authStorage = authStorage;
const oauthAlertState = new OAuthAlertStateStore({
statePath: getFusionOAuthAlertStatePath(),
});
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
FN-7574: proactively refresh OAuth access tokens ahead of expiry (widened window,
see OAUTH_REFRESH_BUFFER_MS in auth-storage.ts) so a healthy subscription session
never lapses waiting for something else to request a runtime API key. Reuses the
same authStorage instance as OAuthExpiryMonitor below so detection/notification and
proactive refresh observe a consistent, single credential source.
FNXC:ClaudeOAuth 2026-07-08-12:10:
Start the proactive refresher BEFORE OAuthExpiryMonitor. Both are awaited on startup
and share this authStorage; the monitor's OAuthExpiryMonitor.start() runs its first
check() synchronously, and that check is refresh-blind (it only reads the stored
`expires` timestamp, with no refresh token or getApiKey in its interface). If the
monitor ran first, a stale-but-refreshable access token (the normal state after the
app has been closed a while) fired a false "OAuth token expired" ntfy push even
though the connection was fine — the refresher would silently renew the token moments
later. Refreshing first means the scheduler's awaited initial tick() renews the token
and the monitor (which reload()s authStorage at the top of check()) sees the fresh
`expires`, so the alarm only fires when refresh genuinely fails.
*/
this.oauthRefreshScheduler = new OAuthRefreshScheduler({ authStorage });
await this.oauthRefreshScheduler.start();
this.oauthExpiryMonitor = new OAuthExpiryMonitor({
authStorage,
notificationService: this.notificationService,
alertState: oauthAlertState,
});
await this.oauthExpiryMonitor.start();
this.oauthValidityLogger = new OAuthValidityLogger({
authStorage,
alertState: oauthAlertState,
});
await this.oauthValidityLogger.start();
// Backward-compatibility shim for gridlock notifications.
// Backward-compatibility shim for gridlock notifications (started in deferred work).
this.notifier = new NtfyNotifier(
store,
{
projectId: this.options.projectId,
ntfyBaseUrl: this.options.ntfyBaseUrl,
agentNameResolver,
agentNameResolver: deferredAgentNameResolver,
},
this.notificationService,
);
await this.notifier.start();
}
this.gridlockDetector = new GridlockDetector(store, {
@@ -956,13 +935,14 @@ export class ProjectEngine {
});
this.gridlockDetector.start();
// 4. Initialize AutomationStore + CronRunner
// 4. Initialize AutomationStore + CronRunner (syncs deferred)
this.setAutomationSubsystemHealth(
"initializing",
"Initializing AutomationStore and CronRunner",
);
let coreAutomationModule: typeof import("@fusion/core") | undefined;
try {
const coreAutomationModule = await import("@fusion/core");
coreAutomationModule = await import("@fusion/core");
const { AutomationStore } = coreAutomationModule;
// FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:05:
// Propagate the backend mode (asyncLayer) from the owning TaskStore so
@@ -984,76 +964,17 @@ export class ProjectEngine {
scope: "project", // Project-scoped execution — global schedules run separately
});
const settings = await store.getSettings();
const startupSyncFailures: string[] = [];
// Sync insight extraction automation on startup
if (typeof coreAutomationModule.syncInsightExtractionAutomation === "function") {
try {
await coreAutomationModule.syncInsightExtractionAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`insight extraction: ${message}`);
runtimeLog.warn(`Insight extraction automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncInsightExtractionAutomation is unavailable; skipping startup sync");
}
// Sync auto-summarize automation on startup
if (typeof coreAutomationModule.syncAutoSummarizeAutomation === "function") {
try {
await coreAutomationModule.syncAutoSummarizeAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`auto-summarize: ${message}`);
runtimeLog.warn(`Auto-summarize automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncAutoSummarizeAutomation is unavailable; skipping startup sync");
}
// Sync memory dreams automation on startup
if (typeof coreAutomationModule.syncMemoryDreamsAutomation === "function") {
try {
await coreAutomationModule.syncMemoryDreamsAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`memory dreams: ${message}`);
runtimeLog.warn(`Memory dreams automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncMemoryDreamsAutomation is unavailable; skipping startup sync");
}
// Sync scheduled eval batch automation on startup
if (typeof coreAutomationModule.syncScheduledEvalBatchAutomation === "function") {
try {
await coreAutomationModule.syncScheduledEvalBatchAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`scheduled eval: ${message}`);
runtimeLog.warn(`Scheduled eval automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncScheduledEvalBatchAutomation is unavailable; skipping startup sync");
}
this.cronRunner.start();
if (startupSyncFailures.length > 0) {
this.setAutomationSubsystemHealth(
"degraded",
`CronRunner started with startup sync warnings: ${startupSyncFailures.join("; ")}`,
);
} else {
this.setAutomationSubsystemHealth(
"ready",
"CronRunner initialized and startup automation sync completed",
);
}
runtimeLog.log("CronRunner initialized and started");
/*
FNXC:FasterStartup 2026-07-15-00:40:
Do not start CronRunner until deferred automation schedule syncs finish.
start() ticks immediately; running it before sync can fire one overdue
schedule with stale settings from the previous process (Greptile P1).
*/
this.setAutomationSubsystemHealth(
"initializing",
"AutomationStore ready; CronRunner starts after schedule sync",
);
runtimeLog.log("AutomationStore initialized; CronRunner start deferred until schedule sync");
} catch (err) {
// Non-fatal — automations are optional
const { message, detail } = formatErrorDetails(err);
@@ -1085,10 +1006,25 @@ export class ProjectEngine {
this.wireTaskPauseMergeInterruption(store);
this.wireAutostashOrphanRecovery(store);
// 7. Auto-merge startup sweep
await this.startupMergeSweep(store);
/*
FNXC:FasterStartup 2026-07-15-00:20:
Clear crash-leftover merging/merging-pr statuses on the critical path so
manual merge is not blocked while deferred work finishes. Auto-merge enqueue
stays deferred (pause-aware) after the engine handle is returnable.
*/
const statusClearT0 = Date.now();
await this.clearStaleMergingStatuses(store);
runtimeLog.log(`ProjectEngine stale merging status clear: ${Date.now() - statusClearT0}ms`);
// 8. Start periodic merge retry sweep
// 7–9. Deferred: notifiers/OAuth (ordered), automation syncs, merge enqueue
const deferredGeneration = this.startupGeneration;
void this.startDeferredStartupWork(store, coreAutomationModule, deferredGeneration).catch((err) => {
if (this.shuttingDown || this.startupGeneration !== deferredGeneration) return;
const message = err instanceof Error ? err.message : String(err);
runtimeLog.error(`Deferred ProjectEngine startup work failed: ${message}`);
});
// 8. Start periodic merge retry sweep (does not require merge enqueue to have finished)
this.scheduleMergeRetry(store);
this.scheduleMergeActiveReconciliation(settings.maintenanceIntervalMs ?? 900_000);
@@ -1097,7 +1033,171 @@ export class ProjectEngine {
this.scheduleStaleAutostashSweep(store);
this.started = true;
runtimeLog.log(`ProjectEngine started for ${this.config.projectId}`);
runtimeLog.log(
`ProjectEngine started for ${this.config.projectId} (critical path ${Date.now() - engineStartT0}ms; deferred work in background)`,
);
}
/**
* Non-route-critical startup work. Runs after the engine handle is returnable.
* OAuth refresh must still complete before the expiry monitor's first check.
* Aborts cleanly when stop() sets shuttingDown.
*/
private deferredStartupAborted(generation: number): boolean {
return this.shuttingDown || this.startupGeneration !== generation;
}
private async startDeferredStartupWork(
store: TaskStore,
coreAutomationModule: typeof import("@fusion/core") | undefined,
generation: number,
): Promise<void> {
if (this.deferredStartupAborted(generation)) return;
const t0 = Date.now();
/*
FNXC:FasterStartup 2026-07-15-00:40:
Isolate notifier/OAuth failures so automation schedule sync and merge
enqueue still run (Greptile: one reject must not skip reconciliation).
OAuth refresh still precedes expiry monitor inside the try block.
*/
if (!this.options.skipNotifier && this.notificationService && this.authStorage) {
try {
const notifiersT0 = Date.now();
await this.notificationService.start();
if (this.deferredStartupAborted(generation)) return;
const oauthAlertState = new OAuthAlertStateStore({
statePath: getFusionOAuthAlertStatePath(),
});
/*
FNXC:ClaudeOAuth 2026-07-05-00:00 / 2026-07-08-12:10 / FNXC:FasterStartup 2026-07-14-23:55:
FN-7574: proactively refresh OAuth before expiry. Refresh scheduler still
starts BEFORE OAuthExpiryMonitor so a stale-but-refreshable token does not
fire a false "OAuth token expired" ntfy on restart. Only the await moved
off the ensureEngine critical path — relative order is unchanged.
*/
this.oauthRefreshScheduler = new OAuthRefreshScheduler({ authStorage: this.authStorage });
await this.oauthRefreshScheduler.start();
if (this.deferredStartupAborted(generation)) return;
this.oauthExpiryMonitor = new OAuthExpiryMonitor({
authStorage: this.authStorage,
notificationService: this.notificationService,
alertState: oauthAlertState,
});
await this.oauthExpiryMonitor.start();
if (this.deferredStartupAborted(generation)) return;
this.oauthValidityLogger = new OAuthValidityLogger({
authStorage: this.authStorage,
alertState: oauthAlertState,
});
await this.oauthValidityLogger.start();
if (this.deferredStartupAborted(generation)) return;
if (this.notifier) {
await this.notifier.start();
}
runtimeLog.log(`ProjectEngine deferred notifiers+oauth: ${Date.now() - notifiersT0}ms`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
runtimeLog.error(`Deferred notifiers/OAuth failed (continuing automation/merge startup): ${message}`);
}
}
if (this.deferredStartupAborted(generation)) return;
if (this.automationStore && coreAutomationModule) {
const syncT0 = Date.now();
const settings = await store.getSettings();
if (this.deferredStartupAborted(generation)) return;
const startupSyncFailures: string[] = [];
if (typeof coreAutomationModule.syncInsightExtractionAutomation === "function") {
try {
await coreAutomationModule.syncInsightExtractionAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`insight extraction: ${message}`);
runtimeLog.warn(`Insight extraction automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncInsightExtractionAutomation is unavailable; skipping startup sync");
}
if (this.deferredStartupAborted(generation)) return;
if (typeof coreAutomationModule.syncAutoSummarizeAutomation === "function") {
try {
await coreAutomationModule.syncAutoSummarizeAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`auto-summarize: ${message}`);
runtimeLog.warn(`Auto-summarize automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncAutoSummarizeAutomation is unavailable; skipping startup sync");
}
if (this.deferredStartupAborted(generation)) return;
if (typeof coreAutomationModule.syncMemoryDreamsAutomation === "function") {
try {
await coreAutomationModule.syncMemoryDreamsAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`memory dreams: ${message}`);
runtimeLog.warn(`Memory dreams automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncMemoryDreamsAutomation is unavailable; skipping startup sync");
}
if (this.deferredStartupAborted(generation)) return;
if (typeof coreAutomationModule.syncScheduledEvalBatchAutomation === "function") {
try {
await coreAutomationModule.syncScheduledEvalBatchAutomation(this.automationStore, settings);
} catch (err) {
const { message, detail } = formatErrorDetails(err);
startupSyncFailures.push(`scheduled eval: ${message}`);
runtimeLog.warn(`Scheduled eval automation startup sync failed:\n${detail}`);
}
} else {
runtimeLog.warn("syncScheduledEvalBatchAutomation is unavailable; skipping startup sync");
}
if (this.deferredStartupAborted(generation)) return;
// Start CronRunner only after schedule sync so the first tick is not stale.
if (this.cronRunner && !this.deferredStartupAborted(generation)) {
this.cronRunner.start();
runtimeLog.log("CronRunner started after schedule sync");
}
if (startupSyncFailures.length > 0) {
this.setAutomationSubsystemHealth(
"degraded",
`CronRunner started with startup sync warnings: ${startupSyncFailures.join("; ")}`,
);
} else {
this.setAutomationSubsystemHealth(
"ready",
"CronRunner initialized and startup automation sync completed",
);
}
runtimeLog.log(`ProjectEngine deferred automation syncs: ${Date.now() - syncT0}ms`);
} else if (this.cronRunner && !this.deferredStartupAborted(generation)) {
// No sync module/store — still start the runner so schedules are not stuck offline.
this.cronRunner.start();
this.setAutomationSubsystemHealth("ready", "CronRunner started without schedule sync module");
}
if (this.deferredStartupAborted(generation)) return;
const mergeT0 = Date.now();
await this.startupMergeEnqueue(store);
if (this.deferredStartupAborted(generation)) return;
runtimeLog.log(
`ProjectEngine deferred mergeEnqueue: ${Date.now() - mergeT0}ms (total deferred ${Date.now() - t0}ms)`,
);
}
/**
@@ -1108,14 +1208,17 @@ export class ProjectEngine {
* promptly without continuing git/verification work after shutdown starts.
*/
async stop(): Promise<void> {
if (!this.started) {
return;
}
/*
FNXC:FasterStartup 2026-07-15-00:20:
Always raise shuttingDown first so deferred startup work (OAuth, automation
sync, merge enqueue) observes the flag even if start() has not flipped
started yet — prevents unhandled post-stop side effects on fast recycle.
*/
this.shuttingDown = true;
this.startupGeneration += 1;
// FNXC:VerificationConcurrency 2026-07-15-09:05: Drop this project's cap so it no longer pins process min.
unregisterProjectVerificationLimit(this.config.projectId);
// Stop merge retry timer
if (this.mergeRetryTimer) {
clearTimeout(this.mergeRetryTimer);
@@ -1131,6 +1234,28 @@ export class ProjectEngine {
}
this.stopPlannerOverseerPoll();
/*
FNXC:FasterStartup 2026-07-15-00:40:
Even when start() never flipped started (partial/failed start), stop any
critical-path timers already running (gridlock, cron) so abandon/stop does
not leak intervals (Greptile partial-start cleanup).
*/
if (!this.started) {
try {
this.gridlockDetector?.stop();
this.cronRunner?.stop();
this.oauthExpiryMonitor?.stop();
this.oauthRefreshScheduler?.stop();
this.oauthValidityLogger?.stop();
this.notificationService?.stop();
this.notifier?.stop();
this.setAutomationSubsystemHealth("not-initialized", "Automation subsystem stopped (partial start)");
} catch {
// Best-effort partial cleanup
}
return;
}
// Abort active/pending merge work before tearing down sessions.
this.mergeAbortController?.abort();
this.mergeAbortController = null;
@@ -4706,32 +4831,56 @@ export class ProjectEngine {
store.on("task:deleted", this.taskDeletedHandler);
}
private async startupMergeSweep(store: TaskStore): Promise<void> {
try {
const tasks = await store.listTasks({ column: "in-review" });
// Clear stale "merging"/"merging-pr" statuses left by a prior crash.
// No merge is actually running at startup, so any task still marked
// as merging is a leftover from a previous engine lifecycle.
// This runs unconditionally (regardless of autoMerge setting) because
// stale statuses block manual merges too.
const staleStatuses = new Set(["merging", "merging-pr"]);
for (const t of tasks) {
if (t.status && staleStatuses.has(t.status)) {
runtimeLog.log(`Startup sweep: clearing stale '${t.status}' status on ${t.id}`);
await store.updateTask(t.id, { status: null });
// Update in-memory object so canMergeTask sees the cleared status
(t as any).status = null;
}
/**
* Clear crash-leftover merging statuses so manual merge is unblocked.
* Unconditional (not gated on autoMerge). Safe to run on the critical path.
*/
private async clearStaleMergingStatuses(store: TaskStore): Promise<Task[]> {
const tasks = await store.listTasks({ column: "in-review" });
// No merge is actually running at startup, so any task still marked
// as merging is a leftover from a previous engine lifecycle.
const staleStatuses = new Set(["merging", "merging-pr"]);
for (const t of tasks) {
if (t.status && staleStatuses.has(t.status)) {
runtimeLog.log(`Startup sweep: clearing stale '${t.status}' status on ${t.id}`);
await store.updateTask(t.id, { status: null });
// Update in-memory object so canMergeTask sees the cleared status
(t as any).status = null;
}
}
return tasks as Task[];
}
/**
* Enqueue auto-merge-eligible in-review tasks. Pause-aware; deferred after
* status clear so ensureEngine is not blocked by enqueue work.
*/
private async startupMergeEnqueue(store: TaskStore): Promise<void> {
if (this.shuttingDown) return;
try {
const settings = await store.getSettings();
if (settings.globalPause || settings.enginePaused) {
runtimeLog.log("Auto-merge startup enqueue skipped: pause active");
return;
}
const tasks = await store.listTasks({ column: "in-review" });
if (this.shuttingDown) return;
const enqueued = await this.enqueueEligibleInReviewTasks(tasks as Task[], settings);
if (enqueued > 0) {
runtimeLog.log(`Auto-merge startup sweep: enqueueing ${enqueued} task(s)`);
}
} catch (err: unknown) {
runtimeLog.warn(
`Auto-merge startup enqueue failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Full startup merge sweep (status clear + enqueue). Kept for tests/callers. */
private async startupMergeSweep(store: TaskStore): Promise<void> {
try {
await this.clearStaleMergingStatuses(store);
await this.startupMergeEnqueue(store);
} catch (err: unknown) {
runtimeLog.warn(
`Auto-merge startup sweep failed: ${err instanceof Error ? err.message : String(err)}`,