From 42a30d13dbcf572585cc566f37d0c28d69f4c3d1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 18:39:03 -0500 Subject: [PATCH] fix(desktop): settle sessions that vanish from the live snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../contrib/hooks/live-status-reap.test.ts | 79 +++++++++++++++++++ .../app/contrib/hooks/use-background-sync.ts | 65 ++++++++++++++- apps/desktop/src/store/gateway-switch.ts | 2 + 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/app/contrib/hooks/live-status-reap.test.ts diff --git a/apps/desktop/src/app/contrib/hooks/live-status-reap.test.ts b/apps/desktop/src/app/contrib/hooks/live-status-reap.test.ts new file mode 100644 index 00000000000..8b4688331c0 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/live-status-reap.test.ts @@ -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']) + }) +}) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index 5925b4a7dfb..68a802dbc91 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -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>() + /** 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() + 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('session.active_list', {}) if (!cancelled) { - rehydrateLiveSessionStatuses(response) + rehydrateLiveSessionStatuses(response, Date.now(), activeGatewayProfile) } } catch { // Older gateways may not expose session.active_list. Live stream events diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index 291b803283a..df5227af9e1 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -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()