mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(desktop): keep quiet sessions visibly running (#65870)
* fix(desktop): keep quiet sessions visibly running * fix(desktop): restore running status after reconnect * fix(desktop): keep live session status unmistakable
This commit is contained in:
parent
59fdd41f5a
commit
c59ca46940
11 changed files with 390 additions and 135 deletions
43
apps/desktop/src/app/chat/sidebar/session-row-state.test.ts
Normal file
43
apps/desktop/src/app/chat/sidebar/session-row-state.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { sessionDotState, sessionShowsRunningArc } from './session-row-state'
|
||||
|
||||
describe('session row running appearance', () => {
|
||||
it('keeps the running arc when an authoritative turn becomes quiet', () => {
|
||||
expect(sessionShowsRunningArc({ isWorking: true, needsInput: false })).toBe(true)
|
||||
expect(
|
||||
sessionDotState({
|
||||
hasBackground: false,
|
||||
isStalled: true,
|
||||
isUnread: false,
|
||||
isWorking: true,
|
||||
needsInput: false
|
||||
})
|
||||
).toBe('stalled')
|
||||
})
|
||||
|
||||
it('uses the needs-input treatment instead of the running arc', () => {
|
||||
expect(sessionShowsRunningArc({ isWorking: true, needsInput: true })).toBe(false)
|
||||
expect(
|
||||
sessionDotState({
|
||||
hasBackground: true,
|
||||
isStalled: true,
|
||||
isUnread: true,
|
||||
isWorking: true,
|
||||
needsInput: true
|
||||
})
|
||||
).toBe('needs-input')
|
||||
})
|
||||
|
||||
it('keeps background and unread states below active-turn states', () => {
|
||||
expect(
|
||||
sessionDotState({
|
||||
hasBackground: true,
|
||||
isStalled: false,
|
||||
isUnread: true,
|
||||
isWorking: false,
|
||||
needsInput: false
|
||||
})
|
||||
).toBe('background')
|
||||
})
|
||||
})
|
||||
42
apps/desktop/src/app/chat/sidebar/session-row-state.ts
Normal file
42
apps/desktop/src/app/chat/sidebar/session-row-state.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export type SessionDotState = 'background' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'
|
||||
|
||||
interface SessionRowState {
|
||||
hasBackground: boolean
|
||||
isStalled: boolean
|
||||
isUnread: boolean
|
||||
isWorking: boolean
|
||||
needsInput: boolean
|
||||
}
|
||||
|
||||
/** Resolve the sidebar dot's mutually-exclusive display state by priority. */
|
||||
export function sessionDotState({
|
||||
hasBackground,
|
||||
isStalled,
|
||||
isUnread,
|
||||
isWorking,
|
||||
needsInput
|
||||
}: SessionRowState): SessionDotState {
|
||||
if (needsInput) {
|
||||
return 'needs-input'
|
||||
}
|
||||
|
||||
if (isWorking) {
|
||||
return isStalled ? 'stalled' : 'working'
|
||||
}
|
||||
|
||||
if (hasBackground) {
|
||||
return 'background'
|
||||
}
|
||||
|
||||
return isUnread ? 'unread' : 'idle'
|
||||
}
|
||||
|
||||
/** A quiet turn is still authoritatively running. Keep the unmistakable row
|
||||
* arc until the gateway reports completion; only a blocking prompt suppresses
|
||||
* it in favour of the needs-input treatment. */
|
||||
export function sessionShowsRunningArc({
|
||||
isWorking,
|
||||
needsInput
|
||||
}: Pick<SessionRowState, 'isWorking' | 'needsInput'>): boolean {
|
||||
return isWorking && !needsInput
|
||||
}
|
||||
|
|
@ -17,11 +17,12 @@ import { cn } from '@/lib/utils'
|
|||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $sessionColorById } from '@/store/session-color'
|
||||
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
|
|
@ -90,6 +91,9 @@ export function SidebarSessionRow({
|
|||
// True when the session's most recent turn finished in the background (while
|
||||
// the user was viewing a different session) and hasn't been opened since.
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
// True when the turn is still running but the stream has been quiet long
|
||||
// enough to soften the animation. This must never look like an idle row.
|
||||
const isStalled = useStore($stalledSessionIds).includes(session.id)
|
||||
// True when a terminal(background=true) process is alive in this session.
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
|
||||
// The session's resolved color (idle dot tint), read from the ONE shared map
|
||||
|
|
@ -99,15 +103,7 @@ export function SidebarSessionRow({
|
|||
// Resolve the dot's display state once — the four signals are mutually
|
||||
// exclusive by priority, so threading them as booleans through wrappers just
|
||||
// to collapse them at the leaf is backwards.
|
||||
const dotState: SessionDotState = needsInput
|
||||
? 'needs-input'
|
||||
: isWorking
|
||||
? 'working'
|
||||
: hasBackground
|
||||
? 'background'
|
||||
: isUnread
|
||||
? 'unread'
|
||||
: 'idle'
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
|
|
@ -183,7 +179,7 @@ export function SidebarSessionRow({
|
|||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
||||
{sessionShowsRunningArc({ isWorking, needsInput }) && <span aria-hidden="true" className="arc-border" />}
|
||||
<SidebarRowBody
|
||||
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
|
||||
// Middle-click = open in a new tab (browser muscle memory). Swallow
|
||||
|
|
@ -271,11 +267,6 @@ export function SidebarSessionRow({
|
|||
)
|
||||
}
|
||||
|
||||
/** The session's display state for the sidebar lead dot. The call site
|
||||
* resolves this from the four underlying signals (needs-input, working,
|
||||
* background, unread) so the dot component itself is a pure lookup. */
|
||||
type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'working'
|
||||
|
||||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
dotState = 'idle',
|
||||
|
|
@ -333,6 +324,14 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
|||
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
|
||||
role: 'status'
|
||||
},
|
||||
// Quiet accent pulse — the turn is still authoritative-running, but no
|
||||
// stream activity has arrived for the watchdog window.
|
||||
stalled: {
|
||||
ariaLabel: r => r.sessionRunning,
|
||||
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
|
||||
role: 'status',
|
||||
title: r => r.sessionRunning
|
||||
},
|
||||
// Pulsing gray — a terminal(background=true) process is alive while the LLM
|
||||
// is idle. Gray (not accent) reads as "something chugging along". Brighter
|
||||
// than muted-foreground so it's visible against the sidebar surface.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
$stalledSessionIds,
|
||||
$workingSessionIds,
|
||||
clearAllSessionStates,
|
||||
SESSION_WATCHDOG_TIMEOUT_MS
|
||||
} from '@/store/session-states'
|
||||
|
||||
import { rehydrateLiveSessionStatuses } from './use-background-sync'
|
||||
|
||||
describe('rehydrateLiveSessionStatuses', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('restores running sessions after reconnect without opening them', () => {
|
||||
const now = 1_800_000_000_000
|
||||
|
||||
rehydrateLiveSessionStatuses(
|
||||
{
|
||||
sessions: [
|
||||
{
|
||||
id: 'runtime-overnight',
|
||||
last_active: (now - SESSION_WATCHDOG_TIMEOUT_MS - 1_000) / 1000,
|
||||
session_key: 'overnight-exam-learning',
|
||||
status: 'working'
|
||||
},
|
||||
{
|
||||
id: 'runtime-cleanup',
|
||||
last_active: now / 1000,
|
||||
session_key: 'temporary-file-cleanup',
|
||||
status: 'working'
|
||||
}
|
||||
]
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['overnight-exam-learning', 'temporary-file-cleanup'])
|
||||
expect($stalledSessionIds.get()).toEqual(['overnight-exam-learning'])
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('restores a waiting turn as working and needing attention', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [{ id: 'runtime-needs-user', session_key: 'needs-user', status: 'waiting' }]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['needs-user'])
|
||||
expect($attentionSessionIds.get()).toEqual(['needs-user'])
|
||||
expect($stalledSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores idle, starting, and malformed live-session rows', () => {
|
||||
rehydrateLiveSessionStatuses({
|
||||
sessions: [
|
||||
{ id: 'runtime-idle', session_key: 'idle-session', status: 'idle' },
|
||||
{ id: 'runtime-starting', session_key: 'starting-session', status: 'starting' },
|
||||
{ id: 'runtime-malformed', status: 'working' }
|
||||
]
|
||||
})
|
||||
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
expect($stalledSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { refreshActiveProfile } from '@/store/profile'
|
||||
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
|
||||
import {
|
||||
$sessionStates,
|
||||
publishSessionState,
|
||||
SESSION_WATCHDOG_TIMEOUT_MS,
|
||||
setSessionStalled
|
||||
} from '@/store/session-states'
|
||||
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
|
|
@ -11,8 +18,79 @@ import type { GatewayRequester } from '../types'
|
|||
const CRON_POLL_INTERVAL_MS = 30_000
|
||||
const MESSAGING_POLL_INTERVAL_MS = 10_000
|
||||
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
|
||||
// Match the TUI's live-session refresh cadence. Auto-compression can rotate a
|
||||
// stored session id while its turn keeps running; until the next snapshot the
|
||||
// sidebar row points at the new id while the renderer still knows the old one.
|
||||
// A 15s cadence made that healthy transition look finished long enough to be
|
||||
// alarming (and clicking the row appeared to "fix" it by touching the live
|
||||
// session). This snapshot is small and already polled at 1.5s by the TUI.
|
||||
const LIVE_SESSION_STATUS_POLL_INTERVAL_MS = 1_500
|
||||
|
||||
interface LiveSessionStatusItem {
|
||||
id?: string
|
||||
last_active?: number
|
||||
session_key?: string
|
||||
status?: 'idle' | 'starting' | 'waiting' | 'working'
|
||||
}
|
||||
|
||||
interface LiveSessionStatusResponse {
|
||||
sessions?: LiveSessionStatusItem[]
|
||||
}
|
||||
|
||||
/** Restore sidebar liveness after a renderer/backend reconnect. Stream events
|
||||
* normally own these states, but events emitted while Desktop was disconnected
|
||||
* cannot be replayed. `session.active_list` is the authoritative in-memory
|
||||
* snapshot and does not resume, focus, or otherwise mutate a chat. */
|
||||
export function rehydrateLiveSessionStatuses(response: LiveSessionStatusResponse, nowMs = Date.now()): void {
|
||||
for (const session of response.sessions ?? []) {
|
||||
const runtimeSessionId = session.id?.trim()
|
||||
const storedSessionId = session.session_key?.trim()
|
||||
const needsInput = session.status === 'waiting'
|
||||
const working = session.status === 'working' || needsInput
|
||||
|
||||
if (!runtimeSessionId || !storedSessionId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = $sessionStates.get()[runtimeSessionId]
|
||||
|
||||
// Avoid re-arming the watchdog on every poll. Publish only when the
|
||||
// authoritative live snapshot differs from the renderer mirror; normal
|
||||
// gateway events continue to own subsequent transitions.
|
||||
if (
|
||||
!existing ||
|
||||
existing.storedSessionId !== storedSessionId ||
|
||||
existing.busy !== working ||
|
||||
existing.needsInput !== needsInput
|
||||
) {
|
||||
publishSessionState(runtimeSessionId, {
|
||||
...(existing ?? createClientSessionState(storedSessionId)),
|
||||
busy: working,
|
||||
needsInput,
|
||||
storedSessionId
|
||||
})
|
||||
}
|
||||
|
||||
if (!working) {
|
||||
setSessionStalled(storedSessionId, false)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const lastActiveMs = Number(session.last_active) * 1000
|
||||
|
||||
const isQuiet =
|
||||
session.status === 'working' &&
|
||||
Number.isFinite(lastActiveMs) &&
|
||||
lastActiveMs > 0 &&
|
||||
nowMs - lastActiveMs >= SESSION_WATCHDOG_TIMEOUT_MS
|
||||
|
||||
setSessionStalled(storedSessionId, isQuiet)
|
||||
}
|
||||
}
|
||||
|
||||
interface BackgroundSyncParams {
|
||||
activeGatewayProfile: string
|
||||
activeIsMessaging: boolean
|
||||
activeSessionId: null | string
|
||||
freshDraftReady: boolean
|
||||
|
|
@ -51,6 +129,7 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void {
|
|||
* All the "the desktop websocket won't tell us, so poll" logic in one place.
|
||||
*/
|
||||
export function useBackgroundSync({
|
||||
activeGatewayProfile,
|
||||
activeIsMessaging,
|
||||
activeSessionId,
|
||||
freshDraftReady,
|
||||
|
|
@ -89,6 +168,49 @@ export function useBackgroundSync({
|
|||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions, requestGateway])
|
||||
|
||||
// A reconnect loses renderer-only working/attention atoms while the backend
|
||||
// keeps the actual turns alive. Re-seed from the gateway's in-memory session
|
||||
// registry immediately, then cheaply poll while visible so a profile switch
|
||||
// or missed reconnect edge cannot leave running rows dark until clicked.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let inFlight = false
|
||||
|
||||
const refreshLiveStatuses = async () => {
|
||||
if (inFlight) {
|
||||
return
|
||||
}
|
||||
|
||||
inFlight = true
|
||||
|
||||
try {
|
||||
const response = await requestGateway<LiveSessionStatusResponse>('session.active_list', {})
|
||||
|
||||
if (!cancelled) {
|
||||
rehydrateLiveSessionStatuses(response)
|
||||
}
|
||||
} catch {
|
||||
// Older gateways may not expose session.active_list. Live stream events
|
||||
// still work as before; leave the current sidebar state untouched.
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
const dispose = visiblePoll(LIVE_SESSION_STATUS_POLL_INTERVAL_MS, () => void refreshLiveStatuses())
|
||||
|
||||
void refreshLiveStatuses()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
dispose()
|
||||
}
|
||||
}, [activeGatewayProfile, gatewayState, requestGateway])
|
||||
|
||||
// Keep the cron-jobs section live without a user action (scheduler ticks in
|
||||
// the background); re-check on tab re-focus too.
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -652,6 +652,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
// Keep app data live while the gateway is open (on-connect reseed + the
|
||||
// cron / messaging / transcript visibility polls + fresh-draft reseed).
|
||||
useBackgroundSync({
|
||||
activeGatewayProfile,
|
||||
activeIsMessaging,
|
||||
activeSessionId,
|
||||
freshDraftReady,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { publishSessionState, setWatchdogClearFn } from '@/store/session-states'
|
||||
import { publishSessionState } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
|
|
@ -289,33 +289,6 @@ export function useSessionStateCache({
|
|||
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
|
||||
}, [])
|
||||
|
||||
// Wire the watchdog's force-clear callback to our cache. When the watchdog
|
||||
// fires (8 min of stream silence — a hung or looping turn that never
|
||||
// delivered its terminal event), it calls this to clear the session's busy
|
||||
// state. Clearing the sidebar dot alone would leave the composer wedged on
|
||||
// "Thinking"/Stop; updateSessionState propagates the clear to $sessionStates
|
||||
// → $workingSessionIds (computed) follows automatically, and
|
||||
// syncSessionStateToView re-syncs $busy when the healed session is the one
|
||||
// on screen.
|
||||
useEffect(() => {
|
||||
setWatchdogClearFn(runtimeId => {
|
||||
const state = sessionStateByRuntimeIdRef.current.get(runtimeId)
|
||||
|
||||
if (!state?.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(runtimeId, current => ({
|
||||
...current,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}))
|
||||
})
|
||||
|
||||
return () => setWatchdogClearFn(null)
|
||||
}, [updateSessionState])
|
||||
|
||||
return {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '@/store/session'
|
||||
import { $stalledSessionIds } from '@/store/session-states'
|
||||
|
||||
import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from './gateway-switch'
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
|
|||
setSessionsTotal(1)
|
||||
setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never])
|
||||
setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never])
|
||||
$stalledSessionIds.set(['s1'])
|
||||
setSessionsLoading(false)
|
||||
setFreshDraftReady(false)
|
||||
$sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3)
|
||||
|
|
@ -39,6 +41,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
|
|||
setSessions([])
|
||||
setCronSessions([])
|
||||
setMessagingSessions([])
|
||||
$stalledSessionIds.set([])
|
||||
setSessionsLoading(true)
|
||||
$gatewaySwitching.set(false)
|
||||
})
|
||||
|
|
@ -50,6 +53,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
|
|||
expect($sessionsTotal.get()).toBe(0)
|
||||
expect($cronSessions.get()).toEqual([])
|
||||
expect($messagingSessions.get()).toEqual([])
|
||||
expect($stalledSessionIds.get()).toEqual([])
|
||||
expect($sessionsLoading.get()).toBe(true)
|
||||
expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE)
|
||||
expect($freshDraftReady.get()).toBe(true)
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ export function wipeSessionListsForGatewaySwitch(): void {
|
|||
setMessagingPlatformTotals({})
|
||||
setMessagingTruncated(false)
|
||||
// Clearing $sessionStates automatically clears $workingSessionIds and
|
||||
// $attentionSessionIds (they're computed from it). $unreadFinishedSessionIds
|
||||
// is separate (transient, not computable) so wipe it explicitly.
|
||||
// $attentionSessionIds (computed) and $stalledSessionIds (owned beside it).
|
||||
// $unreadFinishedSessionIds is separate, so wipe it explicitly.
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
setSessionsLoading(true)
|
||||
|
|
|
|||
|
|
@ -45,17 +45,31 @@ import { isSecondaryWindow } from './windows'
|
|||
|
||||
export const $sessionStates = atom<Record<string, ClientSessionState>>({})
|
||||
|
||||
// --- Watchdog: force-clears busy after 8 min of stream silence -------------
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// Stored session ids whose authoritative state is still busy, but whose
|
||||
// runtime has produced no state publish for the watchdog window. Silence is
|
||||
// not completion: long tool calls can legitimately stay quiet, so this is a
|
||||
// presentation hint and never mutates the backend-derived busy state.
|
||||
export const $stalledSessionIds = atom<string[]>([])
|
||||
|
||||
type WatchdogClearFn = (runtimeId: string) => void
|
||||
let watchdogClearFn: WatchdogClearFn | null = null
|
||||
export function setSessionStalled(storedSessionId: string | null | undefined, stalled: boolean) {
|
||||
if (!storedSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
export function setWatchdogClearFn(fn: WatchdogClearFn | null) {
|
||||
watchdogClearFn = fn
|
||||
const current = $stalledSessionIds.get()
|
||||
const present = current.includes(storedSessionId)
|
||||
|
||||
if (stalled && !present) {
|
||||
$stalledSessionIds.set([...current, storedSessionId])
|
||||
} else if (!stalled && present) {
|
||||
$stalledSessionIds.set(current.filter(id => id !== storedSessionId))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watchdog: marks busy sessions quiet after 8 min of stream silence -----
|
||||
export const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function armWatchdog(runtimeId: string) {
|
||||
const existing = sessionWatchdogTimers.get(runtimeId)
|
||||
|
||||
|
|
@ -67,7 +81,11 @@ function armWatchdog(runtimeId: string) {
|
|||
runtimeId,
|
||||
setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(runtimeId)
|
||||
watchdogClearFn?.(runtimeId)
|
||||
const current = $sessionStates.get()[runtimeId]
|
||||
|
||||
if (current?.busy) {
|
||||
setSessionStalled(current.storedSessionId, true)
|
||||
}
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
)
|
||||
}
|
||||
|
|
@ -125,13 +143,19 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi
|
|||
}
|
||||
|
||||
clearSettled(previous.storedSessionId)
|
||||
setSessionStalled(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
// Watchdog: arm on any busy publish, disarm on idle.
|
||||
// Every busy publish is stream activity: clear the quiet hint and restart
|
||||
// the silence window. A real terminal transition clears both the timer and
|
||||
// any hint, but only that authoritative transition clears working/busy.
|
||||
if (next.busy) {
|
||||
setSessionStalled(next.storedSessionId, false)
|
||||
armWatchdog(runtimeId)
|
||||
} else {
|
||||
clearWatchdog(runtimeId)
|
||||
setSessionStalled(next.storedSessionId, false)
|
||||
setSessionStalled(previous?.storedSessionId, false)
|
||||
}
|
||||
|
||||
const storedId = next.storedSessionId
|
||||
|
|
@ -175,6 +199,7 @@ export function dropSessionState(runtimeId: string) {
|
|||
clearWatchdog(runtimeId)
|
||||
|
||||
const current = $sessionStates.get()
|
||||
setSessionStalled(current[runtimeId]?.storedSessionId, false)
|
||||
|
||||
if (!(runtimeId in current)) {
|
||||
return
|
||||
|
|
@ -196,6 +221,7 @@ export function clearAllSessionStates() {
|
|||
|
||||
sessionWatchdogTimers.clear()
|
||||
settledExpiry.clear()
|
||||
$stalledSessionIds.set([])
|
||||
$sessionStates.set({})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,11 @@ import { createClientSessionState } from '@/lib/chat-runtime'
|
|||
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from './session'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
$sessionStates,
|
||||
$stalledSessionIds,
|
||||
$workingSessionIds,
|
||||
clearAllSessionStates,
|
||||
getRecentlySettledSessionIds,
|
||||
publishSessionState,
|
||||
setWatchdogClearFn
|
||||
publishSessionState
|
||||
} from './session-states'
|
||||
|
||||
const WATCHDOG_MS = 8 * 60 * 1000
|
||||
|
|
@ -24,8 +23,6 @@ describe('session status transitions', () => {
|
|||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
// clearAllSessionStates also disarms watchdog timers + drops settle-grace
|
||||
// entries, so no leftover state can leak in from a previous test.
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
|
|
@ -45,25 +42,18 @@ describe('session status transitions', () => {
|
|||
const s = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
|
||||
// idle → working
|
||||
const next = { ...s, busy: true }
|
||||
publishSessionState('rt1', next)
|
||||
publishSessionState('rt1', { ...s, busy: true })
|
||||
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
})
|
||||
|
||||
it('removes a session from $workingSessionIds when busy transitions to false', () => {
|
||||
const s = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
// Simulate the working state being set
|
||||
const working = { ...s, busy: true }
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
|
||||
// Now transition to idle
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
publishSessionState('rt1', { ...working, busy: false })
|
||||
|
||||
expect($workingSessionIds.get()).not.toContain('s1')
|
||||
})
|
||||
|
|
@ -72,77 +62,63 @@ describe('session status transitions', () => {
|
|||
const s = state({ busy: true, needsInput: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
|
||||
const next = { ...s, needsInput: true }
|
||||
publishSessionState('rt1', next)
|
||||
publishSessionState('rt1', { ...s, needsInput: true })
|
||||
|
||||
expect($attentionSessionIds.get()).toContain('s1')
|
||||
})
|
||||
|
||||
it('marks a background session unread when its turn finishes', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
publishSessionState('rt1', { ...working, busy: false })
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark unread when the finishing session is the active one', () => {
|
||||
$selectedStoredSessionId.set('s1')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
publishSessionState('rt1', { ...working, busy: false })
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const idle = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' }))
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('grants settle grace when a working session goes idle', () => {
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
publishSessionState('rt1', { ...working, busy: false })
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does not grant grace on idle→idle re-asserts', () => {
|
||||
const idle = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' }))
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears settle grace when the session goes busy again', () => {
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
|
||||
|
||||
// New turn for the same session
|
||||
const workingAgain = { ...idle, busy: true }
|
||||
publishSessionState('rt1', workingAgain)
|
||||
publishSessionState('rt1', { ...idle, busy: true })
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
|
@ -166,67 +142,61 @@ describe('session watchdog', () => {
|
|||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('drops a stuck session from $workingSessionIds once the silence window elapses', () => {
|
||||
// Wire a clear fn like use-session-state-cache does in the real app: the
|
||||
// watchdog hands us the runtime id, we publish the busy:false state.
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => {
|
||||
clearedRuntimeIds.push(runtimeId)
|
||||
const current = $sessionStates.get()[runtimeId]
|
||||
it('marks a silent session stalled without pretending it finished', () => {
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
|
||||
if (current) {
|
||||
publishSessionState(runtimeId, { ...current, busy: false, needsInput: false })
|
||||
}
|
||||
})
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
|
||||
// Watchdog fires after 8 min of silence → the wired clear fn runs and the
|
||||
// computed working set drops the session. This asserts the timer→callback
|
||||
// wiring, not just the projection.
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect(clearedRuntimeIds).toEqual(['rt1'])
|
||||
expect($workingSessionIds.get()).not.toContain('s1')
|
||||
|
||||
setWatchdogClearFn(null)
|
||||
expect($stalledSessionIds.get()).toContain('s1')
|
||||
})
|
||||
|
||||
it('never fires for a session that settles before the window', () => {
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
|
||||
|
||||
it('clears stalled on new activity and rearms the watchdog', () => {
|
||||
const working = state({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt2', working)
|
||||
|
||||
// Session settles before the watchdog window
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt2', idle)
|
||||
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
expect($stalledSessionIds.get()).toContain('s2')
|
||||
|
||||
// The watchdog was disarmed — the clear fn never ran.
|
||||
expect(clearedRuntimeIds).toEqual([])
|
||||
expect($workingSessionIds.get()).not.toContain('s2')
|
||||
publishSessionState('rt2', { ...working, awaitingResponse: true })
|
||||
expect($stalledSessionIds.get()).not.toContain('s2')
|
||||
|
||||
setWatchdogClearFn(null)
|
||||
vi.advanceTimersByTime(WATCHDOG_MS - 1)
|
||||
expect($stalledSessionIds.get()).not.toContain('s2')
|
||||
expect($workingSessionIds.get()).toContain('s2')
|
||||
})
|
||||
|
||||
it('does not fire after clearAllSessionStates disarms every timer', () => {
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
|
||||
it('clears both running and stalled on an authoritative terminal transition', () => {
|
||||
const working = state({ busy: true, storedSessionId: 's3' })
|
||||
publishSessionState('rt3', working)
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
expect($stalledSessionIds.get()).toContain('s3')
|
||||
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
clearAllSessionStates()
|
||||
publishSessionState('rt3', { ...working, busy: false })
|
||||
|
||||
expect($workingSessionIds.get()).not.toContain('s3')
|
||||
expect($stalledSessionIds.get()).not.toContain('s3')
|
||||
})
|
||||
|
||||
it('never marks a session stalled when it settles before the window', () => {
|
||||
const working = state({ busy: true, storedSessionId: 's4' })
|
||||
publishSessionState('rt4', working)
|
||||
publishSessionState('rt4', { ...working, busy: false })
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect(clearedRuntimeIds).toEqual([])
|
||||
expect($workingSessionIds.get()).not.toContain('s4')
|
||||
expect($stalledSessionIds.get()).not.toContain('s4')
|
||||
})
|
||||
|
||||
setWatchdogClearFn(null)
|
||||
it('clears stalled state and disarms timers on a gateway wipe', () => {
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
expect($stalledSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
clearAllSessionStates()
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
expect($stalledSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue