Move to Cloud: one action for the palette, menus and cmux cloud move (cx-rppd.3)
Summary
A local agent chat can now move to a Cloud machine from three places, all running one action (cloudMove): the palette's Move to Cloud, cmux cloud move (optional --path and --machine), and right-click (the tab menu's Move folder and the chat page's menu). This is slice 3 of Move to Cloud (cmuxterm-hq#1874).
What the action does:
- It picks the folder. That's the git working copy holding the chat's folder, or the chat's folder outside git.
.and..parts are resolved before the walk up to the working copy.- A path that climbs above
/, a relative path, the home folder and/are refused plainly. - The CLI makes a relative
--pathabsolute in the caller's folder. - Without a path it uses the chat's own folder (its tab's, else its session's), then a chat's folder in the workspace, then a terminal's.
- It reuses a Cloud machine (the named one, else a connected one, else the first) or creates one through the existing
cloud.createMachinepath. It connects through the existing machine link; there is no new VM lifecycle. - It runs
cmux link sync pushfrom this Mac's daemon to~/work/<folder>-<hash>on the machine (/home/cmux/work, falling back to/root/workon older images). - It runs
cmux link agent movefrom this Mac's daemon to the machine's daemon, with--cwdset to the synced folder. - On the machine it opens a Cloud workspace with a terminal in the synced folder and a chat tab bound to the moved session. That tab is in the machine's tree, so it carries the machine badge (the existing Cloud machine caption). The window reveals and selects it.
- It closes this Mac's tab, whose chat the move already ended.
Swift stays a thin shell: the copy and the chat transfer are the two cmux-tui CLIs, and the app only reads their JSON answer lines. Each CLI run is capped at 30 minutes (CloudMoveSetup.timeout; a first sync of a big repository can take minutes) and is killed past it. Separately, cmux cloud move waits up to 45 seconds for the app's answer; a longer move goes on in the app after the CLI returns.
Stops and Retry
Progress and errors are toasts, never modal. A stopped move keeps this Mac's tab and records what Retry does for it (CloudMoveRetries). The toast's Retry and running the action again on that tab both use it:
-
A move that may not have finished gets Retry, which runs the move again on the machine it started on, passed as the machine argument, so it never lands on another machine. This covers:
move_unresolvedandmove_in_progress;- a lost connection and a machine that didn't connect;
- a sync failure;
- a CLI that timed out, crashed or answered with something other than a JSON line. These say the move didn't finish, never "failed", because the move may have committed.
A rerun of
link agent moveresolves an unresolved move first. If that machine is offline, the toast says so and Retry stays on it. -
A move that committed but whose machine tab didn't open gets Retry, which only opens that tab again. The tab creation is idempotent by the move's id. It never syncs or moves again. This Mac's tab stays until the machine's tab opens.
-
agent_session.team_vm_blockedsays the machine is a team machine and chats move only to one you own.agent_session.host_unverifiedsays cmux can't confirm who owns it. Neither offers Retry. -
A Cloud machine on the app-server link (
CMUX_NEXT_CLOUD_LINK=app) is refused with a plain message. Its link socket is single-use, and the two CLIs need their own connections.
The chat page's menu hides Move to Cloud for a chat of another machine, as the tab menu does.
The tab can't keep its id
A tab lives in exactly one daemon store, and that store mints its ids. A remote chat attaches through the surface of the machine daemon's own store (agent-session-attach). bind-conversation-tab-session changes a tab's session but never its host, and MachineRegistry.agentDaemon(forTab:) refuses a local-tree tab whose session host is remote.
So a tab in this Mac's tree can't show a chat that now runs on the machine. The closest equivalent is what this PR does:
- a new tab in a Cloud workspace on the machine;
- bound to the moved
session_idthrough the machine's store (registry:<store>); - opened and selected in the same window;
- with the local tab closed (
reason: .sessionEnd).
Keeping the id would need cross-store tab ids or a tab whose host can change.
Seam for Bring Back (slice 4)
CloudMoveCLI already wraps both CLIs with a from-socket and a to-socket, and CloudMoveFolder maps a local folder to its synced path. Bring Back can reuse both with the sockets swapped. It isn't built here.
Test-only Cloud host seam (cmux-tui)
A real process counts as a Cloud host only with root-owned records under /etc/cmux (image stamp, vm-kind), and only a Cloud host serves fs-v1 and takes moved chats under $HOME/work.
For the Linux journey, debug builds read CMUX_TEST_CLOUD_ETC=<dir> once at daemon start. Under it, link/cloud_fs.rs and agent_start_host.rs read those records from that folder, owned by the daemon's own uid, with the walk stopping there.
The detection itself is unchanged:
- the Linux check, the stamp prefix and the model-plane identity;
- the owner versus team rule;
- the group/other-write, symlink and O_NOFOLLOW rules.
It is #[cfg(debug_assertions)] only, so release binaries keep /etc/cmux, uid 0 and /.
Testing
App: CloudMoveTests (Packages/macOS/CmuxNext)
The test sends cmux cloud move exactly as the CLI does (action.run, cli: true, origin cli, a tab: target) to the app's real action registry. This Mac and the Cloud machine are two real cmux-tui daemons; the machine's is reached the way the app reaches a machine's link socket. The two CLIs are a stand-in cmux that records its arguments and answers as each case asks.
aCLIMoveOfALiveChatEndsWithTheTabAttachedToTheMachineChatchecks:- the exact sync and move argv;
- the machine's chat tab is bound to the moved session through the machine's store and attaches through it;
- that tab has the machine badge;
- the terminal runs in the synced folder;
- this Mac's tab is gone, with no retry left.
aMoveThatStopsKeepsTheTabAndRetriesOnlyWhatIsLeftruns one case per stop:move_unresolved,move_in_progress, a garbage answer, a CLI timeout, a machine tab that fails after the commit, and a team machine. Each case checks:- this Mac's tab is kept;
- the retry state and its target machine are right (
vm-move, or none for a team machine); - for the tab failure, a Retry runs only the tab: no new sync or move, the machine's chat tab opens, and this Mac's tab closes.
Red and green, same runner (scripts/ci/run-swift-testing-suites.sh Packages/macOS/CmuxNext via cmux-ci run --class light):
- Success test, red on
4f449e8467a63771398494d9ac37eb0752882e6d(branchcontinuity-cloud-move-red), stepe345dc71109929222b34468ewith--red: nocloud moveaction.- That branch is the test commit
0309b9bba5d0plus its compile fixc8d00598b034.0309b9bba5d0didn't compile on its own, and wasn't rewritten because this branch was already pushed.
- That branch is the test commit
- Failure-path test, red on
0e8bc6cfeb222db292cb06522b521ebe17746f55(branchcontinuity-cloud-move-red3), step294cf2a9b86a92a30e29f932with--red. The five retryable cases fail, with no retry recorded and nothing kept to retry. The team-machine case and the success test pass. - Green on the head,
20bc0e7c5a437a89c502f7bdc1782123f171f65d.--shards 4, stepsba9a8ff81342c7b2d93e96b2,98816891f84ed4cfca9de261,c7eda2eedfb8a4dc8e06e0fcand969e91758d3b1a1cc05063fe: all suites pass. CloudMoveTests (the success test and all six stop cases) ran in step969e91758d3b1a1cc05063fe, with this tree's cmux-tui published (pushed as acmux-tui-pin-*ref), so the live-daemon suites ran, not skipped. That also builds the merged head and its tests.
Linux journey: cloud_move_journey.rs (cmux-tui, real processes)
The journey runs two headless cmux-tui daemons, each with its own real acpmux running acpmux/tests/fake_agent.py:
- Mac: a chat with a finished turn, a turn running, a queued prompt and a draft, bound to an agent tab of its store.
- Cloud VM: shaped like a personal one, with a HOME holding
~/work,CMUX_VM_IDandCMUX_CODEROUTER_URL, and the image stamp andvm-kind ownerin theCMUX_TEST_CLOUD_ETCfolder.
The steps:
cmux link sync pushcopies a dirty git working copy into the VM's~/work. The.gitfolder, the uncommitted change and the untracked file arrive, andgit status --porcelain=v2 --branchandHEADmatch.cmux link agent move --interrupt --cwd <synced folder>answers ok with the session id.--interruptis needed because the source refuses to begin a move while a turn runs.- On the VM:
- the history and draft are there;
- the cwd is the synced folder;
- the queued prompt ran exactly once (one user message, one reply, one turn result, rechecked after 2 s), and the queue is empty.
- On the Mac: the session is closed with a
transferredrecord, and the queued prompt never ran there.
Both CLIs are given the VM daemon's own Unix socket, as the trusted local client that the Cloud link's remote connect bridge gives the app; a headless remote connect bridge between the two daemons wasn't attempted. The file is #![cfg(target_os = "linux")], because a Cloud host is Linux.
Hosted cmux-tui.yml, focused mode, test_filter=cloud_move_journey, with the same command for every run:
| Run | Commit | What it is | Result |
|---|---|---|---|
| 38189137373 | ba9dbf78bc32 | the test alone | Red: the sync is refused with fs.unavailable (not a Cloud host) |
| 38189942278 | eb6ebd8fb3d2 | adds the seam | Fails later: the sync passes, but the move refuses the fake agent's short session id |
| 38191044969 | f5248bbef263 | real-length fake session ids | Green |
| 38194409042 | 20bc0e7c5a43 | this head | Green (cloud_move_journey_moves_a_dirty_folder_and_its_chat_to_a_personal_cloud_vm ... ok) |
Fleet scripts/ci/cmux-tui-rust-check.sh --arg clippy (macOS compile and clippy; the journey is compiled out there): steps afc305dac98f42f45d8d8c78 and e976fc9faf6fc83117839412, both exit 0.
Other checks
- Action surfaces:
scripts/measure/export-action-surfaces.shon the fleet, stepd45c7dd4bba9a2144016666con the merged head, exports committed in20bc0e7c5a43. - Release compile:
scripts/cmux-next/check-release-compile.shon this head20bc0e7c5a437a89c502f7bdc1782123f171f65d, stepbbf41afb98140b0a0ebdc4da, BUILD SUCCEEDED. - Local:
check-no-godfiles.shexits 0.check-concurrency.shreports only its 2 violations that are already on the base.crash_ratchet.pyreports the same Swift gains as the base tree, none in this change's files, and no Rust gains.
- Windows:
cargo check --target x86_64-pc-windows-gnu -p cmux-tui-core -p cmux-tuiwas not run. The workflow's Windows job runs only in full mode. Every changed Rust source line is in unix-gated modules, and the test is Linux-gated.
UNVERIFIED: the real-VM GUI proof
It waits for Leo's M5 Pro; fleet Macs are not signed into Cloud. That run should prove:
- A live local chat with history and a queued prompt, in a dirty git folder.
cmux cloud move.- The folder and git state on the VM under
~/work. - The chat adopted there, with its queued prompt running once. First confirm that the current dev VM image writes
/etc/cmux/vm-kind=ownerat bind; otherwise the move is refused asagent_session.host_unverified. - The terminal recreated, and the machine's tab showing the VM chat with the Cloud badge.
Localization audit
21 new keys, each translated into all 20 app locales and imported with scripts/localize-changes:
- 20 keys in
CmuxNextApp/Resources/Cloud.xcstrings(cloud.move.*: progress, success, Retry and every error, includingcloud.move.relativePath); action.cloudMove("Move to Cloud") inCmuxNextActions/ActionCatalog.xcstrings.
The optional --machine argument reuses the existing argument.machine key. The catalog parity error count is unchanged from the base.
Changelog
Added: Move to Cloud moves a local agent chat and its project folder to a Cloud machine from the palette, the tab or chat right-click menu, or cmux cloud move
Proof
No capture: the live GUI run waits for the real VM above.
🤖 Generated with Claude Code