Skip to content

fix(chat): stop stranding queued sends rejected before commit - #212

Merged
caregullin merged 2 commits into
mainfrom
chat-queue-precommit-retry
Aug 26, 2026
Merged

fix(chat): stop stranding queued sends rejected before commit#212
caregullin merged 2 commits into
mainfrom
chat-queue-precommit-retry

Conversation

@caregullin

@caregullin caregullin commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

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:

if (!isQueuedSessionReady(runtime, ...) || getQueuedMessageKey(retryHead) !== key || ...) {
  return;   // burns the single retry and gives up
}

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.md requires 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 unsettled creationState (pending or failed). The write becomes an explicit null, 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.

sendQueuedMessageWithAutoCompact returns false whenever compaction fails (useChatSessionController.ts:2062), and each failure appends an error notification and calls setChatState(sessionId, "idle") (useChat.ts:424-430). Since isQueuedSessionReady requires chatState === "idle", that failure reads as a readiness edge — which both reset the backoff and fell through to tryDrainQueuedMessage. 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 showInComposer forced 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 thrown PreCommitSendRejectedError and from a plain false return, and the catch at 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 check passes clean. src/features/chat passes 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

Generated with Claude Code

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>
@caregullin
caregullin marked this pull request as ready for review August 25, 2026 18:32
@caregullin
caregullin requested a review from a team August 25, 2026 18:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/features/chat/hooks/useMessageQueue.ts
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 morgmart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

@caregullin
caregullin merged commit 0821223 into main Aug 26, 2026
8 checks passed
@caregullin
caregullin deleted the chat-queue-precommit-retry branch August 26, 2026 17:40
caregullin added a commit that referenced this pull request Aug 26, 2026
## 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>
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.

2 participants