fix(desktop): stop stranding queued prompts across backend bounces
A prompt typed mid-turn ("ghost bubble") could stick forever and never
send when the backend restarted/reconnected during the turn. Two fragile
assumptions in the composer queue drain caused it:
1. Drain fired ONLY on an observed busy true→false edge. A remount/
reconnect resets `previousBusyRef` to the current busy value, so the
settle edge is swallowed and the queue never drains. Replace
`shouldAutoDrainOnSettle` with the edge-independent `shouldAutoDrain`
(idle + non-empty), driven on the settle edge, on mount/reconnect, and
after a re-key. The drain lock still serializes sends.
2. The queue is keyed by `queueSessionKey || sessionId`. When a backend
resume mints a new runtime session id for the same conversation, the
entry strands under the dead key. Pass the *stable* stored id as
`queueSessionKey` so the composer can tell runtime churn from a real
session switch, and `migrateQueuedPrompts` re-keys pending entries on a
runtime-id change only (never on a deliberate switch).
Also make the drain resilient to a thrown/rejected onSubmit (e.g. a stale-
session 404): the entry stays queued and is retried on the next idle, with
a per-entry attempt cap (MAX_AUTO_DRAIN_ATTEMPTS) to avoid spin-loops and a
quiet toast once it gives up. A manual send clears the backoff.
Tests: composer-queue covers edge-free drain + re-key migration;
use-prompt-actions covers rejected-drain-keeps-entry + idle retry sends.
This commit is contained in:
@@ -209,31 +209,58 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
|
||||
writeSession(sid, [])
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
|
||||
export interface AutoDrainSettleInput {
|
||||
wasBusy: boolean
|
||||
/**
|
||||
* Move pending entries from a dead session key onto a live one, preserving FIFO
|
||||
* (existing target entries first, migrated entries appended). A backend bounce /
|
||||
* resume can mint a fresh runtime session id for the *same* conversation; the
|
||||
* entries enqueued under the old id would otherwise be stranded under a key
|
||||
* nothing reads anymore. No-op unless both keys resolve and differ.
|
||||
*/
|
||||
export const migrateQueuedPrompts = (
|
||||
fromKey: string | null | undefined,
|
||||
toKey: string | null | undefined
|
||||
): boolean => {
|
||||
const from = sidOf(fromKey)
|
||||
const to = sidOf(toKey)
|
||||
|
||||
if (!from || !to || from === to) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pending = queueFor(from)
|
||||
|
||||
if (pending.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = { ...$queuedPromptsBySession.get() }
|
||||
delete next[from]
|
||||
next[to] = [...queueFor(to), ...pending]
|
||||
|
||||
$queuedPromptsBySession.set(next)
|
||||
save(next)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrain}. */
|
||||
export interface AutoDrainInput {
|
||||
isBusy: boolean
|
||||
queueLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the composer should auto-drain the next queued prompt when a
|
||||
* turn settles (busy transitions true → false).
|
||||
* Decide whether the composer should auto-drain the next queued prompt.
|
||||
*
|
||||
* Queued turns always advance once the session is idle again, whether the turn
|
||||
* finished naturally or the user interrupted it. Interrupting to reach a queued
|
||||
* message is the whole point of the queue, so we never suppress the drain. The
|
||||
* gateway guarantees a settle (message.complete + session.info running:false)
|
||||
* even after an interrupt, so this single edge reliably advances the queue. To
|
||||
* cancel queued turns the user deletes them from the panel.
|
||||
* Edge-independent on purpose: the queue must advance whenever the session is
|
||||
* idle and has pending entries, NOT only on an observed busy true → false edge.
|
||||
* A backend bounce / websocket reconnect remounts the composer and resets the
|
||||
* busy ref to the current value, swallowing the settle edge — an edge-gated
|
||||
* drain would then strand the entry forever. The caller's drain lock
|
||||
* (`drainingQueueRef`) serializes sends so being edge-free can't double-submit.
|
||||
*/
|
||||
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
||||
const { isBusy, queueLength, wasBusy } = params
|
||||
export const shouldAutoDrain = ({ isBusy, queueLength }: AutoDrainInput): boolean => !isBusy && queueLength > 0
|
||||
|
||||
// Only react to a true → false transition; ignore steady state and entry.
|
||||
if (isBusy || !wasBusy) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueLength > 0
|
||||
}
|
||||
/** Auto-drain attempts for one entry before we stop retrying and toast. The
|
||||
* entry stays queued for a manual send; a remount/reconnect resets the count. */
|
||||
export const MAX_AUTO_DRAIN_ATTEMPTS = 4
|
||||
|
||||
Reference in New Issue
Block a user