link: cmux link seed push and seed clone, a Cloud project seed with the copy as fallback

Merged
TE
4d ago
+1296-33

Piece 4 of the Cloud project seed (leo-workspace design/cloud.md, "Getting the project onto the machine"), the CLI half. Leo, on the start screen's line:

Your project folder is copied up.
we'll want to deal with the edge cases for this too

This adds cmux link seed push and cmux link seed clone, which put a project on a Cloud machine the way the design says: a git folder with a GitHub remote is seeded (the machine clones the remote, this Mac sends one bundle of what the remote lacks), and everything else takes the streamed copy. It builds on #30464 (the Mac-side plan), #30382 (the machine's git-seed-v1), #30427 (the copy fallback) and #30280 (the move pump's --sync). start-chat copy and the app switch to it in the next PR.

CLI

cmux link seed push --socket S LOCAL REMOTE [--since-last]
cmux link seed clone --socket S --repo OWNER/NAME [--ref REF] REMOTE
cmux link agent move ... --sync seed --sync-socket S --sync-local L --sync-remote R
  • push reads identify. A machine without fs-v1 answers machine_outdated. Otherwise it plans (seed::plan), uploads the bundle through fs-v1 to REMOTE/../.cmux-seed-<name>-<id>.bundle (inside the fs roots), calls git.seed.start, and reads git.seed.status once every 500 ms until the job ends or 45 minutes pass. That wait is a bounded loop inside the CLI process, not an agent polling loop. The upload is deleted afterwards either way.

  • Fallback to the copy (sync_cli::push, a full copy) happens when:

    • the plan answers a Fallback (not_git, not_top, no_commit, no_remote, ambiguous_remote, not_github, no_base, unmerged, submodule_local_work, git_failed);
    • the machine is another build without git-seed-v1 (machine_outdated);
    • the machine answers seed.needs_github, seed.base_missing or seed.clone_failed (or seed.unavailable).

    seed.base_missing first re-plans once from the remote default branch's tip (plan_with_base with Planned::bases' last). If the failed seed made the folder, the folder is removed before the copy. The answer then says mode:"copy", fallback_code and fallback_why. For needs_github it also carries install_url, so the app can offer the install while the chat goes on.

  • --since-last never falls back to a full copy:

    • After a seed, it sends a delta bundle (plan_since). Nothing changed: unchanged:true and no upload at all.
    • After a copy (mode copy), it re-pushes the copy to the same REMOTE, which the copy's manifest makes incremental.
    • No record, or a record from another machine: seed.no_previous. The machine's seed.since_mismatch and seed.machine_changed come back as errors.
  • Record: <state>/link-seed/<sha256(local NUL remote)>.json (0600, folder 0700), beside sync_cli's <state>/link-sync base record. It holds {mode, machine (identify's session_id), url, head, index, work}.

  • clone: the clone-only git.seed.start. It answers {ok, mode:"clone", head, head_ref}, or the error code plus install_url.

  • agent move --sync seed: once the chat is frozen, the pump calls seed_cli::push_since_last. An error gives the chat back, as --sync push does, with seed_cli::failure_code's code.

Output

stderr progress JSON lines (throttled to one per 250 ms, first and last always):

  • {"progress":{"phase":"upload","bytes_done","bytes_total"}} while the bundle uploads;
  • {"progress":{"phase":"clone|fetch|bundle|checkout|submodules|lfs","bytes_done","objects","objects_total"}} from the machine's job;
  • the copy's own {"progress":{"files_done","files_total","bytes_done","bytes_total"}} for a copy.

stdout, one line:

{"ok":true,"mode":"seed"|"delta"|"copy","unchanged":bool,"bundle_bytes":N,"head":SHA|null,"work":SHA|null,
 "skipped_files":[{"path","kind","why"}], "copy":{sync summary}?, "fallback_code"?, "fallback_why"?, "install_url"?}
{"ok":false,"error","error_code","install_url"?}

Test seam

git-seed-v1 got a debug-build-only seam, CMUX_TEST_GIT_SEED_GITHUB=<dir>. It makes the daemon's seeds clone https://github.com/O/N.git from file://<dir>/O/N.git with no credential. GitSeedService::with_test_github does the same for in-process tests. Release builds never read the variable, and nothing on the wire reaches it.

Tests (Linux, cmux-ci test)

  • tests/link_seed.rs: a real FsService and GitSeedService behind a socket, with a file:// bare repo as GitHub. It covers:
    • a full seed (unpushed commit, staged and untracked files, .env skipped by name, ignored folder left out, no bundle left behind), then a delta, then an unchanged delta that makes no fs.write;
    • --since-last errors: seed.no_previous (and nothing copied), seed.machine_changed, seed.since_mismatch;
    • fallback to the copy for a non-git folder (not_git, then a copy-mode --since-last re-push that writes only the new file), for a GitLab remote (not_github) and for a repo the machine cannot clone (seed.clone_failed);
    • a machine without git-seed-v1 (machine_outdated);
    • seed clone, ok and seed.clone_failed.
  • tests/cloud_move_journey.rs (two real daemons): the journey now seeds the dirty folder with cmux link seed push (mode seed, git status identical on the VM). It then moves the chat mid-turn with --sync seed, and the turn's last edit (src/turn.txt, written after the seed) arrives through the delta bundle.

Risk: the change is additive. start-chat copy and the app still call link sync push until the next PR. The only edit to existing behaviour is the agent move's --sync parse (push and pull unchanged) and two small sync_cli helpers (upload, is_refusal).

After review: the bundle is written in a private 0700 folder (git writes it world-readable under umask 022); a failed big upload's .cmux-sync-part is deleted too; seed clone asks for LFS files; a machine that does not answer identify is an error.

Before a fallback copy's own lines, stderr carries one {"progress":{"phase":"copy","fallback_code","install_url"?}}, so the card can say why while the copy runs.

Follow-ups: #30644 (--since-last checks the machine's folder before answering unchanged); a full re-seed into a folder that already holds a seed fetches into it instead of starting clean; submodules with local work still fall back to the copy (plan's submodule_local_work).

Agent session: agent session

šŸ¤– Generated with Claude Code