acpmux: session move SESSION... --host PEER|auto (move chats to fleet hosts)

Merged
TE
7d ago
+1537-49

Leo (2026-10-29): "i thought acpmux had a move... or rather i did some stuff in cmux classic to do it tho it was a bit hacky maybe". He wants idle sessions moved off an overloaded interactive Mac to fleet hosts, not just stopped.

What

acpmux session move SESSION... --host PEER|auto [--all-idle] [--interrupt] [--leave-dirty] [--dry-run] moves chats from this daemon to an ssh peer's daemon, with their history, queue and draft. It builds on the existing _acpmux/transfer_* protocol (session transfer, which only reaches a socket on the same machine):

  1. Refusals (cli/session_move_plan.rs): a running turn without --interrupt, a chat that is being moved, one that already moved away, a folder that no longer exists here, a folder outside the home (unless --cwd PATH names the folder on the peer), a detached HEAD, a checkout with no origin, or uncommitted changes without --leave-dirty.
  2. Target: the peer named, or auto, which takes the placement's pick (_acpmux/place) among the peers. It never picks this machine or a host the fleet registry holds (placement::held, the one reader of hold and reservations, from #27731). Chats in one checkout go to one host, and a batch spreads by the chats it has already placed.
  3. Freeze first: the chat is frozen (transfer_begin) before anything else is done, the checkout is read again, and any later failure aborts the move.
  4. Folder: a checkout's branch is pushed to origin (never forced) and becomes a fresh worktree of the same branch on the peer (wt new where the peer has it, else a fresh mktemp clone of its own under ~/Projects/checkouts), with the subfolder kept (added to a sparse checkout). A plain folder under the home maps to the same path under the peer's home.
  5. Move: the peer daemon's own local socket is forwarded over ssh -L (ssh_forward.rs), so the peer accepts the move as a local client. The source's watch knows the target as {socket, ssh, host}, so a move whose client goes away is still ended by asking the target through a forward, and records the peer it went to.
  6. One writer: the commit records SessionMeta.moved_to {host, sessionId, cwd, branch, commit}. A moved chat is restored frozen after a restart (moving), so every prompt, queue, draft and policy change is refused. reopen_to_start refuses it with transfer.moved, and a second move is refused. A stopped (closed) chat can now move, because commit, abort and the watch key on moved_to instead of on Closed; the mark is saved at commit, so a restart keeps it moved, and a stopped chat whose move only began is never replaced by a same-id receive. A restart in the middle of moving a stopped chat freezes it again from its bundle under transfers/.
  7. Links follow it: summaries carry movedTo. resolve (agent links, PR footers) ranks the copy the chat moved to first and adds it when only the old copy matched. The CLI card prints moved to HOST/ID.

Placement (placement.rs): host rows now carry load (refused only for room, load or swap), which the move's auto choice uses. Holds and reservations come from the base's reader; this PR adds no second one.

Tests

  • session_move_plan_tests: cwd remap (worktree, including subfolder; plain folder under home), refusals (outside home, detached, dirty, no origin, unsafe branch), repo slugs, turn/moving/moved refusals.
  • session_move_tests: the auto choice (never local, spreads a batch, overloaded fallback, never reserved), the forward argv, checks on the peer's reply, a named peer must be an ssh peer.
  • placement_tests: a reserved host is skipped while the reservation is active, also when every other host is full, and lapses after until; an unreadable until holds the host.
  • locate tests: a link to a moved chat leads to the new copy, and the old card says where it went.
  • hub_integration/transfer: a stopped chat moves; the source refuses a prompt and a second move, never reopens, and reports movedTo; after a source restart it is still moved; a stopped chat whose move only began refuses a same-id receive.
  • session_move_plan_tests: --cwd names the folder outright and refuses a relative or .. path; the home maps to the peer's home with no trailing slash.

Ran on Linux CI (cmux-ci): acpmux lib tests (session_move, placement, locate, protocol, transfer), hub_integration transfer, transfer_cli, clippy, fmt.

Reviewed by Codex gpt-6-astra (high, fast): two NO-SHIP rounds, every finding fixed here.

No capture: no native or web UI change; the only web change is the regenerated protocol type for the new optional movedTo field.

Risk

Medium. This touches the move protocol's commit and abort conditions (now keyed on moved_to, not Closed) and adds a SessionSummary.movedTo field (optional, skipped when absent). It does not change how a non-stopped chat moves.

šŸ¤– Generated with Claude Code

Agent session: agent session