From f38f7a387013a3191b0eab37ac47f96ee07ee7b3 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:45:08 +0530 Subject: [PATCH] fix(desktop): recover stale session before stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop already recovers from a stale runtime session id when `prompt.submit` returns `session not found` after a gateway restart or sleep/wake. The stop path did not have the same recovery: `cancelRun` called `session.interrupt` once with the stale runtime id, then surfaced `Stop failed / session not found`. This makes stop/cancel mirror the prompt recovery path. If `session.interrupt` reports `session not found` and the selected stored session id is available, Desktop resumes that durable session, updates the active runtime ref with the recovered id, and retries `session.interrupt` once against the recovered runtime id. Salvaged from #43941 — rebased onto current main, dropping the unrelated `package-lock.json` (@types/node 24.13.1->24.13.2) and `nix/lib.nix` hash churn. That bump is a local npm 11 re-resolution artifact, not a CI requirement: repo CI runs node 22 (npm 10) and main is green at @types/node 24.13.1, so the lockfile and nix hash do not need to change. Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com> --- .../session/hooks/use-prompt-actions.test.tsx | 47 +++++++++++++++++-- .../app/session/hooks/use-prompt-actions.ts | 42 +++++++++++++++-- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 3418e0bad80..e7dfe9d7da5 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -42,6 +42,7 @@ function sessionInfo(overrides: Partial = {}): SessionInfo { } interface HarnessHandle { + cancelRun: () => Promise steerPrompt: (text: string) => Promise submitText: ( text: string, @@ -102,8 +103,12 @@ function Harness({ }) useEffect(() => { - onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText }) - }, [actions.steerPrompt, actions.submitText, onReady]) + onReady({ + cancelRun: actions.cancelRun, + steerPrompt: actions.steerPrompt, + submitText: actions.submitText + }) + }, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady]) return null } @@ -629,6 +634,43 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) + it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => { + const calls: { method: string; params?: Record }[] = [] + let interruptAttempts = 0 + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + if (method === 'session.interrupt') { + interruptAttempts += 1 + if (interruptAttempts === 1) { + throw new Error('session not found') + } + return {} as never + } + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + await handle!.cancelRun() + + expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt']) + expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) + }) + it('surfaces the original error (no resume) when the failure is not "session not found"', async () => { const calls: string[] = [] const states: Record[] = [] @@ -818,4 +860,3 @@ describe('uploadComposerAttachment remote read failures', () => { ).rejects.toThrow('ENOENT: no such file') }) }) - diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 15831bb4189..b09d86ffd10 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -108,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string { return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() } +function isSessionNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /session not found/i.test(message) +} + function base64FromDataUrl(dataUrl: string): string { const comma = dataUrl.indexOf(',') @@ -661,9 +667,7 @@ export function usePromptActions({ try { await requestGateway('prompt.submit', { session_id: sessionId, text }) } catch (firstErr) { - const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr) - - if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) { + if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { // Re-register the session in the gateway and get a fresh live ID. const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current @@ -1273,11 +1277,39 @@ export function usePromptActions({ try { await requestGateway('session.interrupt', { session_id: sessionId }) } catch (err) { + let stopError = err + + if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { + try { + const resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: selectedStoredSessionIdRef.current + }) + const recoveredId = resumed?.session_id + + if (recoveredId) { + activeSessionIdRef.current = recoveredId + await requestGateway('session.interrupt', { session_id: recoveredId }) + + return + } + } catch (resumeErr) { + stopError = resumeErr + } + } + setMutableRef(busyRef, false) setBusy(false) - notifyError(err, copy.stopFailed) + notifyError(stopError, copy.stopFailed) } - }, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState]) + }, [ + activeSessionId, + activeSessionIdRef, + busyRef, + copy.stopFailed, + requestGateway, + selectedStoredSessionIdRef, + updateSessionState + ]) // Steer = nudge the live turn without interrupting: the gateway appends the // text to the next tool result so the model reads it on its next iteration