cmux NEXT: an update hands off each home's replaced acpmux, so a Chief chat is not left refused

Merged
TE
11d ago
+122-2

claude-sr2, relaying Leo's question "what's going on here?" about a cmux NIGHTLY chat that showed "Another acpmux service is using this socket. It will reconnect when that service is ready." with Retry. The order: "Fix it so the app adopts a healthy daemon on its own socket, of the same build or a compatible one, and the banner clears without Retry. Also find out why it didn't recover by itself."

What caused the banner

The healthy daemon on the app's own ~/.acpmux socket was not the problem. The app had already handed it off and started the bundled one after the update. The chat was a Chief-home chat, so it ran on the Chief home's own acpmux (~/.cmux/chief/default/acpmux; AcpmuxChiefSessionRouter's own host). That daemon still ran from the deleted old bundle (build 7957b9853, started 10-23 by the Sparkle copy that the update removed).

  • AcpmuxVersionHandoff.handOffIfStale (Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxVersionHandoff.swift:98 on base) let the handoff happen only once per bundled build, for the whole app process: handedOff.insert(bundled ?? "pid N"). The app home's handoff at launch used the key e7fcced5e 2026-10-25. So for the Chief home's daemon (same replaced bundle, decide() == .restart), the guard quietly returned false.
  • AcpmuxHost.findOrStart (AgentPaneHost.swift:361) then fell through to verifiedEndpoint. The server peer check cannot read a deleted executable (UnixSocketPeer is nil, so .noPeer), which gives daemonRefused(holder: nil) and the generic banner (AgentPaneStrings.swift:193).

Why it did not recover by itself

The once-only latch is process-wide and lasts for the app's whole run. Each automatic retry (about every 31 s in the app log) and each Retry went down the same path, hit the latch and was refused again. Person-key enroll refused the unknown peer as well. Crash recovery never applied, because the old daemon still answered _acpmux/status. Only relaunching the app cleared it.

The fix

The latch key is now <socket path> <bundled build>. The handoff still happens at most once per socket, and each home's replaced daemon gets its own. When the latch does block a second handoff on one socket, an error line is logged instead of returning silently. Nothing else changes: the other-home check (serves(home:)), attached and startsDaemons: false hosts, the agent-hosts requirement, stray takeover and the Chief host handoff (AcpmuxChiefHandoff, which has its own latch) are untouched. A truly foreign or incompatible daemon still gets the banner.

Tests (red, then green)

AcpmuxTwoHomesUpdateTests (new): two homes in one .app bundle each run a daemon from build N. The bundle is replaced in place, then build N+1 prewarms home a and then home b. Both must end up running the bundled acpmux. The test signals only daemons it started, checked by pid start time.

  • Red at c904f2b (test only, base code), fleet Mac: build N+1's launch on home b failed: daemonRefused(holder: nil, socketPath: ".../b/s.sock", reason: "the socket's peer process is unknown"). This is the same refusal cosmic-gray logged. Home a's daemon.log shows its handoff. AcpmuxBundleReplacedTests passed.
  • Green at 31cfe7b (the fix; 7c41242 changes only the test's cleanup), fleet Mac: AcpmuxTwoHomesUpdateTests, AcpmuxBundleReplacedTests, AcpmuxLostStartRaceTests, AcpmuxStrayDaemonTests, AcpmuxOtherHomeDaemonTests and AcpmuxChiefHandoffTests all passed. The PR's own swift test covers 7c41242.

Review

Codex gpt-6-astra xhigh, independent.

  • Round 1 at 31cfe7b: BLOCK on one test-hygiene finding (cleanup could SIGKILL a reused pid). Fixed in 7c41242 by checking the start time before signalling.
  • Round 2: SHIP, "Reviewed head: 7c41242a1f81a83f9c43f5dd967846ddfe5d24ea". The reviewer found no caller that relied on one handoff per build across sockets, and no conflict with #24420 or #24421.

Before / after (gallery renders)

Before: a Chief chat after an update. After: the same chat connects on its own, with no banner.

BeforeAfter
beforeafter

These are gallery fixture renders (agent-pane.host-error with the banner's text, and agent-pane.composer/new-chat), captured with Playwright Chromium on the fast-checks job from a capture-only branch. This PR changes no UI code. The banner itself is unchanged; the fix stops it from showing for this case.

No capture: this PR changes no UI code (one Swift latch key and a test). The banner's look is unchanged; the gallery renders above show the before and after states of the chat.

Risk and overlap

  • Risk: low. A second handoff can now happen in one app run, but only for a second socket, and only on the same decide() == .restart conditions as before.
  • No new timer or observer. The test's waits are bounded at 10 s each.
  • pr-overlap: #24070, #24026 and #23199 (all Leo's, stale or conflicting since 10-22) restructure handOffIfStale but keep the per-build key. None fixes this, and each would take a one-line rebase over it.
  • #24420 and #24421 (refused-daemon Restart UI) touch other files.

Agent session: big-red
šŸ¤– Generated with Claude Code