diff --git a/apps/desktop/src/app/session/hooks/default-new-session.test.tsx b/apps/desktop/src/app/session/hooks/default-new-session.test.tsx index afaaa162b17c2..20c27993b412b 100644 --- a/apps/desktop/src/app/session/hooks/default-new-session.test.tsx +++ b/apps/desktop/src/app/session/hooks/default-new-session.test.tsx @@ -42,6 +42,10 @@ vi.mock('@/store/gateway', async original => ({ retainGatewayForAgent: vi.fn(async () => () => undefined) })) +// Routed session.create dials are user gestures (send / "New session"), so the +// hook tags them foreground (#105104); the two undefineds are timeout/signal. +const FOREGROUND_CREATE_DIAL = [undefined, undefined, { spawnPriority: 'foreground' }] as const + function mountActions() { const ref = (current: T) => ({ current }) const requestGateway = vi.fn(async () => ({ session_id: 'ambient', stored_session_id: 'ambient-stored' }) as never) @@ -193,7 +197,8 @@ describe('generic new session default routing', () => { 'peer-host', 'peer-agent', 'session.create', - expect.objectContaining({ profile: 'peer-agent' }) + expect.objectContaining({ profile: 'peer-agent' }), + ...FOREGROUND_CREATE_DIAL ) }) @@ -216,7 +221,8 @@ describe('generic new session default routing', () => { connectionId, profile, 'session.create', - expect.objectContaining({ profile }) + expect.objectContaining({ profile }), + ...FOREGROUND_CREATE_DIAL ) expect(getSessionOwnerHint('created-stored')).toEqual({ connectionId, profile }) } @@ -239,7 +245,8 @@ describe('generic new session default routing', () => { expected.connectionId, expected.profile, 'session.create', - expect.objectContaining({ profile: expected.profile }) + expect.objectContaining({ profile: expected.profile }), + ...FOREGROUND_CREATE_DIAL ) expect(getSessionOwnerHint('created-stored')).toEqual(expected) expect($sessions.get().find(row => row.id === 'created-stored')).toMatchObject({ diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index ec19503dfad57..20fc0aa01d19b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -22,7 +22,7 @@ import { import { createClientSessionState } from '@/lib/chat-runtime' import { $clarifyRequests, clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' -import { requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway' +import { requestGatewayForAgent, requestGatewayForProfile, retainGatewayForAgent } from '@/store/gateway' import { $pinnedSessionIds } from '@/store/layout' import { $activeGatewayProfile, @@ -893,8 +893,14 @@ describe('createBackendSessionForSend profile routing', () => { 'source-a', 'default', 'session.create', - expect.objectContaining({ profile: 'backend-default', source: 'desktop' }) + expect.objectContaining({ profile: 'backend-default', source: 'desktop' }), + undefined, + undefined, + // #105104 / #105390: first send on a fresh chat is a user gesture; the + // create dial must not queue behind background roster hydration. + { spawnPriority: 'foreground' } ) + expect(retainGatewayForAgent).toHaveBeenCalledWith('source-a', 'default', { spawnPriority: 'foreground' }) expect(ambientRequest).not.toHaveBeenCalledWith('session.create', expect.anything()) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 635e836d42ff3..d4f7378edbb7a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -609,8 +609,15 @@ export function useSessionActions({ // foreground hold below takes over from that point until the created // chat is selected. Between the two, nothing may close the socket // that just minted the runtime. + // + // 'foreground' spawn priority (#102281 primitive): this is the user + // hitting send on a fresh chat, so a cold spawn must not queue behind + // background roster hydration on a saturated pool. The retain is the + // first dial, so it carries the tag as well as the create RPC. const releaseCreateLease = capturedRoute - ? await retainGatewayForAgent(capturedRoute.connectionId, capturedRoute.profile) + ? await retainGatewayForAgent(capturedRoute.connectionId, capturedRoute.profile, { + spawnPriority: 'foreground' + }) : () => undefined let created: SessionCreateResponse @@ -622,7 +629,10 @@ export function useSessionActions({ capturedRoute.connectionId, capturedRoute.profile, 'session.create', - params + params, + undefined, + undefined, + { spawnPriority: 'foreground' } ) : await requestGateway('session.create', params) @@ -843,9 +853,13 @@ export function useSessionActions({ // Same lease chain as createBackendSessionForSend: owner socket held // across the create, then the foreground hold carries it until the - // tile is mounted ($sessionTiles names the owner from then on). + // tile is mounted ($sessionTiles names the owner from then on). Same + // 'foreground' spawn priority too: "New session" / tab-strip "+" is a + // direct user click, not background hydration. const releaseCreateLease = capturedRoute - ? await retainGatewayForAgent(capturedRoute.connectionId, capturedRoute.profile) + ? await retainGatewayForAgent(capturedRoute.connectionId, capturedRoute.profile, { + spawnPriority: 'foreground' + }) : () => undefined let created: SessionCreateResponse @@ -857,7 +871,10 @@ export function useSessionActions({ capturedRoute.connectionId, capturedRoute.profile, 'session.create', - params + params, + undefined, + undefined, + { spawnPriority: 'foreground' } ) : await requestGateway('session.create', params) diff --git a/apps/desktop/src/plugins/hermes-bots/canonical-chat-registry.test.ts b/apps/desktop/src/plugins/hermes-bots/canonical-chat-registry.test.ts index 791b710dd3dd4..d366884a7ca02 100644 --- a/apps/desktop/src/plugins/hermes-bots/canonical-chat-registry.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/canonical-chat-registry.test.ts @@ -68,13 +68,15 @@ vi.mock('./shared', () => ({ getPluginCtx: () => null })) /** Route every RPC through one table, recording what was asked. */ function respondWith(handler: (method: string, params: Record) => unknown) { - const calls: Array<{ method: string; params: Record }> = [] + const calls: Array<{ method: string; options?: { spawnPriority?: string }; params: Record }> = [] - requestForBotMock.mockImplementation(async (_bot: unknown, method: string, params: Record) => { - calls.push({ method, params: structuredClone(params ?? {}) }) + requestForBotMock.mockImplementation( + async (_bot: unknown, method: string, params: Record, options?: { spawnPriority?: string }) => { + calls.push({ method, options, params: structuredClone(params ?? {}) }) - return handler(method, params) - }) + return handler(method, params) + } + ) return calls } @@ -134,6 +136,9 @@ describe('the registry row wins, always', () => { include_hidden: true, title: 'Bot Chat' }) + // #105104: the click's first RPC is the one that cold-spawns the bot's + // backend; it must dial foreground or it queues behind roster hydration. + expect(list?.options).toEqual({ spawnPriority: 'foreground' }) }) it('opens the lineage tip of a compression-rotated registry row', async () => { @@ -197,6 +202,10 @@ describe('no registry row → create', () => { hidden: true, title: 'Bot Chat' }) + // Same click gesture as the lookup: the create dials foreground too, while + // the follow-up title write stays untagged (the socket is already open). + expect(calls.find(call => call.method === 'session.create')?.options).toEqual({ spawnPriority: 'foreground' }) + expect(calls.find(call => call.method === 'session.title')?.options).toBeUndefined() // The eager title write persists the row; no user-attributed intro. expect(calls.find(call => call.method === 'session.title')?.params).toMatchObject({ session_id: 'rt-1' }) expect(calls.find(call => call.method === 'prompt.submit')).toBeUndefined() diff --git a/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts b/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts index 81abb9afaec85..9bde0b75eda51 100644 --- a/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts @@ -242,12 +242,22 @@ async function findExistingCanonicalChat(owner: RosterRow | string): Promise(bot, 'session.list', { - profile: backendTargetProfile(route, name), - title: CANONICAL_CHAT_TITLE, - limit: PROFILE_SESSION_LIST_LIMIT, - include_hidden: true - }) + // Every caller is a user gesture (roster click, Create Bot), and this is + // the FIRST RPC of the gesture — the one that cold-spawns the bot's + // backend on a local pool. Dial foreground so the click is not queued + // behind background roster hydration on a saturated pool (#105104: roster + // click, zero backend activity, "try again" toast). + res = await requestForBot<{ sessions?: CanonicalChatRow[] }>( + bot, + 'session.list', + { + profile: backendTargetProfile(route, name), + title: CANONICAL_CHAT_TITLE, + limit: PROFILE_SESSION_LIST_LIMIT, + include_hidden: true + }, + { spawnPriority: 'foreground' } + ) } catch (error) { // Plugin tests and host bridges can return Error-like values from another // JS realm, where `instanceof Error` is false. Preserve the provider/RPC @@ -390,22 +400,29 @@ export function createCanonicalChat( return existing.id } - const res = await requestForBot<{ session_id?: string; stored_session_id?: string }>(bot, 'session.create', { - profile: backendTargetProfile(route, name), - title: CANONICAL_CHAT_TITLE, - // Always born hidden from the global sidebar — Bot Mode sessions are - // plugin-owned. Core applies this via the generic `hidden` flag - // (deferred as pending_hidden until the row exists); older gateways - // ignore the unknown param and it stays visible. - hidden: true, - // Explicit contract (PR #97008): this session's runtime always follows - // the member profile's CURRENT config. Resume must NOT restore the - // stored model/provider pin from an old row — that left bot DMs stuck - // on a stale/dead provider after a profile switch. Older gateways - // ignore the unknown param; the server's exact-title backfill then - // covers the legacy path. - follow_profile_config: true - }) + // Same click gesture as the foreground lookup above: a first-ever open + // has no row to find and mints one, still on the user's dial. + const res = await requestForBot<{ session_id?: string; stored_session_id?: string }>( + bot, + 'session.create', + { + profile: backendTargetProfile(route, name), + title: CANONICAL_CHAT_TITLE, + // Always born hidden from the global sidebar — Bot Mode sessions are + // plugin-owned. Core applies this via the generic `hidden` flag + // (deferred as pending_hidden until the row exists); older gateways + // ignore the unknown param and it stays visible. + hidden: true, + // Explicit contract (PR #97008): this session's runtime always follows + // the member profile's CURRENT config. Resume must NOT restore the + // stored model/provider pin from an old row — that left bot DMs stuck + // on a stale/dead provider after a profile switch. Older gateways + // ignore the unknown param; the server's exact-title backfill then + // covers the legacy path. + follow_profile_config: true + }, + { spawnPriority: 'foreground' } + ) const sid = res?.stored_session_id const runtime = res?.session_id diff --git a/apps/desktop/src/plugins/hermes-bots/routing.test.ts b/apps/desktop/src/plugins/hermes-bots/routing.test.ts index e48851e253235..af37d9297365b 100644 --- a/apps/desktop/src/plugins/hermes-bots/routing.test.ts +++ b/apps/desktop/src/plugins/hermes-bots/routing.test.ts @@ -261,6 +261,32 @@ describe('requestForBot rides the bot’s own source', () => { }) }) + it('passes a foreground spawnPriority through to host.requestProfile and keeps untagged calls at three args', async () => { + // #105104: the Bot Chat open is a user click. Its RPC must reach the SDK + // with the dial tag; passive roster warming (no options) must keep the + // exact call shape older shells expect. + hostMock.requestProfile.mockResolvedValue({}) + const bot = { connectionId: 'local', name: 'ops', sourceScoped: true } as RosterRow + + await requestForBot(bot, 'session.list', {}, { spawnPriority: 'foreground' }) + await requestForBot(bot, 'profiles.list', {}) + + expect(hostMock.requestProfile).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ connectionId: 'local', profile: 'ops' }), + 'session.list', + {}, + undefined, + { spawnPriority: 'foreground' } + ) + expect(hostMock.requestProfile).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ connectionId: 'local', profile: 'ops' }), + 'profiles.list', + {} + ) + }) + it('fails closed rather than falling back to the ambient request', async () => { // A scoped row whose shell predates requestProfile must NOT silently // execute against whichever gateway happens to be active. diff --git a/apps/desktop/src/plugins/hermes-bots/routing.ts b/apps/desktop/src/plugins/hermes-bots/routing.ts index 6631207e40c13..23e0f456449cb 100644 --- a/apps/desktop/src/plugins/hermes-bots/routing.ts +++ b/apps/desktop/src/plugins/hermes-bots/routing.ts @@ -197,10 +197,18 @@ export function botBackendProfileScope(route: null | ProfileRoute | undefined, f /** Gateway RPC on the bot's OWN source. Source-scoped rows always use the * explicit descriptor, including a registered local source. */ +export interface BotRequestOptions { + /** 'foreground' for an explicit user gesture (roster click, Create Bot) so + * a cold backend spawn takes the pool's reserved slot; leave unset for + * passive roster warming. Only source-scoped routes can carry it. */ + spawnPriority?: 'background' | 'foreground' +} + export async function requestForBot( bot: Partial | null | undefined, method: string, - params: Record = {} + params: Record = {}, + options?: BotRequestOptions ): Promise { const route = botConnectionRoute(bot) @@ -210,7 +218,13 @@ export async function requestForBot( } try { - return await host.requestProfile(route, method, scopedBotParams(route, method, params)) + const routedParams = scopedBotParams(route, method, params) + + // Keep the three-argument shape when no options were given so older + // desktop shells (and the arity-pinning tests) see the same call. + return await (options?.spawnPriority + ? host.requestProfile(route, method, routedParams, undefined, { spawnPriority: options.spawnPriority }) + : host.requestProfile(route, method, routedParams)) } catch (error) { // React 19 formats query errors with `(error.name || '').trim()`. IPC / // JSON-RPC rejections are often plain objects whose `name` is a number, diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 432d0a483023d..6c3466c13d30d 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -55,7 +55,8 @@ import { requestGatewayForProfile, retainGatewayForAgent, retainGatewayForRelay, - retireLocalProfileGateways + retireLocalProfileGateways, + type SpawnPriority } from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' import { @@ -233,18 +234,44 @@ const $busyBySession = computed($sessionStates, states => { const $viewport = atom(readViewport()) +/** Options a plugin may attach to one `host.requestProfile` call. */ +export interface PluginProfileRequestOptions { + /** Tag the dial that may cold-spawn this route's backend. Default + * 'background'; an explicit user action passes 'foreground' so its spawn + * takes the pool's reserved interactive slot (#102281 primitive). */ + spawnPriority?: SpawnPriority +} + async function requestPluginProfile( route: PluginProfileRoute | string, method: string, params: Record, - timeoutMs?: number + timeoutMs?: number, + options?: PluginProfileRequestOptions ): Promise { + const spawnPriority = options?.spawnPriority + + // Preserve the exact call arity the pool tests pin: pass the deadline and the + // dial options only when the caller set them, so a plain routed RPC keeps its + // four-argument shape and a timeout-only caller its five-argument shape. + const dialProfile = (profile: string): Promise => + spawnPriority + ? requestGatewayForProfile(profile, method, params, timeoutMs, undefined, { spawnPriority }) + : timeoutMs === undefined + ? requestGatewayForProfile(profile, method, params) + : requestGatewayForProfile(profile, method, params, timeoutMs) + if (typeof route !== 'string') { if (!route.connectionId.trim() || !route.profile.trim() || !route.targetProfile.trim()) { throw new Error('Profile route must include connectionId, profile, and targetProfile') } - // Omit the bound entirely when unset so callers stay on the pool default. + if (spawnPriority) { + return requestGatewayForAgent(route.connectionId, route.profile, method, params, timeoutMs, undefined, { + spawnPriority + }) + } + return timeoutMs === undefined ? requestGatewayForAgent(route.connectionId, route.profile, method, params) : requestGatewayForAgent(route.connectionId, route.profile, method, params, timeoutMs) @@ -253,9 +280,7 @@ async function requestPluginProfile( const getAgentRoster = window.hermesDesktop?.getAgentRoster if (!getAgentRoster) { - return timeoutMs === undefined - ? requestGatewayForProfile(route, method, params) - : requestGatewayForProfile(route, method, params, timeoutMs) + return dialProfile(route) } const roster = await getAgentRoster() @@ -267,9 +292,7 @@ async function requestPluginProfile( // its live enumeration transiently failed. Any additional source requires a // descriptor because an undialed/unreachable source may expose the same name. if (soleLocalSource) { - return timeoutMs === undefined - ? requestGatewayForProfile(profile, method, params) - : requestGatewayForProfile(profile, method, params, timeoutMs) + return dialProfile(profile) } throw new Error( @@ -1331,13 +1354,21 @@ export const host = { * `timeoutMs` opts one call out of the pool's generic deadline (#93911: a * method whose backend contract is minutes long, such as `bot_relay.deliver`, * otherwise dies at 30s and reports an unclassified failure). Leave it unset - * to keep the default. */ + * to keep the default. + * + * `options.spawnPriority: 'foreground'` marks the call as an explicit user + * action (a roster click opening a Bot Chat) so the dial that may cold-spawn + * the route's backend takes the pool's reserved interactive slot instead of + * queuing behind background roster hydration (#105104). `timeoutMs` stays + * the fourth positional argument so existing callers keep their shape; pass + * `undefined` there to set options alone. Default is 'background'. */ requestProfile: async ( route: PluginProfileRoute | string, method: string, params: Record = {}, - timeoutMs?: number - ): Promise => requestPluginProfile(route, method, params, timeoutMs), + timeoutMs?: number, + options?: PluginProfileRequestOptions + ): Promise => requestPluginProfile(route, method, params, timeoutMs, options), /** Pin a route's pooled gateway socket open across repeated `requestProfile` * calls (#93594: the bot-relay drain loop was dialing and tearing down a diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index c92b0d75f1320..6d5f242c4429f 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -535,6 +535,39 @@ describe('connection-aware plugin host APIs', () => { }) }) + it('forwards { spawnPriority: "foreground" } to the route dial and keeps timeoutMs positional (#105104)', async () => { + // An explicit user action (Bot Chat roster click) must reach the pool as a + // foreground dial; the SDK passes the options object through so the + // registry secondary's probe + connect carry it. The numeric fourth arg is + // still the timeout, so a caller can set both. + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'remote-worker', + targetProfile: 'backend-worker' + } + + await host.requestProfile(route, 'session.list', { title: 'Bot Chat' }, 45_000, { spawnPriority: 'foreground' }) + + expect(requestGatewayForAgent).toHaveBeenCalledWith( + 'source-a', + 'remote-worker', + 'session.list', + { title: 'Bot Chat' }, + 45_000, + undefined, + { spawnPriority: 'foreground' } + ) + }) + + it('forwards the foreground tag on the profile-only overload without inventing a timeout', async () => { + await host.requestProfile('legacy-worker', 'session.list', {}, undefined, { spawnPriority: 'foreground' }) + + expect(requestGatewayForProfile).toHaveBeenCalledWith('legacy-worker', 'session.list', {}, undefined, undefined, { + spawnPriority: 'foreground' + }) + }) + it('keeps the profile-only request overload as a legacy fallback', async () => { const result = await host.requestProfile('legacy-worker', 'profiles.list', { include_sessions: true }) diff --git a/apps/desktop/src/store/gateway-spawn-priority.test.ts b/apps/desktop/src/store/gateway-spawn-priority.test.ts index 58ea0a5eb2886..e1fd131a73384 100644 --- a/apps/desktop/src/store/gateway-spawn-priority.test.ts +++ b/apps/desktop/src/store/gateway-spawn-priority.test.ts @@ -19,6 +19,7 @@ vi.mock('@/hermes', () => ({ } onEvent = vi.fn(() => () => {}) onState = vi.fn(() => () => {}) + request = vi.fn(async () => ({})) } })) vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() })) @@ -31,6 +32,8 @@ const { ensureGatewayForProfile, openGatewayForAgent, openGatewayForProfile, + requestGatewayForAgent, + retainGatewayForAgent, setPrimaryGateway } = await import('./gateway') @@ -110,3 +113,72 @@ describe('user opens dial main as foreground from the first IPC (#102281)', () = expect(seen.every(priority => priority === 'foreground')).toBe(true) }) }) + +// requestGatewayForAgent / retainGatewayForAgent are the RPC-lease pair that +// createBackendSessionForSend, openNewSessionTile and the plugin SDK's +// host.requestProfile dial through. They are not activation doors, so they +// never hardcode 'foreground'; a user gesture (first send, "New session", a +// Bot Chat roster click) passes { spawnPriority: 'foreground' } and every dial +// the call makes — the shared-remote probe and the secondary connect — must +// carry it. Untagged callers keep main's pre-priority IPC payload (#105104). +describe('RPC-lease dials forward an explicit spawnPriority (#105104)', () => { + const registryPriorities = (desktop: ReturnType) => + priorities(desktop.getConnectionFor, args => (args[0] as { priority?: string }).priority) + + it('requestGatewayForAgent tags the probe and the connect when the caller says foreground', async () => { + const desktop = installDesktop() + + await requestGatewayForAgent('homelab', 'research', 'session.create', {}, undefined, undefined, { + spawnPriority: 'foreground' + }) + + const seen = registryPriorities(desktop) + expect(seen.length).toBeGreaterThanOrEqual(1) + expect(seen.every(priority => priority === 'foreground')).toBe(true) + }) + + it('requestGatewayForAgent without options never tags a registry dial', async () => { + const desktop = installDesktop() + + await requestGatewayForAgent('homelab', 'research', 'session.create', {}) + + const seen = registryPriorities(desktop) + expect(seen.length).toBeGreaterThanOrEqual(1) + expect(seen.every(priority => priority === undefined)).toBe(true) + }) + + it('retainGatewayForAgent tags the lease dial when the caller says foreground', async () => { + const desktop = installDesktop() + + const release = await retainGatewayForAgent('homelab', 'research', { spawnPriority: 'foreground' }) + release() + + const seen = registryPriorities(desktop) + expect(seen.length).toBeGreaterThanOrEqual(1) + expect(seen.every(priority => priority === 'foreground')).toBe(true) + }) + + it('retainGatewayForAgent without options never tags a registry dial', async () => { + const desktop = installDesktop() + + const release = await retainGatewayForAgent('homelab', 'research') + release() + + const seen = registryPriorities(desktop) + expect(seen.length).toBeGreaterThanOrEqual(1) + expect(seen.every(priority => priority === undefined)).toBe(true) + }) + + it('retainGatewayForAgent on the plain-profile route (null connection) still dials foreground', async () => { + // Neither #105390 nor #110354 covered this branch: the v1 profile resolver + // goes through gatewayForProfile, not the registry secondary. + const desktop = installDesktop() + + const release = await retainGatewayForAgent(null, 'research', { spawnPriority: 'foreground' }) + release() + + const seen = priorities(desktop.getConnection, args => (args[1] as { priority?: string } | undefined)?.priority) + expect(seen.length).toBeGreaterThanOrEqual(1) + expect(seen.every(priority => priority === 'foreground')).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 3045835fa6f6e..942a4c21449ea 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -31,7 +31,7 @@ const normKey = (profile: string | null | undefined): string => (profile ?? ''). // user-initiated open is 'foreground' and may take the pool's reserved slot; // roster hydration, hover prewarm and untagged dials are 'background' — main's // default, so background dials keep the pre-priority IPC payload shape. -type SpawnPriority = 'foreground' | 'background' +export type SpawnPriority = 'foreground' | 'background' function dialPriority(spawnPriority: SpawnPriority): { priority: 'foreground' } | Record { return spawnPriority === 'foreground' ? { priority: 'foreground' } : {} @@ -1022,6 +1022,12 @@ export async function requestGatewayForProfile( * composite (connectionId, profile) pool key prevents same-named agents on two * sources from sharing a socket. Only null/empty ids retain the v1 profile * resolver; explicit `local` is a registry source and must use getConnectionFor. + * + * `spawnPriority` defaults to 'background' like every other dial in this file. + * A user gesture that reaches the pool through this RPC path (first send on a + * fresh chat, "New session", an explicit Bot Chat open) passes 'foreground' so + * its cold spawn takes the pool's reserved interactive slot instead of queuing + * behind roster hydration (#102281 primitive; #105104 symptom). */ export async function requestGatewayForAgent( connectionId: null | string, @@ -1029,13 +1035,14 @@ export async function requestGatewayForAgent( method: string, params: Record = {}, timeoutMs?: number, - signal?: AbortSignal + signal?: AbortSignal, + { spawnPriority = 'background' }: { spawnPriority?: SpawnPriority } = {} ): Promise { const key = normKey(profile) const scope = registryBackendScopeKey(connectionId, key) if (scope === key) { - return requestGatewayForProfile(key, method, params, timeoutMs, signal) + return requestGatewayForProfile(key, method, params, timeoutMs, signal, { spawnPriority }) } // A primary remote selected from the connection registry carries its source @@ -1047,10 +1054,10 @@ export async function requestGatewayForAgent( // Require both owner identities to agree before collapsing the route; a // different source or profile must retain its isolated secondary. if (isPrimaryRegistryRoute(connectionId, key)) { - return requestGatewayForProfile(key, method, params, timeoutMs, signal) + return requestGatewayForProfile(key, method, params, timeoutMs, signal, { spawnPriority }) } - if (await isAttachedSharedRemote(connectionId, key)) { + if (await isAttachedSharedRemote(connectionId, key, spawnPriority)) { return requestOnPrimaryGateway(method, { ...params, profile: key }, timeoutMs, signal) } @@ -1074,7 +1081,7 @@ export async function requestGatewayForAgent( try { if (!isOpen(entry.gateway)) { - await openSecondary(entry) + await openSecondary(entry, spawnPriority) } return await (timeoutMs === undefined && signal === undefined @@ -1225,20 +1232,28 @@ export function retainGatewayForRelay(connectionId: null | string, profile: stri * acquire this lease before the first session-scoped RPC and release it in a * `finally`; the refcount keeps the socket (and the session it minted) alive * for the whole sequence. Primary/shared-primary routes return a no-op release. + * + * `spawnPriority` follows requestGatewayForAgent: the retain is the FIRST dial + * of a session-create gesture, so a user click passes 'foreground' here or the + * cold spawn still queues behind background hydration before the create RPC. */ -export async function retainGatewayForAgent(connectionId: null | string, profile: string): Promise<() => void> { +export async function retainGatewayForAgent( + connectionId: null | string, + profile: string, + { spawnPriority = 'background' }: { spawnPriority?: SpawnPriority } = {} +): Promise<() => void> { const key = normKey(profile) const scope = registryBackendScopeKey(connectionId, key) if (scope === key) { // Plain-profile route: gatewayForProfile's request lease IS the retain — // hold it until the caller releases. - const route = await gatewayForProfile(key, true) + const route = await gatewayForProfile(key, true, spawnPriority) return route.release } - if (isPrimaryRegistryRoute(connectionId, key) || (await isAttachedSharedRemote(connectionId, key))) { + if (isPrimaryRegistryRoute(connectionId, key) || (await isAttachedSharedRemote(connectionId, key, spawnPriority))) { // Primary socket stays open for the window lifetime — no secondary to hold. return () => undefined } @@ -1294,7 +1309,7 @@ export async function retainGatewayForAgent(connectionId: null | string, profile try { if (!isOpen(entry.gateway)) { - await openSecondary(entry) + await openSecondary(entry, spawnPriority) } } catch (error) { release()