From f82c9c2f1a14b8cde45affb8f25ca75400201f9e Mon Sep 17 00:00:00 2001 From: Luke Marsden Date: Fri, 19 Jun 2026 14:24:45 +0100 Subject: [PATCH 1/2] fix(frontend): interrupt-promotion on deferred prompts + queue UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes to the spec-task prompt queue (RobustPromptInput), all surfaced while validating the boot-race interrupt work: 1. Empty-Enter interrupt promotion ("Enter twice") scanned only pendingPrompts, so a queue message the backend had deferred to 'failed' (which a long current turn does almost immediately) was not a promotion candidate — the promotion silently no-op'd and the message stayed queue-mode. It now scans the deferred (failed) prompts too. 2. Refactor: the [...failedPrompts, ...pendingPrompts] combination was recomputed ad-hoc in five places and one (the promotion) had diverged — the root cause of #1. Extracted a single canonical `queuedPrompts`; every site routes through it, so the divergence is structurally impossible. 3. Optimistic-hide: the visible queue now excludes 'sending' (backend dispatched to Zed, awaiting first message_added). A just-sent prompt disappears the moment dispatch is confirmed instead of lingering until the next sync flips it to 'sent'; if it bounces it returns via 'failed'. See design/2026-06-19-incident-interrupt-during-boot-context-loss.md. Co-Authored-By: Claude Opus 4.8 --- .../components/common/RobustPromptInput.tsx | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/common/RobustPromptInput.tsx b/frontend/src/components/common/RobustPromptInput.tsx index 1f05d0c548..9474cfaa90 100644 --- a/frontend/src/components/common/RobustPromptInput.tsx +++ b/frontend/src/components/common/RobustPromptInput.tsx @@ -596,6 +596,22 @@ const RobustPromptInput: FC = ({ pinPrompt, } = usePromptHistory({ sessionId, specTaskId, projectId, apiClient }) + // Canonical "still actionable in the queue" list, failed-first. Computed in ONE + // place so every consumer — queue display, the interrupt toggle, the empty-Enter + // interrupt-promotion, the client-side pump — operates on the SAME set. + // Previously each site recomputed [...failedPrompts, ...pendingPrompts] + // independently, and the promotion path diverged to pendingPrompts-only — so it + // silently skipped a prompt the instant the backend deferred it to 'failed' + // (which a long current turn does almost immediately). + // + // We exclude 'sending' (backend has dispatched to Zed, awaiting first + // message_added): once a message is in flight it can't be promoted/toggled, and + // showing it in the queue until the *next* sync flips it to 'sent' is the lag + // that makes a just-sent prompt linger. Dropping 'sending' hides it optimistically + // the moment dispatch is confirmed; if it later bounces it returns via 'failed'. + // See design/2026-06-19-incident-interrupt-during-boot-context-loss.md. + const queuedPrompts = [...failedPrompts, ...pendingPrompts].filter(p => p.status !== 'sending') + // Track previous appendText to detect changes const prevAppendTextRef = useRef(undefined) @@ -621,7 +637,7 @@ const RobustPromptInput: FC = ({ if (processingRef.current || !isOnline || disabled) return // Interrupt-mode messages first, then queue-mode, each oldest-first. - const sortedQueue = [...failedPrompts, ...pendingPrompts].sort((a, b) => { + const sortedQueue = [...queuedPrompts].sort((a, b) => { const aInterrupt = a.interrupt !== false const bInterrupt = b.interrupt !== false if (aInterrupt && !bInterrupt) return -1 @@ -659,7 +675,7 @@ const RobustPromptInput: FC = ({ setSendingId(null) processingRef.current = false } - }, [backendQueueEnabled, isOnline, disabled, failedPrompts, pendingPrompts, sendingId, editingId, onSend, markAsSent, markAsFailed]) + }, [backendQueueEnabled, isOnline, disabled, queuedPrompts, sendingId, editingId, onSend, markAsSent, markAsFailed]) // Pump the queue when messages are pending and we're online. useEffect(() => { @@ -750,7 +766,7 @@ const RobustPromptInput: FC = ({ }, [draft, adjustHeight]) // Notify parent when queue changes (affects overall height) - const queueLength = pendingPrompts.length + failedPrompts.length + const queueLength = queuedPrompts.length useEffect(() => { if (onHeightChange) { // Small delay to allow Collapse animation to start @@ -820,11 +836,11 @@ const RobustPromptInput: FC = ({ // Toggle interrupt mode for a queued message const handleToggleInterrupt = useCallback((entryId: string) => { - const entry = [...failedPrompts, ...pendingPrompts].find(e => e.id === entryId) + const entry = queuedPrompts.find(e => e.id === entryId) if (entry) { updateInterrupt(entryId, entry.interrupt === false) } - }, [failedPrompts, pendingPrompts, updateInterrupt]) + }, [queuedPrompts, updateInterrupt]) // Restart Zed thread after a Claude Agent crash. Calls the backend endpoint // which clears the dead acp_thread_id and resets crashed prompts back to @@ -938,7 +954,10 @@ const RobustPromptInput: FC = ({ // Empty field: promote most-recent queued entry to interrupt instead of sending nothing. if (!content && attachments.length === 0) { if (disabled) return - const candidates = pendingPrompts.filter(p => + // Promote the most-recent NON-interrupt queued message to interrupt. + // Scans queuedPrompts (failed + pending) so a deferred message — the one + // the user is actually trying to escalate — is still a candidate. + const candidates = queuedPrompts.filter(p => p.interrupt === false && !p.deleted && p.id !== sendingId && @@ -998,7 +1017,7 @@ const RobustPromptInput: FC = ({ e.preventDefault() } } - }, [draft, disabled, attachments, saveToHistory, clearDraft, navigateUp, navigateDown, pendingPrompts, updateInterrupt, sendingId, editingId]) + }, [draft, disabled, attachments, saveToHistory, clearDraft, navigateUp, navigateDown, queuedPrompts, updateInterrupt, sendingId, editingId]) // Add a file as an attachment (queues for upload, uploads if online) const addFileAsAttachment = useCallback((file: File): string => { @@ -1219,7 +1238,7 @@ const RobustPromptInput: FC = ({ } // All queued messages (pending + failed), sorted: interrupt mode first, then queue mode - const queuedMessages = [...failedPrompts, ...pendingPrompts].sort((a, b) => { + const queuedMessages = [...queuedPrompts].sort((a, b) => { // Interrupt mode (true or undefined) comes first const aInterrupt = a.interrupt !== false const bInterrupt = b.interrupt !== false From 969fe32016e2701c7d5dac715f1831982b850a7c Mon Sep 17 00:00:00 2001 From: Luke Marsden Date: Fri, 19 Jun 2026 14:38:24 +0100 Subject: [PATCH 2/2] fix(frontend): don't drop un-pushed interrupt change on backend poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interrupt promotion showed the lightning icon in the UI but the backend kept interrupt=false, so the message queued instead of interrupting. Root cause: mergeWithBackend (and the status-poll path) re-marked any backend-present entry syncedToBackend=true based purely on id presence. When a backend poll landed between updateInterrupt (which sets the entry dirty, syncedToBackend=false) and the debounced syncToBackend push, it clobbered the dirty flag — so syncToBackend (which only pushes !syncedToBackend) skipped the entry and the interrupt=true change never reached the backend. Fix: a pull/merge may confirm-sync only entries with no pending local change. mergeWithBackend keeps syncedToBackend=false when it's already false; the status-poll path preserves the dirty flag while still reflecting backend status. Only push-ack paths clear the dirty flag. Follow-up (noted, not in this PR): the "only push clears dirty; pulls never do" invariant is still applied ad-hoc across the sync sites — worth centralizing behind one reconcile helper with hook tests. See design/2026-06-19-incident-interrupt-during-boot-context-loss.md. Co-Authored-By: Claude Opus 4.8 --- frontend/src/hooks/usePromptHistory.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/usePromptHistory.ts b/frontend/src/hooks/usePromptHistory.ts index bc9dc0653a..5a32c7b0d0 100644 --- a/frontend/src/hooks/usePromptHistory.ts +++ b/frontend/src/hooks/usePromptHistory.ts @@ -209,9 +209,19 @@ export function usePromptHistory({ // IDs that are locally tombstoned — never re-import these from backend const deletedIds = new Set(prev.filter(e => e.deleted).map(e => e.id)) - // Mark existing entries that are in backend as synced (skip deleted ones) + // Mark existing entries that are in backend as synced (skip deleted ones). + // CRITICAL: never re-confirm an entry that has an un-pushed local change + // (syncedToBackend === false, set by updateInterrupt/updateContent). A pull + // confirms the backend has *some* version, not the local one — flipping a + // dirty entry back to "synced" makes the next syncToBackend skip it, so the + // local change is silently dropped. This is exactly how promoting a queued + // prompt to interrupt showed the lightning in the UI while the backend kept + // interrupt=false: a backend poll landed between the promote and the push. + // See design/2026-06-19-incident-interrupt-during-boot-context-loss.md. const updatedPrev = prev.map(e => - backendIds.has(e.id) && !e.deleted ? { ...e, syncedToBackend: true } : e + backendIds.has(e.id) && !e.deleted && e.syncedToBackend !== false + ? { ...e, syncedToBackend: true } + : e ) // Add any backend entries that don't exist locally (mark as synced) @@ -443,7 +453,10 @@ export function usePromptHistory({ retryCount: backendEntry.retryCount, nextRetryAt: backendEntry.nextRetryAt, errorMessage: backendEntry.errorMessage, - syncedToBackend: true + // Reflect backend-owned status, but PRESERVE a pending local + // change (e.g. an interrupt promotion not yet pushed) — don't + // clobber the dirty flag, or syncToBackend will skip the push. + syncedToBackend: h.syncedToBackend === false ? false : true, } } // Reconcile against the source of truth: a queue entry we previously