cmux NEXT Cloud: a first chat an older daemon refused updates the machine and starts once more
Leo: "whenever i start a chat it should be able to send me to cloud automatically".
What was wrong
A freshly created Cloud machine refused its creator's Claude chat with agent_session.team_vm_blocked ("Agent chats cannot be started from this Mac on a team VM yet"). The refusal came from the machine's own cmux-tui.
- The image is behind. A fresh machine boots the cmux-tui its image baked.
images/cmux-vm/inputs.lock.jsonpins308bd36969c8(#27936, Oct 29), which predates #29107 (c8af6687). #29107 is the change that lets a team VM take its creator's chat on the Cloud API'svmKind: "owner"word. - The owner word does get sent. The app sends
vm_kind: "owner"whenever the daemon advertises it, and the API'scallerVmKindgives the creatorowner. The old daemon refuses a team VM whatever word it gets. - The app's update loses a race. The app already updates an older Cloud machine to its own pinned build (
cmux link machine update --to-bundled --if-older,CloudMachineUpdatesandDaemonUpdatePolicy). That update runs in the background from the machine's handshake, and nothing waits for it. A chat started right after the machine comes up (cloud.startChat,cmux cloud start-session, a first send) reaches the old daemon. Leo's VM worked only after a newer cmux-tui was copied into~/.cmux/bin/cmux-tuiby hand.
What changes
A start that the machine's older daemon refuses with team_vm_blocked or host_unverified now updates the machine and starts once more. This only happens on the caller's own machine, meaning the link's owner answer is present.
DaemonUpdatePolicy.updateNow(_:)is new.- It queues the machine at the front of the shared policy's queue and returns once that turn ends.
- Like the person's Update click, neither the ledger nor this run's memory holds it back.
- It joins an update of that machine that is already running instead of running a second one.
- It returns at once while
agent.updatePeersis off, or when the start is cancelled.
AgentTabRemoteHost.updateMachineis new.keyedStartruns it on those two refusals, re-runs the start'sproceedcheck (the account check), then tries the start once more.- Every other refusal, and a refusal with no update ran, stands as before.
CloudMachineUpdates.updateNowbuilds the same target as the background offer, so the update keeps the same guarantees:--if-older, never a move back;- the app's own pinned build;
- terminals and chats keep running, and a rollback happens if the new daemon does not serve.
- The wiring sets
updateMachineonly for Cloud machines. It calls it only while the link's owner answer is present. A teammate's machine is still refused by the app before any start is sent, and is never retried.
The new logic stays in the existing Swift update policy and host. The update itself is already Rust (cmux link machine update), and this adds only the "wait for it, then start again" glue around it.
Tests
DaemonUpdatePolicyTestscoversupdateNow:- it updates the machine and returns after the turn;
- an earlier failure on this build does not hold it back;
- it joins an update that is already running (one call, not two);
- it returns at once with the toggle off.
CloudStartSessionTests.aStartAnOlderDaemonRefusedUpdatesTheMachineAndStartsOnceMorecovers:- each of the two codes leads to update,
proceed, then a second start; - no update, or a refusal outside the set, leads to one start only.
- each of the two codes leads to update,
Risk
- A refused first start on an outdated Cloud machine now waits for that machine's update, which takes up to a few minutes while the chat shows starting. Before, it failed at once.
- The update runs mid-turn, as the automatic update already does (rulebook: "THE DAEMON UPDATE ... RESUMES WHATEVER IT INTERRUPTED").
- No timer or observer is added. Each wait ends when its target's turn ends, when the queue drops the target, or when the task is cancelled.
Not in this PR
- Capture. The pr-capture Cloud fixture pinned the same 308bd369 on its own machine (cmuxterm-hq#5399), and a DEV capture app cannot update machines because it ships no signed pin. The nightly signed-in leg (hq run 41207279977) therefore failed by construction. A cmuxterm-hq PR makes the fixture's machine run the app's own bundled cmux-tui commit.
- Image. No workflow rebakes the Cloud image when cmux-tui moves. The pin, bake, promote and channel PR are all manual. Filed as a follow-up.
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
A first chat on a fresh Cloud machine now survives the machine's older daemon: when the daemon refuses the start with team_vm_blocked or host_unverified, the app updates the machine to its own build and starts once more.
- A new machine boots its image's cmux-tui, which predates the owner-chat support the app's start relies on; the automatic background update started at handshake usually lost a race to the first chat.
- The retry queues the machine at the front of the shared daemon update policy, due now and joining an update of that machine that already runs; it waits for that turn to end on a capped stream, including the next turn when a joined one ends before its update ran, then re-runs the account check before starting again.
- A refusal that joins a running update never runs a second one, even when that update fails; the joined turn itself answers the refusal.
- The retry happens only on the caller's own machine, where the link's owner answer is present; teammate machines, and any other refusal, behave as before.
- A refused first chat now waits up to a few minutes for the machine's update instead of failing immediately.