Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '../lib/session-source'
import { latestSessionTodos } from '../lib/todos'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import { ensureGatewayForProfileOpen } from '../store/gateway'
import {
$panesFlipped,
$pinnedSessionIds,
Expand All @@ -48,6 +49,7 @@ import {
} from '../store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$currentCwd,
$freshDraftReady,
$gatewayState,
Expand All @@ -60,6 +62,7 @@ import {
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
setBusy,
setCronSessions,
Expand All @@ -79,6 +82,7 @@ import {
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
import type { SessionResumeResponse } from '../types/hermes'

import { ChatView } from './chat'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
Expand Down Expand Up @@ -111,7 +115,7 @@ import { useModelControls } from './session/hooks/use-model-controls'
import { usePreviewRouting } from './session/hooks/use-preview-routing'
import { usePromptActions } from './session/hooks/use-prompt-actions'
import { useRouteResume } from './session/hooks/use-route-resume'
import { useSessionActions } from './session/hooks/use-session-actions'
import { applyRuntimeInfo, patchSessionWorkspace, useSessionActions } from './session/hooks/use-session-actions'
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
import { AppShell } from './shell/app-shell'
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
Expand Down Expand Up @@ -191,6 +195,7 @@ export function DesktopController() {

const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
const recoveringDetachedSessionsRef = useRef(false)
const refreshSessionsRequestRef = useRef(0)

const gatewayState = useStore($gatewayState)
Expand Down Expand Up @@ -751,6 +756,108 @@ export function DesktopController() {
updateSessionState
})

const recoverDetachedSessions = useCallback(async () => {
if (recoveringDetachedSessionsRef.current) {
return
}

const candidates = new Set<string>()
const selectedStored = selectedStoredSessionIdRef.current

if (selectedStored) {
candidates.add(selectedStored)
}

for (const id of $workingSessionIds.get()) {
candidates.add(id)
}

for (const id of $attentionSessionIds.get()) {
candidates.add(id)
}

if (!candidates.size) {
return
}

recoveringDetachedSessionsRef.current = true

try {
const visibleSessions = $sessions.get()

for (const storedSessionId of candidates) {
const stored = visibleSessions.find(
session => session.id === storedSessionId || session._lineage_root_id === storedSessionId
)

const storedProfile = stored?.profile ? normalizeProfileKey(stored.profile) : undefined
const recoveryProfile = storedProfile ?? normalizeProfileKey($activeGatewayProfile.get())

try {
const recoveryGateway = await ensureGatewayForProfileOpen(recoveryProfile)

if (!recoveryGateway) {
throw new Error(`Hermes gateway unavailable for profile ${recoveryProfile}`)
}

const resumed = await recoveryGateway.request<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(storedProfile && storedProfile !== 'default' ? { profile: storedProfile } : {})
})

const previousRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)

const isActive =
storedSessionId === selectedStoredSessionIdRef.current || previousRuntimeId === activeSessionIdRef.current

const previousState = sessionStateByRuntimeIdRef.current.get(previousRuntimeId || resumed.session_id)
const messages = preserveLocalAssistantErrors(toChatMessages(resumed.messages), previousState?.messages ?? [])
const status = resumed.status

const resumedRunning = Boolean(
resumed.running || status === 'working' || status === 'streaming'
)

const runtimeInfo = isActive ? applyRuntimeInfo(resumed.info) : null

if (isActive && runtimeInfo?.cwd) {
patchSessionWorkspace(storedSessionId, runtimeInfo.cwd)
}

updateSessionState(
resumed.session_id,
state => ({
...state,
...(runtimeInfo ?? {}),
awaitingResponse: resumedRunning,
busy: resumedRunning,
messages
}),
storedSessionId
)

if (previousRuntimeId && previousRuntimeId !== resumed.session_id) {
sessionStateByRuntimeIdRef.current.delete(previousRuntimeId)
}

if (isActive) {
setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
setBusy(resumedRunning)
busyRef.current = resumedRunning
setAwaitingResponse(resumedRunning)
}
} catch {
// Best-effort: one stale/orphaned row must not prevent other live
// sessions from reattaching to the fresh WebSocket.
}
}
} finally {
recoveringDetachedSessionsRef.current = false
}
}, [activeSessionIdRef, busyRef, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, updateSessionState])

useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
onConnectionReady: c => {
Expand All @@ -759,6 +866,7 @@ export function DesktopController() {
onGatewayReady: g => {
gatewayRef.current = g
},
onReconnectReady: recoverDetachedSessions,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onReconnectReady only runs after the primary useGatewayBoot socket reconnects. Secondary profile sockets reconnect independently; wire recovery to each profile gateway's open transition (and test a detached secondary-profile turn), otherwise that session remains on the detached transport.

refreshHermesConfig,
refreshSessions
})
Expand Down
28 changes: 26 additions & 2 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ class FakeWebSocket {
}

private emit(type: string, ev: unknown) {
for (const fn of this.listeners[type] ?? []) fn(ev)
for (const fn of this.listeners[type] ?? []) {
fn(ev)
}
}
}

Expand Down Expand Up @@ -102,11 +104,12 @@ function fakeDesktop() {
}
}

function Harness() {
function Harness({ onReconnectReady }: { onReconnectReady?: () => Promise<void> | void }) {
useGatewayBoot({
handleGatewayEvent: () => undefined,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
onReconnectReady,
refreshHermesConfig: async () => undefined,
refreshSessions: async () => undefined
})
Expand Down Expand Up @@ -250,9 +253,11 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()

for (let i = 0; i < 8; i += 1) {
await advanceBackoff()
}

expect($desktopBoot.get().error).toBeTruthy()

// The remote comes back: next reconnect attempt opens.
Expand All @@ -262,4 +267,23 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect($gatewayState.get()).toBe('open')
expect($desktopBoot.get().error).toBeNull()
})

it('FIX: a successful post-boot reconnect notifies the app to reattach live sessions', async () => {
const onReconnectReady = vi.fn(async () => undefined)

render(<Harness onReconnectReady={onReconnectReady} />)
await flushAsync()

expect(onReconnectReady).not.toHaveBeenCalled()

FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()

FakeWebSocket.mode = 'open'
await advanceBackoff()

expect($gatewayState.get()).toBe('open')
expect(onReconnectReady).toHaveBeenCalledTimes(1)
})
})
20 changes: 20 additions & 0 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface GatewayBootOptions {
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
) => void
onGatewayReady: (gateway: HermesGateway | null) => void
onReconnectReady?: () => Promise<void> | void
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
}
Expand All @@ -54,13 +55,15 @@ export function useGatewayBoot({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
onReconnectReady,
refreshHermesConfig,
refreshSessions
}: GatewayBootOptions) {
const callbacksRef = useRef({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
onReconnectReady,
refreshHermesConfig,
refreshSessions
})
Expand All @@ -69,6 +72,7 @@ export function useGatewayBoot({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
onReconnectReady,
refreshHermesConfig,
refreshSessions
}
Expand Down Expand Up @@ -158,6 +162,12 @@ export function useGatewayBoot({
// 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)
// A reconnect creates a fresh WebSocket transport. Any turns that were
// running when the old socket dropped are still alive server-side but
// parked on the detached transport until the renderer explicitly resumes
// them. Let the app rebind those stored-session lineages now; otherwise
// their final events are written to the drop sink and the UI looks stuck.
await Promise.resolve(callbacksRef.current.onReconnectReady?.()).catch(() => undefined)
} catch (err) {
// OAuth session expired mid-reconnect: surface the actionable "sign in
// again" message once instead of silently looping the backoff against a
Expand All @@ -184,6 +194,14 @@ export function useGatewayBoot({
// 1s, 2s, 4s … capped at 15s.
const delay = Math.min(15_000, 1_000 * 2 ** Math.min(reconnectAttempt, 4))
reconnectAttempt += 1

// After a prolonged post-boot outage, stop presenting this as endless
// "connecting" and surface the recovery UI. Keep scheduling retries below:
// a transient network can still come back without forcing a full restart.
if (reconnectAttempt >= 6 && !$desktopBoot.get().error) {
failDesktopBoot('Hermes gateway connection could not be restored.')
}

reconnectTimer = setTimeout(() => {
reconnectTimer = null
void attemptReconnect()
Expand Down Expand Up @@ -359,10 +377,12 @@ export function useGatewayBoot({
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)

if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}

await callbacksRef.current.refreshHermesConfig()

if (cancelled) {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/app/session/hooks/use-session-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ function upsertOptimisticSession(
setSessions(prev => [session, ...prev.filter(s => s.id !== id)])
}

function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
export function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
if (!cwd) {
return
}
Expand Down Expand Up @@ -289,7 +289,7 @@ type SessionRuntimeStatePatch = Partial<
>
>

function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
if (!info) {
return null
}
Expand Down Expand Up @@ -678,6 +678,7 @@ export function useSessionActions({
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})

// The rejection is consumed by the `await` below; this guard only
// keeps it from surfacing as unhandled while the prefetch settles.
resumePromise.catch(() => undefined)
Expand Down
Loading