fix(desktop): route host.warmProfile through the guarded prewarm resolver

Plugin rosters warm profile backends on pointerEnter with no dwell of
their own. warmProfile dialed openGatewayForProfile directly, bypassing
the pool-saturation guard, hover dwell, and per-profile throttle that
prewarmProfileBackend enforces for the built-in rail — so a pointer
sweep across a roster could spawn past maxBackends and leave the next
profile's real spawn queued until the 30s slot timeout, surfacing as a
profile surface that hangs forever while every other profile renders.

Delegate to prewarmProfileBackend so every speculative warm shares one
resolver and one policy, as the design guide requires. The real click
still spawns on demand; only the speculative head start is gated.
This commit is contained in:
Abdulrahman Jahfali
2026-09-05 14:55:40 +03:00
committed by Teknium
parent 8706517544
commit b23559877a
2 changed files with 60 additions and 9 deletions

View File

@@ -1,10 +1,55 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { host } from '@/sdk'
import { setActiveSessionId, setAwaitingResponse, setBusy } from '@/store/session'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
// The warm path must route through the guarded prewarm resolver, not dial the
// gateway directly: gateway.ts's openSecondaryCount and pool-limits' cap atom
// are the two signals prewarmProfileBackend consults, so mocking them lets the
// tests observe the guard's decision through the ONLY side effect that matters
// — whether openGatewayForProfile was dialed.
const warmMocks = vi.hoisted(() => ({
openGatewayForProfile: vi.fn(async (_profile: string) => undefined),
openSecondaryCount: vi.fn(() => 0)
}))
vi.mock('@/store/gateway', async importOriginal => ({
...((await importOriginal()) as Record<string, unknown>),
openGatewayForProfile: warmMocks.openGatewayForProfile,
openSecondaryCount: warmMocks.openSecondaryCount
}))
vi.mock('@/store/pool-limits', async () => {
const { atom } = await import('nanostores')
return { $poolLimits: atom({ idleMs: 600_000, maxBackends: 3 }) }
})
describe('host.warmProfile pool-saturation contract', () => {
beforeEach(() => {
warmMocks.openGatewayForProfile.mockClear()
warmMocks.openSecondaryCount.mockReturnValue(0)
})
it('dials through the guarded path when a pool slot is free', () => {
warmMocks.openSecondaryCount.mockReturnValue(2)
host.warmProfile('warm-free-slot')
expect(warmMocks.openGatewayForProfile).toHaveBeenCalledWith('warm-free-slot')
})
it('skips the speculative spawn when every pool slot is occupied', () => {
warmMocks.openSecondaryCount.mockReturnValue(3)
host.warmProfile('warm-saturated')
expect(warmMocks.openGatewayForProfile).not.toHaveBeenCalled()
})
})
describe('host.state turn flags', () => {
afterEach(() => {
setActiveSessionId(null)

View File

@@ -67,6 +67,7 @@ import {
newSessionInAgent,
newSessionInProfile,
normalizeProfileKey,
prewarmProfileBackend,
refreshProfiles,
selectProfile,
setActiveProfile,
@@ -666,20 +667,25 @@ export const host = {
},
/** Pre-dial a profile's gateway socket in the background — pool-only, no
* activation, no navigation, no scope change (openGatewayForProfile; it
* already no-ops for shared-remote routes and the primary). Roster UIs
* call this after mount so the FIRST click on an agent doesn't pay the
* whole backend spawn + socket dial latency. Fire-and-forget: failures
* are swallowed — the click path re-runs its own ensure and surfaces
* errors properly. */
* activation, no navigation, no scope change. Delegates to
* prewarmProfileBackend so plugin surfaces get the SAME pool-saturation
* guard, hover dwell, and per-profile throttle as the built-in rail
* (#91545): a pointer sweep across a plugin roster (bot-row's
* onPointerEnter fires with no dwell of its own) previously spawned at
* pointer speed, filled the local backend pool past maxBackends, and left
* the next profile's spawn queued until the 30s slot timeout — observed
* as a profile surface that hangs forever while every other profile
* renders. It already no-ops for shared-remote routes and the primary.
* Fire-and-forget: failures are swallowed — the click path re-runs its
* own ensure and surfaces errors properly. */
warmProfile: (profile: string): void => {
const name = (profile ?? '').trim()
if (!name || name === $activeGatewayProfile.get()) {
if (!name) {
return
}
void openGatewayForProfile(name).catch(() => undefined)
prewarmProfileBackend(name)
},
/** Delete a profile THROUGH the desktop's teardown-routed REST path — the