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:
committed by
Teknium
parent
8706517544
commit
b23559877a
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user