From 1808d33a24b96bc2aaa9971b4d5369c14fa43580 Mon Sep 17 00:00:00 2001 From: LovePlayCode <1244224501@qq.com> Date: Mon, 24 Aug 2026 22:01:30 +0800 Subject: [PATCH] fix(desktop): keep owner-routed tile gateways out of idle prune Bot chats stay on a secondary while chrome stays on the launch profile. The keep-set only counted busy sessions, so idle prune closed the tile socket and resume spun forever. Keep open tiles, route catalog reads to the owner, and hydrate model/provider from resume. Co-authored-by: Cursor --- apps/desktop/src/app/chat/session-tile.tsx | 5 +- .../hooks/use-session-tile-delegate.test.ts | 25 ++++++++ .../hooks/use-session-tile-delegate.ts | 10 +++- .../src/app/gateway/hooks/use-gateway-boot.ts | 19 ++++-- .../src/app/shell/model-catalog-menu.tsx | 7 ++- .../src/app/shell/model-menu-panel.tsx | 12 +++- apps/desktop/src/lib/model-options.test.ts | 40 +++++++++++++ apps/desktop/src/lib/model-options.ts | 30 ++++++++-- .../store/gateway-connection-scope.test.ts | 13 +++++ .../src/store/session-states-scopes.test.ts | 58 +++++++++++++++++++ apps/desktop/src/store/session-states.ts | 37 +++++++++++- contributors/emails/1244224501@qq.com | 1 + 12 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 contributors/emails/1244224501@qq.com diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 0c9aa9b175..7678cd13b9 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -232,13 +232,12 @@ function TileChat({ () => gatewayOpen ? ( ) : null, - [activeGatewayProfile, gateway, gatewayOpen, ownerRoute?.profile, requestTileGateway, selectModel] + [activeGatewayProfile, gatewayOpen, ownerRoute?.profile, ownerRoute?.targetProfile, requestTileGateway, selectModel] ) return ( diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts index 8bf3ffb50e..e99c262c88 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts @@ -206,6 +206,31 @@ describe('useSessionTileDelegate resumeTile', () => { ) }) + it('hydrates the tile model and provider from resume info', async () => { + setSessions([row({ id: 'stored-model', profile: 'default' })]) + + const updateSessionState = vi.fn() + + vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ + info: { fast: true, model: 'gpt-5', provider: 'openai', reasoning_effort: 'high', running: false }, + session_id: 'runtime-model' + } as never) + + renderTile(vi.fn(), { updateSessionState }) + const runtimeId = await sessionTileDelegate()!.resumeTile('stored-model') + + expect(runtimeId).toBe('runtime-model') + expect(updateSessionState).toHaveBeenCalled() + + const updater = updateSessionState.mock.calls[0][1] as (state: { messages: unknown[] }) => Record + const next = updater({ messages: [] }) + + expect(next.model).toBe('gpt-5') + expect(next.provider).toBe('openai') + expect(next.reasoningEffort).toBe('high') + expect(next.fast).toBe(true) + }) + it('invalidateRuntimeBindings clears the stored→runtime map so tiles re-resume after reconnect', async () => { setSessions([row({ id: 'stored-c', profile: 'default' })]) diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index ed49e99061..593017df64 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -193,11 +193,19 @@ export function useSessionTileDelegate({ throw new Error('resume returned no session id') } + const info = resumed?.info + updateSessionState( runtimeId, state => ({ ...state, - busy: Boolean(resumed?.info?.running), + busy: Boolean(info?.running), + // Persist the session's own model/provider from resume so the tile + // pill does not wait on a chrome-scoped catalog read (#93892). + ...(typeof info?.model === 'string' ? { model: info.model } : {}), + ...(typeof info?.provider === 'string' ? { provider: info.provider } : {}), + ...(typeof info?.reasoning_effort === 'string' ? { reasoningEffort: info.reasoning_effort } : {}), + ...(typeof info?.fast === 'boolean' ? { fast: info.fast } : {}), messages: state.messages.length > 0 ? state.messages : toChatMessages(prefetch?.messages ?? resumed?.messages ?? []) }), diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 0241b7f8b8..034993e46e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -61,7 +61,9 @@ import { import { $attentionSessionIds, $workingSessionIds, + $sessionTiles, liveSessionScopes, + openTileGatewayScopes, reconcileBusyStatesOnReconnect, recordSessionEventScope, resetTileRuntimeBindings @@ -773,10 +775,13 @@ export function useGatewayBoot({ touchSecondaryGateways() }, 60_000) - // Bound concurrency cost to live work: keep a background socket only while - // its profile has a running (working) or blocked (needs-input) session. - // Once that profile goes idle its socket is dropped and its backend is free - // to idle-reap. The active profile is always spared. + // Bound concurrency cost to consumers: keep a background socket while its + // profile has a running (working) or blocked (needs-input) session, OR an + // open owner-routed tile (Bot chats stay on a secondary while chrome stays + // on the launch profile). Once the last consumer leaves, the socket drops + // and its backend is free to idle-reap. The active profile is always spared. + // Do not key this off `entry.retained` — that flag only skips dispose-after- + // RPC; idle prune is what reclaims hover-warmed sockets after you leave. const recomputeKeptGateways = () => { const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()]) // Registry-scoped (connectionId, profile) scopes with live work. Two @@ -791,12 +796,17 @@ export function useGatewayBoot({ } } + for (const scope of openTileGatewayScopes()) { + keep.add(scope) + } + pruneSecondaryGateways(keep) } const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways()) const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways()) const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways()) + const offTiles = $sessionTiles.subscribe(() => recomputeKeptGateways()) const offWindowState = desktop.onWindowStateChanged?.(payload => { const current = $connection.get() @@ -991,6 +1001,7 @@ export function useGatewayBoot({ offWorking() offAttention() offActiveProfile() + offTiles() window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisible) window.removeEventListener('focus', onFocus) diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 1dfd70f15b..7e971437c6 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -93,6 +93,9 @@ interface ModelCatalogMenuProps { /** Rows appended under the catalog (Refresh Models, Edit Models, …). */ footer?: ReactNode gateway?: HermesGateway + /** Owner-routed RPC for catalog reads. Preferred over `gateway.request` so + * a tile's menu queries the session owner's backend, not chrome's. */ + request?: (method: string, params?: Record) => Promise /** Render the virtual `moa` provider's presets as a selectable section. * Off for override surfaces, where a MoA preset isn't a worker model. */ includeMoa?: boolean @@ -122,6 +125,7 @@ export function ModelCatalogMenu({ gateway, includeMoa = false, profile = 'default', + request, sessionId = null }: ModelCatalogMenuProps) { const { t } = useI18n() @@ -141,7 +145,8 @@ export function ModelCatalogMenu({ // Gateway-first even with no session: a connected (possibly remote) // gateway owns the model catalog, including virtual providers the local // REST fallback can't know about (#53817). - queryFn: (): Promise => requestModelOptions({ gateway, sessionId }) + queryFn: (): Promise => + requestModelOptions({ gateway, profile, request, sessionId }) }) const loading = modelOptions.isPending && !modelOptions.data diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 3aebe38de7..c65282ffca 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -73,7 +73,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re // never repaint that fallback once the catalog resolved. const modelOptions = useQuery({ queryKey: modelOptionsQueryKey(profile, activeSessionId), - queryFn: (): Promise => requestModelOptions({ gateway, sessionId: activeSessionId }) + queryFn: (): Promise => + requestModelOptions({ gateway, profile, request: requestGateway, sessionId: activeSessionId }) }) const { model: optionsModel, provider: optionsProvider } = currentPickerSelection( @@ -95,7 +96,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re try { const queryKey = modelOptionsQueryKey(profile, activeSessionId) - const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId }) + const next = await requestModelOptions({ + gateway, + profile, + refresh: true, + request: requestGateway, + sessionId: activeSessionId + }) queryClient.setQueryData(queryKey, next) } catch { @@ -238,6 +245,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re gateway={gateway} includeMoa profile={profile} + request={requestGateway} sessionId={activeSessionId} /> ) diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts index 4220d7494c..7ad3e23a2e 100644 --- a/apps/desktop/src/lib/model-options.test.ts +++ b/apps/desktop/src/lib/model-options.test.ts @@ -117,6 +117,46 @@ describe('requestModelOptions', () => { expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true, refresh: true }) }) + + it('prefers an owner-routed request over the ambient gateway socket', async () => { + const gatewayPayload = { + model: 'chrome-model', + provider: 'nous', + providers: [{ models: ['chrome-model'], name: 'Nous', slug: 'nous' }] + } + const routedPayload = { + model: 'berry-model', + provider: 'openai', + providers: [{ models: ['berry-model'], name: 'OpenAI', slug: 'openai' }] + } + const gateway = { + request: vi.fn(() => Promise.resolve(gatewayPayload)) + } + const request = vi.fn(() => Promise.resolve(routedPayload)) + + await expect( + requestModelOptions({ gateway: gateway as never, request, sessionId: 'tile-1' }) + ).resolves.toBe(routedPayload) + + expect(request).toHaveBeenCalledWith('model.options', { explicit_only: true, session_id: 'tile-1' }) + expect(gateway.request).not.toHaveBeenCalled() + }) + + it('scopes REST recovery to the catalog owner profile', async () => { + const restPayload = { + model: 'berry-local', + provider: 'hermes-local', + providers: [{ models: ['berry-local'], name: 'Hermes Local', slug: 'hermes-local' }] + } + const request = vi.fn(() => Promise.reject(new Error('gateway request unavailable'))) + + vi.mocked(getGlobalModelOptions).mockResolvedValueOnce(restPayload) + + await expect(requestModelOptions({ profile: 'berry', request, sessionId: 'tile-1' })).resolves.toEqual( + restPayload + ) + expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true }, 'berry') + }) }) describe('modelOptionsQueryKey', () => { diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index 6add1bc684..d736402d7b 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -40,6 +40,13 @@ interface ModelOptionsRequest { * providers are listed (#56974). */ explicitOnly?: boolean gateway?: HermesGateway + /** Owner-routed RPC. When set, catalog reads hit this dispatcher instead of + * `gateway.request` — a tile's model menu must not query the ambient + * chrome socket (#93892). */ + request?: (method: string, params?: Record) => Promise + /** Profile for the REST recovery path. Must match the catalog owner so a + * secondary tile does not fall back to the launch profile's models. */ + profile?: null | string refresh?: boolean sessionId?: null | string } @@ -54,13 +61,28 @@ function hasSelectableModels(options: ModelOptionsResponse | null | undefined): return options?.providers?.some(provider => (provider.models?.length ?? 0) > 0) ?? false } +function restModelOptions( + explicitOnly: boolean, + refresh: boolean, + profile?: null | string +): Promise { + const opts = { explicitOnly, ...(refresh ? { refresh: true } : {}) } + const profileKey = (profile ?? '').trim() + + return profileKey ? getGlobalModelOptions(opts, profileKey) : getGlobalModelOptions(opts) +} + export async function requestModelOptions({ explicitOnly = true, gateway, + profile, refresh = false, + request, sessionId }: ModelOptionsRequest): Promise { - if (gateway) { + const dispatch = request ?? (gateway ? gateway.request.bind(gateway) : null) + + if (dispatch) { const params: Record = {} if (sessionId) { @@ -79,7 +101,7 @@ export async function requestModelOptions({ let gatewayOptions: ModelOptionsResponse | undefined try { - gatewayOptions = await gateway.request('model.options', params) + gatewayOptions = await dispatch('model.options', params) } catch (error) { gatewayError = error } @@ -93,7 +115,7 @@ export async function requestModelOptions({ // catalog is already populated. Recover through the same profile-scoped // endpoint Settings uses, but keep the live session selection authoritative. try { - const restOptions = await getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) }) + const restOptions = await restModelOptions(explicitOnly, refresh, profile) if (hasSelectableModels(restOptions)) { return { @@ -114,5 +136,5 @@ export async function requestModelOptions({ throw gatewayError } - return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) }) + return restModelOptions(explicitOnly, refresh, profile) } diff --git a/apps/desktop/src/store/gateway-connection-scope.test.ts b/apps/desktop/src/store/gateway-connection-scope.test.ts index c1a2954071..9c0105cb06 100644 --- a/apps/desktop/src/store/gateway-connection-scope.test.ts +++ b/apps/desktop/src/store/gateway-connection-scope.test.ts @@ -136,4 +136,17 @@ describe('pruneSecondaryGateways with registry-scoped entries', () => { expect(gatewayMocks.closed).toHaveLength(1) }) + + it("does not let a remote tile keep-set pin a local same-named secondary", async () => { + // Chrome is on another profile so 'default' is a real secondary, not the + // spared active key. A homelab bot tile keep-set must keep only the + // composite scope — the local 'default' socket still idles out. + setPrimaryGateway({ connectionState: 'open' } as never, 'research') + await openGatewayForAgent(null, 'default') + await openGatewayForAgent('homelab', 'default') + + pruneSecondaryGateways(new Set(['conn:homelab::default'])) + + expect(gatewayMocks.closed).toEqual(['wss://local.invalid/api/ws?token=t']) + }) }) diff --git a/apps/desktop/src/store/session-states-scopes.test.ts b/apps/desktop/src/store/session-states-scopes.test.ts index 437895f435..55169e3ba6 100644 --- a/apps/desktop/src/store/session-states-scopes.test.ts +++ b/apps/desktop/src/store/session-states-scopes.test.ts @@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createClientSessionState } from '@/lib/chat-runtime' import { $sessionStates, + $sessionTiles, clearAllSessionStates, dropSessionState, liveSessionScopes, + openTileGatewayScopes, publishSessionState, recordSessionEventScope } from '@/store/session-states' @@ -79,3 +81,59 @@ describe('liveSessionScopes', () => { expect(liveSessionScopes()).toEqual(new Set()) }) }) + +describe('openTileGatewayScopes', () => { + beforeEach(() => { + $sessionTiles.set([]) + }) + + afterEach(() => { + $sessionTiles.set([]) + }) + + it('keeps a local bot tile on both the bare profile and the explicit local registry scope', () => { + $sessionTiles.set([ + { + ownerRoute: { connectionId: 'local', mode: 'local', profile: 'berry' }, + storedSessionId: 'bot-chat-berry' + } + ]) + + expect(openTileGatewayScopes()).toEqual(new Set(['berry', 'conn:local::berry'])) + }) + + it('keeps a remote tile on its composite scope only', () => { + $sessionTiles.set([ + { + ownerRoute: { connectionId: 'homelab', profile: 'default' }, + storedSessionId: 'bot-chat-homelab' + } + ]) + + expect(openTileGatewayScopes()).toEqual(new Set(['conn:homelab::default'])) + expect(openTileGatewayScopes().has('default')).toBe(false) + }) + + it('keys the keep-set on route.profile, not a remapped targetProfile', () => { + // openGatewayForAgent dials (connectionId, profile). targetProfile only + // rewrites RPC params — using it here would miss the live socket. + $sessionTiles.set([ + { + ownerRoute: { + connectionId: 'barry', + profile: 'oxcoder', + targetProfile: 'backend-oxcoder' + }, + storedSessionId: 'bot-chat-oxcoder' + } + ]) + + expect(openTileGatewayScopes()).toEqual(new Set(['conn:barry::oxcoder'])) + }) + + it('ignores tiles without an owner route', () => { + $sessionTiles.set([{ storedSessionId: 'plain' }]) + + expect(openTileGatewayScopes()).toEqual(new Set()) + }) +}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index fc3c89039f..3ea60a20ea 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -16,7 +16,7 @@ * itself here as the delegate so tile UI stays dependency-light. */ -import { registryBackendScopeKey } from '@hermes/shared' +import { LOCAL_CONNECTION_ID, registryBackendScopeKey } from '@hermes/shared' import { atom, computed } from 'nanostores' import type { ClientSessionState } from '@/app/types' @@ -742,6 +742,41 @@ export function sessionTileOwnerRoute(storedSessionId: string): SessionProfileRo return $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.ownerRoute } +/** + * Gateway keep-set scopes for currently open tiles. Bot chats (and any other + * owner-routed tile) hold a secondary socket even while chrome stays on the + * launch profile; without these keys, idle prune closes that socket and the + * tile's resume/unbind loop spins forever. Local routes contribute both the + * bare profile (openGatewayForProfile) and the explicit `conn:local::…` key + * (openGatewayForAgent). Remote routes contribute only the composite key so + * a homelab tile cannot pin another source's same-named profile. + */ +export function openTileGatewayScopes(): Set { + const scopes = new Set() + + for (const tile of $sessionTiles.get()) { + const route = tile.ownerRoute + + if (!route) { + continue + } + + const profile = normalizeProfileKey(route.profile) + const connectionId = String(route.connectionId ?? '').trim() + const localRoute = !connectionId || connectionId === LOCAL_CONNECTION_ID || route.mode === 'local' + + if (localRoute) { + scopes.add(profile) + } + + if (connectionId) { + scopes.add(registryBackendScopeKey(connectionId, profile)) + } + } + + return scopes +} + /** * Sync owner resolution for a session id that may be a RUNTIME or a STORED id. * Tile route first (exact connectionId+profile, survives relaunch), then the diff --git a/contributors/emails/1244224501@qq.com b/contributors/emails/1244224501@qq.com new file mode 100644 index 0000000000..a3932e8913 --- /dev/null +++ b/contributors/emails/1244224501@qq.com @@ -0,0 +1 @@ +LovePlayCode