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