fix(chat): stop stranding queued sends rejected before commit - #212
Conversation
A queued send that is rejected pre-commit was retried exactly once, and that retry bailed out if the session was not ready when the timer fired. During draft promotion the session is not ready yet, so the single retry burned on the readiness check. Pre-commit rejection is silent, leaving no store transition to re-trigger the drain, so the message sat in the queue forever with no visible failure. Retries now back off exponentially up to 30s and re-arm when the session is still not ready instead of abandoning the record. Any later readiness transition resets the backoff. LAWS/CHAT.md requires the queue to resume dispatching once the session becomes ready. Separately, queues are keyed by session id, and a session that is still creating only has a client-local draft id. Persisting its queue restores, on next launch, a record bound to an id no backend session will ever have, which can never drain. Quitting mid-creation now drops the message rather than stranding it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 76151d4b50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Retrying until the session is ready is correct for a readiness or ownership race, but the previous commit applied it to every pre-commit rejection, including permanent ones. Auto-compaction returns false whenever compaction fails, and each failure appends an error notification and sets the session back to idle. That idle edge reads as a readiness transition, which both reset the backoff and re-triggered the drain, so a persistently failing send looped as fast as compaction could fail and appended an error notification every pass. A test reproducing twelve such cycles dispatched 73 times before this change. Rejections are now bounded to five per payload, counted at the single drain choke point so every trigger is covered rather than just the retry timer. The counter deliberately survives readiness edges, since the transition a failed send causes must not grant a fresh budget; a user edit or a new head still resets it. The record stays queued and visible, so the send is retryable by hand. Waiting for readiness stays unbounded, because abandoning it is the original stranding bug, but it now backs off to the 30s cap instead of re-arming at a flat one second forever: an unready session woke 960 times in 16 minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
morgmart
left a comment
There was a problem hiding this comment.
🤖 Automated code review
APPROVE. Fresh static review completed against the exact three-dot comparison d67756c..57e06f6, with no publishable findings. The final self-check covered the queued-send retry and draft-session persistence flows; accessibility and localization impact; navigation and consent guards; async failure, never-ready, lifecycle, remount, timer, and race behavior; test honesty; architectural laws and project rules; duplicate overlap; and the evidence required for blocking severity. The supplied GitHub evidence was inspected: all eight reported checks for the exact head SHA completed successfully. Required checks still independently govern merge readiness.
Deterministic publication result: 0 blocking and 0 non-blocking finding(s) publishable; 1 duplicate(s) suppressed.
Pending checks: 1 check(s) are not complete.
This approval reflects the completed code review only; merge readiness remains governed by the repository's required checks.
## Why Home has no way to keep a prompt you run over and over. You either retype it or go digging through session history, which is friction on exactly the prompts you use most. This adds a prompt you can pin to the home canvas and run with one click. https://github.com/user-attachments/assets/240b82aa-0e0f-4ec9-a464-57917fb8bb05 ## What - New `promptPin` widget with two modes: an editor for writing the prompt, and a compact ready card that acts as a one-row launcher (title + play + pencil). Play sends immediately through global compose to a concrete target rather than dropping you into an empty composer. - New `prompt` layout kind plumbed through the home layout mapper and the layout API, so pins persist across restarts. - The editor supports `@`-mention agent picking, following the existing `MentionAutocomplete` dismissal contract so Escape closes the menu and lets you keep typing. - A corner X in the editor removes the pin. The collapsed card deliberately does *not* get an X: it is a one-row launcher and a second control crowds it, so removal lives one click behind the pencil. - Gated behind the `prompt-pins` experiment, off by default. - `en` + `es` strings, plus tests for the widget, catalog, picker, layout mapper, and prompt runner. ## Risk Assessment Low. The widget is behind the `prompt-pins` experiment and off by default, so nobody who does not opt in sees a UI change. The one change that reaches shared code regardless of the flag is the `MentionAutocomplete` dismissal refactor, which the main composer also uses. That is the part worth close review. The two queue fixes this work surfaced have been split into #212, which this PR now stacks on. The pin depends on the retry fix: without it, a play whose session is still being created is rejected pre-commit and never retried, so the prompt silently never sends. Validation: `just check` passes clean, and the touched areas (`home`, `chat`, `experiments`) pass 3096 tests across 217 files. Full Vitest suite comes back `1 failed | 7084 passed`; the one failure is `src/shared/telemetry/client.test.ts` › "still fires when the window session store is unavailable", which fails identically on pristine `main` and is pre-existing. Separately, `AgentShareDialog.test.tsx` is a load-dependent flake — it failed on two full-suite runs with a different failure count each time, then passed on a third and on main's full suite. This branch touches neither file. Manual verification: - [x] compact card rendering - [x] `@`-mention attach including Escape-then-keep-typing - [x] play auto-executing - [x] persistence across restart - [x] quit-during-creation drop ## References - Experiment flag: `prompt-pins` in `src/features/experiments/experimentDefinitions.ts` - Behavior rules this had to satisfy live in `LAWS/` - Stacked on #212 (queued-send retry + mid-creation persistence fixes) Generated with [Claude Code](https://claude.ai/code) --- ### Update 2026-08-25 Split the two shared queue fixes out into #212 and rebased this branch on top of it. Those changes were not experiment-gated, so they shipped to everyone while the pin itself stayed behind a default-off flag — the flag gave no blast-radius protection on the riskiest code in the diff. This PR is now 27 files of experiment-gated UI; #212 is the 4 shared chat files. The combined tree is byte-identical to the previous single commit (`70a1ad92`), so nothing was dropped or changed in the split. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Why
Two latent bugs in the window where a chat session is still being created. Both are silent: the user sees a message accepted into the queue and then nothing, with no error and no visible failure state.
A pre-commit rejection stranded the message forever. A queued send rejected before commit was retried exactly once, and that retry bailed out if the session was not ready when the timer fired:
During draft promotion the session is not ready yet, so the one retry is spent on the readiness check. Pre-commit rejection is silent and leaves no store transition behind, so nothing ever re-triggers the drain.
LAWS/CHAT.mdrequires that "when a chat's session becomes ready, that chat's queue MUST resume dispatching its first message to that session" — this violated it.Quitting mid-creation replayed into an unreachable id. Queues are keyed by session id, and a session that is still creating only has a client-local draft id. Persisting its queue means the next launch restores a record bound to an id no backend session will ever have, so it can never drain.
What
useMessageQueue: replace the single-retry latch with exponential backoff capped at 30s. A retry that fires while the session is still not ready re-arms instead of abandoning the record, and backs off as it does so. Any later readiness transition resets the backoff to the initial 1s.useMessageQueue: bound rejections to 5 per payload while leaving the readiness wait unbounded. See the note below — this is the important half of the change.queuePersistence: skip queue writes for sessions with an unsettledcreationState(pending or failed). The write becomes an explicitnull, so quitting mid-creation drops the message rather than stranding an undrainable record.Why retries are bounded but readiness waiting is not
Retrying until the session is ready is right for a readiness or ownership race. Applying it to every pre-commit rejection is not, because some rejections are permanent.
sendQueuedMessageWithAutoCompactreturnsfalsewhenever compaction fails (useChatSessionController.ts:2062), and each failure appends an error notification and callssetChatState(sessionId, "idle")(useChat.ts:424-430). SinceisQueuedSessionReadyrequireschatState === "idle", that failure reads as a readiness edge — which both reset the backoff and fell through totryDrainQueuedMessage. So a persistently failing send did not retry every second; it looped as fast as compaction could fail, appending an error notification to the transcript every pass. A test driving twelve such cycles dispatched 73 times.The ceiling is counted at the single drain choke point rather than in the retry timer, so every trigger is covered (timer, readiness edge, lease release, store subscription). The counter deliberately survives readiness edges — the transition a failed send causes must not hand it a fresh budget — while a user edit or a new head does reset it. The record stays queued with
showInComposerforced true, so it remains visible and manually retryable.Waiting for readiness stays unbounded, since giving up there is the original stranding bug. But it now backs off to the 30s cap instead of re-arming at a flat 1s: measured, an unready session woke 960 times in 16 minutes before this change.
Risk Assessment
Moderate — this is shared chat code on the send path, not behind any experiment flag, so it affects every user.
The behavior change is bounded to sessions that are not ready, not yet created, or persistently rejecting. A send into a ready session that succeeds is unaffected: the retry and rejection state both clear on success exactly as before.
The remaining judgement call is the ceiling of 5. Too low and a genuine multi-attempt ownership race gives up early; too high and a persistent failure appends more error notifications before stopping. Five spans ~31s of backoff, and the failure mode when it is wrong is a visible retryable record rather than a lost message. Reviewers should push back if a real race is known to need more.
Not addressed here: the rejection reason is not actually classified.
finalize(false)is reached both from a thrownPreCommitSendRejectedErrorand from a plainfalsereturn, and thecatchat the call site discards the error. Distinguishing ownership races from terminal transport/preparation failures by type would be a better fix than a count, and is a reasonable follow-up.Validation:
just checkpasses clean.src/features/chatpasses 2505 tests across 169 files. Both new tests were confirmed to fail without the fix — 73 dispatches instead of 5, and[1000, 1000, 1000, 1000, 1000]instead of a growing backoff — so neither is vacuous.References
LAWS/CHAT.md— queue dispatch and readiness rulesGenerated with Claude Code