Problem to solve
In the TUI, tool-response PendingMessages occasionally accumulate as visible entries that never disappear. Once stuck, they persist for the lifetime of the TUI process — only a restart clears them. Real Message rows do exist in the DB for these tool calls; the duplicates are stale PendingMessage entries that never received their removal broadcast.
This is the second known papercut in the pending-message broadcast path. The first — pending entries being styled like user messages — was addressed in #366. The current behavior shares the same surface but has a different root cause: it's not a styling drift, it's missing/lost broadcasts for some PMs in a batch.
Hypothesis
The leak is correlated with batch tool calls — when one LLM response contains multiple tool_use blocks, the system creates one PendingMessage per tool, each is executed, and each is then promoted by DrainJob. The fact that some but not all PMs leak (and only when there are multiple in the same round) points at a per-broadcast issue rather than a per-session issue.
Two candidate failure modes worth examining:
- Some
broadcast_removed events don't reach the TUI. PendingMessage#after_destroy_commit :broadcast_removed fires ActionCable.server.broadcast directly (app/models/pending_message.rb:121, 422). If a render/decorator path raises during broadcast_payload for a specific PM, the broadcast for that one PM is lost while siblings go through. The TUI never learns to remove the stale entry.
- The TUI's removal handling skips when the matching create broadcast was missed. Race: PM is created → broadcast fires → TUI subscriber not yet attached →
broadcast_removed arrives later for an entry the TUI never saw create. Less likely given persistent ActionCable subscription, but possible during reconnects.
The "only some, only in batches" pattern strongly suggests (1) — a per-PM render path that fails for certain tool_response shapes.
Research approach
Wire up debug logging (dev environment only) on the broadcast path to find the answer:
- Log every
PendingMessage#broadcast_created and #broadcast_removed invocation with PM id, message_type, source_name, and tool_use_id.
- Log every
MessageBroadcaster#emit invocation with message id and tool_use_id.
- On the TUI side, log every received broadcast with action (create/remove/update) and id.
Then reproduce with a session that triggers a batch tool round (e.g. spawn 5 sub-agents in parallel — exactly what triggered today's session). Compare:
- N PMs created → N
broadcast_created logged → N broadcasts received by TUI?
- N PMs destroyed → N
broadcast_removed logged → N broadcasts received by TUI?
- N Messages created → N
MessageBroadcaster#emit logged → N create broadcasts received by TUI?
Any mismatch identifies the leak point.
Suspected choke points (starting hints, not prescribed)
app/models/pending_message.rb:301-424 — broadcast_payload, broadcast_created, broadcast_removed. The payload assembly calls decorate(...).render(mode) which can raise per-decorator. If one PM's decorator raises, the broadcast is lost — but DrainJob still proceeds because broadcast_* callbacks aren't gating the transaction.
app/decorators/pending_tool_response_decorator.rb and the pending_from_*_decorator.rb family — different render paths per source_type. A decorator that crashes on a specific tool_response shape (e.g. error-typed responses, or specific MCP tool names) would leak only that subset.
lib/events/subscribers/message_broadcaster.rb:24-30 — also calls message.decorate.render(session.view_mode). Same kind of failure mode for the create broadcast: if it fails, the TUI never knows to attach the new Message to the entry that the pending broadcast already created.
app/jobs/drain_job.rb:80-90 — tool_responses.each(&:promote!) runs N independent session.transaction blocks. Each promote! creates a Message + destroys a PM. The broadcasts fire from after_*_commit callbacks, so they're outside the transaction — exceptions there are silent on the DB side but eat the broadcast.
Acceptance criteria
Why this matters
A stale pending entry is visually indistinguishable from a real one (since #366 removed the styling difference). The TUI grows visually noisier across a long session and the human can't tell which entries are real conversation vs. broadcast leaks. It's the kind of small degradation that erodes trust in the UI without ever throwing a visible error.
Related
Problem to solve
In the TUI, tool-response PendingMessages occasionally accumulate as visible entries that never disappear. Once stuck, they persist for the lifetime of the TUI process — only a restart clears them. Real
Messagerows do exist in the DB for these tool calls; the duplicates are stalePendingMessageentries that never received their removal broadcast.This is the second known papercut in the pending-message broadcast path. The first — pending entries being styled like user messages — was addressed in #366. The current behavior shares the same surface but has a different root cause: it's not a styling drift, it's missing/lost broadcasts for some PMs in a batch.
Hypothesis
The leak is correlated with batch tool calls — when one LLM response contains multiple
tool_useblocks, the system creates onePendingMessageper tool, each is executed, and each is then promoted byDrainJob. The fact that some but not all PMs leak (and only when there are multiple in the same round) points at a per-broadcast issue rather than a per-session issue.Two candidate failure modes worth examining:
broadcast_removedevents don't reach the TUI.PendingMessage#after_destroy_commit :broadcast_removedfiresActionCable.server.broadcastdirectly (app/models/pending_message.rb:121, 422). If a render/decorator path raises duringbroadcast_payloadfor a specific PM, the broadcast for that one PM is lost while siblings go through. The TUI never learns to remove the stale entry.broadcast_removedarrives later for an entry the TUI never saw create. Less likely given persistent ActionCable subscription, but possible during reconnects.The "only some, only in batches" pattern strongly suggests (1) — a per-PM render path that fails for certain tool_response shapes.
Research approach
Wire up debug logging (dev environment only) on the broadcast path to find the answer:
PendingMessage#broadcast_createdand#broadcast_removedinvocation with PM id, message_type, source_name, and tool_use_id.MessageBroadcaster#emitinvocation with message id and tool_use_id.Then reproduce with a session that triggers a batch tool round (e.g. spawn 5 sub-agents in parallel — exactly what triggered today's session). Compare:
broadcast_createdlogged → N broadcasts received by TUI?broadcast_removedlogged → N broadcasts received by TUI?MessageBroadcaster#emitlogged → Ncreatebroadcasts received by TUI?Any mismatch identifies the leak point.
Suspected choke points (starting hints, not prescribed)
app/models/pending_message.rb:301-424—broadcast_payload,broadcast_created,broadcast_removed. The payload assembly callsdecorate(...).render(mode)which can raise per-decorator. If one PM's decorator raises, the broadcast is lost — butDrainJobstill proceeds becausebroadcast_*callbacks aren't gating the transaction.app/decorators/pending_tool_response_decorator.rband thepending_from_*_decorator.rbfamily — different render paths per source_type. A decorator that crashes on a specific tool_response shape (e.g. error-typed responses, or specific MCP tool names) would leak only that subset.lib/events/subscribers/message_broadcaster.rb:24-30— also callsmessage.decorate.render(session.view_mode). Same kind of failure mode for the create broadcast: if it fails, the TUI never knows to attach the new Message to the entry that the pending broadcast already created.app/jobs/drain_job.rb:80-90—tool_responses.each(&:promote!)runs N independentsession.transactionblocks. Eachpromote!creates a Message + destroys a PM. The broadcasts fire fromafter_*_commitcallbacks, so they're outside the transaction — exceptions there are silent on the DB side but eat the broadcast.Acceptance criteria
Why this matters
A stale pending entry is visually indistinguishable from a real one (since #366 removed the styling difference). The TUI grows visually noisier across a long session and the human can't tell which entries are real conversation vs. broadcast leaks. It's the kind of small degradation that erodes trust in the UI without ever throwing a visible error.
Related