diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7bf789ccf118d..9ea84d327a278 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -131,6 +131,11 @@ import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' +import { + createProfileBackendStartupQueue, + normalizeProfileBackendStartReason, + reusePoolConnection +} from './profile-backend-startup' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import * as remoteLifecycle from './remote-lifecycle' import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' @@ -988,6 +993,10 @@ let softRehomeInProgress = false // with no named profiles never populates this map, so their experience is // byte-for-byte the single-backend behavior. const backendPool = new Map() // profile -> { process, port, token, connectionPromise, lastActiveAt } +// A cold local profile boot performs the same Python import, plugin/MCP setup, +// and readiness work as the primary backend. Let only one profile do that work +// at a time; existing pool promises and remote profiles do not wait here. +const profileBackendStartupQueue = createProfileBackendStartupQueue() // Keep the pool light: cap concurrent profile backends (LRU eviction) and reap // idle ones. A user idles at exactly the primary backend; pool backends only // exist while a non-primary profile is actively being chatted through. @@ -7471,26 +7480,48 @@ function primaryProfileKey() { // profile to startHermes() (the window backend: boot UI, bootstrap, remote // mode), and any OTHER profile to a lazily-spawned pool backend. An empty / // unknown profile resolves to the primary, so all legacy callers are unchanged. -async function ensureBackend(profile) { +async function ensureBackend(profile, requestedReason = 'unknown') { const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey() + const reason = normalizeProfileBackendStartReason(requestedReason) if (key === primaryProfileKey()) { - return startHermes() - } + if (backendConnectionState.getPromise()) { + return startHermes() + } - const existing = backendPool.get(key) + const startedAt = Date.now() + const primaryReason = reason === 'unknown' ? 'primary_boot' : reason + rememberLog(`Starting primary Hermes backend (reason ${primaryReason})`) + + try { + const connection = await startHermes() + rememberLog( + `Primary Hermes backend ready (reason ${primaryReason}, mode ${connection.mode}, duration ${Date.now() - startedAt}ms)` + ) + + return connection + } catch (error) { + rememberLog( + `Primary Hermes backend failed (reason ${primaryReason}, duration ${Date.now() - startedAt}ms): ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } - if (existing) { - existing.lastActiveAt = Date.now() + const existingConnection = reusePoolConnection(backendPool.get(key)) - return existing.connectionPromise + if (existingConnection) { + return existingConnection } evictLruPoolBackends(POOL_MAX_BACKENDS - 1) const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() } - entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => { - backendPool.delete(key) + entry.connectionPromise = startPoolBackend(key, entry, reason).catch(error => { + if (backendPool.get(key) === entry) { + backendPool.delete(key) + } + throw error }) backendPool.set(key, entry) @@ -7499,6 +7530,63 @@ async function ensureBackend(profile) { return entry.connectionPromise } +async function startPoolBackend(profile, entry, reason) { + const requestedAt = Date.now() + const remote = await resolveRemoteBackend(profile) + + if (remote) { + rememberLog(`Connecting to remote Hermes backend for profile "${profile}" (reason ${reason})`) + + try { + await waitForHermes(remote.baseUrl, remote.token) + } catch (error) { + rememberLog( + `Remote Hermes backend failed for profile "${profile}" (reason ${reason}, duration ${Date.now() - requestedAt}ms): ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + + rememberLog( + `Remote Hermes backend ready for profile "${profile}" (reason ${reason}, duration ${Date.now() - requestedAt}ms)` + ) + + return { + ...remote, + profile, + logs: hermesLog.slice(-80), + ...getWindowState() + } + } + + rememberLog(`Queueing local Hermes backend for profile "${profile}" (reason ${reason})`) + + return profileBackendStartupQueue.run(async () => { + if (backendPool.get(profile) !== entry) { + throw new Error(`Hermes backend for profile "${profile}" was removed before startup.`) + } + + const spawnStartedAt = Date.now() + const queueWaitMs = spawnStartedAt - requestedAt + rememberLog( + `Starting queued local Hermes backend for profile "${profile}" (reason ${reason}, queue wait ${queueWaitMs}ms)` + ) + + try { + const connection = await spawnPoolBackend(profile, entry, reason) + rememberLog( + `Local Hermes backend ready for profile "${profile}" (reason ${reason}, queue wait ${queueWaitMs}ms, spawn ${Date.now() - spawnStartedAt}ms, total ${Date.now() - requestedAt}ms)` + ) + + return connection + } catch (error) { + rememberLog( + `Local Hermes backend failed for profile "${profile}" (reason ${reason}, queue wait ${queueWaitMs}ms, spawn ${Date.now() - spawnStartedAt}ms): ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + }) +} + // Mark a pool profile as recently used so the idle reaper spares it. The // renderer calls this when it opens a profile's chat WS and periodically while // streaming, since the main process can't see the direct renderer↔backend WS. @@ -7573,26 +7661,7 @@ function startPoolIdleReaper() { // Spawn an additional dashboard backend pinned to a named profile. Mirrors the // local-spawn portion of startHermes() but without the boot-progress UI, // bootstrap, or remote handling (those belong to the primary backend only). -async function spawnPoolBackend(profile, entry) { - // A profile may point at its OWN remote backend (connection.json - // `profiles[name]`), or inherit the app-wide remote (env / global settings). - // In either case there is no local child to spawn — we just verify the - // remote is reachable and hand back its connection descriptor. The pool - // entry keeps `entry.process === null`, which stopPoolBackend/evict already - // tolerate. - const remote = await resolveRemoteBackend(profile) - - if (remote) { - await waitForHermes(remote.baseUrl, remote.token) - - return { - ...remote, - profile, - logs: hermesLog.slice(-80), - ...getWindowState() - } - } - +async function spawnPoolBackend(profile, entry, reason) { const token = crypto.randomBytes(32).toString('base64url') // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override // step 3 in hermes_cli/main.py), so the child re-homes to this profile. @@ -7605,7 +7674,7 @@ async function spawnPoolBackend(profile, entry) { const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null - rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) + rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label} (reason ${reason})`) const child = spawn( backend.command, @@ -8597,7 +8666,7 @@ function createWindow() { // 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)) + ensureBackend(null, 'primary_boot').catch(error => rememberLog(error.stack || error.message)) mainWindow.webContents.once('did-finish-load', () => { // Zoom restore is handled by wireCommonWindowHandlers (shared with session @@ -8607,7 +8676,7 @@ function createWindow() { }) } -ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile)) +ipcMain.handle('hermes:connection', async (_event, profile, reason) => ensureBackend(profile, reason)) // Reconnect-after-wake recovery. A REMOTE primary backend has no child process, // so the 'exit'/'error' handlers that would clear a dead connection promise never // fire — once the remote becomes unreachable across a sleep/wake the renderer diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7652a7688dd25..69dfd137fbc9f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,7 +1,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' contextBridge.exposeInMainWorld('hermesDesktop', { - getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), + getConnection: (profile, reason) => ipcRenderer.invoke('hermes:connection', profile, reason), revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), diff --git a/apps/desktop/electron/profile-backend-startup.test.ts b/apps/desktop/electron/profile-backend-startup.test.ts new file mode 100644 index 0000000000000..786366effc375 --- /dev/null +++ b/apps/desktop/electron/profile-backend-startup.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + createProfileBackendStartupQueue, + normalizeProfileBackendStartReason, + reusePoolConnection +} from './profile-backend-startup' + +test('serializes concurrent local profile cold starts', async () => { + const queue = createProfileBackendStartupQueue() + let active = 0 + let maximumActive = 0 + let releaseFirst: () => void + + const firstGate = new Promise(resolve => { + releaseFirst = resolve + }) + + let markFirstStarted: () => void + + const firstStarted = new Promise(resolve => { + markFirstStarted = resolve + }) + + const started: string[] = [] + + const first = queue.run(async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + started.push('first') + markFirstStarted!() + await firstGate + active -= 1 + + return 'first' + }) + + const second = queue.run(async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + started.push('second') + active -= 1 + + return 'second' + }) + + await firstStarted + assert.deepEqual(started, ['first']) + releaseFirst!() + assert.deepEqual(await Promise.all([first, second]), ['first', 'second']) + assert.deepEqual(started, ['first', 'second']) + assert.equal(maximumActive, 1) +}) + +test('a failed local startup releases the next queued profile', async () => { + const queue = createProfileBackendStartupQueue() + + const first = queue.run(async () => { + throw new Error('profile failed to start') + }) + + const second = queue.run(async () => 'ready') + + await assert.rejects(first, /profile failed to start/) + assert.equal(await second, 'ready') +}) + +test('reuses an existing backend promise immediately while another profile starts', async () => { + const connection = Promise.resolve({ profile: 'already-running' }) + const entry = { connectionPromise: connection, lastActiveAt: 1 } + + assert.equal(reusePoolConnection(entry, 99), connection) + assert.equal(entry.lastActiveAt, 99) +}) + +test('normalizes untrusted renderer start reasons to unknown', () => { + assert.equal(normalizeProfileBackendStartReason('profile_activate'), 'profile_activate') + assert.equal(normalizeProfileBackendStartReason('not-a-reason'), 'unknown') + assert.equal(normalizeProfileBackendStartReason({}), 'unknown') +}) diff --git a/apps/desktop/electron/profile-backend-startup.ts b/apps/desktop/electron/profile-backend-startup.ts new file mode 100644 index 0000000000000..b2543bcbb0442 --- /dev/null +++ b/apps/desktop/electron/profile-backend-startup.ts @@ -0,0 +1,61 @@ +export type ProfileBackendStartReason = 'primary_boot' | 'profile_activate' | 'background_session' | 'unknown' + +const START_REASONS = new Set([ + 'primary_boot', + 'profile_activate', + 'background_session', + 'unknown' +]) + +export function normalizeProfileBackendStartReason(value: unknown): ProfileBackendStartReason { + return typeof value === 'string' && START_REASONS.has(value as ProfileBackendStartReason) + ? (value as ProfileBackendStartReason) + : 'unknown' +} + +/** + * Runs local profile backend startup work one at a time. This intentionally + * does not own remote connections: they do not spawn a local child or compete + * for the machine resources this queue protects. + */ +export function createProfileBackendStartupQueue() { + let tail: Promise = Promise.resolve() + + return { + run(task: () => Promise): Promise { + const previous = tail + let release: () => void + + tail = new Promise(resolve => { + release = resolve + }) + + return previous + .catch(() => undefined) + .then(task) + .finally(release!) + } + } +} + +export interface ReusablePoolConnection { + connectionPromise: Promise + lastActiveAt: number +} + +/** + * Keep an existing pool connection on its current path. In particular, do not + * make it wait behind a different profile's cold start. + */ +export function reusePoolConnection( + entry: ReusablePoolConnection | undefined, + now = Date.now() +): Promise | null { + if (!entry) { + return null + } + + entry.lastActiveAt = now + + return entry.connectionPromise +} diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 24abcf3736971..e1b1c932ac08b 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -11,19 +11,12 @@ export interface ContextSuggestion { meta?: string } -export interface QuickModelOption { - provider: string - providerName: string - model: string -} - export interface ChatBarState { model: { model: string provider: string canSwitch: boolean loading?: boolean - quickModels?: QuickModelOption[] /** Reused status-bar dropdown (built with gateway + selectModel upstream). */ modelMenuContent?: ReactNode } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index bd18be0c4834f..20c14f6493ac2 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,6 +1,5 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { useQuery } from '@tanstack/react-query' import type * as React from 'react' import { Suspense, useCallback, useEffect, useMemo } from 'react' import { useLocation } from 'react-router-dom' @@ -17,16 +16,15 @@ import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger' import { type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime' +import { sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' -import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { cn } from '@/lib/utils' import { migrateSessionDraft } from '@/store/composer' import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' -import { $activeGatewayProfile, $gatewaySwapTarget, $profiles } from '@/store/profile' +import { $gatewaySwapTarget, $profiles } from '@/store/profile' import { $contextSuggestions, $freshDraftReady, @@ -40,7 +38,6 @@ import { sessionPinId } from '@/store/session' import { isSecondaryWindow, isWatchWindow } from '@/store/windows' -import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar' @@ -255,7 +252,6 @@ export function ChatView({ const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}` const awaitingResponse = useStore(view.$awaitingResponse) const busy = useStore(view.$busy) - const activeGatewayProfile = useStore($activeGatewayProfile) const contextSuggestions = useStore($contextSuggestions) // Per-session (SessionView) reads — a tile IS its session, so these come // from the view slice, not the global atoms (which track the primary only). @@ -357,17 +353,6 @@ export function ChatView({ const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow() const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') - const modelOptionsQuery = useQuery({ - queryKey: modelOptionsQueryKey(activeGatewayProfile, activeSessionId), - queryFn: () => requestModelOptions({ gateway: gateway || undefined, sessionId: activeSessionId }), - enabled: gatewayOpen - }) - - const quickModels = useMemo( - () => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel), - [currentModel, currentProvider, modelOptionsQuery.data] - ) - const chatBarState = useMemo( () => ({ model: { @@ -375,8 +360,7 @@ export function ChatView({ provider: currentProvider, canSwitch: gatewayOpen, loading: !gatewayOpen || (!currentModel && !currentProvider), - modelMenuContent, - quickModels + modelMenuContent }, tools: { enabled: true, @@ -388,7 +372,7 @@ export function ChatView({ active: false } }), - [contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels] + [contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent] ) // Drop files anywhere in the conversation area, not just on the composer diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 52fa8de19c0af..ba23c8ab44c27 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -66,8 +66,6 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog' import { RenameProfileDialog } from '../../profiles/rename-profile-dialog' import { PROFILES_ROUTE } from '../../routes' -import { useProfilePrewarm } from './use-profile-prewarm' - const RAIL_GAP = 4 // px — matches gap-1 between squares. // Past this many profiles the strip of colored squares stops scaling (tiny @@ -471,14 +469,11 @@ function ProfileDropdown({ ) } -// One dropdown row per profile — its own component so each row can own a -// hover-intent prewarm timer (see useProfilePrewarm). function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) { const hue = color ?? 'var(--ui-text-quaternary)' - const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name) return ( - +