From 93192059c96c5ba56c28c6c41255bc63af4c95fa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 18:36:17 -0500 Subject: [PATCH] fix(desktop): let the session watchdog heal a stuck "looping" turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../session/hooks/use-session-state-cache.ts | 26 ++++++++ .../src/store/session-watchdog.test.ts | 59 +++++++++++++++++++ apps/desktop/src/store/session.ts | 18 ++++++ 3 files changed, 103 insertions(+) create mode 100644 apps/desktop/src/store/session-watchdog.test.ts diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 1445dd17a75..93946367715 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -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, diff --git a/apps/desktop/src/store/session-watchdog.test.ts b/apps/desktop/src/store/session-watchdog.test.ts new file mode 100644 index 00000000000..75be2f203ca --- /dev/null +++ b/apps/desktop/src/store/session-watchdog.test.ts @@ -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([]) + }) +}) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 958801df1f3..ba2756fed52 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -342,6 +342,20 @@ export const setSessionPickerOpen = (next: Updater) => updateAtom($sess const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000 const sessionWatchdogTimers = new Map>() +// 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() + +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)