From 93228d52999623c8e0eea107eb6a1a12c74cf788 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 3 Jun 2026 12:39:31 -0500 Subject: [PATCH 1/2] fix(desktop): persist pins, reconnect after sleep, dedupe session search Four related desktop session-management bugs: - Pins lost until refresh: pinned sessions are joined against the paginated in-memory session list, so a pinned chat that aged off the most-recent page got evicted on the next refresh (every message.complete triggers one) and the Pinned section went empty. mergeWorkingSessions -> mergeSessionPage now also preserves pinned rows (matched by live id or lineage root). Pin id checks in the chat header, command center, and delete/archive are normalized to the durable sessionPinId so pins survive auto-compression. - Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer WebSocket; nothing reconnected on wake so the composer stayed disabled. The gateway boot hook now auto-reconnects with backoff on close/error and on wake signals (powerMonitor resume/unlock-screen IPC, window online, visibilitychange). connect() gains an open timeout so a hung reconnect can't deadlock in 'connecting'. Composer placeholder distinguishes "Reconnecting to Hermes" from a cold start. - Loses chats from itself: the same hard-replace that dropped pins also dropped loaded sessions; mergeSessionPage keeps them. - Multiple copies/branches in search: /api/sessions/search deduped only by raw session_id, so compression segments and branches surfaced as separate hits. It now dedupes by lineage root and returns the live compression tip, matching the session_search tool's behavior. --- apps/desktop/electron/main.cjs | 28 ++++ apps/desktop/electron/preload.cjs | 5 + apps/desktop/src/app/chat/composer/index.tsx | 12 +- apps/desktop/src/app/chat/index.tsx | 15 ++- apps/desktop/src/app/chat/sidebar/index.tsx | 1 + apps/desktop/src/app/command-center/index.tsx | 14 +- apps/desktop/src/app/desktop-controller.tsx | 15 ++- .../src/app/gateway/hooks/use-gateway-boot.ts | 120 +++++++++++++++++- .../app/session/hooks/use-session-actions.ts | 11 +- apps/desktop/src/global.d.ts | 1 + apps/desktop/src/store/session.test.ts | 42 ++++-- apps/desktop/src/store/session.ts | 45 +++++-- apps/desktop/src/types/hermes.ts | 4 + apps/shared/src/json-rpc-gateway.ts | 54 +++++++- hermes_cli/web_server.py | 96 ++++++++++++-- tests/hermes_cli/test_web_server.py | 38 ++++++ 16 files changed, 443 insertions(+), 58 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index d94decaf5..fb40e4663 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -9,6 +9,7 @@ const { nativeImage, nativeTheme, net: electronNet, + powerMonitor, protocol, safeStorage, session, @@ -2693,6 +2694,32 @@ function sendClosePreviewRequested() { webContents.send('hermes:close-preview-requested') } +// Tell the renderer the machine just woke. Sleep silently drops the +// renderer's WebSocket to the local backend; the renderer reconnects on this +// signal so the chat composer doesn't stay stuck on "Starting Hermes...". +function sendPowerResume() { + if (!mainWindow || mainWindow.isDestroyed()) return + const { webContents } = mainWindow + if (!webContents || webContents.isDestroyed()) return + webContents.send('hermes:power-resume') +} + +let powerResumeRegistered = false + +function registerPowerResumeListeners() { + if (powerResumeRegistered) return + powerResumeRegistered = true + try { + // 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a + // full suspend. Either can drop an idle socket. + powerMonitor.on('resume', sendPowerResume) + powerMonitor.on('unlock-screen', sendPowerResume) + } catch { + // powerMonitor is unavailable before app 'ready' on some platforms; the + // caller registers after 'ready', so this should not normally throw. + } +} + function getAppIconPath() { return APP_ICON_PATHS.find(fileExists) } @@ -4205,6 +4232,7 @@ app.whenReady().then(() => { registerMediaProtocol() ensureWslWindowsFonts() configureSpellChecker() + registerPowerResumeListeners() createWindow() app.on('activate', () => { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 384972fe7..03fe08ef4 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -83,6 +83,11 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:backend-exit', listener) return () => ipcRenderer.removeListener('hermes:backend-exit', listener) }, + onPowerResume: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:power-resume', listener) + return () => ipcRenderer.removeListener('hermes:power-resume', listener) + }, onBootProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:boot-progress', listener) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index aae4827ed..cc2f33b13 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -34,7 +34,7 @@ import { shouldAutoDrainOnSettle, updateQueuedPrompt } from '@/store/composer-queue' -import { $messages } from '@/store/session' +import { $gatewayState, $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions' @@ -156,7 +156,15 @@ export function ChatBar({ const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' const showHelpHint = draft === '?' - const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up' + const gatewayState = useStore($gatewayState) + // When the bar is disabled it's because the gateway isn't open. Distinguish a + // cold start ("Starting Hermes...") from a dropped connection we're trying to + // restore (e.g. after the Mac slept) so the stuck state reads as recoverable. + const placeholder = disabled + ? gatewayState === 'closed' || gatewayState === 'error' + ? 'Reconnecting to Hermes…' + : 'Starting Hermes...' + : 'Send follow-up' const focusInput = useCallback(() => { focusComposerInput(editorRef.current) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index c9e0bb0db..996826adf 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -36,7 +36,8 @@ import { $introSeed, $messages, $selectedStoredSessionId, - $sessions + $sessions, + sessionPinId } from '@/store/session' import type { ModelOptionsResponse } from '@/types/hermes' @@ -96,9 +97,17 @@ function ChatHeader({ }: ChatHeaderProps) { const sessions = useStore($sessions) const pinnedSessionIds = useStore($pinnedSessionIds) - const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null + const activeStoredSession = + sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session' - const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false + // Pins live on the durable lineage-root id, but selectedSessionId is the live + // (tip) id — resolve through the loaded row so the menu reflects the pin + // state after auto-compression rotates the id. + const selectedIsPinned = activeStoredSession + ? pinnedSessionIds.includes(sessionPinId(activeStoredSession)) + : selectedSessionId + ? pinnedSessionIds.includes(selectedSessionId) + : false return (
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index d02c0e8e9..35171ce66 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -143,6 +143,7 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { cwd: null, ended_at: null, id: result.session_id, + _lineage_root_id: result.lineage_root ?? null, input_tokens: 0, is_active: false, last_active: ts, diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index eb4156894..758b7ade3 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -30,7 +30,7 @@ import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' -import { $sessions } from '@/store/session' +import { $sessions, sessionPinId } from '@/store/session' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton } from '../overlays/overlay-chrome' @@ -102,6 +102,8 @@ const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [ interface SessionSearchHit { detail?: string kind: 'session' + /** Durable lineage-root id used for pinning so the pin survives compression. */ + pinId: string sessionId: string snippet: string title: string @@ -260,6 +262,7 @@ export function CommandCenterView({ return { detail, kind: 'session', + pinId: result.lineage_root || result.session_id, sessionId: result.session_id, snippet: result.snippet || '', title @@ -491,7 +494,7 @@ export function CommandCenterView({ {group.results.map(result => { if (result.kind === 'session') { - const pinned = pinnedSessionIds.includes(result.sessionId) + const pinned = pinnedSessionIds.includes(result.pinId) return ( @@ -515,7 +518,7 @@ export function CommandCenterView({ onClick={event => { event.preventDefault() event.stopPropagation() - pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId) + pinned ? unpinSession(result.pinId) : pinSession(result.pinId) }} title={pinned ? 'Unpin session' : 'Pin session'} > @@ -580,7 +583,8 @@ export function CommandCenterView({ ) : (
{filteredSessions.map(session => { - const pinned = pinnedSessionIds.includes(session.id) + const pinId = sessionPinId(session) + const pinned = pinnedSessionIds.includes(pinId) return ( @@ -595,7 +599,7 @@ export function CommandCenterView({
(pinned ? unpinSession(session.id) : pinSession(session.id))} + onClick={() => (pinned ? unpinSession(pinId) : pinSession(pinId))} title={pinned ? 'Unpin session' : 'Pin session'} > {pinned ? : } diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ad74f3cf4..635fe4a9c 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -34,7 +34,7 @@ import { $selectedStoredSessionId, $sessions, $workingSessionIds, - mergeWorkingSessions, + mergeSessionPage, sessionPinId, setAwaitingResponse, setBusy, @@ -208,12 +208,13 @@ export function DesktopController() { const result = await listSessions(limit, 1) if (refreshSessionsRequestRef.current === requestId) { - // Don't hard-replace: a session whose first turn is still in flight has - // message_count 0 in the DB, so min_messages=1 omits it. Since every - // message.complete refreshes the list, a plain replace would drop the - // other still-running new chats the moment one of them finishes. Keep - // any working session the server hasn't surfaced yet. - setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get())) + // Don't hard-replace. Two kinds of rows must survive a refresh the + // server didn't return: (1) sessions whose first turn is still in + // flight (message_count 0, so min_messages=1 omits them) and (2) + // pinned sessions that have aged off the most-recent page — otherwise + // the pin "disappears until you refresh". mergeSessionPage keeps both. + const keepIds = new Set([...$workingSessionIds.get(), ...$pinnedSessionIds.get()]) + setSessions(prev => mergeSessionPage(prev, result.sessions, keepIds)) setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) } } finally { diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 7904128eb..815ce205b 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -63,6 +63,94 @@ export function useGatewayBoot({ return () => void (cancelled = true) } + // --- Reconnect-after-sleep machinery ------------------------------------- + // macOS sleep silently drops the renderer's WebSocket. The backend Python + // process keeps running, but nothing re-opened the socket on wake, so the + // composer stayed disabled forever on "Starting Hermes...". Once the + // initial boot succeeds we treat any non-open state as recoverable and + // reconnect with backoff, and we nudge a reconnect on the OS/browser + // signals that fire around wake (power resume, network online, the window + // becoming visible). + let bootCompleted = false + let reconnecting = false + let reconnectTimer: ReturnType | null = null + let reconnectAttempt = 0 + + // Wrap the live getter in a call so TS control-flow analysis doesn't narrow + // `connectionState` to a constant across the early-return guards (the state + // genuinely changes between reads). + const gatewayOpen = () => gateway.connectionState === 'open' + + const clearReconnectTimer = () => { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + } + + const attemptReconnect = async () => { + if (cancelled || reconnecting || gatewayOpen()) { + return + } + + reconnecting = true + + try { + const conn = await desktop.getConnection() + + if (cancelled) { + return + } + + publish(conn) + await gateway.connect(conn.wsUrl) + + if (cancelled) { + return + } + + reconnectAttempt = 0 + // Resync state that may have moved on the backend while we were asleep. + await callbacksRef.current.refreshHermesConfig().catch(() => undefined) + await callbacksRef.current.refreshSessions().catch(() => undefined) + } catch { + // Fall through to scheduleReconnect's backoff below. + } finally { + reconnecting = false + + if (!cancelled && !gatewayOpen()) { + scheduleReconnect() + } + } + } + + function scheduleReconnect() { + if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) { + return + } + + // 1s, 2s, 4s … capped at 15s. + const delay = Math.min(15_000, 1_000 * 2 ** Math.min(reconnectAttempt, 4)) + reconnectAttempt += 1 + reconnectTimer = setTimeout(() => { + reconnectTimer = null + void attemptReconnect() + }, delay) + } + + const reconnectNow = () => { + if (cancelled || !bootCompleted) { + return + } + + clearReconnectTimer() + reconnectAttempt = 0 + + if (!gatewayOpen()) { + void attemptReconnect() + } + } + const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload)) void desktop .getBootProgress() @@ -79,9 +167,34 @@ export function useGatewayBoot({ callbacksRef.current.onGatewayReady(gateway) setGateway(gateway) - const offState = gateway.onState(st => void setGatewayState(st)) + const offState = gateway.onState(st => { + setGatewayState(st) + + if (st === 'open') { + reconnectAttempt = 0 + clearReconnectTimer() + } else if (bootCompleted && (st === 'closed' || st === 'error')) { + // The socket dropped after a healthy boot (typically sleep/wake). Try + // to bring it back instead of leaving the composer stuck disabled. + scheduleReconnect() + } + }) const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event)) + // Wake signals: power resume (macOS/Windows), network coming back, and the + // window regaining focus/visibility. Each nudges an immediate reconnect. + const offPowerResume = desktop.onPowerResume?.(() => reconnectNow()) + + const onOnline = () => reconnectNow() + const onVisible = () => { + if (document.visibilityState === 'visible') { + reconnectNow() + } + } + + window.addEventListener('online', onOnline) + document.addEventListener('visibilitychange', onVisible) + const offWindowState = desktop.onWindowStateChanged?.(payload => { const current = $connection.get() @@ -141,6 +254,7 @@ export function useGatewayBoot({ }) await callbacksRef.current.refreshSessions() completeDesktopBoot() + bootCompleted = true } catch (err) { if (!cancelled) { const message = err instanceof Error ? err.message : String(err) @@ -155,6 +269,10 @@ export function useGatewayBoot({ return () => { cancelled = true + clearReconnectTimer() + window.removeEventListener('online', onOnline) + document.removeEventListener('visibilitychange', onVisible) + offPowerResume?.() offState() offEvent() offExit() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index a07bfdb64..546ccca2b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -16,6 +16,7 @@ import { $messages, $sessions, getRememberedWorkspaceCwd, + sessionPinId, setActiveSessionId, setAwaitingResponse, setBusy, @@ -692,12 +693,15 @@ export function useSessionActions({ const closingRuntimeId = wasSelected ? activeSessionId : null const previousMessages = $messages.get() const previousPinned = $pinnedSessionIds.get() + // Pins are keyed on the durable lineage-root id; the stored id may be the + // live tip after compression. Drop both so the pin can't linger. + const removedPinId = removed ? sessionPinId(removed) : storedSessionId setSessions(prev => prev.filter(s => s.id !== storedSessionId)) // Keep $sessionsTotal in sync so the sidebar's "Load N more" footer // doesn't keep claiming the removed row is still on the server. setSessionsTotal(prev => Math.max(0, prev - 1)) - $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId)) + $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== removedPinId)) // Tear down before awaiting so the route effect can't resume the // doomed session via the stale / URL. @@ -769,6 +773,9 @@ export function useSessionActions({ const archived = $sessions.get().find(s => s.id === storedSessionId) const wasSelected = selectedStoredSessionId === storedSessionId const previousPinned = $pinnedSessionIds.get() + // Pins are keyed on the durable lineage-root id; the stored id may be the + // live tip after compression. Drop both so the pin can't linger. + const archivedPinId = archived ? sessionPinId(archived) : storedSessionId // Soft-hide: drop from the sidebar immediately, keep the data. setSessions(prev => prev.filter(s => s.id !== storedSessionId)) @@ -776,7 +783,7 @@ export function useSessionActions({ // on the next refresh, so they count as "removed" for the load-more // footer math. setSessionsTotal(prev => Math.max(0, prev - 1)) - $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId)) + $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== archivedPinId)) if (wasSelected) { startFreshSessionDraft(true) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 0888e60d0..3f8a4dace 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -49,6 +49,7 @@ declare global { onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void onBackendExit: (callback: (payload: BackendExit) => void) => () => void + onPowerResume?: (callback: () => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise resetBootstrap: () => Promise<{ ok: boolean }> diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index 685165266..a6c1627eb 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { SessionInfo } from '@/types/hermes' -import { mergeWorkingSessions, sessionPinId } from './session' +import { mergeSessionPage, sessionPinId } from './session' const session = (over: Partial): SessionInfo => ({ archived: false, @@ -35,12 +35,12 @@ describe('sessionPinId', () => { }) }) -describe('mergeWorkingSessions', () => { - it('returns the server page untouched when nothing is working', () => { +describe('mergeSessionPage', () => { + it('returns the server page untouched when there is nothing to keep', () => { const previous = [session({ id: 'a' }), session({ id: 'b' })] const incoming = [session({ id: 'a' })] - expect(mergeWorkingSessions(previous, incoming, [])).toBe(incoming) + expect(mergeSessionPage(previous, incoming, [])).toBe(incoming) }) it('keeps a still-working session the server omitted', () => { @@ -50,7 +50,7 @@ describe('mergeWorkingSessions', () => { const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })] const incoming = [session({ id: 'a', message_count: 2 })] - const merged = mergeWorkingSessions(previous, incoming, ['b', 'c']) + const merged = mergeSessionPage(previous, incoming, ['b', 'c']) expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a']) // The finished session comes from the fresh server payload, not the stale @@ -62,18 +62,42 @@ describe('mergeWorkingSessions', () => { const previous = [session({ id: 'b' }), session({ id: 'a' })] const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })] - const merged = mergeWorkingSessions(previous, incoming, ['b']) + const merged = mergeSessionPage(previous, incoming, ['b']) expect(merged.map(s => s.id)).toEqual(['b', 'a']) expect(merged.find(s => s.id === 'b')?.message_count).toBe(4) }) - it('never resurrects a non-working session the server dropped', () => { + it('never resurrects a session the server dropped that is not in the keep set', () => { // A deleted/archived session is removed from `previous` optimistically and - // is not in the working set, so it must stay gone after a refresh. + // is not in the keep set, so it must stay gone after a refresh. const previous = [session({ id: 'b' }), session({ id: 'gone' })] const incoming = [session({ id: 'b' })] - expect(mergeWorkingSessions(previous, incoming, ['b']).map(s => s.id)).toEqual(['b']) + expect(mergeSessionPage(previous, incoming, ['b']).map(s => s.id)).toEqual(['b']) + }) + + it('keeps a pinned session that has aged off the recent page', () => { + // Repro of "loses pins until you refresh": a pinned chat falls off the + // most-recent page, so the server stops returning it. A hard replace would + // evict it and the Pinned section would go empty. The keep set (which + // carries pinned ids) must hold it in memory. + const previous = [session({ id: 'recent' }), session({ id: 'pinned' })] + const incoming = [session({ id: 'recent' })] + + const merged = mergeSessionPage(previous, incoming, ['pinned']) + + expect(merged.map(s => s.id)).toEqual(['pinned', 'recent']) + }) + + it('keeps a pinned session matched by its lineage root after compression', () => { + // The pin is stored on the lineage-root id, but the loaded row surfaces + // under its live compression tip. Matching on _lineage_root_id keeps it. + const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] + const incoming = [session({ id: 'other' })] + + const merged = mergeSessionPage(previous, incoming, ['root']) + + expect(merged.map(s => s.id)).toEqual(['tip', 'other']) }) }) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 85c314faf..c3226a6d5 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -28,28 +28,45 @@ export const sessionPinId = (session: Pick ): SessionInfo[] { - if (workingIds.length === 0) { + const keep = keepIds instanceof Set ? keepIds : new Set(keepIds) + + if (keep.size === 0) { return incoming } - const working = new Set(workingIds) const incomingIds = new Set(incoming.map(session => session.id)) - const survivors = previous.filter(session => working.has(session.id) && !incomingIds.has(session.id)) + const survivors = previous.filter( + session => + !incomingIds.has(session.id) && + (keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id))) + ) return survivors.length ? [...survivors, ...incoming] : incoming } diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 56d28c0c5..388fb283c 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -503,8 +503,12 @@ export interface ToolsetConfig { } export interface SessionSearchResult { + /** Lineage root of the matched conversation. Stable across compression and + * used as the durable pin id; falls back to session_id when absent. */ + lineage_root?: string | null model: string | null role: string | null + /** Live compression tip of the matched conversation — resume by this id. */ session_id: string session_started: number | null snippet: string diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index bbf4b3abd..d7d30c200 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -49,6 +49,7 @@ type PendingCall = { export interface GatewayClientOptions { closedErrorMessage?: string connectErrorMessage?: string + connectTimeoutMs?: number createRequestId?: (nextId: number) => GatewayRequestId requestIdPrefix?: string requestTimeoutMs?: number @@ -58,6 +59,10 @@ export interface GatewayClientOptions { const ANY = '*' const DEFAULT_REQUEST_TIMEOUT_MS = 120_000 +// A reconnect after sleep/wake must not hang forever in 'connecting' (which +// keeps the composer disabled and stuck on "Starting Hermes..."). If the open +// handshake doesn't land in this window, fail to 'error' so callers can retry. +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000 export class JsonRpcGatewayClient { private nextId = 0 @@ -73,6 +78,7 @@ export class JsonRpcGatewayClient { this.options = { closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed', connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed', + connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`), notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected', @@ -106,20 +112,64 @@ export class JsonRpcGatewayClient { }) await new Promise((resolve, reject) => { - const onOpen = () => { + let settled = false + let timer: ReturnType | undefined + + const cleanup = () => { + if (timer !== undefined) { + clearTimeout(timer) + } + + socket.removeEventListener('open', onOpen) socket.removeEventListener('error', onError) + } + + const onOpen = () => { + if (settled) { + return + } + + settled = true + cleanup() this.setState('open') resolve() } const onError = () => { - socket.removeEventListener('open', onOpen) + if (settled) { + return + } + + settled = true + cleanup() this.setState('error') reject(new Error(this.options.connectErrorMessage)) } socket.addEventListener('open', onOpen, { once: true }) socket.addEventListener('error', onError, { once: true }) + + if (this.options.connectTimeoutMs > 0) { + timer = setTimeout(() => { + if (settled) { + return + } + + settled = true + cleanup() + // Drop the half-open socket so the next connect() starts clean + // instead of short-circuiting on a zombie 'connecting' state. + try { + this.socket?.close() + } catch { + // ignore + } + + this.socket = null + this.setState('error') + reject(new Error(this.options.connectErrorMessage)) + }, this.options.connectTimeoutMs) + } }) } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b9b690769..e94385e94 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1529,7 +1529,18 @@ async def get_sessions( @app.get("/api/sessions/search") async def search_sessions(q: str = "", limit: int = 20): - """Full-text search across session message content using FTS5.""" + """Full-text search across session message content using FTS5. + + Results are deduped by *conversation lineage*, not by raw ``session_id``. + Auto-compression rotates a conversation onto a fresh session id (and leaves + the old segment's messages in the FTS index), and branches copy the + transcript into a new row — so one logical chat owns many ``sessions`` rows + that all match the same query. Without lineage dedup the sidebar shows the + same conversation several times, which is the "multiple copies / branches" + navigation complaint. We collapse every match to its lineage root and + surface the live compression tip so clicking the result resumes the current + session. + """ if not q or not q.strip(): return {"results": []} try: @@ -1547,20 +1558,79 @@ async def search_sessions(q: str = "", limit: int = 20): else: terms.append(token + "*") prefix_query = " ".join(terms) - matches = db.search_messages(query=prefix_query, limit=limit) - # Group by session_id — return unique sessions with their best snippet + # Over-fetch so lineage dedup can still surface `limit` distinct + # conversations even when several hits collapse onto one root. + fetch_limit = max(limit * 5, 50) + matches = db.search_messages(query=prefix_query, limit=fetch_limit) + + # Walk parent_session_id to the lineage root, memoized so a chain of + # compression segments only costs one walk. + root_cache: dict = {} + + def lineage_root(session_id: str) -> str: + if not session_id: + return session_id + if session_id in root_cache: + return root_cache[session_id] + chain = [] + cur = session_id + visited = set() + root = session_id + while cur and cur not in visited: + visited.add(cur) + chain.append(cur) + if cur in root_cache: + root = root_cache[cur] + break + try: + s = db.get_session(cur) + except Exception: + s = None + if not s: + root = cur + break + parent = s.get("parent_session_id") if isinstance(s, dict) else None + if not parent: + root = cur + break + cur = parent + for node in chain: + root_cache[node] = root + return root + + tip_cache: dict = {} + + def lineage_tip(root_id: str) -> str: + if root_id in tip_cache: + return tip_cache[root_id] + tip = root_id + try: + resolved = db.get_compression_tip(root_id) + if resolved: + tip = resolved + except Exception: + pass + tip_cache[root_id] = tip + return tip + + # Keep the best (first / most relevant) hit per lineage root. seen: dict = {} for m in matches: - sid = m["session_id"] - if sid not in seen: - seen[sid] = { - "session_id": sid, - "snippet": m.get("snippet", ""), - "role": m.get("role"), - "source": m.get("source"), - "model": m.get("model"), - "session_started": m.get("session_started"), - } + raw_sid = m["session_id"] + root = lineage_root(raw_sid) + if root in seen: + continue + seen[root] = { + "session_id": lineage_tip(root), + "lineage_root": root, + "snippet": m.get("snippet", ""), + "role": m.get("role"), + "source": m.get("source"), + "model": m.get("model"), + "session_started": m.get("session_started"), + } + if len(seen) >= limit: + break return {"results": list(seen.values())} finally: db.close() diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index cac40c800..9ff0e1444 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -428,6 +428,44 @@ class TestWebServerEndpoints: tip = next(r for r in rows if r["id"] == "tip-new") assert tip.get("_lineage_root_id") == "root-old" + def test_search_dedupes_compression_lineage_to_tip(self): + """A conversation that auto-compresses leaves the matched term in both + the root segment and the continuation. Search must collapse them to a + single result keyed by the lineage root and pointing at the live tip, + so the sidebar stops showing the same chat several times.""" + import time as _time + + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="search-root", source="cli") + db.append_message(session_id="search-root", role="user", content="distinctneedle in the root") + db.end_session("search-root", "compression") + now = _time.time() + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?", + (now - 100, now - 90, "search-root"), + ) + db.create_session(session_id="search-tip", source="cli", parent_session_id="search-root") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (now - 90, "search-tip")) + db.append_message(session_id="search-tip", role="user", content="distinctneedle again in the tip") + db._conn.commit() + finally: + db.close() + + resp = self.client.get("/api/sessions/search?q=distinctneedle") + assert resp.status_code == 200 + results = resp.json()["results"] + + lineage_hits = [r for r in results if r.get("lineage_root") == "search-root"] + # One conversation -> exactly one result despite two FTS hits. + assert len(lineage_hits) == 1 + hit = lineage_hits[0] + # Surfaced under the live tip so clicking resumes the current session. + assert hit["session_id"] == "search-tip" + assert hit["lineage_root"] == "search-root" + def test_get_sessions_archived_is_boolean(self): from hermes_state import SessionDB From 1b89715e153f3daac8f697c95f386a1a4bf0947d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 3 Jun 2026 13:13:21 -0500 Subject: [PATCH 2/2] fix(desktop): guard reconnect sockets and keep branch search precise Avoid stale WebSocket events from an old reconnect attempt flipping the gateway state after a newer socket opens. Also limit session-search dedupe to compression edges so branch-specific hits still open the branch instead of collapsing to the parent. --- apps/shared/src/json-rpc-gateway.ts | 27 ++++++++++++------ hermes_cli/web_server.py | 43 ++++++++++++++++++++--------- tests/hermes_cli/test_web_server.py | 34 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index d7d30c200..af48290d7 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -103,10 +103,19 @@ export class JsonRpcGatewayClient { this.socket = socket socket.addEventListener('message', message => { + if (this.socket !== socket) { + return + } + this.handleMessage(message.data) }) socket.addEventListener('close', () => { + if (this.socket !== socket) { + return + } + + this.socket = null this.setState('closed') this.rejectAllPending(new Error(this.options.closedErrorMessage)) }) @@ -125,7 +134,7 @@ export class JsonRpcGatewayClient { } const onOpen = () => { - if (settled) { + if (settled || this.socket !== socket) { return } @@ -136,7 +145,7 @@ export class JsonRpcGatewayClient { } const onError = () => { - if (settled) { + if (settled || this.socket !== socket) { return } @@ -159,13 +168,15 @@ export class JsonRpcGatewayClient { cleanup() // Drop the half-open socket so the next connect() starts clean // instead of short-circuiting on a zombie 'connecting' state. - try { - this.socket?.close() - } catch { - // ignore - } + if (this.socket === socket) { + try { + socket.close() + } catch { + // ignore + } - this.socket = null + this.socket = null + } this.setState('error') reject(new Error(this.options.connectErrorMessage)) }, this.options.connectTimeoutMs) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e94385e94..01eab9196 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1531,15 +1531,12 @@ async def get_sessions( async def search_sessions(q: str = "", limit: int = 20): """Full-text search across session message content using FTS5. - Results are deduped by *conversation lineage*, not by raw ``session_id``. + Results are deduped by compression lineage, not by raw ``session_id``. Auto-compression rotates a conversation onto a fresh session id (and leaves - the old segment's messages in the FTS index), and branches copy the - transcript into a new row — so one logical chat owns many ``sessions`` rows - that all match the same query. Without lineage dedup the sidebar shows the - same conversation several times, which is the "multiple copies / branches" - navigation complaint. We collapse every match to its lineage root and - surface the live compression tip so clicking the result resumes the current - session. + the old segment's messages in the FTS index), so one logical chat can own + many ``sessions`` rows that all match the same query. Branches also use + ``parent_session_id``, but they are real alternate conversations; don't + collapse branch-specific hits back into the parent. """ if not q or not q.strip(): return {"results": []} @@ -1563,11 +1560,13 @@ async def search_sessions(q: str = "", limit: int = 20): fetch_limit = max(limit * 5, 50) matches = db.search_messages(query=prefix_query, limit=fetch_limit) - # Walk parent_session_id to the lineage root, memoized so a chain of - # compression segments only costs one walk. + # Walk parent_session_id to the compression root, memoized so a + # chain of compression segments only costs one walk. We deliberately + # stop at branch/delegate edges: those sessions may diverge from the + # parent and should remain searchable on their own. root_cache: dict = {} - def lineage_root(session_id: str) -> str: + def compression_root(session_id: str) -> str: if not session_id: return session_id if session_id in root_cache: @@ -1593,6 +1592,24 @@ async def search_sessions(q: str = "", limit: int = 20): if not parent: root = cur break + try: + parent_session = db.get_session(parent) + except Exception: + parent_session = None + if not parent_session: + root = cur + break + parent_ended_at = parent_session.get("ended_at") + started_at = s.get("started_at") + is_compression_edge = ( + parent_session.get("end_reason") == "compression" + and parent_ended_at is not None + and started_at is not None + and started_at >= parent_ended_at + ) + if not is_compression_edge: + root = cur + break cur = parent for node in chain: root_cache[node] = root @@ -1613,11 +1630,11 @@ async def search_sessions(q: str = "", limit: int = 20): tip_cache[root_id] = tip return tip - # Keep the best (first / most relevant) hit per lineage root. + # Keep the best (first / most relevant) hit per compression root. seen: dict = {} for m in matches: raw_sid = m["session_id"] - root = lineage_root(raw_sid) + root = compression_root(raw_sid) if root in seen: continue seen[root] = { diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 9ff0e1444..7ff246035 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -466,6 +466,40 @@ class TestWebServerEndpoints: assert hit["session_id"] == "search-tip" assert hit["lineage_root"] == "search-root" + def test_search_keeps_branch_specific_hits_on_branch(self): + """Branch sessions share parent_session_id, but they are not compression + continuations. A query that only exists in the branch must open the + branch instead of being collapsed back to the parent/root.""" + import time as _time + + from hermes_state import SessionDB + + db = SessionDB() + try: + now = _time.time() + db.create_session(session_id="branch-parent", source="cli") + db.append_message(session_id="branch-parent", role="user", content="ancestor context") + db.end_session("branch-parent", "branched") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?", + (now - 100, now - 90, "branch-parent"), + ) + db.create_session(session_id="branch-child", source="cli", parent_session_id="branch-parent") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (now - 80, "branch-child")) + db.append_message(session_id="branch-child", role="user", content="branchspecificneedle only here") + db._conn.commit() + finally: + db.close() + + resp = self.client.get("/api/sessions/search?q=branchspecificneedle") + assert resp.status_code == 200 + results = resp.json()["results"] + + assert any( + r["session_id"] == "branch-child" and r.get("lineage_root") == "branch-child" + for r in results + ) + def test_get_sessions_archived_is_boolean(self): from hermes_state import SessionDB