From 3139a30e52b560ea96c69b003cf9eb49652fa11f Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Mon, 10 Aug 2026 05:45:43 -0500 Subject: [PATCH] =?UTF-8?q?perf(desktop):=20multi-tile=20grids=20stop=20la?= =?UTF-8?q?gging=20=E2=80=94=20evict=20leaked=20session=20states,=20index?= =?UTF-8?q?=20lineage=20aliases,=20split=20the=20turn=20journal=20(#83133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): evict settled session states nothing on screen references Closing a tile never removed its runtime's entry from $sessionStates, so every tile ever closed parked its full transcript in the map for the life of the process. Each leftover entry taxes every subsequent stream flush — the map is spread-copied per delta and the busy/attention/draft projections walk every entry per publish — so the app got slower the longer it ran, which users read as "I need to clean my sessions/dbs". Publish now evicts a settling state when no tile and not the primary view holds its runtime (transition side effects still fire, so the settle keeps its unread dot), and closing a tile drops an already-settled state on the spot. Busy and needs-input states stay: background turns feed the sidebar dots, and a first publish always lands because a resume can publish a beat before the surface binds the runtime. 16 tiles streaming in a 2x2 grid with a day's worth of closed-tile residue: worst-second 34 -> 58 fps, p99 frame 90 -> 28 ms, longtasks 37 -> 0. * perf(desktop): index lineage aliases per sessions-list reference lineageAliases scanned the whole recents list per call, and it is called per cached session state per status projection per message delta — with a populated sessions DB and a few busy sessions that multiplied out to millions of row checks a second during streaming. Build the alias index once per list reference (the list is replaced wholesale, never mutated) and look aliases up in O(1). * perf(desktop): journal each in-flight turn under its own storage key The v1 journal kept every session's tail in one localStorage key, so each throttled write re-parsed and re-stringified EVERY busy session's snapshot — a grid of concurrent streams turned that into a whole-store JSON round trip dozens of times a second, all on the main thread. Per-session keys make a write O(own tail) no matter how many other sessions are streaming. A v1 store migrates on first touch; expired/overflow crash residue is pruned once per renderer. * perf(desktop): stress the multitab scenario across grid/streaming/DB axes The one-stack multitab run hid every cost this round of fixes removed: it drove hook.publish (store only — no journal, no wiring cache), with an empty recents list and no closed-tile residue. Streaming now routes through hook.update (the real gateway write path), and the scenario grows axes for the workloads users actually hit: --zones splits tiles across visible grid zones, --streaming caps how many sessions are mid-turn (zone leaders first), --sessions seeds a lived-in recents list, --dead models settled sessions no surface references. launch.mjs pins HERMES_DESKTOP_CDP_PORT so a non-default --port survives the app's own dev-CDP flag. --- apps/desktop/scripts/perf/lib/launch.mjs | 5 + .../scripts/perf/scenarios/multitab.mjs | 166 +++++++++++++--- .../src/lib/inflight-turn-journal.test.ts | 50 ++++- apps/desktop/src/lib/inflight-turn-journal.ts | 182 +++++++++++------- .../src/store/session-states-eviction.test.ts | 107 ++++++++++ apps/desktop/src/store/session-states.ts | 68 ++++++- apps/desktop/src/store/session.ts | 65 +++++-- 7 files changed, 519 insertions(+), 124 deletions(-) create mode 100644 apps/desktop/src/store/session-states-eviction.test.ts diff --git a/apps/desktop/scripts/perf/lib/launch.mjs b/apps/desktop/scripts/perf/lib/launch.mjs index 08410bbf2869f..783a04ea2e5d6 100644 --- a/apps/desktop/scripts/perf/lib/launch.mjs +++ b/apps/desktop/scripts/perf/lib/launch.mjs @@ -238,6 +238,11 @@ export async function startIsolatedInstance({ const env = { ...process.env, HERMES_HOME: home, + // The app's dev-CDP resolver (electron/dev-cdp.ts) appends its own + // remote-debugging-port switch AFTER argv, so on a non-default --port the + // Chromium flag below loses and the instance binds 9222 anyway. The env + // override is the supported knob — set it so --port actually wins. + HERMES_DESKTOP_CDP_PORT: String(port), XCURSOR_SIZE: '24' } diff --git a/apps/desktop/scripts/perf/scenarios/multitab.mjs b/apps/desktop/scripts/perf/scenarios/multitab.mjs index d93116f12855c..d105d14f808df 100644 --- a/apps/desktop/scripts/perf/scenarios/multitab.mjs +++ b/apps/desktop/scripts/perf/scenarios/multitab.mjs @@ -3,11 +3,21 @@ // "5 tabs doing PR review" workload. Measures frame pacing + longtasks while // the whole stack streams, which is where multitab renderers crawl. // -// Drives the real pipeline synthetically (no backend, no credits): -// publishSessionState per session per flush — exactly what the gateway's -// delta flush does — via the __HERMES_SESSION_TILES__ hook. +// --zones M splits the tiles across M VISIBLE split zones (a 2×2 grid for 4) +// instead of one tab stack — the "4 tiles with 4 sessions each" workload, +// where M transcripts stream on screen at once and the rest are mounted +// keep-alive tabs behind them. --streaming S caps how many sessions are +// actually mid-turn (zone leaders first, so S=zones means "every visible +// transcript streams, every hidden tab idles"); the rest sit settled. +// --sessions N seeds a populated recents list (a lived-in sessions DB). +// +// Drives the real pipeline synthetically (no backend, no credits): each tick +// routes one delta per streaming session through `hook.update` — the same +// wiring-cache write (journal + publish + view sync) the gateway's delta +// flush performs — via the __HERMES_SESSION_TILES__ hook. // // node scripts/perf/run.mjs multitab --spawn [--tiles 5] [--tokens 240] +// node scripts/perf/run.mjs multitab --spawn --tiles 16 --zones 4 --sessions 300 import { sleep } from '../lib/cdp.mjs' import { frameHistogram, percentile } from '../lib/stats.mjs' @@ -50,12 +60,19 @@ const COLLECT = ` })() ` -/** Page-side setup: open `tiles` session tiles stacked into the main zone, - * bind fake runtime ids, and seed each with a realistic transcript. */ -const setup = (tiles, seedTurns, streamSeed) => ` +/** Page-side setup: open `tiles` session tiles — one tab stack in the main + * zone (zones=1), or spread across `zones` visible splits (a 2×2 grid for 4) + * — bind fake runtime ids, and seed each with a realistic transcript. + * + * States are written through `hook.update` — the REAL gateway write path + * (wiring cache + in-flight journal + publish + view sync). Driving + * `hook.publish` alone under-models a stream: it skips the journal and the + * cache, which is exactly where multi-session cost used to hide. */ +const setup = (tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead) => ` (() => { const hook = window.__HERMES_SESSION_TILES__ if (!hook) return 'no-hook' + if (!hook.update) return 'no-update-hook' const turn = (sid, i) => ([ { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), @@ -75,29 +92,89 @@ const setup = (tiles, seedTurns, streamSeed) => ` ].join('\\n') }] } ]) - const state = (sid, rid) => { + const state = (sid, rid, isStreaming) => { const messages = [] for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i)) - // Streaming tail the driver grows (--code seeds an open fence). - messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, - parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] }) + // Streaming tail the driver grows (--code seeds an open fence); a + // non-streaming session sits settled — open, mounted, mid-nothing. + if (isStreaming) { + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] }) + } return { storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', - busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + busy: isStreaming, awaitingResponse: false, + streamId: isStreaming ? sid + '-stream' : null, sawAssistantPayload: true, pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, - needsInput: false, turnStartedAt: Date.now(), usage: null + needsInput: false, turnStartedAt: isStreaming ? Date.now() : null, usage: null } } - window.__MT__ = { ids: [], timer: null } + // A populated recents list (--sessions): every store publish re-runs the + // busy/attention/draft projections against it, so an empty list hides + // that scaling. Restored by CLEANUP. + if (${seedSessions} > 0) { + window.__MT_SAVED_SESSIONS__ = hook.sessions() + const rows = [] + for (let i = 0; i < ${seedSessions}; i++) { + rows.push({ + id: 'perf-row-' + i, title: 'Seeded session ' + i, ended_at: null, + input_tokens: 1200, output_tokens: 800, is_active: false, + last_active: Date.now() - i * 60000, message_count: 12, + model: 'hermes-4', preview: 'seeded row', cwd: '/tmp/proj-' + (i % 7) + }) + } + hook.seedSessions(rows) + } + + // Leaked residue (--dead): sessions that ran with no surface referencing + // them and then settled — what a day of opening and closing tiles + // accumulates. Modeled on the real path (insert while busy, then the + // settle publish) so publish-time eviction, where present, engages. + // CLEANUP drops whatever survives, for builds without eviction. + window.__MT_DEAD__ = [] + for (let d = 0; d < ${dead}; d++) { + const sid = 'perf-dead-' + d + const rid = 'perf-dead-rt-' + d + window.__MT_DEAD__.push(rid) + const settled = state(sid, rid, false) + hook.publish(rid, { ...settled, busy: true }) + hook.publish(rid, settled) + } + + // Zone leaders open as visible splits (right of the workspace, then + // subdividing that column into a grid); followers stack as tabs into + // their zone. zones=1 keeps the classic one-stack workload. + const perZone = Math.ceil(${tiles} / ${zones}) + const leaders = [] + + // Streaming slots go to zone LEADERS first (rank orders round-robin across + // zones), so --streaming ${'$'}{zones} means "every VISIBLE transcript streams, + // every hidden tab idles" — the split the all-vs-visible snapshots diff. + window.__MT__ = { ids: [], leaders, streaming: [], timer: null } for (let n = 1; n <= ${tiles}; n++) { const sid = 'perf-tile-' + n const rid = 'perf-rt-' + n window.__MT__.ids.push({ sid, rid }) - hook.open(sid, 'center') + const zone = ${zones} > 1 ? Math.floor((n - 1) / perZone) : 0 + const posInZone = ${zones} > 1 ? (n - 1) % perZone : n - 1 + const rank = posInZone * ${zones} + zone + const isStreaming = rank < ${streaming} + if (isStreaming) window.__MT__.streaming.push(rid) + const leader = leaders[zone] + if (leader) { + hook.open(sid, 'center', 'session-tile:' + leader) + } else if (${zones} === 1) { + hook.open(sid, 'center') + } else { + leaders[zone] = sid + if (zone === 0) hook.open(sid, 'right') + else if (zone === 1) hook.open(sid, 'bottom', 'session-tile:' + leaders[0]) + else hook.open(sid, 'right', 'session-tile:' + leaders[zone - 2]) + } hook.patch(sid, { runtimeId: rid }) - hook.publish(rid, state(sid, rid)) + hook.update(rid, () => state(sid, rid, isStreaming)) } return 'ok' })() @@ -108,23 +185,23 @@ const setup = (tiles, seedTurns, streamSeed) => ` const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` /** Page-side driver: grow every tile's streaming tail by `chunk` each - * `intervalMs`, through the same publish path the gateway flush uses. */ + * `intervalMs`, through the same write path the gateway flush uses. */ const drive = (chunk, intervalMs, totalTokens) => ` (() => { const hook = window.__HERMES_SESSION_TILES__ let pushed = 0 const tick = () => { - const states = hook.states() - for (const { rid } of window.__MT__.ids) { - const prev = states[rid] - if (!prev) continue - const messages = prev.messages.map(m => { - if (m.id !== prev.streamId) return m - const head = m.parts.slice(0, -1) - const last = m.parts[m.parts.length - 1] - return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] } + for (const rid of window.__MT__.streaming) { + hook.update(rid, prev => { + if (!prev.streamId) return prev + const messages = prev.messages.map(m => { + if (m.id !== prev.streamId) return m + const head = m.parts.slice(0, -1) + const last = m.parts[m.parts.length - 1] + return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] } + }) + return { ...prev, messages } }) - hook.publish(rid, { ...prev, messages }) } pushed += 1 if (pushed < ${totalTokens}) window.__MT__.timer = setTimeout(tick, ${intervalMs}) @@ -137,16 +214,24 @@ const drive = (chunk, intervalMs, totalTokens) => ` const CLEANUP = ` (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (window.__MT_DEAD__) { + for (const rid of window.__MT_DEAD__) hook.drop?.(rid) + window.__MT_DEAD__ = null + } if (window.__MT__) { clearTimeout(window.__MT__.timer) for (const { sid, rid } of window.__MT__.ids) { - window.__HERMES_SESSION_TILES__.publish(rid, { - ...window.__HERMES_SESSION_TILES__.states()[rid], busy: false, streamId: null - }) - window.__HERMES_SESSION_TILES__.close(sid) + // Settle through the real path so the in-flight journal entry clears. + hook.update(rid, prev => ({ ...prev, busy: false, streamId: null })) + hook.close(sid) } window.__MT__ = null } + if (window.__MT_SAVED_SESSIONS__) { + hook.seedSessions(window.__MT_SAVED_SESSIONS__) + window.__MT_SAVED_SESSIONS__ = null + } return 'cleaned' })() ` @@ -157,7 +242,11 @@ export default { description: 'N mounted session-tile tabs all streaming: frame pacing + longtasks.', async run(cdp, opts = {}) { const tiles = Number(opts.tiles ?? 5) + const zones = Number(opts.zones ?? 1) const seedTurns = Number(opts.turns ?? 20) + const seedSessions = Number(opts.sessions ?? 0) + const streaming = Math.min(Number(opts.streaming ?? tiles), tiles) + const dead = Number(opts.dead ?? 0) const tokens = Number(opts.tokens ?? 240) // Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush. const intervalMs = Number(opts.intervalMs ?? 33) @@ -172,7 +261,7 @@ export default { await cdp.send('Runtime.enable') - const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed)) + const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead)) if (ok !== 'ok') { throw new Error(`multitab setup failed (${ok}) — dev hooks missing? (needs a dev/probe renderer)`) @@ -184,6 +273,17 @@ export default { await sleep(350) } + // Front each zone's leader so the visible set is one transcript per zone + // (the reveal loop above leaves each zone on its LAST tab). + if (zones > 1) { + const leaders = JSON.parse(await cdp.eval('JSON.stringify(window.__MT__.leaders)')) + + for (const sid of leaders) { + await cdp.eval(reveal(sid)) + await sleep(150) + } + } + await sleep(1000) await cdp.eval(RECORDERS) await cdp.eval(drive(chunk, intervalMs, tokens)) @@ -235,6 +335,10 @@ export default { }, detail: { tiles, + zones, + streaming, + dead, + sessions: seedSessions, code: Boolean(opts.code), windowS: Math.round(windowS * 10) / 10, avgFps: Math.round(avgFps * 10) / 10, diff --git a/apps/desktop/src/lib/inflight-turn-journal.test.ts b/apps/desktop/src/lib/inflight-turn-journal.test.ts index 573d5f82276e5..206a122288949 100644 --- a/apps/desktop/src/lib/inflight-turn-journal.test.ts +++ b/apps/desktop/src/lib/inflight-turn-journal.test.ts @@ -10,7 +10,8 @@ import { recoverInFlightTurnJournal } from '@/lib/inflight-turn-journal' -const STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1' +const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:' +const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1' function user(id: string, text: string): ChatMessage { return { id, role: 'user', parts: [{ type: 'text', text }] } @@ -112,12 +113,53 @@ describe('persistInFlightTurnState', () => { persistInFlightTurnState(journalState()) vi.advanceTimersByTime(400) - const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!) - raw.entries['stored-1'].updatedAt = Date.now() - 8 * 24 * 60 * 60 * 1000 - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(raw)) + const raw = JSON.parse(window.localStorage.getItem(`${STORAGE_PREFIX}stored-1`)!) + raw.updatedAt = Date.now() - 8 * 24 * 60 * 60 * 1000 + window.localStorage.setItem(`${STORAGE_PREFIX}stored-1`, JSON.stringify(raw)) expect(readInFlightTurnJournal('stored-1')).toBeNull() }) + + it('writes each session under its own key, untouched by other sessions settling', () => { + persistInFlightTurnState(journalState()) + persistInFlightTurnState(journalState({ storedSessionId: 'stored-2' })) + vi.advanceTimersByTime(400) + + expect(window.localStorage.getItem(`${STORAGE_PREFIX}stored-1`)).not.toBeNull() + expect(window.localStorage.getItem(`${STORAGE_PREFIX}stored-2`)).not.toBeNull() + + clearInFlightTurnJournal('stored-2') + + expect(readInFlightTurnJournal('stored-1')).not.toBeNull() + expect(readInFlightTurnJournal('stored-2')).toBeNull() + }) + + it('recovers entries journaled by the v1 single-key store', () => { + // A pre-upgrade crash leaves a v1 store behind; the first journal touch + // after the upgrade must still recover its turns. + window.localStorage.setItem( + LEGACY_STORAGE_KEY, + JSON.stringify({ + entries: { + 'stored-legacy': { + messages: [user('u1', 'legacy prompt'), assistant('a1', 'legacy partial', { pending: true })], + streamId: 'a1', + turnStartedAt: 500, + updatedAt: Date.now() + } + }, + version: 1 + }) + ) + + const entry = readInFlightTurnJournal('stored-legacy') + + expect(entry?.streamId).toBe('a1') + expect(entry?.messages).toHaveLength(2) + expect(window.localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull() + + clearInFlightTurnJournal('stored-legacy') + }) }) describe('recoverInFlightTurnJournal', () => { diff --git a/apps/desktop/src/lib/inflight-turn-journal.ts b/apps/desktop/src/lib/inflight-turn-journal.ts index 050c4ff0c94ba..27912970ff672 100644 --- a/apps/desktop/src/lib/inflight-turn-journal.ts +++ b/apps/desktop/src/lib/inflight-turn-journal.ts @@ -16,8 +16,13 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/c * Best-effort by design: storage failures must never break chat streaming. */ -const STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1' -const STORE_VERSION = 1 +/** One localStorage key PER SESSION. The v1 single-key store meant every + * throttled write re-parsed and re-stringified EVERY busy session's tail — + * with a grid of concurrent streams that was a whole-store JSON round-trip + * dozens of times a second, all on the main thread. Per-session keys make a + * write O(own tail) regardless of how many other sessions are streaming. */ +const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:' +const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1' const MAX_ENTRIES = 24 const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 /** Streaming repaints arrive every ~33ms; localStorage writes are synchronous. @@ -41,11 +46,6 @@ export interface JournalableSessionState { turnStartedAt: null | number } -interface JournalStore { - entries: Record - version: typeof STORE_VERSION -} - export interface InFlightRecoveryResult { applied: boolean /** The base transcript already contains the journaled turn's completed @@ -64,73 +64,131 @@ function storage(): Storage | null { } } -function emptyStore(): JournalStore { - return { entries: {}, version: STORE_VERSION } +const entryKey = (storedSessionId: string) => `${STORAGE_PREFIX}${storedSessionId}` + +function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean { + return now - entry.updatedAt > MAX_AGE_MS } -function loadStore(): JournalStore { +function loadEntry(storedSessionId: string): InFlightTurnSnapshot | null { const store = storage() if (!store) { - return emptyStore() + return null } try { - const raw = store.getItem(STORAGE_KEY) + const raw = store.getItem(entryKey(storedSessionId)) + const parsed = raw ? (JSON.parse(raw) as InFlightTurnSnapshot) : null - if (!raw) { - return emptyStore() - } - - const parsed = JSON.parse(raw) - - if ( - !parsed || - parsed.version !== STORE_VERSION || - typeof parsed.entries !== 'object' || - Array.isArray(parsed.entries) - ) { - return emptyStore() - } - - return { - entries: parsed.entries as Record, - version: STORE_VERSION - } + return parsed && typeof parsed.updatedAt === 'number' && Array.isArray(parsed.messages) ? parsed : null } catch { - return emptyStore() + return null } } -function saveStore(journal: JournalStore): void { +function saveEntry(storedSessionId: string, entry: InFlightTurnSnapshot): void { + try { + storage()?.setItem(entryKey(storedSessionId), JSON.stringify(entry)) + } catch { + // Quota/private-mode failures: the journal is a recovery aid, not truth. + } +} + +function removeEntry(storedSessionId: string): void { + try { + storage()?.removeItem(entryKey(storedSessionId)) + } catch { + // Same best-effort stance as saveEntry. + } +} + +// Split a v1 single-key store into per-session entries. Checked on every +// journal touch (a null getItem is free); a populated v1 store exists at most +// once, right after the upgrade. +function migrateLegacyStore(store: Storage): void { + try { + const legacy = store.getItem(LEGACY_STORAGE_KEY) + + if (!legacy) { + return + } + + const parsed = JSON.parse(legacy) + + if (parsed && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) { + for (const [id, entry] of Object.entries(parsed.entries as Record)) { + saveEntry(id, entry) + } + } + } catch { + // A corrupt v1 store has nothing worth carrying over. + } + + try { + store.removeItem(LEGACY_STORAGE_KEY) + } catch { + // Best-effort, like every other journal write. + } +} + +// One-time prune per renderer: drop expired/overflow entries. Startup-only on +// purpose — entries clear on settle, so anything left over is crash residue, +// and enumerating localStorage on the write path would defeat the point. +let housekeepingDone = false + +function ensureHousekeeping(): void { const store = storage() if (!store) { return } + migrateLegacyStore(store) + + if (housekeepingDone) { + return + } + + housekeepingDone = true + try { - const entries = Object.fromEntries( - Object.entries(journal.entries) - .filter(([, entry]) => !isExpired(entry)) - .sort((a, b) => b[1].updatedAt - a[1].updatedAt) - .slice(0, MAX_ENTRIES) - ) + const keys: string[] = [] - if (Object.keys(entries).length === 0) { - store.removeItem(STORAGE_KEY) + for (let index = 0; index < store.length; index += 1) { + const key = store.key(index) - return + if (key?.startsWith(STORAGE_PREFIX)) { + keys.push(key) + } } - store.setItem(STORAGE_KEY, JSON.stringify({ entries, version: STORE_VERSION })) - } catch { - // Quota/private-mode failures: the journal is a recovery aid, not truth. - } -} + const live: { key: string; updatedAt: number }[] = [] -function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean { - return now - entry.updatedAt > MAX_AGE_MS + for (const key of keys) { + let entry: InFlightTurnSnapshot | null = null + + try { + entry = JSON.parse(store.getItem(key) ?? '') as InFlightTurnSnapshot + } catch { + // Unparseable — prune below. + } + + if (!entry || typeof entry.updatedAt !== 'number' || isExpired(entry)) { + store.removeItem(key) + } else { + live.push({ key, updatedAt: entry.updatedAt }) + } + } + + live.sort((a, b) => b.updatedAt - a.updatedAt) + + for (const { key } of live.slice(MAX_ENTRIES)) { + store.removeItem(key) + } + } catch { + // Best-effort, like every other journal write. + } } function cloneMessages(messages: ChatMessage[]): ChatMessage[] { @@ -419,15 +477,13 @@ function writeSnapshot(storedSessionId: string, state: JournalableSessionState): return } - const journal = loadStore() - - journal.entries[storedSessionId] = { + ensureHousekeeping() + saveEntry(storedSessionId, { messages: tail, streamId: state.streamId, turnStartedAt: state.turnStartedAt, updatedAt: Date.now() - } - saveStore(journal) + }) } /** Persist the running turn's visible tail (throttled), or clear the entry the @@ -471,16 +527,15 @@ export function readInFlightTurnJournal(storedSessionId: null | string): InFligh return null } - const journal = loadStore() - const entry = journal.entries[storedSessionId] + ensureHousekeeping() + const entry = loadEntry(storedSessionId) if (!entry) { return null } if (isExpired(entry)) { - delete journal.entries[storedSessionId] - saveStore(journal) + removeEntry(storedSessionId) return null } @@ -533,13 +588,6 @@ export function clearInFlightTurnJournal(storedSessionId: null | string): void { } persistLatest.delete(storedSessionId) - - const journal = loadStore() - - if (!(storedSessionId in journal.entries)) { - return - } - - delete journal.entries[storedSessionId] - saveStore(journal) + ensureHousekeeping() + removeEntry(storedSessionId) } diff --git a/apps/desktop/src/store/session-states-eviction.test.ts b/apps/desktop/src/store/session-states-eviction.test.ts new file mode 100644 index 0000000000000..19432e7a69bb3 --- /dev/null +++ b/apps/desktop/src/store/session-states-eviction.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { createClientSessionState } from '@/lib/chat-runtime' +import { $activeSessionId, $selectedStoredSessionId, $sessions, $unreadFinishedSessionIds } from '@/store/session' +import { $sessionStates, $sessionTiles, closeSessionTile, publishSessionState } from '@/store/session-states' + +/** + * The closed-tile leak: gateway events keep publishing for sessions whose + * surface is gone, and every parked transcript taxes every later publish (map + * spread + the status projections run per entry per message delta). A settled + * state nothing references must leave the map; everything a surface still + * needs must stay. + */ + +const state = (storedId: string, patch: Partial> = {}) => ({ + ...createClientSessionState(storedId), + messages: [{ id: `${storedId}-m`, role: 'assistant' as const, parts: [{ type: 'text' as const, text: 'hi' }] }], + ...patch +}) + +beforeEach(() => { + $sessionStates.set({}) + $sessionTiles.set([]) + $sessions.set([]) + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + $unreadFinishedSessionIds.set([]) +}) + +describe('publish-time eviction', () => { + it('evicts a settling session no surface references, keeping its unread dot', () => { + publishSessionState('rt-1', state('stored-1', { busy: true })) + expect($sessionStates.get()['rt-1']).toBeDefined() + + publishSessionState('rt-1', state('stored-1', { busy: false })) + + expect($sessionStates.get()['rt-1']).toBeUndefined() + // The settle transition still fired: the sidebar's unread marker landed. + expect($unreadFinishedSessionIds.get()).toContain('stored-1') + }) + + it('keeps a busy session with no surface — its background turn feeds the sidebar dot', () => { + publishSessionState('rt-1', state('stored-1', { busy: true })) + publishSessionState('rt-1', state('stored-1', { busy: true, awaitingResponse: true })) + + expect($sessionStates.get()['rt-1']).toBeDefined() + }) + + it('keeps a needsInput session with no surface — the attention dot reads it', () => { + publishSessionState('rt-1', state('stored-1', { busy: true })) + publishSessionState('rt-1', state('stored-1', { busy: false, needsInput: true })) + + expect($sessionStates.get()['rt-1']).toBeDefined() + }) + + it('keeps a settled session an open tile references, by runtime or stored id', () => { + $sessionTiles.set([{ runtimeId: 'rt-1', storedSessionId: 'stored-1' }]) + publishSessionState('rt-1', state('stored-1', { busy: true })) + publishSessionState('rt-1', state('stored-1', { busy: false })) + expect($sessionStates.get()['rt-1']).toBeDefined() + + // Mid-resume a tile holds only the stored id (runtime binding not patched + // in yet) — that reference must count too. + $sessionTiles.set([{ storedSessionId: 'stored-2' }]) + publishSessionState('rt-2', state('stored-2', { busy: true })) + publishSessionState('rt-2', state('stored-2', { busy: false })) + expect($sessionStates.get()['rt-2']).toBeDefined() + }) + + it('keeps the primary view\'s settled session', () => { + $activeSessionId.set('rt-1') + publishSessionState('rt-1', state('stored-1', { busy: true })) + publishSessionState('rt-1', state('stored-1', { busy: false })) + + expect($sessionStates.get()['rt-1']).toBeDefined() + }) + + it('always lands a FIRST publish — resume can publish before the surface points at the runtime', () => { + publishSessionState('rt-1', state('stored-1', { busy: false })) + + expect($sessionStates.get()['rt-1']).toBeDefined() + }) +}) + +describe('closeSessionTile eviction', () => { + it('drops a settled session\'s state on close — no later publish may come', () => { + $sessionTiles.set([{ runtimeId: 'rt-1', storedSessionId: 'stored-1' }]) + publishSessionState('rt-1', state('stored-1', { busy: false })) + + closeSessionTile('stored-1') + + expect($sessionStates.get()['rt-1']).toBeUndefined() + }) + + it('keeps a busy session\'s state on close — the background turn is still running', () => { + $sessionTiles.set([{ runtimeId: 'rt-1', storedSessionId: 'stored-1' }]) + publishSessionState('rt-1', state('stored-1', { busy: true })) + + closeSessionTile('stored-1') + + expect($sessionStates.get()['rt-1']).toBeDefined() + + // ... and its settle publish is what evicts it. + publishSessionState('rt-1', state('stored-1', { busy: false })) + expect($sessionStates.get()['rt-1']).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 79020f0450bbd..9e6b33c42719a 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -40,7 +40,8 @@ import { $unreadFinishedSessionIds, lineageAliases, sessionMatchesStoredId, - setActiveSessionStoredIdRotation + setActiveSessionStoredIdRotation, + setSessions } from './session' import { isSecondaryWindow } from './windows' @@ -191,6 +192,28 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi } } +/** Is any surface on THIS window still holding the runtime — the primary view + * or an open tile? (A tile mid-resume references by stored id only; its + * runtime binding is patched in after `resumeTile` returns.) */ +function runtimeReferenced(runtimeId: string, storedSessionId: null | string): boolean { + if (runtimeId === $activeSessionId.get()) { + return true + } + + return $sessionTiles.get().some( + t => t.runtimeId === runtimeId || (storedSessionId !== null && t.storedSessionId === storedSessionId) + ) +} + +/** A state no surface needs anymore: its turn is over (not busy, not waiting + * on the user) and neither the primary view nor any tile holds the runtime. + * `needsInput` states stay — the sidebar's attention dot reads them. */ +function evictable(runtimeId: string, state: ClientSessionState): boolean { + return ( + !state.busy && !state.needsInput && !state.awaitingResponse && !runtimeReferenced(runtimeId, state.storedSessionId) + ) +} + /** Publish one session's state. Automatically fires transition side-effects * (watchdog arm/disarm, settle grace, unread marker, compression id rotation) * by diffing previous vs next — callers never need to manually call a @@ -203,7 +226,17 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi * ($workingSessionIds, $attentionSessionIds) and their subscribers * unnecessarily. The runtime-id→state cache (sessionStateByRuntimeIdRef) * is updated independently by the caller, so the visual path stays live - * without the store churn. */ + * without the store churn. + * + * A settled state nothing references is EVICTED instead of republished: + * gateway events keep flowing for sessions whose tile was closed mid-turn, + * and parking each one's full transcript here forever is the leak that made + * the app crawl after a day of tile use — every entry taxes every later + * publish (map spread + the status-set projections). Transition side effects + * still fire, so the closed session's settle keeps its unread dot. Only an + * entry already in the map is evicted — a FIRST publish always lands, because + * a resume can publish its idle state a beat before `$activeSessionId` / + * the tile's runtime binding points at it. */ export function publishSessionState(runtimeId: string, state: ClientSessionState) { const current = $sessionStates.get() const prev = current[runtimeId] ?? null @@ -212,6 +245,14 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState return } + if (prev && evictable(runtimeId, state)) { + handleTransition(prev, state, runtimeId) + const { [runtimeId]: _dropped, ...rest } = current + $sessionStates.set(rest) + + return + } + $sessionStates.set({ ...current, [runtimeId]: state }) handleTransition(prev, state, runtimeId) } @@ -753,6 +794,18 @@ export function closeSessionTile(storedSessionId: string) { } saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) + + // A settled session may never publish again, so the publish-time eviction + // in publishSessionState can't reach it — drop its cached state here. A + // BUSY one stays: its turn keeps streaming in the background, the sidebar + // dot reads it, and settle evicts it. ⌘⇧T reopen re-publishes from the + // wiring cache (resumeTile's warm path), so nothing is lost. + const runtimeId = tile?.runtimeId + const state = runtimeId ? $sessionStates.get()[runtimeId] : undefined + + if (runtimeId && state && evictable(runtimeId, state)) { + dropSessionState(runtimeId) + } } /** Drop a DEAD tile — a persisted tile whose session no longer exists on the @@ -869,10 +922,19 @@ $selectedStoredSessionId.listen(selected => { if ((import.meta.env.DEV || import.meta.env.VITE_PERF_PROBE === '1') && typeof window !== 'undefined') { ;(window as unknown as Record).__HERMES_SESSION_TILES__ = { close: closeSessionTile, + drop: dropSessionState, open: openSessionTile, patch: patchSessionTile, publish: publishSessionState, + /** Seed the recents list — models a populated sessions DB in perf runs. */ + seedSessions: (rows: SessionInfo[]) => setSessions(rows), + sessions: () => $sessions.get(), states: () => $sessionStates.get(), - tiles: () => $sessionTiles.get() + tiles: () => $sessionTiles.get(), + /** THE real gateway write path (wiring cache + journal + publish + view + * sync), unlike `publish` which only touches the store. Perf scenarios + * must drive this or they under-model streaming cost. */ + update: (runtimeId: string, updater: (state: ClientSessionState) => ClientSessionState) => + sessionTileDelegate()?.updateSession(runtimeId, updater) } } diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 794e7be61e4e1..497909a10c289 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -254,6 +254,48 @@ export const sessionMatchesStoredId = ( storedSessionId: string ): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId +// Alias lookup, memoized per sessions-list reference. `lineageAliases` runs +// per cached session state per status projection per message delta — an +// O(sessions) scan there multiplies out to states × sessions × ~30Hz per busy +// session, which is what made a populated recents list drag every stream. The +// list is replaced wholesale (never mutated), so its reference is the cache key. +type LineageRow = Pick +const lineageIndexBySessions = new WeakMap>() + +function lineageIndex(sessions: readonly LineageRow[]): Map { + const cached = lineageIndexBySessions.get(sessions) + + if (cached) { + return cached + } + + const index = new Map() + + const add = (key: string, value: string) => { + const bucket = index.get(key) + + if (!bucket) { + index.set(key, [value]) + } else if (!bucket.includes(value)) { + bucket.push(value) + } + } + + for (const session of sessions) { + add(session.id, session.id) + + if (session._lineage_root_id) { + add(session.id, session._lineage_root_id) + add(session._lineage_root_id, session.id) + add(session._lineage_root_id, session._lineage_root_id) + } + } + + lineageIndexBySessions.set(sessions, index) + + return index +} + /** Every id one conversation answers to: the id we were handed, plus the live * id and lineage root of each session it resolves to. * @@ -262,25 +304,10 @@ export const sessionMatchesStoredId = ( * same lineage after a compression. Publishing every alias lets those surfaces * keep using a plain membership test instead of each re-deriving lineage — * and getting it wrong, which reads as a running session going idle mid-turn. */ -export function lineageAliases( - storedId: string, - sessions: readonly Pick[] -): string[] { - const aliases = new Set([storedId]) - - for (const session of sessions) { - if (!sessionMatchesStoredId(session, storedId)) { - continue - } - - aliases.add(session.id) - - if (session._lineage_root_id) { - aliases.add(session._lineage_root_id) - } - } - - return [...aliases] +export function lineageAliases(storedId: string, sessions: readonly LineageRow[]): string[] { + // Every key is in its own bucket by construction, so the bucket IS the + // alias set. Copied so no caller can mutate the shared index. + return lineageIndex(sessions).get(storedId)?.slice() ?? [storedId] } /** True when two ids name the same conversation across compression tip rotation. */