fix(desktop): let the session watchdog heal a stuck "looping" turn

The 8-minute stream-silence watchdog only removed a stuck session from
$workingSessionIds (the sidebar dot). The composer's busy state lives in
the session-state cache and was never cleared, so a hung or looping turn
that never delivered its terminal event — including an old session
re-opened while the backend still reports it "running" — stayed wedged on
"Thinking" / Stop indefinitely.

Have the watchdog notify subscribers when it force-clears a session, and
subscribe from the session-state cache to also drop that session's
busy/awaiting/needsInput flags. updateSessionState re-syncs $busy when the
healed session is the one on screen, so the composer recovers instead of
spinning forever.

Frontend-only safety net; doesn't touch the turn lifecycle. The backend
root (a stale in-memory session["running"] surviving a dead turn thread
and re-arming busy on every resume) is a separate follow-up.
This commit is contained in:
Brooklyn Nicholson 2026-06-24 18:36:17 -05:00
parent 41b9b7e719
commit 93192059c9
3 changed files with 103 additions and 0 deletions

View file

@ -9,6 +9,7 @@ import {
$busy,
$messages,
noteSessionActivity,
onSessionWatchdogClear,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
@ -276,6 +277,31 @@ export function useSessionStateCache({
[ensureSessionState, syncSessionStateToView]
)
// When the store watchdog force-clears a stuck session (8 min of stream
// silence — a hung or looping turn that never delivered its terminal event),
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
// re-syncs `$busy` when the healed session is the one on screen.
useEffect(
() =>
onSessionWatchdogClear(storedSessionId => {
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
if (!runtimeId || !state?.busy) {
return
}
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
}),
[updateSessionState]
)
return {
activeSessionIdRef,
ensureSessionState,

View file

@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $workingSessionIds, onSessionWatchdogClear, setSessionWorking, setWorkingSessionIds } from './session'
const WATCHDOG_MS = 8 * 60 * 1000
describe('session watchdog', () => {
beforeEach(() => {
vi.useFakeTimers()
setWorkingSessionIds(() => [])
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('drops a stuck session and notifies listeners once the silence window elapses', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
setSessionWorking('s1', true)
expect($workingSessionIds.get()).toContain('s1')
vi.advanceTimersByTime(WATCHDOG_MS)
// Both the sidebar dot AND the busy-clearing signal fire — the contract
// that lets the composer recover from a hung/looping turn, not just the dot.
expect($workingSessionIds.get()).not.toContain('s1')
expect(cleared).toEqual(['s1'])
off()
})
it('never fires for a session that settles before the window', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
setSessionWorking('s2', true)
setSessionWorking('s2', false)
vi.advanceTimersByTime(WATCHDOG_MS)
expect(cleared).toEqual([])
off()
})
it('stops notifying after unsubscribe', () => {
const cleared: string[] = []
const off = onSessionWatchdogClear(id => cleared.push(id))
off()
setSessionWorking('s3', true)
vi.advanceTimersByTime(WATCHDOG_MS)
expect(cleared).toEqual([])
})
})

View file

@ -342,6 +342,20 @@ export const setSessionPickerOpen = (next: Updater<boolean>) => updateAtom($sess
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Notified (with the stored session id) whenever the watchdog force-clears a
// stuck session. The session-state cache subscribes to also drop that session's
// busy/awaiting flags — clearing `$workingSessionIds` alone only removes the
// sidebar dot, leaving the composer stuck on "Thinking"/Stop for a hung or
// looping turn that never streamed its terminal event.
type SessionWatchdogListener = (storedSessionId: string) => void
const sessionWatchdogListeners = new Set<SessionWatchdogListener>()
export function onSessionWatchdogClear(listener: SessionWatchdogListener): () => void {
sessionWatchdogListeners.add(listener)
return () => void sessionWatchdogListeners.delete(listener)
}
function armSessionWatchdog(sessionId: string) {
const existing = sessionWatchdogTimers.get(sessionId)
@ -357,6 +371,10 @@ function armSessionWatchdog(sessionId: string) {
if ($workingSessionIds.get().includes(sessionId)) {
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
}
for (const listener of sessionWatchdogListeners) {
listener(sessionId)
}
}, SESSION_WATCHDOG_TIMEOUT_MS)
sessionWatchdogTimers.set(sessionId, timer)