fix(webviews): relay local daemon peer state events

Merged
TE
6d ago
+824-15

Carries #27971 (its lane is gone) with its commits and authorship, merged with current feat-cmux-next. The merge resolution keeps both the new _acpmux/peer_state_changed and base's _cmux/question_key_request notification branches, and when an open draws its chat from the attach before the paced list (attachedBeforeList, #29126), the peer snapshot is read once that list settles, so it still comes after the list's _acpmux/watch.

At the merge head: bun test of peerStateEvents, peerStateReducer, chat-unavailable, question-key-bridge, chatOpenPacedList, session-pages, cached-chat-open, switch-frames and peer-copy: 103 pass, 0 fail.


Takes over #26885 (its lane is gone) with its commits and authorship, rebased onto current feat-cmux-next, and fixes the review finding that blocked it.

Local daemon peer transitions do not reach the web session's peer state today: the picker only sees the last _acpmux/status read. This relays them.

  • A zero-parameter _acpmux/peers relay in both host policies: the macOS host (AcpmuxPaneMethods) and the shared Rust policy (cmux-agent-pane-policy/policy.json), so a Rust host doesn't refuse the request. The reply is filtered to name, state, lastSeen and error, never URLs or credentials. The Rust ReplyShape gains Number (a finite number, never a boolean, matching Swift's .number) so lastSeen survives the filter.
  • The web client reads one snapshot once the chat list's _acpmux/watch answers, on each reconnect and after an _acpmux/lagged notice, then applies _acpmux/peer_state_changed notes from the same connection. It never polls. The snapshot isn't on the first frame's path: it lands and draws on its own, so the first frame keeps #27755's one round trip.
  • Notes, snapshot reads and _acpmux/status reads are ordered on one clock (the tick each read started at). An older read of either kind can't overwrite a newer note or a newer read, and a status read replaces any entry written before it started.
  • It only turns on when initialize advertises peerStateEvents from a local origin. Peer, remote and unknown origins and older daemons keep the existing snapshot behavior.

The review finding, fixed here

The earlier review blocked #26885 at fbb1240e because peerStateEvents.ts accepted only the five PeerLinkState values. _acpmux/peers summaries use PeerState::as_str (cmux-tui/crates/acpmux/src/peer_link.rs), which also sends restarting while a peer's daemon restarts (peer_state_changed maps that to reconnecting). The adapter dropped that row, so the authoritative snapshot cleared the peer's state and connected, and environmentReady (state undefined, connected !== false) treated the peer as ready. The adapter now accepts every state the daemon sends. A restarting peer keeps state: "restarting" and connected: false, so it is listed but not offered as ready, the same as the _acpmux/status path already does on base.

Later reviews of this branch found more:

  • A second path to the same result: a peer that _acpmux/status lists but the authoritative snapshot omits (removed between the two reads, or a row the adapter could not read) also lost its state and connected. applyPeerStates now marks such a peer not connected, keeping the lastSeen and attempts the offline banner shows.
  • A snapshot that said restarting never cleared if the restart then failed. Restarting and reconnecting are both reconnecting on the wire, so the daemon sends no note, and the snapshot kept overriding newer status reads. A status read now replaces it (the one-clock fence above), and chatUnavailable counts restarting as still connecting.
  • The Rust relay policy didn't list _acpmux/peers (above). frames.json had sampled it as a method the pane never sends; that refusal sample moved to _acpmux/peer_remove, which stays unlisted.

Checks on the original

  • defaults guard: the "default" is the replyShapes table hash (AcpmuxPaneMethods+ReplyShapes.swift, the new _acpmux/peers reply filter). It is a relay reply allowlist, not a setting default, and it changes no user-facing default. The guard clears with an independent review receipt at the head.

  • check-fixed-waits (web lint): red on base. It flags chatOpenTrips.test.ts, added by #27755, which this PR doesn't touch, so I pushed with a CMUX_WEB_LINT_SKIP_REASON naming the base sha.

  • tests/swift.rs the_known_params_are_the_macos_hosts: red on base (acp.session.fork's live param is missing from policy.json); this PR's _acpmux/peers entries match the Swift tables.

No capture: no new or changed UI. This keeps the existing picker rows' existing state labels current from daemon events, and a real peer transition can't be staged in a capture scene.

Relay analysis

_acpmux/peers takes no parameters (any parameter is refused, AcpmuxPanePeerSnapshotTests) and runs no command. It returns only the local daemon's peer names and link state, which _acpmux/status already relays. Only the local origin may consume it, so a peer daemon's connection never sees this host's peer inventory (#26860).

Testing

Each fix has its red test committed first:

  • test(webviews): reproduce a restarting peer dropped from the peer snapshot: cd webviews && bun test src/agent-session/acpmux/peerStateReducer.test.ts src/agent-session/acpmux/peerStateEvents.test.ts gives 16 pass, 2 fail (the reducer drops the restarting row; the client never reports restarting, so the picker reads the peer as ready). After the fix: 18 pass.

  • test(webviews): reproduce a listed peer the peer snapshot omits reading as ready: bun test src/agent-session/acpmux/peerStateEvents.test.ts gives 10 pass, 1 fail (peerConnected is undefined). After the fix: 11 pass.

  • The restarting-state, first-frame and chatUnavailable reds: bun test src/agent-session/acpmux/peerStateEvents.test.ts 11 pass, 2 fail, and bun test test/chat-unavailable.test.ts 1 fail; then green.

  • The out-of-order reads: bun test src/agent-session/acpmux/peerStateEvents.test.ts 13 pass, 2 fail; then green.

  • The Rust policy: cargo test -p cmux-agent-pane-policy on CI (cmux-ci). Parity replies failed at the red commit; at the head, parity passes 16 of 16.

At the head, the peer-state, reducer, environments and chatUnavailable suites give 30 pass, 0 fail.

Observed / Not run

  • Observed: the focused Bun suites above, red then green; scripts/web-lint.sh (the type-aware lint and type check are left to CI on this host); scripts/cmux-next/prepush-gate.py, no new violations.
  • Not run: the native AcpmuxPanePeerSnapshotTests (left to CI's Swift lanes); a real remote-machine transition. A run of the whole src/agent-session/acpmux folder in one Bun process timed out on CI, so the PR's own web test jobs cover the rest.

Changelog

  • Fixed: the machine picker follows local peers connecting, dropping and restarting live, and a restarting or just-removed machine is no longer offered as ready.

Agent session: agent session

šŸ¤– Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Relays local daemon peer state transitions to the web session so the machine picker shows peers connecting, dropping, and restarting live. The picker previously only saw the last _acpmux/status read.

  • Adds a zero-parameter _acpmux/peers relay to both the macOS and Rust host policies, filtered to name, state, lastSeen, and error.
  • The web client reads one peer snapshot after the chat list's _acpmux/watch answers and then applies _acpmux/peer_state_changed notes; it never polls, and the snapshot stays off the first frame's path.
  • Notes, snapshot reads, and status reads are ordered on a single clock, so an older read can't overwrite a newer note or read.
  • A restarting peer keeps state: "restarting" and connected: false, and a peer the snapshot omits is marked not connected, so neither is offered as ready.
  • Activates only when initialize advertises peerStateEvents from a local origin; peer, remote, and unknown origins and older daemons keep the existing snapshot behavior.