fix(desktop): drop cached state for a reclaimed session

Evict the runtime the backend just reclaimed instead of waiting for a
resume to 404, and refresh the lists whose ended_at moved. The stored
row is untouched, so reopening resumes from the DB.
This commit is contained in:
Brooklyn Nicholson 2026-07-31 23:04:45 -05:00
parent 2b039d90e3
commit 9824e9585c
2 changed files with 143 additions and 0 deletions

View File

@ -59,6 +59,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { dropSessionState } from '@/store/session-states'
import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents'
import { clearActiveSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
@ -330,6 +331,23 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
}
return
} else if (event.type === 'session.reclaimed') {
// The backend reclaimed a live session we may still be holding (idle
// TTL, LRU cap, or the WS-orphan reap). Without this the runtime id
// stays cached until something fails against it, which reads as the
// session vanishing rather than being reclaimed. Drop the cached state
// now — the stored row is untouched, so the sidebar keeps the
// conversation and reopening it resumes from the DB.
const reclaimedRuntimeId = String((payload as { session_id?: string } | undefined)?.session_id ?? '')
if (reclaimedRuntimeId) {
dropSessionState(reclaimedRuntimeId)
}
// The row's ended_at moved, so refresh the lists that render it.
notifySessionsChanged()
return
} else if (event.type === 'session.info') {
// Apply session-scoped fields when the event targets the active

View File

@ -0,0 +1,125 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $sessionStates, publishSessionState } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
// `session.reclaimed`: the backend tore down a live session we're still
// holding (idle TTL, LRU cap, WS-orphan reap). Before this event the runtime id
// stayed cached until something failed against it, which read to the user as
// the session vanishing rather than being reclaimed.
const ACTIVE_SID = 'session-active'
const ACTIVE_PROFILE = 'compass'
let handleEvent: ((event: RpcEvent) => void) | null = null
let queryClient: QueryClient
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const stream = useMessageStream({
activeGatewayProfile: ACTIVE_PROFILE,
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient,
refreshHermesConfig: vi.fn<() => Promise<void>>(async () => undefined),
refreshSessions: vi.fn<() => Promise<void>>(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const reclaim = (sessionId: string, reason = 'ws_orphan_reap') =>
act(() =>
handleEvent!({
payload: { reason, session_id: sessionId, stored_session_id: 'stored-1' },
session_id: '',
type: 'session.reclaimed'
} as RpcEvent)
)
beforeEach(() => {
handleEvent = null
queryClient = new QueryClient()
$sessionStates.set({})
})
afterEach(() => {
cleanup()
$sessionStates.set({})
vi.restoreAllMocks()
})
describe('session.reclaimed', () => {
it('drops the cached state for the reclaimed runtime', async () => {
await mountStream()
publishSessionState('live-gone', createClientSessionState())
expect($sessionStates.get()['live-gone']).toBeDefined()
reclaim('live-gone')
expect($sessionStates.get()['live-gone']).toBeUndefined()
})
it('leaves every other live session alone', async () => {
await mountStream()
publishSessionState('live-gone', createClientSessionState())
publishSessionState('live-kept', createClientSessionState())
reclaim('live-gone')
// Both halves matter: the target went, the bystander stayed. Asserting
// only the survivor would pass with no handler at all.
expect($sessionStates.get()['live-gone']).toBeUndefined()
expect($sessionStates.get()['live-kept']).toBeDefined()
})
it('ignores a payload with no runtime id instead of clearing everything', async () => {
await mountStream()
publishSessionState('live-a', createClientSessionState())
publishSessionState('live-b', createClientSessionState())
reclaim('')
// A malformed/empty id must be a no-op, never a blanket wipe.
expect(Object.keys($sessionStates.get()).sort()).toEqual(['live-a', 'live-b'])
})
it('drops the runtime regardless of which reclaim reason fired', async () => {
for (const reason of ['idle_timeout', 'lru_evict', 'ws_orphan_reap']) {
$sessionStates.set({})
cleanup()
handleEvent = null
await mountStream()
publishSessionState('live-gone', createClientSessionState())
reclaim('live-gone', reason)
expect($sessionStates.get()['live-gone'], reason).toBeUndefined()
}
})
})