From f1657382a13c4eccc2366f8d0aa65296401ae480 Mon Sep 17 00:00:00 2001 From: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:04:43 -0400 Subject: [PATCH 1/2] fix(desktop): foreground spawn priority through requestGatewayForAgent/retainGatewayForAgent and the session-create dials The #102281/#104139 chain tagged every activation-door dial (openGatewayForAgent/openGatewayForProfile/ensureGatewayForAgent/ sharedPrimaryRoute) as 'foreground' so a user-initiated open takes the pool's reserved slot instead of queuing behind background roster hydration. requestGatewayForAgent and retainGatewayForAgent never got a spawnPriority, so they always dialed 'background', and they are the only dial path createBackendSessionForSend (first send on a fresh chat) and openNewSessionTile ("New session" / tab-strip "+") use. On a saturated 3-slot local pool the user's first message or new-tab click could wait out the background slot timeout. Add `{ spawnPriority }` options (main's requestGatewayForProfile shape, 5eb0ed4531) to both functions and forward it into every dial they make: requestGatewayForProfile on the scope===key and primary-registry branches, isAttachedSharedRemote, openSecondary, and the plain-profile retain branch's gatewayForProfile(key, true, ...) which neither #105390 nor #110354 covered. The four session-create call sites pass 'foreground'. Tests: gateway-spawn-priority.test.ts gains both polarities for request/retain plus the plain-profile retain branch; the use-session-actions and default-new-session matchers pin the tagged dial. SpawnPriority is now exported for the SDK. --- .../hooks/default-new-session.test.tsx | 13 +++- .../hooks/use-session-actions.test.tsx | 10 ++- .../hooks/use-session-actions/index.ts | 27 +++++-- .../src/store/gateway-spawn-priority.test.ts | 72 +++++++++++++++++++ apps/desktop/src/store/gateway.ts | 35 ++++++--- 5 files changed, 137 insertions(+), 20 deletions(-) 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/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() From 338d83385c1202ff76ecabfe5d8590a204d5924a Mon Sep 17 00:00:00 2001 From: jxfjosh <282433633+jxfjosh@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:04:43 -0400 Subject: [PATCH 2/2] fix(desktop): canonical Bot Chat lookup dials foreground through host.requestProfile options Clicking a bot in the roster runs findExistingCanonicalChat -> requestForBot(bot, 'session.list') -> host.requestProfile(route, ...) -> requestGatewayForAgent with no intent, so the first RPC of the gesture, the one that cold-spawns the bot's backend on a local pool, dialed 'background'. With three roster backends already hydrating the click produced no backend activity and the fail-closed lookup surfaced as a "try again" toast (#105104). The later host.openSession is already foreground, but it never runs until this lookup returns. host.requestProfile gains a fifth `options?: { spawnPriority }` argument; `timeoutMs` stays the fourth positional so no existing caller changes shape, and the SDK keeps its exact call arity when no options are given. requestForBot takes the same options and forwards them only when set, so passive roster warming (profiles.list, ui_meta) still dials background. The canonical `session.list` and the `session.create` that follows on a first-ever open both pass 'foreground'. Tests: profile-routing.test.ts (options form with and without a timeout), routing.test.ts (tag forwarded, untagged call keeps three args), canonical-chat-registry.test.ts (session.list and session.create carry foreground; session.title does not). --- .../canonical-chat-registry.test.ts | 19 ++++-- .../src/plugins/hermes-bots/canonical-chat.ts | 61 ++++++++++++------- .../src/plugins/hermes-bots/routing.test.ts | 26 ++++++++ .../src/plugins/hermes-bots/routing.ts | 18 +++++- apps/desktop/src/sdk/index.ts | 55 +++++++++++++---- apps/desktop/src/sdk/profile-routing.test.ts | 33 ++++++++++ 6 files changed, 171 insertions(+), 41 deletions(-) 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 })