fix(desktop): never surface "session busy" — retry every submit past it

"Session busy" (4009) is the gateway's concurrency guard, not a user-facing
error. The queue already covers the deliberate "type while busy" case, so
the only leak was a submit racing the settle edge. Generalize the rewind
path's busy-retry into a shared `withSessionBusyRetry` and wrap every
`prompt.submit` (fresh send, session-resume resubmit, and rewind) so a
transient busy is ridden out within a bounded deadline and the call lands
silently. The fromQueue swallow stays as a backstop for the pathological
>deadline case.
This commit is contained in:
Brooklyn Nicholson 2026-06-13 00:26:34 -05:00
parent f23a4b7bb3
commit 18916376f1
2 changed files with 44 additions and 40 deletions

View file

@ -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<string, unknown>[] = []
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
})

View file

@ -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<void>(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<T>(call: () => Promise<T>): Promise<T> {
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]
)