fix(coding-agent): single-source the interactive queue state - #1815
fix(coding-agent): single-source the interactive queue state#1815snimu wants to merge 30 commits into
Conversation
…-blanket-timeouts
…d-rename-authority
…-blanket-timeouts
…d-rename-authority
| } else { | ||
| status = "rejected"; | ||
| } | ||
| if (discardStaleSelection()) return true; |
There was a problem hiding this comment.
🟡 Medium interactive/interactive-mode.ts:7114
An applied queue mutation resets browsing before the corresponding session_action_update is processed, so an immediate browse or edit reads the stale queue and can show deleted items or submit a rejected mutation. Wait for sessionEventQueue after the mutation, then re-check the session generation before resetting the selection.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/interactive/interactive-mode.ts around line 7114:
An `applied` queue mutation resets browsing before the corresponding `session_action_update` is processed, so an immediate browse or edit reads the stale queue and can show deleted items or submit a rejected mutation. Wait for `sessionEventQueue` after the mutation, then re-check the session generation before resetting the selection.
Evidence trail:
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2509-2517, 2658-2661, 5074-5086, 7057-7138 at 20c2482a9aca8748f9831e1af81b03be34f592b4; packages/coding-agent/src/core/agent-session.ts:6361-6381 at 20c2482a9aca8748f9831e1af81b03be34f592b4
| followUp: [...queue.followUp], | ||
| private getConnectionQueue(): AgentConnectionQueueState { | ||
| return { | ||
| steering: [...(this.connectionState?.sessionActions.steering ?? [])], |
There was a problem hiding this comment.
🟡 Medium interactive/interactive-mode.ts:2514
refreshConnectionCatalog() can overwrite a newer session_action_update with its stale getState() result, so the TUI displays and browses obsolete queued messages and subsequent edits are rejected until another queue event arrives. Version the state snapshot (or otherwise reconcile it against intervening session-action events) before applyConnectionStateSnapshot(state) so an older RPC response cannot replace newer queue state.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/interactive/interactive-mode.ts around line 2514:
`refreshConnectionCatalog()` can overwrite a newer `session_action_update` with its stale `getState()` result, so the TUI displays and browses obsolete queued messages and subsequent edits are rejected until another queue event arrives. Version the state snapshot (or otherwise reconcile it against intervening session-action events) before `applyConnectionStateSnapshot(state)` so an older RPC response cannot replace newer queue state.
Evidence trail:
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2519-2532, 2580-2600, 2640-2661, 5074-5086, 5359-5362 at 20c2482a9aca8748f9831e1af81b03be34f592b4; packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts:393-401, 1612-1622 at 20c2482a9aca8748f9831e1af81b03be34f592b4
|
|
||
| private refreshQueueSelectionFromState(): void { | ||
| const selected = this.queueSelection.selected; | ||
| if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { |
There was a problem hiding this comment.
🟡 Medium interactive/interactive-mode.ts:2642
A rejected queued-message edit leaves QueueSelection pointing at the stale (lane, index, text), so subsequent edits or reorders target that tuple and are rejected instead of using the current queue or returning to a new prompt. Because refreshQueueSelectionFromState() skips reconciliation while pendingQueueEdit is set, the intervening session_action_update is ignored; reconcile against getConnectionQueue() after the mutation returns a non-applied status before retaining the edit.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/interactive/interactive-mode.ts around line 2642:
A rejected queued-message edit leaves `QueueSelection` pointing at the stale `(lane, index, text)`, so subsequent edits or reorders target that tuple and are rejected instead of using the current queue or returning to a new prompt. Because `refreshQueueSelectionFromState()` skips reconciliation while `pendingQueueEdit` is set, the intervening `session_action_update` is ignored; reconcile against `getConnectionQueue()` after the mutation returns a non-`applied` status before retaining the edit.
Evidence trail:
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2640-2644, 2658-2661, 7057-7138 @ 20c2482a; packages/coding-agent/src/modes/interactive/queue-selection.ts:63-83 @ 20c2482a; packages/coding-agent/test/interactive-queue-edit.test.ts:353-418 @ 20c2482a
| @@ -2806,7 +2799,8 @@ export class InteractiveMode { | |||
| this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); | |||
| } | |||
| this.subscribeToAgent(); | |||
There was a problem hiding this comment.
🟡 Medium interactive/interactive-mode.ts:2801
rebindCurrentSession renders queue previews from the state fetched before subscribeToAgent(), so a queue mutation during that gap is missed and the UI keeps stale entries until a later event or resync; edits then use outdated indices/text and are rejected. Refresh and apply the current connection state after subscribing before updating the queue display.
this.subscribeToAgent();
+const state = await this.agentConnection.getState();
+this.applyConnectionStateSnapshot(state);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/interactive/interactive-mode.ts around line 2801:
`rebindCurrentSession` renders queue previews from the state fetched before `subscribeToAgent()`, so a queue mutation during that gap is missed and the UI keeps stale entries until a later event or resync; edits then use outdated indices/text and are rejected. Refresh and apply the current connection state after subscribing before updating the queue display.
Evidence trail:
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2519-2532, 2785-2810, 5074-5137, 5359-5363, 7018-7026, 7088-7096, 7385-7423 @ 20c2482a9aca8748f9831e1af81b03be34f592b4
packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts:382-387 @ 20c2482a9aca8748f9831e1af81b03be34f592b4
packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts:126-131 @ 20c2482a9aca8748f9831e1af81b03be34f592b4
packages/coding-agent/src/core/agent-session.ts:1496-1499, 6361-6369 @ 20c2482a9aca8748f9831e1af81b03be34f592b4
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 20c2482. Configure here.
| else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); | ||
| else this.showStatus("Queue changed; reorder not applied"); | ||
| } finally { | ||
| this.pendingQueueMove = false; |
There was a problem hiding this comment.
Move refresh races queue event
Medium Severity
After a queue move, selection is re-derived from getConnectionQueue() immediately after await this.sessionEventQueue. That only drains events already queued, not the mutation's own session_action_update. If that event lands after the RPC response, refreshAt sees the pre-move snapshot, treats the neighbor as a mismatch, and reset()s browse mode. Chained moves and edits then send nothing or keep the edit only as leftover editor text.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 20c2482. Configure here.
|
Superseded: the chained stack was restructured into independent PRs (byte-identical combined tree). A fresh standalone PR for this change follows on the same branch name. |


What was wrong
The interactive queue was stored three times: the core session's action store (authoritative), the TUI's connection snapshot, and a second TUI-local mirror that optimistically re-applied every mutation. Keeping the copies aligned required identity guards, a sync() pass, and a text-based indexOf fallback — and that fallback had a real bug: with two identical queued messages, editing the later one could silently edit the first.
The fix
Net −167 lines in the first pass; the review round added the deterministic selection re-derivation (+69/−15).
How it's verified
Reviewer independently simulated server semantics against the real prototype methods: consecutive moves, move-then-edit, rapid unawaited keypresses, duplicate-text edits, selection reset races, and external queue shrink — all correct or safely rejected. New stateful regression asserts a 2→1→0 move/edit chain lands with the right indices. tsgo/biome/focused suites green (86 tests); repo-wide check green. Two-model implement/review loop, approved on second pass.
Stacked on #1702 (test the whole stack at the leaf; merge base-first).
Note: intentionally no Linear ticket for this cleanup stack, so that check stays red.
Note
Medium Risk
RPC and daemon networking behavior changes (no default client timeouts, no post-send message retry) plus TUI queue editing paths; mistakes could hang clients or mis-route edits, though the diff targets known duplicate-send and duplicate-queue bugs.
Overview
The interactive TUI drops its local queue mirror and reads steering/follow-up from
connectionState.sessionActions. Queue browse/move/edit now mutates the selected(lane, index, text)tuple and refreshes selection viaQueueSelection.refreshAtinstead of text-based retargeting, optimistic local patching, andsync()—fixing wrong-target edits when duplicate prompts are queued.Daemon changes route rename/set-name commands through
setStateSessionNameForCommand, so supervisor-approved worker renames apply without a second availability check.sendRemoteAgentSessionMessageretries only until a connection is established, then sends once; a lost response after delivery surfaces as an error instead of resending.RpcClient removes the default 30s per-command timeout (including refine) and makes
waitForIdle/collectEventstimeouts optional;promptnow waits for and validates the RPC response. Transport failures (process error, stdout close, stop) reject pending requests and event waiters viafailPendingOperations.User-facing notes land in
packages/coding-agent/.changes/; tests are updated for the new queue and RPC behavior.Reviewed by Cursor Bugbot for commit 20c2482. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Single-source interactive queue state in
InteractiveModeand hardenRpcClientlifecycleconnectionQueuemirror fromInteractiveMode; queue reads now come fromconnectionState.sessionActionsand are refreshed viasession_action_updateevents instead of optimistic local patchesQueueSelection.sync(queue)withQueueSelection.refreshAt(queue, lane, index, expectedText), which strictly verifies lane/index/text; drops the selection and restores the stashed draft when the item is not at the expected coordinateRpcClientto remove fixed client-side timeouts forsend,refine, andwaitForIdle; adds atransportErrorfield andfailPendingOperationsthat rejects all pending requests and event waiters when the child process errors or stdout closessendRemoteAgentSessionMessagesend the remote agent message exactly once; retries only apply to establishing the supervisor connection, not to the request/response exchangesetStateSessionNameForCommand, which usesapplyStateSessionName(bypassing availability checks) when a worker context is presentRpcClient.promptnow throws on error responses instead of deferring discovery to event streams;RpcClient.stoprejects in-flight requests before SIGTERM;moveQueueSelection/applyQueueSelectionno longer optimistically update a local queue and rely solely on daemon mutation status and authoritative connection state for reconciliation📊 Macroscope summarized 20c2482. 8 files reviewed, 5 issues evaluated, 0 issues filtered, 4 comments posted
🗂️ Filtered Issues
Linear: ENG-5649
Supersedes #1705 (recreated as a plain PR against
main; GitHub's stack lock prevented retargeting the stacked PR).