Skip to content

fix(#241): clear stale Codex queued prompts via idle-convergence reconciliation - #304

Merged
Juliusolsson05 merged 1 commit into
mainfrom
fix/241-codex-queue-idle-reconcile
Jun 21, 2026
Merged

Juliusolsson05 merged 1 commit into
mainfrom
fix/241-codex-queue-idle-reconcile

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Root cause

Codex queued follow-up prompts in runtime.queuedMessages are local UI placeholders. They normally clear when the matching committed rollout user row is ingested (the codexPromptsMatchForOwnership ownership filter). When the rollout tail stalls or drops that row, the queued item sticks forever — and the QueueStrip stays visible even after the session is fully idle (#241).

The existing shouldClearIdleCodexQueuedMessages invariant (queueInvariants.ts) already encodes the correct all-idle condition, but it was only ever evaluated at three edge sites: onSessionProcessState, onSessionSemanticEvent, and bootstrap_complete. Idle for Codex is assembled from multiple channels (process-state, semantic phase transitions, JSONL tail). When the last idle signal arrives through a channel that doesn't re-run the invariant, nothing reconciles the queue and it sticks.

Fix: edge-triggered → convergence-triggered

The three edge-site clears remain as the fast path. This PR adds a backstop in the existing ~1s GHOST_ORPHAN_SWEEP_MS sweep timer (useIpcSubscriptions.ts): after the ghost-GC pass, it re-evaluates the same queue invariant per session and clears a stale Codex queue when idle has converged. The two passes compose into a single setRuntimes patch per tick (no early continue between them).

Quiet-window safety gate

A timer backstop risks clearing during the sub-second gap between a turn_completed (everything momentarily looks idle) and the next queued follow-up's turn_started. To prevent that, the sweep only clears once idle has been STABLE past a new QUEUE_IDLE_RECONCILE_MS (2000ms) quiet window, measured from the newest of phaseChangedAt / lastJsonlEntryAt. This makes the clear convergence-triggered: wait for signals to quiesce, then reconcile.

Provider gate protecting Claude

shouldClearIdleCodexQueuedMessages gains an optional providerReportsPendingQueue?: boolean (default false, documented hook) that is AND-ed into the result. Codex emits no such signal today, so every caller passes the default. The existing provider === 'codex' clause keeps the whole mechanism Codex-scoped — Claude's provider-owned queue-operation queue is never touched by this lifecycle reconciler.

Invariant (must hold)

A Codex queued item is cleared ONLY when, after idle has been stable past the quiet window, ALL of: provider === 'codex', queuedMessages.length > 0, processActive === false, streamPhase === 'idle', awaitingAssistant === false, and no provider-reported pending queue. NEVER for Claude, never while any live signal is present, never before the quiet window elapses.

Verification

  • npx tsc -p tsconfig.web.json --noEmit — zero new errors in touched files.
  • Manual repro: queue a Codex follow-up while output is live, then induce a stalled/dropped rollout tail so the committed user row never reaches the renderer. Previously the QueueStrip stayed stuck after the session went idle; with this change the sweep clears it ~2s after the last activity signal quiesces (queue_idle_reconcile feed-debug entry confirms the clear).

🤖 Generated with Claude Code

…nciliation

Codex queued follow-up prompts (runtime.queuedMessages) are local UI
placeholders that normally clear when the matching committed rollout user
row is ingested. If the rollout tail stalls or drops that row, the queued
item sticks forever even after the session goes fully idle.

The existing shouldClearIdleCodexQueuedMessages invariant already covers
the all-idle condition, but it was only ever evaluated at three edge sites
(onSessionProcessState, onSessionSemanticEvent, bootstrap_complete). Idle
for Codex is assembled from several channels, and when the LAST idle signal
arrives via a channel that doesn't re-run the invariant, the queue is never
reconciled and the QueueStrip stays stuck.

This converts the fix from edge-triggered to convergence-triggered: the
existing ~1s ghost-orphan sweep timer now also re-evaluates the queue
invariant per session as a backstop. To avoid clearing during the
sub-second gap between a turn_completed and the next queued follow-up's
turn_started, the sweep only acts once idle has been STABLE past a new
QUEUE_IDLE_RECONCILE_MS quiet window (newest of phaseChangedAt /
lastJsonlEntryAt). The three edge-site clears remain the fast path.

Also adds an optional providerReportsPendingQueue gate to the invariant
(default false; documented hook) so a future Codex server-side queue
signal can veto clearing, and so Claude's provider-owned queue is never
touched by this Codex-scoped reconciler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 1350c59 into main Jun 21, 2026
@Juliusolsson05
Juliusolsson05 deleted the fix/241-codex-queue-idle-reconcile branch June 21, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant