mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
commit
a378b1e980
3 changed files with 103 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
59
apps/desktop/src/store/session-watchdog.test.ts
Normal file
59
apps/desktop/src/store/session-watchdog.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue