cmux NEXT: Cloud machines show Pausing…, Resuming… and Deleting…, and a failed change says why

TE
6d ago
+4473-2112

Leo, 2026-10-29: "let's just get the cloud and its respective ui up to snuff" (and, on the composer: "as well as the ui usability of the cloud stuff").

Friction fixed: Cloud machine in-between states

The cmux-next backend (backend/packages/protocol/src/cloud-machine-ops.ts, domains/cloud-power.ts) answers Pause, Resume and Kill at once and runs the provider call after. Until it finishes, the record reads pausing, starting or deleting. If the call fails, the machine reverts and the record's error says why. The app hid all of that:

  • CloudMachine.init(next:) mapped pausing to Paused, starting to Provisioning and deleting to Destroyed. A pausing machine read Paused and offered Resume. A resuming machine looked like a new machine being created.
  • The pause reason was read as pauseReason, but the wire key is pause_reason, so idle pauses were never recognised as idle.
  • The record's error was dropped. A pause that failed after the server answered OK left no trace.

This PR fixes those:

  • States. CloudMachine.Status gains pausing, starting and deleting. The stage, the sidebar machine header and the hover card show them as Pausing…, Resuming… and Deleting…, with no needs-attention badge. The header draws the on-its-way spinner (MachineLinkState.connecting, from #26786), and the hover card uses the attention tone. The strings are localized into all 21 locales of Cloud.xcstrings and the sidebar catalog.
  • Power actions. Pause and Resume are off while any change is underway, with a tooltip naming the change. Kill is off only while the machine is being deleted, so a machine stuck pausing or resuming can still be killed. The gate keeps any reason an earlier target-reason binding set, so it composes with #27944's state gating whichever lands first.
  • pause_reason. It is read as the backend spells it. pauseReason is still accepted. no_report (the cost backstop) counts as an idle pause.
  • error. It maps to CloudMachine.lastChangeFailure. A failed machine's stage shows that message. A machine that reverted (for example back to Paused) shows "The last change to this machine failed: …" in its header tooltip.
  • From the click. Pause, Resume and Kill show the change as soon as you click (pendingChange). If the call throws, the machine goes back to its real state. The next list read replaces the record.
  • Settling. The machine list is not polled. While a listed machine is changing, the list is read again after 3 s, at most 40 times in a row. The reads stop when nothing is changing, when a read fails, and on CloudService.stop() (a read already in flight then schedules nothing), until start(). That covers the timer's stop conditions and cleanup.

Risk

Medium. Behaviour changes are named here:

  • After a Kill, the machine stays in the sidebar as Deleting… and disconnected until a list drops it. Before, it was removed at once and could reappear when the next list still held the deleting record.
  • The CLI and MCP machine status (cloud_machines.rs) now reports pausing, starting and deleting instead of folding them into paused, provisioning and destroyed. A deleting machine reports deleted: false until it is gone.
  • One bounded re-read loop (above).
  • The sidebar header's status vocabulary grows by three. plans/cmux-next/sidebar-owner.md notes it, and the commit carries a Sidebar-Look: trailer.

Cloud.xcstrings shows a large diff only because of how its JSON lines up. Under git diff --histogram it is 1,320 lines of pure additions (10 keys × 21 locales). The code is about 620 lines.

Overlap: none of these fix the in-between states, so this is not a duplicate.

  • #27944 and its carry #28208 gate Pause/Resume by paused or live. Both bind through setTargetReason. This PR's gate keeps any reason bound before it. #28208's pauseResumeMismatch should do the same, or it should run before this gate, so the two add up.
  • #28010 and its carry #28229 route records through apply(_:to:read:). This PR only adds changes.listed(list) after reconcile, and isLive still counts starting as live, so the two compose. Whichever lands second has a one-line conflict in refresh().
  • #28011 adds a Disconnected header. It touches the same SidebarMachine.Status, header and string lists, so expect textual conflicts and no semantic clash: Disconnected is a link state, and these are record states.
  • #26872 (landed), #27031, #27104, #26791, #26786 and #27923 touch some of the same files but not these mappings or states.

Screenshots

No capture: the stills below are AACloudMachineChangeGalleryTests renders of the real SidebarListView, made on a fleet Mac with a stubbed machine status. A live cmux-ci capture would need a real Cloud machine caught mid-pause, and the capture fixture has no Cloud account (fleet-registry: the cloud-capture environment has no secrets yet).
These come from AACloudMachineChangeGalleryTests on a fleet Mac. They show a stubbed staging machine, staging-vm, with two workspaces, in dark and light. Each workspace row's subtitle carries the machine status.

The columns are: before, where a pausing record read "Paused" (and offered Resume); then after, showing Pausing…, Resuming… and Deleting….

all

Before (pausing read as Paused)After
Darkdark beforedark after
Lightlight beforelight after

Follow-up, not in this PR: in app-server mode, Pause, Resume and Kill still call the /api/vm REST methods instead of cloud.machine.pause/start/delete. That is the same as on base.

UI review checklist

I ran design/ui-review-checklist.md:

  • The header uses the existing status column and the spinner that #26786 draws for a machine on its way. This PR adds no new layout, animation, narration or highlight.
  • The states are shown, not narrated. Copy is single-spaced, and the row keeps one weight.

Tests

  • CloudMachineChangeStateTests (new) covers every wire status, pause_reason in snake case and camel case, error mapping (including /api/vm decode), stage and strings, header status, words and tone, the failed-change tooltip, show and roll back from the click, settling, and the gate rules with the bound actions' target reasons.
  • AACloudMachineChangeGalleryTests (GUI, CMUX_TEST_REQUIRE_GUI=1) renders the screenshots above.
  • At 0b146c3, a fleet compile (cmux-ci compile-check --app) passed for the app and cmux-tui. On a fleet Mac, CloudMachineChangeStateTests, CloudIdlePauseTests (21), CloudMachineUnnamedWorkspaceTests, CloudMachineCapabilityTests, CloudMachineCostTests, CloudListMachinesTests, CloudTrackedResultsTests and MachineLinkStateTests passed. AACloudMachineChangeGalleryTests passed at 9c0fb6c, after the rebase.

Agent session: agent session

🤖 Generated with Claude Code