cmux NEXT Cloud: the app updates every older Cloud machine, not only one that lacks a capability

TE
10d ago
+729-154

The order (claude-sr2): "don't leave old Cloud machines stranded. Give them an update path that doesn't need fs-v1 or detached-terminals-v1: bootstrap the newer daemon over the channel they do have (exec, or ssh)..."

What was wrong

After a nightly update, the app updates acpmux ssh peers and Cloud machines through one queue (DaemonUpdatePolicy). A Cloud machine was only queued when its daemon was missing a capability the app uses (level == .limited). A Cloud build string has no date, so the app had no way to tell an older machine from a newer one, and it played safe by skipping any machine that served everything. A Cloud VM on an older build that already served every capability (one Cloud machine on 8b3b1891 while the app bundled 52b77ef2f) was dropped before any attempt, so no app update ever reached it. The app's update ledger had acpmux-peer:* entries and not one cloud: entry.

Fix

  • cmux-tui: identify {revisions: true} also reports build_time, the unix time of the source commit (cmux_build_identity::BUILD_TIME, the same value remote update-need already orders ssh builds by). It is opt-in like the other revision fields, so strict old SDKs keep their frozen reply shape. Spec, SDK schema, api-compat surface and generated bindings are updated.
  • cmux link machine update --to-bundled --if-older: updates only a machine whose build time is earlier than this client's. A machine that reports no time (a build from before this change) counts as older, as update_need counts a remote with no facts. A newer machine, or the same source tree, answers {"ok":true,"updated":false,"not_older":true} and is left alone. If the time can't be read, the answer is machine_unreachable (asked again later), never a guess.
  • App: the automatic Cloud update now offers every connected machine (any level except refused) and passes --if-older. not_older maps to handledElsewhere: nothing changes, nothing is recorded, and no notice shows.
  • Exec fallback (CloudLegacyMachineUpdate, for a machine without fs-v1): it still runs only after the CLI answers machine_update_unsupported. That answer now names the build_commit it judged older, and the app passes exactly that value to the guest script as <run-dir>/expect-build. The guest script (web/scripts/cloud-vm/cmux-tui-upgrade.sh and its byte-identical app copy) checks it under the machine's upgrade lock, while also holding the in-place updater's lock (~/.cmux/bin/.cmux-tui.update.lock, the one link machine update's apply step takes) for the whole run, before anything changes. It reports SKIP changed (busy: asked again, and --if-older reads the new build) if another app or the fleet updated the machine meanwhile. The fleet runner writes no such file, so its runs are unchanged. An unsupported answer that names no build runs no exec update.
  • Policy: a machine that once said it couldn't update, and now runs another build that needs nothing, has its stale "cannot update" line cleared when it is dropped.

Update Machine (the click) is unchanged: it runs link machine update without --if-older.

Tests

The new journey test machine_update_if_older_updates_only_an_older_machine runs real processes on one Linux box. A machine built "in 2100" is left alone and its daemon is not restarted; one built "in 1970" is updated in place.

Also covered:

  • identify reports build_time only with revisions.
  • Argument parsing and the ordering rule.
  • Swift (CloudMachineUpdateTargetTests): an older machine that serves everything is updated and recorded; a newer one (not_older) is left alone, not recorded and not reconnected; the CLI gets --if-older.

Also: the web test expect-build {absent,same,changed,no-commit,in-place-busy} for the guest script's check (a binary that names no commit is held back too, and a held in-place lock is SKIP busy); the Swift exec test hands the guest the judged build and maps SKIP changed to busy; an unsupported answer without a build runs no exec update; the journey's unsupported case names the identify build_commit and restarts nothing.

No capture: no visible UI change. Only the help text of the Debug Settings toggle "Update Cloud machines automatically" changes; the update itself has no new surface.

Risk

Medium, Cloud only. On every connected Cloud machine whose build is older than the app's, the app now restarts the daemon in place, also mid-turn. That is the standing order: daemon updates don't wait for idle, and the new daemon adopts the terminals and chats it interrupted. Before, only machines missing a capability got this. A machine on a newer build (another app, a newer nightly) is never moved back: the CLI checks the build times before it stages anything.

Timers and observers: none added.

Agent session: agent session
🤖 Generated with Claude Code