Skip to content

refactor(coding-agent): derive scoped heartbeats at render time - #1830

Closed
snimu wants to merge 164 commits into
mainfrom
snimu/derive-scoped-heartbeats
Closed

refactor(coding-agent): derive scoped heartbeats at render time#1830
snimu wants to merge 164 commits into
mainfrom
snimu/derive-scoped-heartbeats

Conversation

@snimu

@snimu snimu commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What was wrong

The TUI stored a scoped heartbeats list — a projection of the heartbeat catalog x connection state x subagent snapshots — kept aligned by updateScopedHeartbeats() calls scattered across unrelated code paths. A missed call site meant stale heartbeat counts or manager rows (audit: dup-truth.md finding 5; TUI-side sibling of #1743).

The fix

The stored projection, its updater, and setHeartbeats() are deleted. The tray label, refresh scheduler, and heartbeat manager derive the scoped list from the three sources at use/render time; the manager holds only selection state by heartbeat ID (re-anchoring gracefully when the selected entry disappears). Production code is net deletion; stale test stubs of the deleted method removed.

How it's verified

Reviewer accounted for all 4 former sync call sites, bounded the per-render derivation cost (tiny, overlay-scoped), traced selection survival through catalog refreshes, and confirmed the getter wiring holds no leak. Focused suites 190/190; full CI-style failing set exactly matches the stack base. Two-model implement/review loop.

Stacked on #1748 (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
Touches daemon protocol revision, session compaction continuation, RPC transport behavior, and RLM child lifecycle; breaking for removed getOverflowPatterns and changed internal _rlmChildSessions map shape.

Overview
This PR removes several mirrored state in the interactive TUI and derives queue, scoped heartbeats, “new chat” hints, and available models from connectionState and catalogs at read/render time. Queue browsing and edits no longer optimistically patch a local queue copy: QueueSelection.refreshAt re-anchors by lane, index, and expected text, and move/replace waits on daemon acknowledgments.

Daemon protocol revision 23 replaces supervisor worker_sync_agent_peers broadcasts with on-demand list_agent_peers; workers query the supervisor when building agent rosters. Supervised workers apply renames via applyStateSessionName after the supervisor reserves the name, and remote agent messages are sent once per call (connect retries no longer resend after a lost response).

AgentSession drops the 100ms post-compaction continuation timer and runs continuation immediately behind idle/refine/queued-work gates. RLM child rosters retain RetainedRlmChild run metadata so reattach snapshots show queued children, activity, and previews correctly; daemon child listing delegates to getRlmChildSnapshots.

RpcClient waits for process spawn instead of a fixed delay, removes default per-request timeouts (including refine), and fails all pending requests/event waiters on transport errors. Smaller cleanups: shared looksLikeSessionPath, legacy RLM registry parsing with default sessionDir, kernel dispose cancels in-flight work before final snapshot, removal of test-only cache/lookup hooks and getOverflowPatterns, and telemetry no longer auto-off under NODE_ENV=test.

Reviewed by Cursor Bugbot for commit 11d0356. Bugbot is set up for automated code reviews on this repo. Configure here.

Linear ticket: ENG-5665
(ticket linked above)

Note

Derive scoped heartbeats and interactive-mode state at render time

  • Removes internal state mirrors (connectionQueue, connectionModels, sessionHasMessages, heartbeats) from InteractiveMode; queue, models, heartbeats, and new-chat detection are now derived from connectionState on demand in interactive-mode.ts
  • HeartbeatManagerComponent now pulls heartbeats via a getHeartbeats callback at render time instead of receiving pushed arrays; selection tracks by heartbeat id across list mutations in heartbeat-manager.ts
  • Queue move/edit operations in InteractiveMode.moveQueueSelection and editQueueSelection no longer apply optimistic local patches; they await server acknowledgment and re-anchor selection via QueueSelection.refreshAt in queue-selection.ts
  • Replaces push-based worker_sync_agent_peers with a pull-based list_agent_peers supervisor RPC (schema revision 22→23); workers query peers on demand and no longer cache them in daemon-mode.ts and daemon-supervisor.ts
  • Overhauls RpcClient in rpc-client.ts: removes per-request default timeouts, adds failPendingOperations for centralized transport-failure rejection, and awaits the child spawn event instead of a fixed delay on start
  • Rewrites post-compaction continuation in agent-session.ts to schedule immediately without a setTimeout delay, coordinating with idle/queued-work state via _runScheduledPostCompactionContinue
  • Risk: RpcClient.send no longer applies a default 30s timeout and will wait indefinitely unless a timeout is explicitly passed or the transport fails; callers relying on implicit timeout behavior need to pass explicit timeouts. getOverflowPatterns is removed from overflow.ts — out-of-tree imports will break. QueueSelection.refreshAt replaces sync() with strict lane/index/text matching; callers using the old API must migrate.
📊 Macroscope summarized e953a58. 39 files reviewed, 10 issues evaluated, 7 issues filtered, 3 comments posted

🗂️ Filtered Issues

packages/coding-agent/src/modes/daemon/daemon-mode.ts — 0 comments posted, 2 evaluated, 2 filtered
  • line 3708: handleCommand has no case "list_agent_peers". The new worker-side listSupervisorAgentPeers() request therefore reaches the supervisor but falls through the command switch (and, as the command allowlist also lacks it, is rejected even earlier), so workers always catch the failure and use an empty remote roster. This breaks cross-worker agent discovery/reachability and message routing introduced by the on-demand replacement. [ Out of scope (triage) ]
  • line 5339: createAgentMessageListResult calls listSupervisorAgentPeers, but the supervisor's DAEMON_COMMAND_TYPES/handleCommand has no list_agent_peers case. Every worker query is rejected as unknown and caught as [], so workers cannot list, resolve, or enforce family reachability for agents hosted by other workers. [ Out of scope (triage) ]
packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 43: The new pull-only heartbeats getter at line 43 has no accompanying invalidation path, so changes to the callback result are not rendered until some unrelated input/render occurs. In particular, a connection snapshot can change the session identity used by getScopedHeartbeats() while the manager is open; its caller only schedules a catalog refresh, and if the scoped jobs are paused there is no timer to cause one. The old setHeartbeats() always called requestRender(), so the manager can now continue displaying (and allow actions on) heartbeats from the prior session after that update. [ Failed validation ]
packages/coding-agent/src/modes/interactive/interactive-mode.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 4420: liveImageMarkerIds() now reads the queue only from connectionState.sessionActions, which is updated asynchronously by session_action_update. After a successful queued-message mutation or prompt admission but before that event is processed, a subsequent large image paste can run eviction without seeing the queued marker and evict its image; recalling/sending that queued prompt then silently omits the attachment. [ Failed validation ]
packages/coding-agent/src/modes/rpc/rpc-client.ts — 0 comments posted, 3 evaluated, 3 filtered
  • line 550: waitForIdle now creates no timer when callers use its documented/default invocation. A live daemon that never emits agent_end leaves the registered waiter pending forever, so callers cannot recover from a stuck run; the former default rejected after 60 seconds. Process-error handling only covers process/stream termination, not this live-but-stalled path. [ Out of scope (triage) ]
  • line 582: collectEvents() no longer has its 60-second default timeout, so a caller using it without an explicit timeout waits forever if the agent remains live but never produces agent_end. This also makes the default promptAndWait path unbounded because it delegates to this collection logic. [ Out of scope (triage) ]
  • line 687: send no longer installs any timeout for a pending RPC request. If the child stays alive but fails to emit a matching response (for example, a stalled daemon command), the promise added to pendingRequests at this call never settles and the public operation hangs indefinitely; previously every command was rejected after 30 seconds. Transport-close handling cannot recover this live-but-unresponsive case. [ Out of scope (triage) ]

Supersedes #1749 (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
snimu added 16 commits August 27, 2026 11:03
if (!this.options.snapshot || !this.isRunning) return;
const pendingExecutions = this.executionQueue;
if (this.activeExecution) void this.interrupt().catch(() => undefined);
let timeout: ReturnType<typeof globalThis.setTimeout> | undefined;

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.

🟠 High kernel/repl-manager.ts:1106

dispose() and shutdown({ snapshot: true }) can wait indefinitely instead of honoring SNAPSHOT_EXECUTION_TIMEOUT_MS: a concurrent non-terminating execute() can be appended after pendingExecutions, leaving the final captureSnapshot() queued behind it. The timeout passed to captureSnapshot() does not start until enqueueRequest() finishes awaiting that queue, so the timeout must cover both queue waiting and snapshot execution.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/kernel/repl-manager.ts around line 1106:

`dispose()` and `shutdown({ snapshot: true })` can wait indefinitely instead of honoring `SNAPSHOT_EXECUTION_TIMEOUT_MS`: a concurrent non-terminating `execute()` can be appended after `pendingExecutions`, leaving the final `captureSnapshot()` queued behind it. The timeout passed to `captureSnapshot()` does not start until `enqueueRequest()` finishes awaiting that queue, so the timeout must cover both queue waiting and snapshot execution.

Evidence trail:
packages/coding-agent/src/core/kernel/repl-manager.ts:454-496 @ e953a58fd8588288a39201899db29a7280eab230; packages/coding-agent/src/core/kernel/repl-manager.ts:1102-1117 @ e953a58fd8588288a39201899db29a7280eab230; packages/coding-agent/src/core/kernel/repl-manager.ts:897-910 @ e953a58fd8588288a39201899db29a7280eab230

@@ -2577,7 +2588,6 @@ export class DaemonSupervisor {
worker.launchEnv = undefined;

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 daemon/daemon-supervisor.ts:2588

After a supervisor restart adopts a pre-revision-23 worker and launches a new worker, the adopted worker's remoteAgentPeers remains stale, so its agent list and family-reachability checks omit the new peer. Because the ready path no longer sends worker_sync_agent_peers, cross-worker messaging and sibling-name admission can be incorrect during mixed-version operation; restore that synchronization after the worker becomes ready.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/daemon/daemon-supervisor.ts around line 2588:

After a supervisor restart adopts a pre-revision-23 worker and launches a new worker, the adopted worker's `remoteAgentPeers` remains stale, so its agent list and family-reachability checks omit the new peer. Because the ready path no longer sends `worker_sync_agent_peers`, cross-worker messaging and sibling-name admission can be incorrect during mixed-version operation; restore that synchronization after the worker becomes ready.

Evidence trail:
packages/coding-agent/src/modes/daemon/daemon-supervisor.ts:2583-2592, 2707-2756, 1536-1553 at e953a58fd8588288a39201899db29a7280eab230; packages/coding-agent/src/modes/daemon/daemon-protocol.ts:716-722 at e953a58fd8588288a39201899db29a7280eab230; packages/coding-agent/src/modes/daemon/daemon-supervisor.ts:3420-3445 at bc0fa7606abb3b7af0f765319518d255e6ae553d; packages/coding-agent/src/modes/daemon/daemon-mode.ts:3657-3667, 5370-5476 at bc0fa7606abb3b7af0f765319518d255e6ae553d; git diff MERGE_BASE REVIEWED_COMMIT -- packages/coding-agent/src/modes/daemon/daemon-supervisor.ts

private async flushSnapshotForDispose(): Promise<void> {
if (!this.options.snapshot || !this.isRunning) return;
const pendingExecutions = this.executionQueue;
if (this.activeExecution) void this.interrupt().catch(() => undefined);

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.

🟠 High kernel/repl-manager.ts:1105

flushSnapshotForDispose aborts an active user cell immediately, so shutdown({ snapshot: true }) and dispose() can snapshot before that cell's updates are committed even when it would finish within the five-second flush window. Remove the immediate interrupt and let the queued execution finish before capturing the snapshot.

-		if (this.activeExecution) void this.interrupt().catch(() => undefined);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/kernel/repl-manager.ts around line 1105:

`flushSnapshotForDispose` aborts an active user cell immediately, so `shutdown({ snapshot: true })` and `dispose()` can snapshot before that cell's updates are committed even when it would finish within the five-second flush window. Remove the immediate interrupt and let the queued execution finish before capturing the snapshot.

Evidence trail:
packages/coding-agent/src/core/kernel/repl-manager.ts:445-497, 831-835, 897-910, 1102-1116 @ e953a58; prime-agent-runtime/src/rlm/repl.md:87-92 @ e953a58

@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 2 potential issues.

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 11d0356. Configure here.

clearTimeout(timeout);
resolve();
});
child.kill("SIGTERM");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

RPC stop can hang forever

Medium Severity

stop() now waits only for the child close event. The 1s timer still sends SIGKILL, but it no longer resolves the waiter. If close already fired in the race after reading this.process, or never fires after kill, stop() never returns and shutdown or tests hang.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 11d0356. Configure here.

this.sessionHasMessages = hasMessages;
this.builtInHeader?.invalidate();
this.subagentSummaryLine.invalidate();
return (this.connectionState?.messageCount ?? 0) === 0 && this.connectionState?.isStreaming !== true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New-chat state lags first message

Low Severity

isNewChat() now stays true until message_end or agent_start. The old path marked the session as having messages on user (or agent) message_start, which is also when the transcript first shows that message. Start hints and new-chat chrome can remain visible after the first prompt has already appeared.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 11d0356. 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