diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3915cc4..fcb30fc 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1899,6 +1899,8 @@ export default function App({ // Epoch counter to force dequeue effect re-run when refs change but state doesn't // Incremented when userCancelledRef is reset while messages are queued const [dequeueEpoch, setDequeueEpoch] = useState(0); + // Strict lock to ensure dequeue submit path is at-most-once while onSubmit is in flight. + const dequeueInFlightRef = useRef(false); // Track last dequeued message for restoration on error // If an error occurs after dequeue, we restore this to the input field (if input is empty) @@ -10695,7 +10697,8 @@ ${SYSTEM_REMINDER_CLOSE} !anySelectorOpen && // Don't dequeue while a selector/overlay is open !waitingForQueueCancelRef.current && // Don't dequeue while waiting for cancel !userCancelledRef.current && // Don't dequeue if user just cancelled - !abortControllerRef.current // Don't dequeue while processConversation is still active + !abortControllerRef.current && // Don't dequeue while processConversation is still active + !dequeueInFlightRef.current // Don't dequeue while previous dequeue submit is still in flight ) { // consumeItems(n) fires onDequeued → setQueueDisplay(prev => prev.slice(n)). const batch = tuiQueueRef.current?.consumeItems(queueLen); @@ -10725,7 +10728,16 @@ ${SYSTEM_REMINDER_CLOSE} // Submit via normal flow — overrideContentPartsRef carries rich content parts. overrideContentPartsRef.current = queuedContentParts; - onSubmitRef.current(concatenatedMessage); + // Lock prevents re-entrant dequeue if deps churn before processConversation + // sets abortControllerRef (which is the normal long-term gate). + dequeueInFlightRef.current = true; + void onSubmitRef.current(concatenatedMessage).finally(() => { + dequeueInFlightRef.current = false; + // If more items arrived while in-flight, bump epoch so the effect re-runs. + if ((tuiQueueRef.current?.length ?? 0) > 0) { + setDequeueEpoch((e) => e + 1); + } + }); } else if (hasAnythingQueued) { // Log why dequeue was blocked (useful for debugging stuck queues) debugLog( diff --git a/src/tests/cli/queue-ordering-wiring.test.ts b/src/tests/cli/queue-ordering-wiring.test.ts index 57d5559..0a0fbe2 100644 --- a/src/tests/cli/queue-ordering-wiring.test.ts +++ b/src/tests/cli/queue-ordering-wiring.test.ts @@ -33,7 +33,8 @@ describe("queue ordering wiring", () => { // Queue is now drained via QueueRuntime.consumeItems; setQueueDisplay is // updated automatically via the onDequeued callback — no direct setState here. expect(segment).toContain("tuiQueueRef.current?.consumeItems(queueLen)"); - expect(segment).toContain("onSubmitRef.current(concatenatedMessage);"); + expect(segment).toContain("onSubmitRef.current(concatenatedMessage)"); + expect(segment).toContain("!dequeueInFlightRef.current"); expect(segment).toContain("queuedOverlayAction,"); });