cmux NEXT: one machine link state for the sidebar and the agent pane; a down machine reconnects on use

Merged
TE
6d ago
+977-42

Item 2 of the machines audit: one feed of machine state, which the sidebar's machine header and the agent pane both read, plus a reconnect when you use a machine whose link is down.

Leo, 2026-10-28: "you can just not choose a project ... you just get a little green dot ... as if you were working on your own computer, but now it's remote."

Leo, 2026-10-28: "whatever's convenient for cloud, like that can be productionized, operationalized, we ought to just put out there"

What changed

  • One word per machine (MachineLinkState, CmuxNextSidebar). Every SidebarMachine.Status maps to one of connected, connecting, offline, paused or attention. connected means the daemon finished its handshake (DaemonHandshake). A bare socket or ssh connect still reads as connecting.

  • The sidebar header draws that word.

    • Connected: a green dot.
    • Connecting, reconnecting or installing: the small spinner (StatusIndicatorView.spinner()), never a pulsing dot.
    • Offline, unreachable, failed or refused: a red dot.
    • Paused: grey.
    • Install needed: the attention dot.

    This Mac still draws no dot while connected. An optional update reads as connected (the green dot, with its words in the tooltip), as in the composer strip.

  • The agent pane gets the same word (AgentPaneMachineLink, CmuxNextAgentPane). The App's tab store owns one instance and hands it to each pane's model. For a chat on another machine, the page gets a machineLink host event ({machine, link, words}), built from the same header builders the sidebar uses (SidebarBridge.paneLink). That makes the composer strip and the sidebar agree by construction. The event goes out when the page loads and again on every change. The strip that draws it is the follow-up PR.

  • A down machine reconnects on use. A remote tab's attach client asks the machine link when any part of its connection fails: the endpoint, the socket or the daemon handshake (AgentSessionAttachClient.Reconnect, from AgentPaneMachineLink.reconnecting(machine:)). When the machine's link is down, it does three things:

    1. runs the header's own Reconnect (the machine.reconnect action: ssh reconnect, server wake, or Cloud retry);
    2. waits for the handshake;
    3. tries the call once more.

    The turn you sent goes out without you pressing Reconnect or Retry. A machine that needs you (sign-in refused, an update required) is never retried; its call fails as before, and a reconnect that ends there fails the send at once instead of waiting out the 45 s.

  • The remote machines' sidebar groups follow acpmux's link pushes. acpmux now pushes _acpmux/peer_state_changed to its watchers when a peer's link changes, and _acpmux/peers says connected only after the handshake (#26756, #26823). The app's own acpmux watch (AgentTurnStateFeed) reads the push and hands it to RemoteMachinesSidebar:

    • a listed peer's header goes up or down at once;
    • a repeat changes nothing (a lagging watcher gets every peer's last note again);
    • an unknown peer, an offline for one already down (it may have been removed), or a reopened watch reads _acpmux/peers again.
    • a peer list read that a push overtook (its answer may be from before the push) is dropped and read once more.

    Only this Mac's own acpmux counts. The Chief home's acpmux, which the app mirrors, never reaches the sidebar, and no peer's states are taken from a peer link. An older daemon sends no push, and the sidebar reads the list on a lane change, as before.

  • From #25993: the machine.reconnect pane action and its App routing, with its test.

Observers and timers (stop condition and cleanup)

  • AgentPaneMachineLink observation task.
    • What it is: one ObservationStream over the machines' link state for the pages it has pushed to. It is event driven, with no polling.
    • Stops: it is cancelled and replaced when a page joins or state is set. It ends when no page is left (pages are held weakly) and once its owner is gone ([weak self]).
  • awaitLink wait.
    • What it is: one per machine, shared by every chat on it.
    • Stops: on the handshake (an ObservationStream), on a one-shot deadline (budget, 45 s), or on cancellation. The task group cancels the other child, and the wait is removed from waits when it settles.
  • Header spinner: it turns only while the header's tone is working, and stops (spin(false)) on any other tone.
  • Page teardown: a tab's page that is released (AgentTabStore.release) leaves the link (forget), and the last one gone ends the observation.
  • Peer state pushes: no new task, timer or observer. The push is handled in the existing watch connection's main-actor batch, so it stops when the watch stops and restarts with it. Each fresh page of the watch (its first, and after every reconnect) re-reads the peer list. The sidebar side does nothing before bind or after stop(), and its one list read at a time is bounded by its 2 s deadline and cancelled by stop().
  • Cancellation: a cancelled send stops waiting at once, runs no Reconnect, and is not retried.

Captures

Below are before/after stills of the real sidebar list: one ssh machine per state (connected, connecting, reconnecting, offline). They come from the AAMachineLinkGalleryTests still.

No capture: fleet capture is unavailable because the capture controller has no free disk.

Risk

Medium, contained to remote machines.

  • The reconnect hook only changes what happens after a remote attach connection has already failed. Its one extra try is gated on the machine's link being down, or on the way and then up within 45 s.
  • The header's dot colours keep their meaning; the visible change is a spinner instead of a static dot while connecting.
  • No new strings, no new polling, and no settings change.

Tests

On the fleet (package-test-lane.sh, Xcode 26.6): MachineLinkStateTests, AgentPaneMachineLinkTests, AgentPaneConnectActionTests, HostGroupHeaderStatusTests, PeerStatePushTests and AAMachineLinkGalleryTests.

  • everyStatusHasOneWord: one word per status, and green only for a finished handshake.
  • onlyADroppedLinkReconnectsOnUse: only offline, unreachable and failed reconnect on use.
  • theHeaderDrawsTheWord: the header's tone, with a spinner while connecting.
  • aDownMachineReconnectsOnUse: a failed connection reconnects, then retries once.
  • theWaitEndsAtTheHandshake: the wait ends on the handshake, at once on a link that needs a person, and on the deadline when it never comes.
  • machineReconnectRunsOnAGesture.
  • PeerStatePushTests: the watch decodes the push; a push from a mirrored acpmux never reaches the sidebar while the app's own does; a listed peer follows at once, a repeat changes nothing, and an unknown or removed peer or a reopened watch reads the list again; a push during a read, one that changes nothing shown included, is followed by one more read, and nothing reads after stop().

The Swift hold for a full base run (#26809) is lifted. The package compile in this PR's own CI is the gate: this PR does not land before it is green.

Not in this PR

  • An idle-paused Cloud VM (grey, not red) is not resumed by a send on this path. That is unchanged from base; a follow-up will call the Cloud session's resume before the endpoint.

Supersedes #25993 (its pieces are reused here).

Agent session: agent session

šŸ¤– Generated with Claude Code