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:
@@ -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 (
|
||||
|
||||
@@ -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' })])
|
||||
|
||||
|
||||
@@ -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 ?? [])
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
1
contributors/emails/1244224501@qq.com
Normal file
1
contributors/emails/1244224501@qq.com
Normal file
@@ -0,0 +1 @@
|
||||
LovePlayCode
|
||||
Reference in New Issue
Block a user