mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): settle sessions that vanish from the live snapshot
session.active_list is authoritative about absence, but the renderer only read the rows it returned. A turn that ends while the websocket is degraded — a remote gateway on a flaky link, a reconnect, a profile swap — drops out of the gateway's _sessions without Desktop ever seeing the running=false edge, so the row spins forever and the busy->idle transition that paints the green unread dot never fires. Track live runtime ids per gateway profile and settle anything that disappears between polls through publishSessionState so the real transition fires. Profile scoping is load-bearing: background profiles are served by other gateways and never appear in this profile's snapshot, so an unscoped reap would dark out every other profile's running rows.
This commit is contained in:
parent
48bdde1deb
commit
42a30d13db
3 changed files with 143 additions and 3 deletions
79
apps/desktop/src/app/contrib/hooks/live-status-reap.test.ts
Normal file
79
apps/desktop/src/app/contrib/hooks/live-status-reap.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $selectedStoredSessionId, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, $workingSessionIds, clearAllSessionStates } from '@/store/session-states'
|
||||
|
||||
import { rehydrateLiveSessionStatuses } from './use-background-sync'
|
||||
|
||||
/**
|
||||
* `session.active_list` is the authoritative snapshot of what is RUNNING in the
|
||||
* polled gateway process. A session that finished while Desktop was looking
|
||||
* elsewhere — or whose runtime id was recycled by a backend respawn — simply
|
||||
* stops appearing in the response. Absence is therefore a completion signal,
|
||||
* not "no news": if nothing reaps it, the row spins forever and the
|
||||
* busy→idle edge that paints the green "your turn" dot never fires.
|
||||
*/
|
||||
describe('rehydrateLiveSessionStatuses — reaping vanished runtimes', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
it('clears a working session that disappears from the live snapshot', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-a', session_key: 'stored-a', status: 'working' }]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['stored-a'])
|
||||
|
||||
// The turn finished and the gateway reaped the session between polls.
|
||||
rehydrateLiveSessionStatuses({ sessions: [] })
|
||||
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('fires the unread "your turn" marker for a vanished background session', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-b', session_key: 'stored-b', status: 'working' }]
|
||||
})
|
||||
|
||||
rehydrateLiveSessionStatuses({ sessions: [] })
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['stored-b'])
|
||||
})
|
||||
|
||||
it('clears a blocked session that disappears from the live snapshot', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-c', session_key: 'stored-c', status: 'waiting' }]
|
||||
})
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual(['stored-c'])
|
||||
|
||||
rehydrateLiveSessionStatuses({ sessions: [] })
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves runtimes this poll never seeded alone', () => {
|
||||
// A background PROFILE's sessions are served by a different gateway and
|
||||
// never appear in this profile's active_list. Reaping them would dark out
|
||||
// every other profile's running rows.
|
||||
rehydrateLiveSessionStatuses(
|
||||
{ sessions: [{ id: 'runtime-other', session_key: 'stored-other', status: 'working' }] },
|
||||
Date.now(),
|
||||
'other'
|
||||
)
|
||||
|
||||
rehydrateLiveSessionStatuses({ sessions: [] }, Date.now(), 'default')
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['stored-other'])
|
||||
})
|
||||
})
|
||||
|
|
@ -37,11 +37,30 @@ interface LiveSessionStatusResponse {
|
|||
sessions?: LiveSessionStatusItem[]
|
||||
}
|
||||
|
||||
// Runtime ids this poll has seen live, per gateway profile. A profile only
|
||||
// ever reaps what its OWN snapshot previously reported: background profiles are
|
||||
// served by different gateways and never appear in this profile's active_list,
|
||||
// so an unscoped reap would dark out every other profile's running rows.
|
||||
const liveRuntimeIdsByProfile = new Map<string, Set<string>>()
|
||||
|
||||
/** Restore sidebar liveness after a renderer/backend reconnect. Stream events
|
||||
* normally own these states, but events emitted while Desktop was disconnected
|
||||
* cannot be replayed. `session.active_list` is the authoritative in-memory
|
||||
* snapshot and does not resume, focus, or otherwise mutate a chat. */
|
||||
export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse, nowMs = Date.now()): void {
|
||||
* snapshot and does not resume, focus, or otherwise mutate a chat.
|
||||
*
|
||||
* The snapshot is authoritative about ABSENCE too. A turn that ends while the
|
||||
* websocket is degraded — a remote gateway over a flaky link, a reconnect, a
|
||||
* profile swap — drops out of `_sessions` without Desktop ever seeing the
|
||||
* `running: false` edge, so the row keeps spinning and the busy→idle transition
|
||||
* that paints the green "your turn" dot never fires. Reaping runtimes that
|
||||
* vanish between polls restores both. */
|
||||
export function rehydrateLiveSessionStatuses(
|
||||
response: LiveSessionStatusResponse,
|
||||
nowMs = Date.now(),
|
||||
profileKey = 'default'
|
||||
): void {
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const session of response.sessions ?? []) {
|
||||
const runtimeSessionId = session.id?.trim()
|
||||
const storedSessionId = session.session_key?.trim()
|
||||
|
|
@ -52,6 +71,8 @@ export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse
|
|||
continue
|
||||
}
|
||||
|
||||
seen.add(runtimeSessionId)
|
||||
|
||||
const existing = $sessionStates.get()[runtimeSessionId]
|
||||
|
||||
// Avoid re-arming the watchdog on every poll. Publish only when the
|
||||
|
|
@ -87,6 +108,44 @@ export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse
|
|||
|
||||
setSessionStalled(storedSessionId, isQuiet)
|
||||
}
|
||||
|
||||
// A runtime this profile's snapshot reported live LAST poll but not this one
|
||||
// has ended: the gateway reaps a session out of `_sessions` when its turn
|
||||
// completes and its transport goes away. Settle it through the normal publish
|
||||
// path so the busy→idle transition fires — that edge is what clears the
|
||||
// spinner AND marks the row unread ("your turn"). Only ids this profile
|
||||
// previously saw are eligible, so another profile's live rows are untouched.
|
||||
const previouslyLive = liveRuntimeIdsByProfile.get(profileKey)
|
||||
|
||||
if (previouslyLive) {
|
||||
for (const runtimeSessionId of previouslyLive) {
|
||||
if (seen.has(runtimeSessionId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = $sessionStates.get()[runtimeSessionId]
|
||||
|
||||
if (existing?.busy || existing?.needsInput) {
|
||||
publishSessionState(runtimeSessionId, {
|
||||
...existing,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false,
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
liveRuntimeIdsByProfile.set(profileKey, seen)
|
||||
}
|
||||
|
||||
/** Forget every profile's live-runtime bookkeeping. A gateway wipe already
|
||||
* drops the session states these ids point at, so a carried-over set would
|
||||
* only reap runtimes that no longer exist. */
|
||||
export function resetLiveRuntimeTracking(): void {
|
||||
liveRuntimeIdsByProfile.clear()
|
||||
}
|
||||
|
||||
interface BackgroundSyncParams {
|
||||
|
|
@ -191,7 +250,7 @@ export function useBackgroundSync({
|
|||
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})
|
||||
|
||||
if (!cancelled) {
|
||||
rehydrateLiveSessionStatuses(response)
|
||||
rehydrateLiveSessionStatuses(response, Date.now(), activeGatewayProfile)
|
||||
}
|
||||
} catch {
|
||||
// Older gateways may not expose session.active_list. Live stream events
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-sync'
|
||||
import { resetSidebarBatchCapability } from '@/hermes'
|
||||
import { invalidateProfileScopedQueries } from '@/lib/query-client'
|
||||
import { resetSessionsLimit } from '@/store/layout'
|
||||
|
|
@ -52,6 +53,7 @@ export function wipeSessionListsForGatewaySwitch(): void {
|
|||
// $attentionSessionIds (computed) and $stalledSessionIds (owned beside it).
|
||||
// $unreadFinishedSessionIds is separate, so wipe it explicitly.
|
||||
clearAllSessionStates()
|
||||
resetLiveRuntimeTracking()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
setSessionsLoading(true)
|
||||
resetSessionsLimit()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue