Merge pull request #66747 from NousResearch/perf/desktop-hot-paths

perf(desktop): cut startup serialization and per-turn REST amplification
This commit is contained in:
brooklyn! 2026-07-18 17:42:41 -04:00 committed by GitHub
commit 73e32f37e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 448 additions and 26 deletions

View file

@ -7655,12 +7655,20 @@ function createWindow() {
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
}
// Start the Python backend NOW, in parallel with the renderer load — not on
// did-finish-load. The backend cold boot (spawn → port announce → /api/status)
// is the dominant startup cost, and serializing it behind Chromium's load
// added the whole renderer load time to first-usable-composer. The promise is
// shared (backendConnectionState), so the renderer's getConnection() joins
// this in-flight boot instead of duplicating it; early boot-progress events
// the renderer misses are recovered by its getBootProgress() pull on mount.
startHermes().catch(error => rememberLog(error.stack || error.message))
mainWindow.webContents.once('did-finish-load', () => {
// Zoom restore is handled by wireCommonWindowHandlers (shared with session
// windows); no need to reapply it here.
broadcastBootProgress()
sendWindowStateChanged()
startHermes().catch(error => rememberLog(error.stack || error.message))
})
}

View file

@ -284,10 +284,14 @@ export function useGatewayBoot({
return
}
// Same shape as boot(): profile first (session scope depends on it),
// then the independent fetches concurrently.
await adoptPrimaryProfile()
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig().catch(() => undefined),
callbacksRef.current.refreshSessions().catch(() => undefined)
])
completeDesktopBoot()
bootCompleted = true
} catch (err) {
@ -460,6 +464,11 @@ export function useGatewayBoot({
return
}
// Profile adoption must land first: refreshSessions scopes its fetch by
// $profileScope ← $activeGatewayProfile. The remaining three fetches
// (cwd seed, config, sessions) are independent REST calls — running
// them serially added their sum to time-to-populated-sidebar when only
// the max is needed.
await adoptPrimaryProfile()
setDesktopBootStep({
@ -467,20 +476,17 @@ export function useGatewayBoot({
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig()
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig(),
callbacksRef.current.refreshSessions()
])
if (cancelled) {
return
}
setDesktopBootStep({
phase: 'renderer.sessions',
message: translateNow('boot.steps.loadingSessions'),
progress: 99
})
await callbacksRef.current.refreshSessions()
completeDesktopBoot()
bootCompleted = true
} catch (err) {

View file

@ -1,5 +1,5 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useRef } from 'react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
@ -26,6 +26,8 @@ import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
$currentModel,
$currentProvider,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
@ -77,6 +79,7 @@ interface GatewayEventDeps {
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
sessionInterrupted: (sessionId: string) => boolean
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
@ -105,12 +108,48 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
} = deps
const unscopedStreamSessionIdRef = useRef<string | null>(null)
// session.info arrives in bursts (agent build ready + turn end + title /
// MCP / compress edges within the same second). Each used to fire its own
// refreshHermesConfig — two REST calls (config + defaults) per event, per
// turn, including for BACKGROUND sessions whose values the fetch can't even
// apply. Coalesce to one trailing fetch per burst; the caller gates on
// `apply` so background traffic doesn't schedule anything.
const configRefreshTimerRef = useRef<null | number>(null)
const scheduleConfigRefresh = useCallback(() => {
if (configRefreshTimerRef.current !== null) {
return
}
if (typeof window === 'undefined') {
void refreshHermesConfig()
return
}
configRefreshTimerRef.current = window.setTimeout(() => {
configRefreshTimerRef.current = null
void refreshHermesConfig()
}, 300)
}, [refreshHermesConfig])
useEffect(
() => () => {
if (configRefreshTimerRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(configRefreshTimerRef.current)
configRefreshTimerRef.current = null
}
},
[]
)
return useCallback(
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
@ -151,6 +190,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
// The backend stamps model/provider (as strings) on EVERY session.info,
// so the presence flags above are true on every heartbeat/turn edge —
// fine for the cheap atom writes below (nanostores skips identical
// values), but they also drove queryClient.invalidateQueries, refetching
// the model-options provider catalog once or twice per turn for a model
// that never changed. Only a genuine VALUE change (vs the session's own
// cached runtime state, captured before the state patch below applies;
// composer atoms as the fallback for an uncached session) invalidates.
const knownState = sessionId ? sessionStateByRuntimeIdRef.current.get(sessionId) : undefined
const modelValueChanged = modelChanged && payload!.model !== (knownState?.model ?? $currentModel.get())
const providerValueChanged =
providerChanged && payload!.provider !== (knownState?.provider ?? $currentProvider.get())
// Config is profile-scoped, but session.info also arrives for background
// sessions. Only an active-session event from the currently active
@ -292,11 +343,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (apply) {
reportInstallMethodWarning(payload?.install_warning)
// Config refetch is only meaningful for the foreground context —
// everything refreshHermesConfig applies is either active-session
// guarded or a composer/global pref. Background sessions' heartbeats
// used to trigger it too (two REST calls each, every turn).
scheduleConfigRefresh()
}
void refreshHermesConfig()
if (modelChanged || providerChanged) {
if (modelValueChanged || providerValueChanged) {
void queryClient.invalidateQueries({
queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options']
})
@ -755,8 +809,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
lastCwdInfoSessionRef,
nativeSubagentSessionsRef,
queryClient,
refreshHermesConfig,
scheduleConfigRefresh,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
]

View file

@ -131,6 +131,46 @@ export function useMessageStream({
[updateSessionState]
)
// Turn-complete triggers a full sidebar refresh (recents + cron + messaging
// REST fan-out, each scanning profile state.dbs server-side) plus a
// cross-window broadcast that makes every other window do the same. Parallel
// tiles / multi-window finishing near-simultaneously used to multiply that.
// Coalesce completions into one trailing refresh per burst — a ~300ms title
// lag is invisible; the redundant aggregator scans are not.
const sessionsRefreshTimerRef = useRef<null | number>(null)
const scheduleSessionsRefresh = useCallback(() => {
if (sessionsRefreshTimerRef.current !== null) {
return
}
const run = () => {
sessionsRefreshTimerRef.current = null
void refreshSessions().catch(() => undefined)
// Sync freshly-titled rows to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
}
if (typeof window === 'undefined') {
run()
return
}
sessionsRefreshTimerRef.current = window.setTimeout(run, 300)
}, [refreshSessions])
useEffect(
() => () => {
if (sessionsRefreshTimerRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(sessionsRefreshTimerRef.current)
sessionsRefreshTimerRef.current = null
}
},
[]
)
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
@ -444,10 +484,7 @@ export function useMessageStream({
}
})
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
scheduleSessionsRefresh()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
@ -464,7 +501,7 @@ export function useMessageStream({
title: translateNow('notifications.native.turnDoneTitle')
})
},
[hydrateFromStoredSession, refreshSessions, updateSessionState]
[hydrateFromStoredSession, scheduleSessionsRefresh, updateSessionState]
)
const failAssistantMessage = useCallback(
@ -526,6 +563,7 @@ export function useMessageStream({
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
})

View file

@ -0,0 +1,155 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
// Per-turn REST amplification guards: session.info must not refetch config for
// background sessions nor invalidate the model-options catalog when the model
// string is merely PRESENT (the backend stamps it on every event) rather than
// actually changed. message.complete must coalesce sidebar refreshes.
const ACTIVE_SID = 'session-active'
let handleEvent: ((event: RpcEvent) => void) | null = null
let refreshHermesConfig: ReturnType<typeof vi.fn<() => Promise<void>>>
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
let queryClient: QueryClient
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const sessionInfo = (sessionId: string, payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: sessionId, type: 'session.info' }))
beforeEach(() => {
handleEvent = null
refreshHermesConfig = vi.fn<() => Promise<void>>(async () => undefined)
refreshSessions = vi.fn<() => Promise<void>>(async () => undefined)
queryClient = new QueryClient()
setCurrentModel('')
setCurrentProvider('')
})
afterEach(() => {
cleanup()
setCurrentModel('')
setCurrentProvider('')
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('session.info config refetch gating', () => {
it('coalesces active-session bursts into one trailing config fetch', async () => {
// Mount under real timers (waitFor), then freeze time for the debounce.
await mountStream()
vi.useFakeTimers()
sessionInfo(ACTIVE_SID, { model: 'm1', running: true })
sessionInfo(ACTIVE_SID, { model: 'm1', running: false })
sessionInfo(ACTIVE_SID, { model: 'm1', title: 't' })
expect(refreshHermesConfig).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshHermesConfig).toHaveBeenCalledTimes(1)
})
it('never fetches config for a background session heartbeat', async () => {
await mountStream()
vi.useFakeTimers()
sessionInfo('session-background', { model: 'm1', running: true })
sessionInfo('session-background', { model: 'm1', running: false })
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshHermesConfig).not.toHaveBeenCalled()
})
})
describe('session.info model-options invalidation gating', () => {
it('skips invalidation when model/provider merely restate the known values', async () => {
await mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
// Seed the session's cached runtime state.
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
invalidate.mockClear()
// Turn-end heartbeat restating the same model/provider — the pre-fix path
// invalidated (and refetched the provider catalog) on every one of these.
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: false })
expect(invalidate).not.toHaveBeenCalled()
})
it('invalidates when the session model actually changes', async () => {
await mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
invalidate.mockClear()
sessionInfo(ACTIVE_SID, { model: 'm2', provider: 'p1', running: true })
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['model-options', ACTIVE_SID] })
})
})
describe('message.complete sidebar refresh coalescing', () => {
it('collapses near-simultaneous completions into one refresh', async () => {
await mountStream()
vi.useFakeTimers()
act(() => handleEvent!({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
act(() => handleEvent!({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
expect(refreshSessions).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,137 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/hermes'
import { $sessions, $sessionsLoading, setSessions, setSessionsLoading } from '@/store/session'
import { useSessionListActions } from './use-session-list-actions'
// Sidebar refresh hygiene: a content-identical refresh (turn complete,
// cross-window broadcast, reconnect) must not replace $sessions' array
// identity — that identity is the dependency for every sidebar memo — and
// must not flicker the loading flag over an already-populated list.
const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
({
ended_at: null,
id,
input_tokens: 0,
is_active: false,
last_active: 1000,
message_count: 3,
model: 'm',
output_tokens: 0,
preview: 'hey',
profile: 'default',
source: 'desktop',
started_at: 900,
title: `Chat ${id}`,
...over
}) as SessionInfo
const listAllProfileSessions = vi.fn()
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
getCronJobs: vi.fn(async () => []),
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args)
}))
beforeEach(() => {
listAllProfileSessions.mockReset()
setSessions([])
setSessionsLoading(false)
})
afterEach(() => {
setSessions([])
setSessionsLoading(false)
})
describe('refreshSessions identity + loading hygiene', () => {
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
const rows = [row('a'), row('b')]
listAllProfileSessions.mockResolvedValue({ sessions: rows, total: 2, profile_totals: { default: 2 } })
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const first = $sessions.get()
expect(first.map(s => s.id)).toEqual(['a', 'b'])
// Second refresh returns fresh (but equal) row objects, as the API does.
listAllProfileSessions.mockResolvedValue({
sessions: [row('a'), row('b')],
total: 2,
profile_totals: { default: 2 }
})
await act(async () => {
await result.current.refreshSessions()
})
expect($sessions.get()).toBe(first)
})
it('swaps the array when rows actually changed', async () => {
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const first = $sessions.get()
listAllProfileSessions.mockResolvedValue({
sessions: [row('a', { last_active: 2000, title: 'Renamed' })],
total: 1,
profile_totals: {}
})
await act(async () => {
await result.current.refreshSessions()
})
expect($sessions.get()).not.toBe(first)
expect($sessions.get()[0].title).toBe('Renamed')
})
it('does not flicker the loading flag over a populated list', async () => {
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const loadingStates: boolean[] = []
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
await act(async () => {
await result.current.refreshSessions()
})
off()
// Only the initial subscribe emission — no true/false churn per refresh.
expect(loadingStates).toEqual([false])
})
it('still shows loading for the initial (empty-list) fetch', async () => {
listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
const loadingStates: boolean[] = []
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
await act(async () => {
await result.current.refreshSessions()
})
off()
expect(loadingStates).toEqual([false, true, false])
})
})

View file

@ -153,7 +153,15 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
setSessionsLoading(true)
// The loading flag exists to drive the initial skeletons (they only render
// while the list is empty). Turn-complete / reconnect refreshes over a
// populated list used to flip it true→false anyway, churning every
// $sessionsLoading subscriber twice per turn for no visible change.
const showLoading = $sessions.get().length === 0
if (showLoading) {
setSessionsLoading(true)
}
try {
const limit = $sessionsLimit.get()
@ -176,12 +184,27 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
})
if (refreshSessionsRequestRef.current === requestId) {
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
// Signature-gate the swap (same pattern as cron/messaging): a refresh
// that returns content-identical rows must keep the previous array
// identity, or every sidebar memo keyed on $sessions recomputes and the
// whole list re-renders once per turn/broadcast for nothing.
setSessions(prev => {
const next = mergeSessionPage(prev, result.sessions, sessionsToKeep())
return sameCronSignature(prev, next) ? prev : next
})
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
setSessionProfileTotals(result.profile_totals ?? {})
setSessionProfileTotals(prev => {
const next = result.profile_totals ?? {}
const prevKeys = Object.keys(prev)
return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
? prev
: next
})
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
if (showLoading && refreshSessionsRequestRef.current === requestId) {
setSessionsLoading(false)
}
}