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 <cursoragent@cursor.com>
This commit is contained in:
LovePlayCode
2026-08-24 22:01:30 +08:00
committed by Teknium
parent 7e0b535610
commit 1808d33a24
12 changed files with 241 additions and 16 deletions

View File

@@ -232,13 +232,12 @@ function TileChat({
() =>
gatewayOpen ? (
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={selectModel}
profile={ownerRoute?.profile || activeGatewayProfile}
profile={ownerRoute?.targetProfile || ownerRoute?.profile || activeGatewayProfile}
requestGateway={requestTileGateway}
/>
) : null,
[activeGatewayProfile, gateway, gatewayOpen, ownerRoute?.profile, requestTileGateway, selectModel]
[activeGatewayProfile, gatewayOpen, ownerRoute?.profile, ownerRoute?.targetProfile, requestTileGateway, selectModel]
)
return (

View File

@@ -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<string, unknown>
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' })])

View File

@@ -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 ?? [])
}),

View File

@@ -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)

View File

@@ -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?: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** 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<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId })
queryFn: (): Promise<ModelOptionsResponse> =>
requestModelOptions({ gateway, profile, request, sessionId })
})
const loading = modelOptions.isPending && !modelOptions.data

View File

@@ -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<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId: activeSessionId })
queryFn: (): Promise<ModelOptionsResponse> =>
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<ModelOptionsResponse>(queryKey, next)
} catch {
@@ -238,6 +245,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
gateway={gateway}
includeMoa
profile={profile}
request={requestGateway}
sessionId={activeSessionId}
/>
)

View File

@@ -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', () => {

View File

@@ -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?: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** 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<ModelOptionsResponse> {
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<ModelOptionsResponse> {
if (gateway) {
const dispatch = request ?? (gateway ? gateway.request.bind(gateway) : null)
if (dispatch) {
const params: Record<string, unknown> = {}
if (sessionId) {
@@ -79,7 +101,7 @@ export async function requestModelOptions({
let gatewayOptions: ModelOptionsResponse | undefined
try {
gatewayOptions = await gateway.request<ModelOptionsResponse>('model.options', params)
gatewayOptions = await dispatch<ModelOptionsResponse>('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)
}

View File

@@ -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'])
})
})

View File

@@ -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())
})
})

View File

@@ -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<string> {
const scopes = new Set<string>()
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

View File

@@ -0,0 +1 @@
LovePlayCode