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 5966ea24679..f9d9e58d09d 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 @@ -364,10 +364,10 @@ describe('usePromptActions submit / queue drain semantics', () => { }) }) - it('a fromQueue drain that hits "session busy" stays quiet (no error bubble) and a retry sends it', async () => { - // The drain can fire on the settle edge before the gateway has fully wound - // the turn down → transient 4009. It must not append a red "session busy" - // bubble; the entry stays queued and the next idle retry succeeds. + it('rides out a transient "session busy" so the user never sees it (retries, no error bubble)', async () => { + // A submit racing the settle edge can hit a transient 4009 before the turn + // has fully wound down. It must be invisible: retried in place until the + // gateway accepts, never a red "session busy" bubble. let attempt = 0 const seeds: Record[] = [] const requestGateway = vi.fn(async (method: string) => { @@ -392,15 +392,12 @@ describe('usePromptActions submit / queue drain semantics', () => { /> ) - const first = await handle!.submitText('queued during a turn', { fromQueue: true }) - expect(first).toBe(false) + expect(await handle!.submitText('sent while settling')).toBe(true) + expect(attempt).toBe(2) // rode past the busy on the second try // No assistant-error message was appended for the transient busy. expect(seeds.some(s => Array.isArray(s.messages) && (s.messages as { error?: string }[]).some(m => m.error))).toBe( false ) - - const second = await handle!.submitText('queued during a turn', { fromQueue: true }) - expect(second).toBe(true) }) it('a normal (non-queue) submit still respects the busyRef guard', async () => { @@ -879,7 +876,7 @@ describe('usePromptActions sleep/wake session recovery', () => { const requestGateway = vi.fn(async (method: string) => { calls.push(method) if (method === 'prompt.submit') { - throw new Error('session busy') + throw new Error('gateway exploded') } return {} as never }) 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 5c7df471a8b..4c1b50b83ad 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -118,10 +118,12 @@ function isSessionNotFoundError(error: unknown): boolean { } // The gateway refuses prompt.submit while a turn is running (4009 "session -// busy"). Edit/restore (revert) can fire mid-turn, so they interrupt first then -// retry the submit until the cooperative interrupt has wound the turn down. -const REWIND_INTERRUPT_TIMEOUT_MS = 6_000 -const REWIND_RETRY_INTERVAL_MS = 150 +// 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 +// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck +// turn still surfaces eventually. +const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000 +const SESSION_BUSY_RETRY_INTERVAL_MS = 150 function isSessionBusyError(error: unknown): boolean { return /session busy/i.test(error instanceof Error ? error.message : String(error)) @@ -129,6 +131,26 @@ function isSessionBusyError(error: unknown): boolean { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) +// Retry a gateway call across transient "session busy" so it never reaches the +// user — the turn settles within the deadline and the call lands. +async function withSessionBusyRetry(call: () => Promise): Promise { + const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS + + for (;;) { + try { + return await call() + } catch (err) { + if (isSessionBusyError(err) && Date.now() < deadline) { + await sleep(SESSION_BUSY_RETRY_INTERVAL_MS) + + continue + } + + throw err + } + } +} + function base64FromDataUrl(dataUrl: string): string { const comma = dataUrl.indexOf(',') @@ -683,7 +705,7 @@ export function usePromptActions({ let submitErr: unknown = null try { - await requestGateway('prompt.submit', { session_id: sessionId, text }) + await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text })) } catch (firstErr) { if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { // Re-register the session in the gateway and get a fresh live ID. @@ -695,7 +717,7 @@ export function usePromptActions({ if (recoveredId) { activeSessionIdRef.current = recoveredId - await requestGateway('prompt.submit', { session_id: recoveredId, text }) + await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text })) } else { submitErr = firstErr } @@ -1460,9 +1482,8 @@ export function usePromptActions({ // text is submitted as a fresh turn. Callers confirm before invoking; errors // are rethrown so the confirmation dialog can surface them inline. // Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore - // can fire while a turn is streaming, interrupt the live turn first, then - // retry the submit until the gateway stops reporting "session busy" — the - // interrupt is cooperative, so the running turn takes a beat to wind down. + // can fire while a turn is streaming, interrupt the live turn first — the + // cooperative interrupt takes a beat, so the shared busy-retry rides it out. const submitRewindPrompt = useCallback( async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => { if (wasRunning) { @@ -1473,27 +1494,13 @@ export function usePromptActions({ } } - const deadline = Date.now() + REWIND_INTERRUPT_TIMEOUT_MS - - for (;;) { - try { - await requestGateway('prompt.submit', { - session_id: sessionId, - text, - ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) - }) - - return - } catch (err) { - if (isSessionBusyError(err) && Date.now() < deadline) { - await sleep(REWIND_RETRY_INTERVAL_MS) - - continue - } - - throw err - } - } + await withSessionBusyRetry(() => + requestGateway('prompt.submit', { + session_id: sessionId, + text, + ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) + }) + ) }, [requestGateway] )