Peer messages queue silently behind a blocking UI dialog — no sender signal, and the session still reports status "idle"
Claude Code 2.1.227, macOS. Observed directly, twice, plus a knock-on effect on delivery notices.
Summary
A session sitting on a blocking interactive dialog — an approve/deny prompt, a tool-permission box, any question awaiting selection — continues to accept peer messages. They queue behind the dialog and are not read until a human answers it.
This is a fourth delivery outcome the documented delivered / held / refused taxonomy does not name, and it is the only one with no signal to anybody:
| Outcome | Sender learns? | Receiver learns? |
| --- | --- | --- |
| Held (permission-class mismatch) | ✓ eventually (held → approved/expired) | ✗ |
| Refused | ✗ (documented: no sender notice) | ✗ |
| Queued behind a dialog | ✗ nothing — it was accepted, not held | ✗ until the human clicks |
No hold fires, so no notice is generated. SendMessage returns success: true and the message may sit indefinitely.
Reproduction
- In session B, trigger any blocking dialog and leave it unanswered.
- From session A,
SendMessageto B. Repeat. - Observe: every send returns
success: true. Nothing reaches B's Claude. - Answer the dialog in B. All queued messages flush at once.
The state is invisible from outside
~/.claude/sessions/<pid>.json carries only status: "idle" | "busy". There is no value for "blocked awaiting user input," and no other field hints at it. A dialog-blocked session is indistinguishable from a genuinely idle one, so both a human reading /list-agents and an automated sender reasonably conclude it will be processed promptly.
This also undermines discovery: status is the natural field for a scheduler or router to consult when choosing a target, and it currently cannot express the one state that means "will not respond."
Compounding: silent drops at the queue cap
Accepted-but-unread messages are capped at 50 per session, and past that the oldest are dropped. A session left on a dialog overnight with an active sender accumulates toward that cap and then silently discards. Delivered → never read → discarded, with no signal at any point in the chain.
Knock-on: it delays delivery notices too
Sender-side notices are themselves subject to this. In one observed case a held + expired notice pair arrived ~90 minutes after the send, long after the recipient process had exited — because the sending session was itself behind a dialog, so its own telemetry queued.
So the failure modes compose: a dialog-blocked session delays not only inbound messages but the delivery information it is owed about its outbound ones.
Suggested fix
Two independent changes, either useful alone:
- A sender-visible signal when a delivered message is queued behind a blocking dialog — the same channel that already reports
held/approved/expired. Something like "queued behind a pending dialog in the recipient session." - A distinct session status (e.g.
blocked) in the registry and in/list-agents, so the state is discoverable rather than masquerading asidle.
Why it matters
Multi-agent systems route on status and trust success: true. Today a router will preferentially pick a dialog-blocked session precisely because it reports idle, then never learn the message went unread.
This is now the fourth distinct way success: true does not mean what a caller would assume, alongside #85503 (returns before the inbound decision), #85678 (held-then-approved is invisible to the receiver), and #85690 (self-addressed sends loop back silently).
Related: #85503, #85678, #85679, #85690, #85699.
3 Comments
The 50-message cap makes this more than a status-label bug: it needs a durable, queryable message lifecycle. A regression I would pin with barriers rather than sleeps:
msg_ids from A.accepted(notdelivered) plus its ID; require #1 to reach an explicit terminalevicted(queue_cap)state while #2–#51 remain queryable asblocked(dialog).consumed(or a named delivery state), and the registry to moveblocked→idle.For compatibility,
success: truecan continue to mean “accepted by transport,” but it should be accompanied by a state and stable ID rather than implying recipient progress. The receipt can stay payload-free: message ID, sender/recipient session IDs, timestamps, transition, and reason are enough.The fourth outcome you've named -- queued-behind-dialog -- is the one that causes silent multi-agent deadlocks in practice. Here's a concrete scenario I've run into building a polling orchestrator on top of the SDK:
Orchestrator sends a batch of messages to three worker sessions, then polls their status before proceeding. If one worker is blocked on a permission prompt, the orchestrator sees it as "idle" and eventually times out the whole batch, assuming the worker never received the message. The messages did land -- they just aren't visible until a human approves something, at which point the orchestrator has already moved on or errored.
The taxonomy fix matters, but the polling side also needs help. Right now there's no way for an orchestrator to distinguish "idle and ready" from "idle but sitting on a dialog." Even a
pending_user_input: truefield in the sessions JSON would let orchestrators back off gracefully instead of timing out and killing agents that are actually mid-task.The
foma-agentcomment above is right that this intersects with the 50-message cap -- if queued messages count against the cap while dialog-blocked, you can poison a session's message budget before any real work starts.That polling scenario sharpens the ordering requirement.
pending_user_input: truehelps only if the session-status transition becomes observable no later than the message acceptance receipt; otherwise an orchestrator can still observe staleidle, classify the worker as ready, and start its timeout.I would pin the fix with a barrier test: block the worker on a permission prompt, send message ID M, wait for the accepted receipt, then poll. The first post-receipt snapshot must report
pending_user_input: true(orstatus: blocked),queued_message_count >= 1, and the configured queue capacity. Releasing the prompt should eventually producepending_user_input: falseplus an M-specific consumed/evicted outcome. Those fields can stay payload-free.That gives polling orchestrators both a backoff signal and enough queue-pressure information to avoid filling all 50 slots while a human decision is outstanding.