perf: instant startup via cache hydration + decoupled readiness (#1650)

* perf(startup): hydrate providers/agents from cache (stale-while-revalidate)

Persist last-known provider/agent snapshots instead of stripping them, so the
model/agent pickers paint instantly on cold start. Freshness is preserved by the
background refresh in initializeApp() and activateDirectory() (which overwrite on
success) and by the existing provider/agent config-change subscriptions, so the
prior stale-provider regression stays fixed without blanking the UI during fetch.

* perf(startup): cache directory session list for instant sidebar

Persist a capped slice of each directory's session list and seed the child store
from it on creation, so the sidebar paints chats immediately on cold start.
Bootstrap phase-3 loadSessions overwrites with the fresh list; its empty-list
race guard preserves the seeded sessions during OpenCode warmup.

* perf(startup): hold API requests through OpenCode warmup instead of 503

The readiness gate returned 503 the instant OpenCode wasn't ready, pushing the
client into an exponential-backoff retry loop (500ms -> 1s -> ...) that wasted
seconds of cold-start time and could fail bootstrap outright. Now hold the
request and poll readiness up to a bounded window so the first call succeeds as
soon as OpenCode is up (typically sub-second); still 503 fast past the window so
a genuinely-down server doesn't hang. Adds coverage for both paths.

* perf(startup): surface cached providers/agents in pickers (optimistic readiness)

The model/agent pickers gated purely on isInitialized, so they showed
"Loading…" for the entire init round-trip even when provider/agent data was
already hydrated from cache — making the persisted-cache work invisible. Treat
the pickers as ready as soon as cached providers are present (stale-while-
revalidate), so they paint last-known models/agents instantly and refresh in the
background. First-ever launch (no cache) still shows Loading until init.

* perf(startup): don't abort directory bootstrap on transient phase-1 failure

A failed initial path.get OR session.status aborted the whole directory
bootstrap, stranding it in loading and skipping phase 2/3 (session load).
session.status is live data the event pipeline keeps current, and path.get is
tolerable once a project is resolved from global state. Now only a total
failure (or path.get failing with no resolved project) aborts, so the sidebar
and chat keep advancing and loading sessions through warmup hiccups.

* perf(startup): don't bootstrap directories from archived sidebar rows

Each sidebar session row called useDirectoryStore(dir), which defaulted to
bootstrap:true and triggered a full directory bootstrap. Archived sessions point
at dozens of (often deleted) worktrees, so on startup this fired a session-list
fetch + 6x2s empty-retry storm per dead directory (the logs the user saw). The
store ref there is only read on-demand via getState() in export handlers, never
subscribed, so archived rows don't need it bootstrapped. Add a { bootstrap }
option to useDirectoryStore and skip bootstrap for archived rows; active rows
still bootstrap so live cross-directory session/status keeps aggregating.

* perf(startup): stop empty-session bootstrap retry storm on web/desktop

The post-bootstrap retry re-ran the full directory bootstrap 6x2s whenever the
session list came back empty, on the theory that empty meant OpenCode wasn't
ready. But loadSessions already retries transient failures twice over
(listGlobalSessionPages throws on 5xx and retries internally), so on web/desktop
an empty result is authoritative — the directory genuinely has no sessions (e.g.
deleted worktrees referenced only by archived sessions). That produced the
dozens of '[bootstrap] sessions empty ... 6 attempts; giving up' log storms.
Gate the retry to VS Code, where the bridge can return an empty 200 during
warmup that the inner retries can't catch.

* perf(startup): scope provider/agent config to project (worktrees inherit)

Providers/agents/defaults are project-level, but were keyed per directory, so a
worktree fetched and cached its own snapshot — duplicating the parent project's
load (the trace showed initializeApp loading the worktree and activateDirectory
loading the project concurrently, ~8s of redundant background work).

- resolveConfigDirectory() maps a worktree to its owning project; loadProviders
  /loadAgents/activateDirectory now key by it, so a worktree reuses one shared
  project snapshot. activateDirectory resolves up-front so activeDirectoryKey and
  the snapshot key always match (picker stays consistent); the OpenCode working
  directory is unaffected.
- Add a 30s runtime freshness guard so the stale-while-revalidate background
  refresh skips re-fetching config that was just loaded (initializeApp then
  activateDirectory for the same project), and to avoid churn on rapid project
  switches. Config-change invalidation clears the snapshot, which bypasses the
  guard, so freshness never masks a needed refresh.

* fix(sidebar): default archived sessions to hidden to avoid startup flash

useSessionDisplayStore defaulted showArchivedSessions to true, so on startup
archived sessions rendered by default and then vanished once the persisted
preference rehydrated to hidden — a visible flash. Default to hidden so the
pre-hydration state is the quiet one; users who opted into showing archived keep
their persisted true (default change doesn't override persisted state).

* perf(startup): persist worktree->project mapping to kill cold double-load

The worktree->project map (availableWorktreesByProject) is populated by async git
discovery, so it isn't ready when initializeApp runs — a worktree's first config
load couldn't resolve to its project and duplicated the project's provider/agent
load, saturating OpenCode during cold start (the source of the slow first
createSession/send the user observed). Cache resolved worktree->project mappings
to localStorage so resolveConfigDirectory resolves synchronously at init on
subsequent launches; the project is loaded once and activateDirectory hits the
freshness guard. worktree->project is immutable so a cached entry is safe; live
resolution still populates/corrects the cache.

* perf(startup): persist worktree map for instant sidebar + first-launch keying

Worktree discovery is async (git), so availableWorktreesByProject was empty at
startup: the sidebar worktree list appeared late, and useConfigStore couldn't
resolve a worktree to its project on the first launch (causing the cold
worktree+project double-load). Persist the discovered worktree map to
localStorage and seed it synchronously on store init (stale-while-revalidate:
discovery refreshes in the background via the existing setState, which now
write-through persists). The sidebar paints worktrees instantly and
resolveConfigDirectory resolves the project from the very first launch.

* perf(startup): coalesce concurrent duplicate OpenCode reads in runtimeFetch

On cold start the sync bootstrap and the config store independently fire the same
idempotent reads (providers, config, path, agents, project) concurrently with no
shared dedup, saturating the single OpenCode process and delaying work queued
behind it (e.g. createSession). Coalesce genuinely-concurrent identical GETs to
those read endpoints at the transport layer so OpenCode does the work once; each
caller receives an independent response clone. Tightly scoped: GET only,
allowlisted read paths, never event streams, never a signal-bearing request (so
one caller's abort can't cancel the shared fetch). Entries clear on settle, so it
only shares overlapping in-flight requests — never a stale response.

* perf(startup): cache git branches so the draft branch selector paints instantly

The branch selector above the composer was the slowest-loading element: it's
gated behind a cold 'git branch' fetch (useGitStore, not persisted). Cache the
per-directory branch list to localStorage and seed the store on init (with
isGitRepo:true so the selector's gate passes), and write the cache on every
successful fetchBranches. The ChatInput draft-branch effect now refreshes on
staleness (>30s) rather than mere absence, so seeded branches show immediately
and still refresh in the background without a spinner — no stale-forever
regression. Only the branch list is cached; status/log/diff are untouched.
This commit is contained in:
Bohdan Triapitsyn
2026-06-15 03:16:34 +03:00
committed by GitHub
parent 928b7ff1d6
commit e372c8d8cb
16 changed files with 610 additions and 103 deletions
+34 -13
View File
@@ -1710,16 +1710,26 @@ export function SyncProvider(props: {
}),
})
// VS Code race: if sessions are still empty after bootstrap, OpenCode
// wasn't ready yet (bridge returned 503). Retry a few times.
const state = store.getState()
if (state.session.length === 0 && attempt < 5) {
console.warn(`[bootstrap] sessions empty for ${directory} after attempt ${attempt + 1}; retrying in 2s`)
await new Promise((r) => setTimeout(r, 2000))
store.setState({ status: "loading" as const })
await runBootstrap(attempt + 1)
} else if (state.session.length === 0) {
console.warn(`[bootstrap] sessions empty for ${directory} after ${attempt + 1} attempts; giving up`)
// VS Code-only race: the bridge can answer with an empty 200 (instead
// of a retryable 503) while OpenCode is still warming up, which the two
// retry layers inside loadSessions can't catch. Re-run a few times there.
//
// On web/desktop this retry is both redundant and harmful: loadSessions
// already retries transient failures (listGlobalSessionPages throws on
// 5xx and retries internally), so an empty result here is AUTHORITATIVE —
// the directory genuinely has no sessions (e.g. a deleted worktree only
// referenced by archived sessions). Re-running the full bootstrap 6×2s
// per such directory is the startup log storm.
if (isVSCodeRuntime()) {
const state = store.getState()
if (state.session.length === 0 && attempt < 5) {
console.warn(`[bootstrap] sessions empty for ${directory} after attempt ${attempt + 1}; retrying in 2s`)
await new Promise((r) => setTimeout(r, 2000))
store.setState({ status: "loading" as const })
await runBootstrap(attempt + 1)
} else if (state.session.length === 0) {
console.warn(`[bootstrap] sessions empty for ${directory} after ${attempt + 1} attempts; giving up`)
}
}
}
@@ -2042,11 +2052,22 @@ export function useGlobalSyncSelector<T>(selector: (state: GlobalSyncStore) => T
return useGlobalSyncStore(selector)
}
/** Get the child store for a directory (defaults to current) */
export function useDirectoryStore(directory?: string): StoreApi<DirectoryStore> {
/**
* Get the child store for a directory (defaults to current).
*
* Pass `{ bootstrap: false }` when you only need the store reference for an
* on-demand `getState()` (not live subscription) and must NOT trigger a full
* directory bootstrap. This avoids storms of pointless session-list fetches +
* empty-retry loops for directories that are merely referenced by sidebar rows
* (e.g. archived sessions on deleted worktrees).
*/
export function useDirectoryStore(
directory?: string,
options?: { bootstrap?: boolean },
): StoreApi<DirectoryStore> {
const system = useSyncSystem()
const dir = directory ?? system.directory
return system.childStores.ensureChild(dir)
return system.childStores.ensureChild(dir, options)
}
/** Select from the current directory's store */