cmux NEXT: the app takes over a stray acpmux on its home instead of refusing it

Merged
TE
16d ago
+236-2

In progress. The review of d6a14bb FAILED; its findings were fixed in 1ce6a13. The re-review of 1ce6a13 FAILED on the parent-pid match (it could boot out a terminal app that started the daemon); fixed in 8d2a276. The re-review of 8d2a276: PASS (low notes below). risk:high (daemon lifecycle): held for coord-deputy before merge.

One acpmux per machine (hq plans/cmux-next/acpmux-one-daemon.md, step 2). When the daemon on the app's acpmux home is not one any cmux install runs, the app now takes it over instead of refusing it. Examples are a ~/.local/bin/acpmux started by launchd (com.acpmux.daemon) or by the CLI's autostart. Before this change, the server peer check refused that daemon because its executable is not the app's, and every chat said acpmux did not start.

  • What counts as stray (AcpmuxStrayDaemon.isStray): the daemon's executable (proc_pidpath, symlinks resolved) is neither the app's acpmux nor inside any .app/Contents/. Another cmux install's daemon is not stray: NIGHTLY shares ~/.acpmux with older builds, and the peer check's existing rules still decide those (lead decision 2026-10-10).
  • The takeover (handOffIfStray) runs only where the app may start a daemon (findOrStart with startsDaemon), before the version handoff. It is the same move as an update handoff: the old daemon stops, its agents detach, and the bundled daemon starts and adopts them. It acts only when the daemon reports agentHosts; an older one would end its agents, so it keeps running and stays refused, as before. It runs at most once per pid per app process.
  • launchd: the app finds the launchd job whose own process is the daemon, by the daemon's pid. Never its parent's: that may be a terminal or another job that only started it. Never an application.* job either. It reads the services list of launchctl print gui/<uid>, then confirms the job with launchctl print gui/<uid>/<label> (pid = N). If it finds one, the app runs launchctl bootout on it, so KeepAlive does not start it again. launchd's SIGTERM goes through the same shutdown_all as _acpmux/shutdown, so the agents detach (agent hosts setsid). The job is not disabled: acpmux host setup/update bootstrap that same com.acpmux.daemon, and launchd refuses a disabled one. A later login may start it again. If the bundled daemon already holds the home, that stray copy fails on the lock and KeepAlive retries it about every 10 s (the throttle) until it is fixed by hand or by host update. If the stray wins, the app takes it over again. A bootout that fails falls back to the shutdown request. If no launchd job runs it, the app sends _acpmux/shutdown over the socket.
  • Who is asked to stop: only the socket's own peer (LOCAL_PEERPID, AcpmuxServerPeer.socketPeer), and only when its pid equals the pid the daemon reports. That pid's executable must be stray and the peer must run as this user. Before this, the code acted on the pid the daemon reported, without checking it.

Risk: high. This stops a daemon the app did not start, and it boots out the launchd job that runs it (until the next login). It happens only for a daemon on the app's own home that runs outside every app bundle and has agent hosts, and no running agent is ended.

No capture: nothing on screen changes, except that chats connect where they used to say acpmux did not start.

Tests (AcpmuxStrayDaemonTests), committed first in ed07687 (red):

  • chatsReachTheBundledAcpmuxWhenAStrayDaemonHoldsTheHome starts the same-tree acpmux from a local/bin copy on a home, then the app's host from its own bin copy. Without the fix, the app's start is refused (daemonRefused, other executable). With it, the daemon then runs from the app's bin with a new pid.
  • onlyADaemonOutsideEveryAppBundleIsStray covers what counts as stray.
  • theLaunchdJobThatRunsTheStrayDaemonIsFoundByItsPid covers finding the job by pid in the services list and reading a job's pid.

Observed / Not run

  • Ran: scripts/cmux-next/prepush-gate.py (no new violations).
  • Fleet runs, all cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg Packages/macOS/CmuxNext on a macOS mini (PR CI skips these suites because they need the same-tree acpmux):
    • Red, on base. chatsReachTheBundledAcpmuxWhenAStrayDaemonHoldsTheHome alone on base 7eed5290 (scratch branch cmux/acpmux-absorb-stray-red @ 34cbc8be). It fails: the app's launch failed: daemonRefused(holder: …/local/bin/acpmux, … reason: "the socket's peer is …/local/bin/acpmux, not the acpmux this app runs"). The red commit ed07687 itself does not compile the test target, because AcpmuxStrayDaemon does not exist yet, so the base run is the behavioural red.
    • Green, at head 8d2a276. All three AcpmuxStrayDaemonTests pass, AcpmuxBundleReplacedTests passes, and no suite was skipped for needing the same-tree acpmux.
  • CI at 8d2a276: fleet/compile-check, Release compile, fast tier and generated files are green. cmux-next swift test fails, but none of its failing tests are on this change's path. They are sidebar render counts, the morphing card, page hibernation, Chief row titles and settings words, while this diff only changes the daemon start in AgentPaneHost.findOrStart. Base push runs skip swift test, and other open PRs fail different UI suites in the same job. Tour the app failed while setting up its runner.
  • Known residuals (low, from the 8d2a276 review): (1) a launchctl bootout that exits non-zero while it is still stopping the job, and a socket that is already gone, can fail one chat open; the next open finds nothing on the socket and starts the bundled daemon. (2) A launchd job that starts the stray as a child (not matched, by design) with KeepAlive can restart it. Each later open then takes it over again: agents are kept, but CLI clients on the stray are disconnected each time.
  • Not run: no Swift build or test ran on huge-blue or on Leo's Macs; all of them ran on the fleet and CI as listed above. The launchd bootout is not exercised end to end; only its parsers are unit-tested. CI must not change a runner's launch agents.

Changelog

Changed: cmux NEXT takes over an acpmux daemon it did not start (such as ~/.local/bin/acpmux) instead of refusing it, so chats connect and the agents keep running.

Leo-ask: ask-ddceee64a4

Agent session: huge-blue

🤖 Generated with Claude Code