acpmux: host enroll keeps the daemon's listener and never takes another daemon's port
The ask
From the fresh-user ergo-walk on gcp-codex-lane-12 (2026-10-25), word for word:
"acpmux host enroll my-linux --harness codex --json on existing non-default listener ā assumes port 47811, restarts the healthy isolated daemon, changes listen from 47921 to random 46673 (47811 belongs to host daemon), then fails and suggests daemon start ā preserve the existing peer URL/listener or refuse before changing it with an exact manual recipe. Recovery used: restore websocket.listen to 127.0.0.1:47921 in remote config, acpmux daemon shutdown; acpmux daemon start, then client acpmux host add my-linux ssh://my-linux:47921. Never touched the live daemon on 47811."
Cause
host enroll built its plan with port: SSH_PEER_PORT (47811) for every host. The daemon step saw the healthy daemon listening on 47921, called that a mismatch, rewrote websocket.listen to 47811 and restarted it. On a host where another daemon owns 47811, the restarted daemon fell back to a free port (46673, daemon.rs's taken-port fallback), so the peer never matched and the step told the user to run daemon start.
What changed (cli/host_enroll.rs)
Plan.portis nowOption<u16>: the port a known peer uses, orNoneforhost enrolland forhost doctoron a host with no ssh peer yet.- The daemon step reads the port the daemon listens on from the running daemon's own
daemon status(listen), falling back to the config. With no port given, enroll peers at that port and does not rewrite or restart a healthy daemon. - If the daemon has no listener yet, enroll writes the default and restarts it, then peers at the port the daemon actually bound. If another daemon holds 47811, that is the free port the daemon moved to, so the peer never points at the other daemon's port.
- If a known peer's port differs from the daemon's, the step refuses before any change. The refusal names both ways to line them up:
acpmux host add NAME ssh://HOST:PORT, or setwebsocket.listenin~/.acpmux/config.jsonand restart that daemon. A restart that still ends on another port now gives this recipe instead of "rundaemon start". - The step's ok detail names the port ("running, pid N, build B, on port P").
Tests
Three new tests in cli/host_enroll_tests.rs use a fake host with two daemons: this user's on 47921 and the host's on 47811, where a restart onto 47811 lands on 46673.
enroll_keeps_the_listener_of_a_daemon_on_another_port: ok, peers at 47921, nothing written or restarted.enroll_never_takes_the_port_another_daemon_holds: with no listener, the step is Fixed and peers at 46673.doctor_refuses_to_move_a_healthy_daemon_to_the_peers_port: with the peer at 47811 and the daemon on 47921, the step fails with thehost add ... :47921recipe and changes nothing.
Red at the test-only commit aca4101c18 (all three fail with the walk's exact message: "...after a restart, the daemon listens on 127.0.0.1:46673, the peer uses port 47811"): https://github.com/manaflow-ai/cmux/actions/runs/40259028855
Green at b681723af9 (cargo fmt --check, cargo clippy -p acpmux --all-targets -D warnings, cargo test -p acpmux --lib host_: 122 passed): https://github.com/manaflow-ai/cmux/actions/runs/40259549887
Review
Codex gpt-6-astra (xhigh, read-only reviewer) blocked b681723 with one finding: a daemon run with --listen/--token has no saved listener, so it was treated as a repair and rewritten onto the peer's port. Fixed in 62106c7: the bound port is compared first and a mismatch is refused before any write, covered by the new test doctor_refuses_to_move_a_daemon_run_with_its_own_listener_and_token. The reviewer re-reviewed and said SHIP. 2dc4ed3 then moves the daemon step unchanged into cli/host_enroll_daemon.rs to keep host_enroll.rs under the 1000-line god-file limit. CI at 2dc4ed3 (fmt, clippy, host_ tests: 123 passed): green.
Risk
The change is contained to the enroll/doctor daemon step. host doctor NAME with a peer behaves as before, except that --fix no longer moves a daemon that listens on another port. It refuses with the recipe instead. No timers or observers are added. No capture: CLI-only, nothing visible in the app changed.
Agent session: big-red
š¤ Generated with Claude Code