From 49e6307939c846146370353705e8abff336ff329 Mon Sep 17 00:00:00 2001 From: Abdulrahman Jahfali <273605138+jahfaliabdulrahman-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:55:40 +0300 Subject: [PATCH] fix(desktop): route host.warmProfile through the guarded prewarm resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/sdk/index.test.ts | 53 +++++++++++++++++++++++++++++- apps/desktop/src/sdk/index.ts | 22 ++++++++----- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index 865a585c6e85d..a93b00916ae4b 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -1,10 +1,61 @@ -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), + 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() + }) + + it('ignores blank profile names', () => { + host.warmProfile(' ') + + expect(warmMocks.openGatewayForProfile).not.toHaveBeenCalled() + }) +}) + describe('host.state turn flags', () => { afterEach(() => { setActiveSessionId(null) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index e69550dac7792..4402f0ac9ed42 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -65,6 +65,7 @@ import { newSessionInAgent, newSessionInProfile, normalizeProfileKey, + prewarmProfileBackend, refreshProfiles, selectProfile, setActiveProfile, @@ -643,20 +644,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