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:
Brooklyn Nicholson 2026-07-26 18:39:03 -05:00
parent 48bdde1deb
commit 42a30d13db
3 changed files with 143 additions and 3 deletions

View 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
* busyidle 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'])
})
})

View file

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

View file

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