NousResearch/hermes-agent

Session recovery after Gateway restart in Desktop/TUI

Cluster 38704 · gateway. Data as of

Nothing open on GitHub. Fix merged in #60874 on 8 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #60874. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items appear to concern sessions becoming unavailable ("session not found") when the Gateway restarts during an active Desktop session. One proposed change relates to recovering sessions from state.db, and another to continuing the selected stored session rather than creating a new one; the titles alone do not show whether either addresses the issue.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60874 fix(desktop): continue the selected stored session instead of minting a new one (#55578)

    merged to main

    files: apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx, apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts, apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts

    Show the diff
    • apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
      @@ -59,7 +59,9 @@ function Harness({
         requestGateway,
         resumeStoredSession,
         seedMessages,
      -  storedSessionId
      +  storedSessionId,
      +  activeSessionId,
      +  createBackendSessionForSend
       }: {
         busyRef?: MutableRefObject<boolean>
         onReady: (handle: HarnessHandle) => void
      @@ -70,8 +72,12 @@ function Harness({
         resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
         seedMessages?: unknown[]
         storedSessionId?: null | string
      +  activeSessionId?: null | string
      +  createBackendSessionForSend?: () => Promise<null | string>
       }) {
      -  const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
      +  const activeSessionIdRef: MutableRefObject<string | null> = {
      +    current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
      +  }
       
         const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
           current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
      @@ -87,11 +93,11 @@ function Harness({
         } as never)
       
         const actions = usePromptActions({
      -    activeSessionId: RUNTIME_SESSION_ID,
      +    activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId,
           activeSessionIdRef,
           branchCurrentSession: async () => true,
           busyRef: localBusyRef,
      -    createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
      +    createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID),
           handleSkinCommand: () => '',
           openMemoryGraph: openMemoryGraph ?? (() => undefined),
           refreshSessions,
      @@ -1190,6 +1196,124 @@ describe('usePromptActions sleep/wake session recovery', () => {
           expect(await handle!.submitText('message')).toBe(false)
           expect(calls).not.toContain('session.resume')
         })
      +
      +  it('recovers via session.resume when prompt.submit TIMES OUT and a stored session is selected (#55578)', async () => {
      +    // A starved gateway loop rejects with "request timed out: prompt.submit".
      +    // With a stored session selected, that must recover exactly like
      +    // "session not found" — resume + retry — not surface an error that leaves
      +    // activeSessionId null and lets the next send mint a new session.
      +    const calls: { method: string; params?: Record<string, unknown> }[] = []
      +    let submitAttempts = 0
      +
      +    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      +      calls.push({ method, params })
      +
      +      if (method === 'prompt.submit') {
      +        submitAttempts += 1
      +
      +        if (submitAttempts === 1) {
      +          throw new Error('request timed out: prompt.submit')
      +        }
      +
      +        return {} as never
      +      }
      +
      +      if (method === 'session.resume') {
      +        return { session_id: RECOVERED_SESSION_ID } as never
      +      }
      +
      +      return {} as never
      +    })
      +
      +    let handle: HarnessHandle | null = null
      +    render(
      +      <Harness
      +        onReady={h => (handle = h)}
      +        refreshSessions={async () => undefined}
      +        requestGateway={requestGateway}
      +        storedSessionId={STORED_SESSION_ID}
      +      />
      +    )
      +
      +    const ok = await handle!.submitText('message during starved loop')
      +
      +    expect(ok).toBe(true)
      +    expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
      +    expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
      +    expect(calls[2]?.params).toEqual({
      +      session_id: RECOVERED_SESSION_ID,
      +      text: 'message during starved loop'
      +    })
      +  })
      +
      +  it('resumes the SELECTED stored session instead of minting a new one when activeSessionId is null (#55578 split)', async () => {
      +    // The exact split path from #55578 symptom (b): the runtime binding is
      +    // gone (orphan-reaped / cleared by a timeout) but a stored session is
      +    // still selected in the sidebar. A follow-up submit must continue that
      +    // conversation via session.resume — createBackendSessionForSend would
      +    // silently fork the user's chat in two.
      +    const calls: { method: string; params?: Record<string, unknown> }[] = []
      +    const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG')
      +
      +    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      +      calls.push({ method, params })
      +
      +      if (method === 'session.resume') {
      +        return { session_id: RECOVERED_SESSION_ID } as never
      +      }
      +
      +      return {} as never
      +    })
      +
      +    let handle: HarnessHandle | null = null
      +    render(
      +      <Harness
      +        activeSessionId={null}
      +        createBackendSessionForSend={createBackendSessionForSend}
      +        onReady={h => (handle = h)}
      +        refreshSessions={async () => undefined}
      +        requestGateway={requestGateway}
      +        storedSessionId={STORED_SESSION_ID}
      +      />
      +    )
      +
      +    const ok = await handle!.submitText('follow-up in the selected chat')
      +
      +    expect(ok).toBe(true)
      +    expect(createBackendSessionForSend).not.toHaveBeenCalled()
      +    expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
      +    expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID })
      +    expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
      +  })
      +
      +  it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => {
      +    const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID)
      +    const calls: string[] = []
      +
      +    const requestGateway = vi.fn(async (method: string) => {
      +      calls.push(method)
      +
      +      return {} as never
      +    })
      +
      +    let handle: HarnessHandle | null = null
      +    render(
      +      <Harness
      +        activeSessionId={null}
      +        createBackendSessionForSend={createBackendSessionForSend}
      +        onReady={h => (handle = h)}
      +        refreshSessions={async () => undefined}
      +        requestGateway={requestGateway}
      +        storedSessionId={null}
      +      />
      +    )
      +
      +    const ok = await handle!.submitText('first message of a new chat')
      +
      +    expect(ok).toBe(true)
      +    expect(createBackendSessionForSend).toHaveBeenCalledTimes(1)
      +    expect(calls).not.toContain('session.resume')
      +  })
       })
       
       describe('usePromptActions eager attachment upload (drop-time)', () => {
    • apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts
      @@ -23,6 +23,7 @@ import {
         inlineErrorMessage,
         isProviderSetupError,
         isSessionBusyError,
      +  isGatewayTimeoutError,
         isSessionNotFoundError,
         type SubmitTextOptions,
         withSessionBusyRetry
      @@ -213,6 +214,34 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
               setMessages(current => [...current, buildUserMessage()])
             }
       
      +      if (!sessionId && selectedStoredSessionIdRef.current) {
      +        // A stored session is SELECTED but its runtime binding is gone (the
      +        // live session was orphan-reaped, or a timeout/reconnect cleared
      +        // activeSessionId). Continuing the selected conversation must mean
      +        // resuming it — minting a brand-new backend session here silently
      +        // splits the user's chat in two (#55578 symptom b). Only fall through
      +        // to session creation when NO stored session is selected (a genuine
      +        // new-chat draft).
      +        try {
      +          const resumed = await requestGateway<{ session_id: string }>('session.resume', {
      +            session_id: selectedStoredSessionIdRef.current
      +          })
      +
      +          if (resumed?.session_id) {
      +            sessionId = resumed.session_id
      +            activeSessionIdRef.current = sessionId
      +          }
      +        } catch {
      +          // Resume failed (session gone from state.db, gateway hiccup) —
      +          // fall through to creating a fresh session rather than dead-ending
      +          // the user's message.
      +        }
      +
      +        if (sessionId) {
      +          seedOptimistic(sessionId)
      +        }
      +      }
      +
             if (!sessionId) {
               try {
                 sessionId = await createBackendSessionForSend(visibleText)
      @@ -257,8 +286,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
                   requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
                 )
               } catch (firstErr) {
      -          if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
      +          if (
      +            (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
      +            selectedStoredSessionIdRef.current
      +          ) {
                   // Re-register the session in the gateway and get a fresh live ID.
      +            // Timeouts recover the same way as "session not found": a starved
      +            // backend loop (#55578 symptom d) rejects the submit even though
      +            // the stored session is fine — resume + retry instead of erroring
      +            // out and losing the session binding.
                   const resumed = await requestGateway<{ session_id: string }>('session.resume', {
                     session_id: selectedStoredSessionIdRef.current
                   })
    • apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts
      @@ -52,6 +52,18 @@ export function isSessionNotFoundError(error: unknown): boolean {
         return /session not found/i.test(message)
       }
       
      +// Gateway JSON-RPC calls reject with "request timed out: <method>" when the
      +// backend event loop is starved (e.g. a poller spin or a heavy async-injected
      +// turn). For prompt.submit this is indistinguishable from a dead runtime
      +// session on the client side — recovery must treat it like one (#55578):
      +// resume the SELECTED stored session and retry, instead of surfacing an error
      +// that leads to a null activeSessionId and a silently minted new session.
      +export function isGatewayTimeoutError(error: unknown): boolean {
      +  const message = error instanceof Error ? error.message : String(error)
      +
      +  return /request timed out/i.test(message)
      +}
      +
       // The gateway refuses prompt.submit while a turn is running (4009 "session
       // busy"). It's a transient concurrency guard, never a user-facing error: a
       // submit racing the settle edge (or a rewind interrupting mid-turn) just waits

1 PR closed without merging

From the close comments: done elsewhere 1

  • #41772 fix(tui_gateway): recover sessions from state.db after gateway restart · done elsewhere → 8e73481, #55578, #60874
Closed issues · 1
  • Closed#38704"session not found" when Gateway restarts during active Desktop session
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#60874
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
8 Jul 2026

For your agent

The same facts, typed.