Merge pull request #52192 from NousResearch/bb/session-loop-guard

fix(desktop): let the session watchdog heal a stuck "looping" turn
This commit is contained in:
brooklyn! 2026-06-24 19:03:43 -05:00 committed by GitHub
commit a378b1e980
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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)