Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -519,4 +519,118 @@ describe('useSessionStateCache — cross-thread error isolation', () => {
cache.runtimeIdByStoredSessionIdRef.current.set('stored-A', 'runtime-B')
expect(cache.getRuntimeIdForStoredSession('stored-A')).toBeNull()
})

describe('reconnect-orphaned transcripts (#95189)', () => {
// Unique per-test runtime/stored ids: earlier describes in this file leave
// states in $sessionStates, and a recycled id would let this test's
// authority probe read THEIR stale flags instead of its own.
const bg = 'orphan-bg-runtime'
const fg = 'orphan-fg-runtime'
const bgStored = 'orphan-bg-stored'
const fgStored = 'orphan-fg-stored'

beforeEach(() => {
$sessionStates.set({})
setActiveSessionId(null)
})

afterEach(() => {
$sessionStates.set({})
setActiveSessionId(null)
})

it('releases a busy transcript once reconciliation settles the authoritative record', () => {
let cache!: Cache

render(<Harness activeSessionId={fg} onReady={value => (cache = value)} selectedStoredSessionId={fgStored} />)

act(() => {
// A mid-turn session carries a growing transcript: without messages
// there would be nothing warm to release.
cache.updateSessionState(
bg,
state => ({
...state,
busy: true,
messages: [
{ id: `${bg}-user`, role: 'user', parts: [{ type: 'text', text: 'hello' }] },
{ id: `${bg}-assistant`, role: 'assistant', parts: [{ type: 'text', text: 'partial reply' }] }
]
}),
bgStored
)
})

expect($sessionStates.get()[bg]?.busy).toBe(true)
expect(cache.sessionStateByRuntimeIdRef.current.has(bg)).toBe(true)

act(() => {
// The minting socket died mid-turn; reconnect reconciliation
// (reconcileBusyStatesOnReconnect) downgrades the authoritative
// record, and the respawned backend re-mints runtime ids so no event
// will ever settle this snapshot's own busy flag again.
const states = $sessionStates.get()

$sessionStates.set({
...states,
[bg]: { ...states[bg]!, busy: false, awaitingResponse: false }
})
})

// Production caches are bounded by the class defaults (24 sessions /
// 32MB), and prune only drains once that budget is exceeded. Simulate
// the reconnect churn of #95189: a stream of settled sessions pushes
// the cache past its cap, and the orphaned entry — oldest touched,
// finally warm-eligible now that the authoritative record settled —
// must be the first thing drained, ownership included.
const liveBusy = `${bg}-still-working`

act(() => {
cache.updateSessionState(
liveBusy,
state => ({
...state,
busy: true,
messages: [{ id: `${liveBusy}-u`, role: 'user', parts: [{ type: 'text', text: 'long turn' }] }]
}),
`${liveBusy}-stored`
)
})

for (let i = 0; i < 24; i += 1) {
act(() => {
cache.updateSessionState(
`${bg}-churn-${i}`,
state => ({
...state,
messages: [{ id: `churn-${i}`, role: 'user', parts: [{ type: 'text', text: `done ${i}` }] }]
}),
`${bg}-churn-${i}-stored`
)
})
}

expect(cache.sessionStateByRuntimeIdRef.current.has(bg)).toBe(false)
expect(cache.runtimeIdByStoredSessionIdRef.current.has(bgStored)).toBe(false)
// A genuinely running turn is never a casualty of the drain.
expect(cache.sessionStateByRuntimeIdRef.current.has(liveBusy)).toBe(true)
})

it('keeps a live background turn cached while its authoritative record is busy', () => {
let cache!: Cache

render(<Harness activeSessionId={fg} onReady={value => (cache = value)} selectedStoredSessionId={fgStored} />)

act(() => {
cache.updateSessionState(bg, state => ({ ...state, busy: true }), bgStored)
})

act(() => {
cache.updateSessionState(fg, state => ({ ...state, model: 'test/model' }))
})

expect(cache.sessionStateByRuntimeIdRef.current.has(bg)).toBe(true)
expect(cache.runtimeIdByStoredSessionIdRef.current.get(bgStored)).toBe(bg)
})
})
})
12 changes: 11 additions & 1 deletion apps/desktop/src/app/session/hooks/use-session-state-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { $sessionTiles, publishSessionState, releaseSessionTranscript } from '@/store/session-states'
import { $sessionStates, $sessionTiles, publishSessionState, releaseSessionTranscript } from '@/store/session-states'

import type { ClientSessionState } from '../../types'
import { SessionStateCache } from '../session-state-cache'
Expand Down Expand Up @@ -98,6 +98,16 @@ export function useSessionStateCache({
tile.runtimeId === runtimeId ||
(state.storedSessionId !== null && tile.storedSessionId === state.storedSessionId)
),
// A connection death mid-turn leaves snapshots whose frozen busy flags
// will never settle (the respawned backend re-mints runtime ids), which
// pinned megabytes of warm transcript per reconnect cycle behind
// #isWarmSettled (#95189). Trust the cached in-flight flags only while
// the authoritative store still claims work for the same runtime id.
isAuthoritativelyActive: runtimeId => {
const live = $sessionStates.get()[runtimeId]

return Boolean(live && (live.busy || live.awaitingResponse))
},
onEvict: (runtimeId, state) => {
// Ownership is removed with the transcript, but only if both sides still
// describe this exact binding. A recycled runtime must not erase its
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/src/app/session/session-state-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,99 @@ describe('SessionStateCache', () => {
expect($sessionStates.get().runtime).toMatchObject({ storedSessionId: 'stored', busy: false, needsInput: false })
expect($sessionStates.get().runtime.messages).toEqual([])
})

describe('authoritative liveness probe (#95189)', () => {
function cacheWithAuthority(evicted: string[]): SessionStateCache {
return new SessionStateCache(
{
isReferenced: () => false,
onEvict: runtimeId => evicted.push(runtimeId),
isAuthoritativelyActive: runtimeId => {
const live = $sessionStates.get()[runtimeId]

return Boolean(live && (live.busy || live.awaitingResponse))
}
},
{ maxBytes: 0, maxCount: 0 }
)
}

it.each([
['busy', (state: ClientSessionState) => ({ ...state, busy: true })],
['awaiting', (state: ClientSessionState) => ({ ...state, awaitingResponse: true })]
])('evicts an orphaned %s transcript once the authoritative record settles', (_label, decorate) => {
const evicted: string[] = []
const cache = cacheWithAuthority(evicted)
const orphaned = decorate(settled('orphaned'))

// Mid-turn the snapshot and the authoritative record agree: protection
// must hold exactly as it does without the probe.
$sessionStates.set({ orphaned })
cache.set('orphaned', orphaned)
cache.prune()
expect(cache.get('orphaned')).toBe(orphaned)

// The minting connection dies mid-turn. Reconnect reconciliation
// settles the authoritative record, but the respawned backend re-mints
// runtime ids — no event will ever reach this snapshot again, so its
// frozen in-flight flags must stop pinning the transcript.
$sessionStates.set({ orphaned: { ...orphaned, busy: false, awaitingResponse: false } })
cache.prune()

expect(cache.has('orphaned')).toBe(false)
expect(evicted).toEqual(['orphaned'])
})

it('evicts an in-flight transcript whose authoritative record was dropped entirely', () => {
const evicted: string[] = []
const cache = cacheWithAuthority(evicted)
const working = { ...settled('working'), busy: true }

$sessionStates.set({ working })
cache.set('working', working)
cache.prune()
expect(cache.has('working')).toBe(true)

// A soft gateway-mode apply wipes every authoritative state; surviving
// snapshots describe dead runtimes (#95189 reconnect churn).
$sessionStates.set({})
cache.prune()

expect(cache.has('working')).toBe(false)
expect(evicted).toEqual(['working'])
})

it('keeps an in-flight transcript pinned while the authoritative store still claims work', () => {
const evicted: string[] = []
const cache = cacheWithAuthority(evicted)
const working = { ...settled('working'), busy: true }

$sessionStates.set({ working })
cache.set('working', working)
cache.prune()

expect(cache.get('working')).toBe(working)
expect(evicted).toEqual([])
})

it.each([
['busy', (state: ClientSessionState) => ({ ...state, busy: true })],
['awaiting', (state: ClientSessionState) => ({ ...state, awaitingResponse: true })]
])('still never evicts %s transcripts when no authority probe is wired', (_label, decorate) => {
// Legacy construction: without the probe there is no way to tell a live
// turn from an orphaned snapshot, so the flags keep blocking eviction.
const working = decorate(settled('working'))
$sessionStates.set({ working: { ...working, busy: false, awaitingResponse: false } })

const cache = new SessionStateCache(
{ isReferenced: () => false, onEvict: () => undefined },
{ maxBytes: 0, maxCount: 0 }
)

cache.set('working', working)
cache.prune()

expect(cache.get('working')).toBe(working)
})
})
})
21 changes: 19 additions & 2 deletions apps/desktop/src/app/session/session-state-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ interface SessionStateCacheLimits {
interface SessionStateCacheCallbacks {
isReferenced: (runtimeId: string, state: ClientSessionState) => boolean
onEvict: (runtimeId: string, state: ClientSessionState) => void
/** Optional liveness check for a cached snapshot's in-flight claims. A
* connection death mid-turn orphans snapshots: the respawned backend
* re-mints runtime ids, so their frozen busy/awaitingResponse flags never
* receive a settling publish (#95189) and would pin megabytes of warm
* transcript per reconnect cycle until restart. When wired, those flags
* only block eviction while the authoritative store still claims work for
* the same runtime id; without the probe they always block. */
isAuthoritativelyActive?: (runtimeId: string, state: ClientSessionState) => boolean
}

function transcriptBytes(state: ClientSessionState): number {
Expand Down Expand Up @@ -117,11 +125,20 @@ export class SessionStateCache extends Map<string, ClientSessionState> {
}

#isWarmSettled(runtimeId: string, state: ClientSessionState): boolean {
// In-flight claims pin a transcript only while they are trustworthy: with
// an authority probe wired, a frozen busy/awaitingResponse on an orphaned
// snapshot stops blocking eviction (see callback docs). Without one, the
// legacy behavior holds and the flags always block.
if (
(state.busy || state.awaitingResponse) &&
this.#callbacks.isAuthoritativelyActive?.(runtimeId, state) !== false
) {
return false
}

return (
Boolean(state.storedSessionId) &&
state.messages.length > 0 &&
!state.busy &&
!state.awaitingResponse &&
!state.needsInput &&
!hasDraftOrInFlightMessage(state) &&
!this.#callbacks.isReferenced(runtimeId, state)
Expand Down
Loading