Skip to content

fix(coding-agent): single-source the interactive queue state - #1815

Closed
snimu wants to merge 30 commits into
mainfrom
snimu/tui-queue-single-source
Closed

fix(coding-agent): single-source the interactive queue state#1815
snimu wants to merge 30 commits into
mainfrom
snimu/tui-queue-single-source

Conversation

@snimu

@snimu snimu commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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

  • The TUI now reads the queue from one place (the connection snapshot of the session's actions). The local mirror, optimistic patching, identity guards, sync(), and the indexOf fallback are deleted.
  • Mutations are validated server-side against the original (lane, index, text) tuple, so an edit can never be redirected to a different item with the same text.
  • After an applied move, the selection re-derives deterministically from the refreshed canonical queue (the moved item is at index±1) — no text retargeting, no sync machinery.
  • Queued mutations resolve their target when their turn in the serialized chain begins, so consecutive moves and move-then-edit chains stay correct.

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 via QueueSelection.refreshAt instead of text-based retargeting, optimistic local patching, and sync()—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. sendRemoteAgentSessionMessage retries 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 / collectEvents timeouts optional; prompt now waits for and validates the RPC response. Transport failures (process error, stdout close, stop) reject pending requests and event waiters via failPendingOperations.

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 InteractiveMode and harden RpcClient lifecycle

  • Removes the local connectionQueue mirror from InteractiveMode; queue reads now come from connectionState.sessionActions and are refreshed via session_action_update events instead of optimistic local patches
  • Replaces QueueSelection.sync(queue) with QueueSelection.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 coordinate
  • Reworks RpcClient to remove fixed client-side timeouts for send, refine, and waitForIdle; adds a transportError field and failPendingOperations that rejects all pending requests and event waiters when the child process errors or stdout closes
  • Makes sendRemoteAgentSessionMessage send the remote agent message exactly once; retries only apply to establishing the supervisor connection, not to the request/response exchange
  • Routes daemon rename commands through setStateSessionNameForCommand, which uses applyStateSessionName (bypassing availability checks) when a worker context is present
  • Behavioral Change: RpcClient.prompt now throws on error responses instead of deferring discovery to event streams; RpcClient.stop rejects in-flight requests before SIGTERM; moveQueueSelection/applyQueueSelection no 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).

snimu added 30 commits August 24, 2026 11:17
} else {
status = "rejected";
}
if (discardStaleSelection()) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 ?? [])],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 20c2482. Configure here.

@snimu

snimu commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

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.

@snimu snimu closed this Aug 27, 2026
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.

1 participant