cmux-host: hold the relay-first chat socket from bind on (hq#7086)
Lane (a) of manaflow-ai/cmuxterm-hq#7086, the relay-first chat path, machine half. Leo: "it should take <30 seconds from starting a thing to having a response on cloud". Asked "So what's the best way?", he approved this design: "OBVIOUSLY".
The Cloud agent in cmux-host now opens one outbound WebSocket to the platform's MachineRelay as soon as the machine is bound, and keeps it open: GET {platform}/v1/cloud/machines/{machine}/relay as this machine's own VM install. A person's chat then reaches the machine over HTTPS and this socket, and never waits for the app's link to the machine. That link is what fails on prod today ("Machine didn't connect", hq#7060).
- New
cloud/relay.rs, a thread with its own current-thread tokio runtime.- It mints the bearer with the machine's install key, the same way the GitHub minter does, before each dial. Reconnect backoff is 1 s doubling to 30 s. It pings every 20 s, and a socket silent for 60 s is dropped and dialed again.
- A chat with no
sessionfirst runscmux-tui acp --json new -d --reuse -m AGENT -n relay-<id> --cwd DIR. - Then
acp --json send --wait --no-steer --prompt-id ID SESSION -runs, with the prompt on stdin. Both run as the work user with a clean environment, the same way the trajectory importer runs acpmux. - Events go back up:
started {session, prompt_id},text(agent message chunks),record(raw_acpmux/eventwith itsseq),permission, thendone {stop_reason}orerror {code, message}. cancelaborts the chat. That drops the child, and the child is killed.
- Frame checks: agent, ids, session and cwd are checked before anything runs, so no value can reach a flag or a shell. The prompt is never an argument. Nothing logs a prompt or a reply.
- Lifecycle:
role.rsstarts the relay instart_liveand replaces it on a rebind. - Lane (c) handoff:
prompt_idandseqlet lane (c)'s direct link resume the same acpmux session without a gap or a second turn.
Depends on manaflow-ai/cmux-platform#820, which adds the routes. Until that is deployed, the dial is refused and retried with backoff, so nothing else changes. The app/CLI caller comes in a separate PR.
Tests
cargo test -p cmux-host --lib cloud::relay: 7 pass, covering frame parsing and refusals, command building, event mapping, and final events. cargo clippy -p cmux-host --tests -- -D warnings is clean.
Observed / Not run
Run: the unit tests and clippy above. Not run yet: a live machine holding the socket. That is next, once platform#820 is on staging, using a staging image built from this branch.
Changelog
none
Agent session: agent session
🤖 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
The Cloud agent opens one outbound WebSocket to the platform's MachineRelay as soon as the machine is bound and holds it open, so a person's first chat reaches the machine and never waits on the app's link to the machine (which fails on prod today with "Machine didn't connect"). A chat sent down the socket runs in the machine's own acpmux as the work user; a tail on the session streams its text, records, and permission asks back up, and done carries the send's whole reply, which stands over the streamed text. cancel aborts a chat; dropping a socket ends all of its chats, a dropped chat also runs acp cancel so the daemon doesn't keep the turn going, and kill_on_drop kills the acpmux children.
- The socket reconnects with backoff (1 s to 30 s), pings every 20 s, and is dropped if silent for 60 s.
- Frame fields are validated before anything runs, so no value can reach a flag or a shell; the prompt is never an argument and nothing logs a prompt or a reply.
- Failures carry acpmux's exit codes, naming timeouts, missing sessions, and permission denials.
role.rsstarts the relay on bind and replaces it on a rebind, closing the old socket's chats at once.- A repeated chat id is refused instead of running a second send.
- A chat's output is drained past its cap so a verbose child never blocks; a reply past the cap ends as
donewithreply_cutand no reply. - Chat processes run with the work user's own supplementary groups, each session is named after its whole chat id, and a socket's frames wait in a bounded queue so a slow socket holds acpmux back instead of queueing.
- The relay refuses to start on kernels without
pidfd_open: the agent blocks SIGCHLD and the thread inherits that mask, so a chat's exit could never be seen. - Needs the platform routes from manaflow-ai/cmux-platform#820; until deployed, the dial is refused and retried with backoff.
prompt_idandseqlet a follow-up direct link resume the same acpmux session without a gap or a second turn.