fix(desktop): pool sizing as a live device preference in Settings (#91545)

Hover-intent prewarm sweeps across the Bots rail spawned past the pool cap,
LRU-evicting the backend the user was about to click — an evict/respawn
cascade that made profile switching progressively slower (#91545).

- prewarmProfileBackend skips speculative spawns once every pool slot holds
  an open socket; the real click still spawns on demand.
- Pool max/idle become a device preference (Settings -> Advanced), persisted
  atomically in userData (pool-limits.json) and applied live over IPC; the
  HERMES_DESKTOP_POOL_* env vars remain the initial fallback. Defaults are
  unchanged (3 backends / 10 min idle).

Squash of the 3-commit PR #92581 branch (a00dc088c5..783899d12f) applied
via diff onto the spawn-coordinator salvage; import + constant-block
conflicts resolved so the coordinator is constructed from, and follows,
the live preference (setLimit added in the next commit).
This commit is contained in:
ClintonEmok
2026-09-03 12:12:24 +05:30
committed by kshitij
parent 5810172f52
commit c401756a6a
12 changed files with 510 additions and 14 deletions
+98 -12
View File
@@ -281,6 +281,7 @@ import {
undialedSshRouteSeeds
} from './plugin-profile-routes'
import { selectPoolEvictions } from './pool-eviction'
import { clampPoolLimits, parsePoolLimits, POOL_LIMITS_DEFAULTS } from './pool-limits'
import {
LocalBackendSpawnCoordinator,
type LocalBackendSpawnRequest,
@@ -1414,14 +1415,87 @@ const profileDeletionGate = new ProfileDeletionGate()
// 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.
const POOL_MAX_BACKENDS = Math.max(1, Number(process.env.HERMES_DESKTOP_POOL_MAX) || 3)
const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || 10 * 60_000)
const localBackendSpawnCoordinator = new LocalBackendSpawnCoordinator(POOL_MAX_BACKENDS)
// Pool sizing is a device preference (Settings → Advanced → pool rows), not a
// launch constant: mutable at runtime, persisted in userData, applied live.
// The legacy HERMES_DESKTOP_POOL_* env vars remain the initial-value fallback
// for scripted/headless setups; after launch the stored preference wins.
const POOL_LIMITS_PATH = path.join(app.getPath('userData'), 'pool-limits.json')
function readPersistedPoolLimits() {
try {
const limits = parsePoolLimits(fs.readFileSync(POOL_LIMITS_PATH, 'utf8'))
rememberLog(
`[pool-limits] loaded from ${POOL_LIMITS_PATH}: maxBackends=${limits.maxBackends}, idleMs=${limits.idleMs}`
)
return limits
} catch {
// No persisted file yet — fall back to the legacy env vars so scripted
// setups keep working. Log which source won: a silently-ignored env var
// here costs a scripted-setup user a debugging session.
const fromEnv = clampPoolLimits({
maxBackends: Number(process.env.HERMES_DESKTOP_POOL_MAX) || undefined,
idleMs: Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || undefined
})
if (fromEnv.maxBackends !== POOL_LIMITS_DEFAULTS.maxBackends || fromEnv.idleMs !== POOL_LIMITS_DEFAULTS.idleMs) {
rememberLog(`[pool-limits] no saved file; using env-var overrides: maxBackends=${fromEnv.maxBackends}, idleMs=${fromEnv.idleMs}`)
} else {
rememberLog('[pool-limits] no saved file and no env overrides; using defaults')
}
return fromEnv
}
}
function persistPoolLimits(limits) {
try {
fs.mkdirSync(path.dirname(POOL_LIMITS_PATH), { recursive: true })
// Atomic write: write to a temp file in the same directory, then rename.
// A crash mid-write would otherwise leave truncated JSON and silently
// lose the user's saved sizing.
const tmpPath = `${POOL_LIMITS_PATH}.tmp`
fs.writeFileSync(tmpPath, JSON.stringify(limits, null, 2), 'utf8')
fs.renameSync(tmpPath, POOL_LIMITS_PATH)
} catch (error) {
rememberLog(`[pool-limits] write failed: ${error.message}`)
}
}
let poolLimits = readPersistedPoolLimits()
// Hard cap on local backends that are starting OR running (the LRU eviction
// above is soft — it spares keepalive-fresh entries). Follows the live
// preference: setPoolLimits() pushes a new max into the coordinator.
const localBackendSpawnCoordinator = new LocalBackendSpawnCoordinator(poolLimits.maxBackends)
// How long a spawn may wait for a free local slot. Must stay under the
// renderer's BACKEND_BOOT_WAIT_TIMEOUT_MS (45s, src/lib/with-timeout.ts) so
// the queued ticket fails before the renderer does and the user sees why.
const POOL_SLOT_WAIT_MS = 30_000
function poolMaxBackends() {
return poolLimits.maxBackends
}
function poolIdleMs() {
return poolLimits.idleMs
}
/**
* Apply new limits live: persist, then converge the running pool — evict
* LRU backends down to the new max, and let the (already running) idle
* reaper handle a shortened idle window on its next tick. Returns the
* limits actually in force (post-clamp).
*/
function setPoolLimits(raw) {
poolLimits = clampPoolLimits(raw)
persistPoolLimits(poolLimits)
localBackendSpawnCoordinator.setLimit(poolLimits.maxBackends)
evictLruPoolBackends(poolMaxBackends())
startPoolIdleReaper()
return { ...poolLimits }
}
// A backend touched within this window has a live renderer socket (the keepalive
// pings every 60s for every open profile). LRU eviction must spare these — a
// concurrent multi-profile session keeps several backends "fresh" at once, and
@@ -1440,7 +1514,7 @@ const POOL_SLOT_WAIT_MS = 30_000
// re-allocating pooled gateway secondaries ~700×/day).
// * 3× ping + 60s headroom = ~4 min, comfortable margin for two missed
// pings + WSL2 IPC stall. The hard ceiling for the cap-eligible set is
// POOL_IDLE_MS above (default 10 min) — this constant only governs the
// pool idle window above (default 10 min) — this constant only governs the
// "is this backend plausibly still alive" question for LRU eviction,
// not when the idle reaper definitively tears a backend down.
const POOL_KEEPALIVE_FRESH_MS = Math.max(
@@ -11271,7 +11345,7 @@ async function ensureBackend(profile) {
return connection
}
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
evictLruPoolBackends(poolMaxBackends() - 1)
const entry = {
process: null,
@@ -11437,7 +11511,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
return existingLocal.connectionPromise
}
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
evictLruPoolBackends(poolMaxBackends() - 1)
const localEntry = {
process: null,
@@ -11508,7 +11582,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
)
}
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
evictLruPoolBackends(poolMaxBackends() - 1)
const entry = {
process: null,
@@ -12163,7 +12237,7 @@ function evictLruPoolBackends(keep) {
const evictions = selectPoolEvictions(backendPool.entries(), Math.max(0, keep), Date.now(), POOL_KEEPALIVE_FRESH_MS)
for (const profile of evictions) {
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`)
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${poolMaxBackends()})`)
stopPoolBackend(profile)
}
}
@@ -12177,8 +12251,8 @@ function startPoolIdleReaper() {
const now = Date.now()
for (const [profile, entry] of [...backendPool.entries()]) {
if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) {
rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`)
if (now - (entry.lastActiveAt || 0) > poolIdleMs()) {
rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(poolIdleMs() / 1000)}s)`)
stopPoolBackend(profile)
}
}
@@ -12303,9 +12377,9 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po
entry.localBackendSlotKey = poolKey
entry.localBackendSpawnRequest = spawnRequest
if (localBackendSpawnCoordinator.activeCount >= POOL_MAX_BACKENDS) {
if (localBackendSpawnCoordinator.activeCount >= poolMaxBackends()) {
rememberLog(
`Profile backend "${profile}" waiting for a free local slot (${localBackendSpawnCoordinator.activeCount}/${POOL_MAX_BACKENDS} busy, ${localBackendSpawnCoordinator.queuedCount} queued)`
`Profile backend "${profile}" waiting for a free local slot (${localBackendSpawnCoordinator.activeCount}/${poolMaxBackends()} busy, ${localBackendSpawnCoordinator.queuedCount} queued)`
)
}
@@ -14724,6 +14798,18 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
return { ok: true }
})
// Pool sizing (Settings → Advanced): device-local, live-applied. Main is
// authoritative (it owns the pool and the persisted copy); the returned
// limits are what actually took effect post-clamp.
ipcMain.handle('hermes:pool-limits:get', async () => ({ ...poolLimits }))
ipcMain.handle('hermes:pool-limits:set', async (_event, raw) => {
const next = setPoolLimits({
maxBackends: typeof raw?.maxBackends === 'number' ? raw.maxBackends : poolLimits.maxBackends,
idleMs: typeof raw?.idleMs === 'number' ? raw.idleMs : poolLimits.idleMs
})
return { ok: true, limits: next }
})
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => {
return gatewayWsUrlIpcResult(() => freshGatewayWsUrl(profile))
})
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { clampPoolLimits, parsePoolLimits, POOL_LIMITS_BOUNDS, POOL_LIMITS_DEFAULTS, POOL_LIMITS_MIN } from './pool-limits'
describe('parsePoolLimits', () => {
it('falls back to defaults for null/empty/corrupt input', () => {
expect(parsePoolLimits(null)).toEqual(POOL_LIMITS_DEFAULTS)
expect(parsePoolLimits(undefined)).toEqual(POOL_LIMITS_DEFAULTS)
expect(parsePoolLimits('')).toEqual(POOL_LIMITS_DEFAULTS)
expect(parsePoolLimits('not json {')).toEqual(POOL_LIMITS_DEFAULTS)
})
it('parses a valid persisted blob', () => {
expect(parsePoolLimits(JSON.stringify({ maxBackends: 11, idleMs: 7_200_000 }))).toEqual({
maxBackends: 11,
idleMs: 7_200_000
})
})
it('fills missing keys from defaults', () => {
expect(parsePoolLimits(JSON.stringify({ maxBackends: 5 }))).toEqual({ ...POOL_LIMITS_DEFAULTS, maxBackends: 5 })
expect(parsePoolLimits('{}')).toEqual(POOL_LIMITS_DEFAULTS)
})
it('ignores non-numeric junk instead of NaN-poisoning the pool', () => {
expect(parsePoolLimits(JSON.stringify({ maxBackends: 'lots', idleMs: null }))).toEqual(POOL_LIMITS_DEFAULTS)
})
})
describe('clampPoolLimits', () => {
it('clamps below the floors', () => {
expect(clampPoolLimits({ maxBackends: 0 }).maxBackends).toBe(POOL_LIMITS_MIN.maxBackends)
expect(clampPoolLimits({ idleMs: 100 }).idleMs).toBe(POOL_LIMITS_MIN.idleMs)
})
it('clamps absurdly high backend counts', () => {
expect(clampPoolLimits({ maxBackends: 10_000 }).maxBackends).toBeLessThanOrEqual(64)
})
it('clamps idleMs to the shared ceiling (7 days)', () => {
expect(clampPoolLimits({ idleMs: 999_000_000 }).idleMs).toBe(POOL_LIMITS_BOUNDS.idleMsMax)
})
it('floors fractional values', () => {
expect(clampPoolLimits({ maxBackends: 2.9 }).maxBackends).toBe(2)
})
})
+81
View File
@@ -0,0 +1,81 @@
/**
* Pool limits — how many bot backends may stay spawned, and how long an
* unused one survives.
*
* A device-local preference (each machine trades RAM against switching
* speed for itself), stored in userData like keep-awake. The main process
* is authoritative: it owns the pool AND the persisted copy, and applies a
* new max IMMEDIATELY by evicting least-recently-used idle backends — no
* app restart. The renderer mirrors the values for its UI and prewarm
* guard over IPC.
*
* Defaults preserve the historical hard-coded behavior (3 backends, 10min
* idle) so machines that never open Settings behave exactly as before.
*/
export interface PoolLimits {
/** Max concurrently spawned non-primary profile backends. */
maxBackends: number
/** Idle lifetime of an unused pool backend, in milliseconds. */
idleMs: number
}
export const POOL_LIMITS_DEFAULTS: PoolLimits = {
maxBackends: 3,
idleMs: 10 * 60_000
}
/** Hard floors — match the clamps the env-var path always applied. */
export const POOL_LIMITS_MIN: PoolLimits = {
maxBackends: 1,
idleMs: 60_000
}
/** Shared bounds for both pool knobs — imported by the Settings UI so the
* advertised input ranges can never drift from what main actually clamps
* to. idleMs has no ceiling: a user who wants backends kept warm all week
* may have exactly that. */
export const POOL_LIMITS_BOUNDS = {
maxBackendsMax: 64,
/** 7 days, matching the UI's suggestion ceiling. */
idleMsMax: 7 * 24 * 60 * 60_000
} as const
const MAX_BACKENDS_CEILING = POOL_LIMITS_BOUNDS.maxBackendsMax
const IDLE_MS_CEILING = POOL_LIMITS_BOUNDS.idleMsMax
/** Clamp a raw partial to the floors/ceilings; missing keys fall to defaults. */
export function clampPoolLimits(raw: Partial<PoolLimits>): PoolLimits {
const maxBackends = Number.isFinite(raw.maxBackends)
? Math.min(MAX_BACKENDS_CEILING, Math.max(POOL_LIMITS_MIN.maxBackends, Math.floor(Number(raw.maxBackends))))
: POOL_LIMITS_DEFAULTS.maxBackends
const idleMs = Number.isFinite(raw.idleMs)
? Math.min(IDLE_MS_CEILING, Math.max(POOL_LIMITS_MIN.idleMs, Math.floor(Number(raw.idleMs))))
: POOL_LIMITS_DEFAULTS.idleMs
return { maxBackends, idleMs }
}
function clampLimits(raw: Partial<PoolLimits>): PoolLimits {
return clampPoolLimits(raw)
}
/** Parse + clamp a persisted JSON blob; anything unreadable falls back to
* defaults so a corrupted file can never wedge the pool. */
export function parsePoolLimits(json: string | null | undefined): PoolLimits {
if (!json) {
return { ...POOL_LIMITS_DEFAULTS }
}
try {
const parsed = JSON.parse(json)
return clampLimits({
maxBackends: typeof parsed?.maxBackends === 'number' ? parsed.maxBackends : undefined,
idleMs: typeof parsed?.idleMs === 'number' ? parsed.idleMs : undefined
})
} catch {
return { ...POOL_LIMITS_DEFAULTS }
}
}
+2
View File
@@ -24,6 +24,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getProfileRoutes: profiles => ipcRenderer.invoke('hermes:plugin-profile-routes', profiles),
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getPoolLimits: () => ipcRenderer.invoke('hermes:pool-limits:get'),
setPoolLimits: limits => ipcRenderer.invoke('hermes:pool-limits:set', limits),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
// Registry-scoped fresh WS URL: { connectionId, profile } → result shape of
// getGatewayWsUrl, minted against that connection's backend.
@@ -46,6 +46,7 @@ import {
} from '@/store/gateway-switch'
import { checkLocalRuntimeUpdate, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
import { notify, notifyError } from '@/store/notifications'
import { loadPoolLimits } from '@/store/pool-limits'
import {
$activeGatewayProfile,
normalizeProfileKey,
@@ -900,6 +901,10 @@ export function useGatewayBoot({
// this a socket dropped during sleep sits closed until the user clicks.
window.addEventListener('focus', onFocus)
// Pool limits are main-process state; mirror them once for the Settings
// rows and prewarmProfileBackend's saturation guard.
void loadPoolLimits()
// Keep live pool backends alive while this window is open (the main process
// can't observe the direct renderer↔backend WS). No-op for the primary.
const keepaliveTimer = setInterval(() => {
@@ -45,6 +45,7 @@ import {
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { PoolLimitsSetting } from './pool-limits-setting'
import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { SettingsProfileScope } from './profile-scope'
import { QuickEntrySettings } from './quick-entry-settings'
@@ -405,6 +406,7 @@ function ConfigSettingsInner({
label={c.disableF12Title}
onChange={setDisableF12}
/>
<PoolLimitsSetting />
<QuickEntrySettings />
</>
)}
@@ -0,0 +1,113 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { ListRow } from '@/app/settings/primitives'
import { Input } from '@/components/ui/input'
import { $poolLimits, loadPoolLimits, savePoolLimits } from '@/store/pool-limits'
// Bounds imported from main's clamp module so the advertised input ranges
// can never drift from what the pool actually enforces (review note on #92581).
import { POOL_LIMITS_BOUNDS } from '../../../electron/pool-limits'
const MAX_BACKENDS_MAX = POOL_LIMITS_BOUNDS.maxBackendsMax
const IDLE_MS_MAX = POOL_LIMITS_BOUNDS.idleMsMax
/** Settings → Advanced: warm-bot-backends count + backend idle timeout.
* Device-local (not profile-scoped): the pool is sized once per machine and
* changes apply live — main evicts/reaps to converge without a restart. */
export function PoolLimitsSetting() {
const limits = useStore($poolLimits)
const [maxDraft, setMaxDraft] = useState(String(limits.maxBackends))
const [idleDraft, setIdleDraft] = useState(String(limits.idleMs))
useEffect(() => {
void loadPoolLimits()
}, [])
useEffect(() => {
setMaxDraft(String(limits.maxBackends))
setIdleDraft(String(limits.idleMs))
}, [limits])
const commitMax = () => {
const parsed = Number(maxDraft)
if (!Number.isFinite(parsed) || parsed === limits.maxBackends) {
setMaxDraft(String(limits.maxBackends))
return
}
void savePoolLimits({ maxBackends: parsed })
.then(() => undefined)
.catch(() => setMaxDraft(String($poolLimits.get().maxBackends)))
}
const commitIdle = () => {
const parsed = Number(idleDraft)
if (!Number.isFinite(parsed) || parsed === limits.idleMs) {
setIdleDraft(String(limits.idleMs))
return
}
void savePoolLimits({ idleMs: parsed })
.then(() => undefined)
.catch(() => setIdleDraft(String($poolLimits.get().idleMs)))
}
return (
<>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label="Warm bot backends"
className="w-20"
inputMode="numeric"
max={MAX_BACKENDS_MAX}
min={1}
onBlur={commitMax}
onChange={event => setMaxDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={maxDraft}
/>
</div>
}
description="How many bot backends stay running for instant switching. Higher = faster switches, more memory (~60MB per backend). Applies immediately."
title="Warm Bot Backends"
/>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label="Backend idle timeout in milliseconds"
className="w-28"
inputMode="numeric"
max={IDLE_MS_MAX}
min={60_000}
onBlur={commitIdle}
onChange={event => setIdleDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={idleDraft}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">ms</span>
</div>
}
description="How long an unused bot backend stays warm before it is shut down. Raise this so bots you revisit every few minutes never pay a cold start."
title="Backend Idle Timeout"
/>
</>
)
}
+10
View File
@@ -1,6 +1,8 @@
import type { GatewayWsUrlResult } from '@hermes/shared'
import type { TranslucencyState } from '@hermes/shared/translucency'
import type { PoolLimits } from '../electron/pool-limits'
import type { WakeIndicatorState } from './lib/wake-indicator'
import type {
PetOverlayBounds,
@@ -46,6 +48,14 @@ declare global {
// Keepalive: mark a pool profile backend as recently used so the idle
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
// Pool sizing (Settings → Advanced): device-local, live-applied by the
// main process. get resolves the limits currently in force; set applies
// (and persists) new ones, evicting/reaping to converge immediately.
getPoolLimits: () => Promise<PoolLimits>
setPoolLimits: (limits: { maxBackends?: number; idleMs?: number }) => Promise<{
ok: boolean
limits: PoolLimits
}>
getGatewayWsUrl: (profile?: null | string) => Promise<GatewayWsUrlResult>
// Open (or focus) a standalone OS window for a single chat session so
// the user can work with multiple chats side by side. Returns ok:false
+18
View File
@@ -1584,6 +1584,24 @@ export function reconnectSecondaryGateways({ forceOpenSockets = false }: { force
}
}
// How many non-primary backends currently hold an open socket. Hover-intent
// prewarming consults this before spawning: a speculative spawn that pushes
// the pool past its cap causes the Electron main to LRU-evict a warm backend
// — often one the user is about to click — turning the prewarm into churn
// (the #91545 evict/respawn cascade). The active gateway's backend is
// primary-routed and never counts toward the pool cap.
export function openSecondaryCount(): number {
let count = 0
for (const entry of g.secondaries.values()) {
if (isOpen(entry.gateway)) {
count += 1
}
}
return count
}
// Keep the idle reaper from killing a backend we still need: ping every live
// secondary. The active one is pinged separately (touchActiveGatewayBackend).
export function touchSecondaryGateways(): void {
+65
View File
@@ -0,0 +1,65 @@
/**
* Pool limits — how many bot backends may stay spawned, and how long an
* unused one survives before it is shut down.
*
* A device-local preference (each machine trades RAM against switching
* speed for itself). The MAIN process is authoritative: it owns the pool
* and the persisted copy, and applies a new max immediately by evicting
* least-recently-used idle backends — no restart. This store mirrors the
* live values for the Settings rows and feeds prewarmProfileBackend's
* saturation guard.
*/
import { atom } from 'nanostores'
export interface PoolLimits {
/** Max concurrently spawned non-primary profile backends. */
maxBackends: number
/** Idle lifetime of an unused pool backend, in milliseconds. */
idleMs: number
}
export const POOL_LIMITS_DEFAULTS: PoolLimits = {
maxBackends: 3,
idleMs: 10 * 60_000
}
export const $poolLimits = atom<PoolLimits>({ ...POOL_LIMITS_DEFAULTS })
/** Seed from main's authoritative state once at startup; no-op without the
* bridge (web/older builds just keep the defaults for the UI). */
export async function loadPoolLimits(): Promise<void> {
try {
const limits = await window.hermesDesktop?.getPoolLimits?.()
if (limits) {
$poolLimits.set(limits)
}
} catch {
// Keep defaults — Settings rows still render and can retry on save.
}
}
/** Push new limits to main; adopt the post-clamp values it reports. */
export async function savePoolLimits(next: { maxBackends?: number; idleMs?: number }): Promise<void> {
const current = $poolLimits.get()
const optimistic: PoolLimits = {
maxBackends: next.maxBackends ?? current.maxBackends,
idleMs: next.idleMs ?? current.idleMs
}
// Optimistic paint, then honest reconciliation with the clamped result.
$poolLimits.set(optimistic)
try {
const result = await window.hermesDesktop?.setPoolLimits?.(next)
if (result?.limits) {
$poolLimits.set(result.limits)
}
} catch {
$poolLimits.set(current)
throw new Error('Applying pool limits failed')
}
}
+55 -1
View File
@@ -9,10 +9,24 @@ import type { ProfileInfo } from '@/types/hermes'
const ensureGatewayForProfile = vi.fn(async () => undefined)
const ensureGatewayForAgent = vi.fn(async () => undefined)
const openGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const openSecondaryCount = vi.fn(() => 0)
const $gateway = atom<unknown>({ id: 'live-socket', connectionState: 'open' })
const resetStarmapGraph = vi.fn()
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForAgent, ensureGatewayForProfile, openGatewayForProfile }))
vi.mock('@/store/gateway', () => ({
$gateway,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForProfile,
openSecondaryCount
}))
// The pool-limits atom is profile.ts's live saturation signal — keep the real
// one so tests can move the cap via the store, but stub its IPC bridge.
vi.mock('@/store/pool-limits', async () => {
const { atom } = await import('nanostores')
return { $poolLimits: atom({ idleMs: 600_000, maxBackends: 3 }) }
})
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
@@ -29,6 +43,8 @@ const {
refreshProfiles
} = await import('./profile')
const { $poolLimits } = await import('@/store/pool-limits')
const { $connection } = await import('./session')
const { invalidateProfileScopedQueries } = await import('@/lib/query-client')
const { getProfiles } = await import('@/hermes')
@@ -55,6 +71,7 @@ beforeEach(() => {
getConnection.mockReset()
ensureGatewayForProfile.mockClear()
openGatewayForProfile.mockClear()
openSecondaryCount.mockReturnValue(0)
$gateway.set({ id: 'live-socket', connectionState: 'open' })
$activeGatewayProfile.set('default')
$connection.set(localConn())
@@ -169,6 +186,43 @@ describe('prewarmProfileBackend (hover-intent pool spawn)', () => {
expect(() => prewarmProfileBackend('warm-failing')).not.toThrow()
})
it('skips pre-warm when the pool is saturated (#91545 evict/respawn cascade)', () => {
// Every pool slot occupied: a speculative spawn would LRU-evict a warm
// backend — often the one the user is about to click. Default limit 3,
// 3 open secondaries → the next spawn would exceed the cap.
openSecondaryCount.mockReturnValue(3)
prewarmProfileBackend('warm-saturated')
expect(openGatewayForProfile).not.toHaveBeenCalled()
})
it('pre-warms while pool slots are free', () => {
openSecondaryCount.mockReturnValue(1)
prewarmProfileBackend('warm-slot-free')
expect(openGatewayForProfile).toHaveBeenCalledWith('warm-slot-free')
})
it('follows the live pool-limit atom, not a hard-coded cap', () => {
// User raises Warm Bot Backends to 8 in Settings: prewarming must keep
// working well past the old default of 3.
openSecondaryCount.mockReturnValue(5)
$poolLimits.set({ idleMs: 600_000, maxBackends: 8 })
prewarmProfileBackend('warm-raised-cap')
expect(openGatewayForProfile).toHaveBeenCalledWith('warm-raised-cap')
// And lowering the cap re-engages the guard at the new boundary.
$poolLimits.set({ idleMs: 600_000, maxBackends: 2 })
prewarmProfileBackend('warm-lowered-cap')
expect(openGatewayForProfile).not.toHaveBeenCalledWith('warm-lowered-cap')
})
})
describe('refreshProfiles shared rail list (#49289)', () => {
+14 -1
View File
@@ -21,9 +21,11 @@ import {
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForAgent,
openGatewayForProfile
openGatewayForProfile,
openSecondaryCount
} from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $poolLimits } from '@/store/pool-limits'
import { notifyRemoteOverrideAuthFailure } from '@/store/profile-remote-override'
import { clearComposerSelectionOwner, setComposerSelectionOwner, setConnection } from '@/store/session'
import type { SessionOwnerRoute } from '@/store/session-request-router'
@@ -423,6 +425,17 @@ export function prewarmProfileBackend(name: string): void {
return
}
// Prewarm/cap harmony (#91545): the pool caps spawned backends at the
// configured max, and a spawn over the cap LRU-evicts the warmest idle
// backend. A hover sweep across the rail therefore evicted backends for
// profiles the user was about to click — prewarming caused the exact churn
// it exists to prevent. Skip speculative spawns once every pool slot is
// occupied by an open socket; the real click still spawns on demand, it
// just doesn't get a head start.
if (openSecondaryCount() + 1 > $poolLimits.get().maxBackends) {
return
}
prewarmedAt.set(key, now)
openGatewayForProfile(key).catch(() => undefined)
}