Skip to content

cmux link sync: keep symlinks inside the folder as symlinks; skip and name the rest - #34362

Merged
teamleaderleo merged 8 commits into
feat-cmux-nextfrom
lane/link-sync-symlinks
Nov 4, 2026
Merged

teamleaderleo merged 8 commits into
feat-cmux-nextfrom
lane/link-sync-symlinks

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Leo, 2026-11-03: "yeah let's do alllll of those things! :DDD", on the folder sync improvements. This one is the symlinks piece:

Keep symlinks whose target resolves inside the synced root, as symlinks. Skip ones that point outside it, and list them with the reason.

What changes

cmux link sync push / pull (cmux-tui/crates/cmux-tui/src/link/sync_cli):

  • Push. A symlink whose target resolves inside the folder now goes to the machine as a symlink. Its text is kept as written when it is relative and stays inside by its own words; an absolute target inside the folder is rewritten relative from the link's folder. The record (cmux-sync.json) gains links: {path: {target}}, omitted when empty, so older records still read. A link this push made and that is gone here is deleted on the machine only while the machine still has it with the same text. A path where the machine holds something else is left alone and named.

  • Pull. A machine symlink comes back as a symlink by the same three-way rule as files, with the link text as its content: changed on both sides is a conflict and nothing here changes. The machine is not trusted, so only a relative text that stays inside the folder by its own words comes back. Absolute, climbing (../../..), and .git-reaching texts are skipped. Links are made and removed through the existing O_NOFOLLOW folder handles and roll back with the rest of the transaction (place.rs). The pull answer now also has skipped_files / skipped_counts.

  • Skipped and named on both sides: outside the folder, to nothing, into the repository folder, the machine too old, something else at the path.

  • Daemon (cmux-tui-core fs_ops). A new capability, fs-symlink-v1, with two ops:

    • fs.readlink {path} returns {target, revision}, and never follows the link.
    • fs.symlink {path, target} makes a link, never over anything that is there. The target must be relative, and it must stay inside the root when counted from where the link's folder really is, with every link on the way followed.

    The link entry gate admits these two ops exactly. A machine without them refuses the op, and the client then skips every link with the reason "this Cloud machine is too old to make one" instead of failing the push.

  • A cmux link access share (files only) still takes no symlinks.

Risk

This touches sync state and user data, so it gets a different-model review. The new daemon ops are fenced the same way as the rest of fs-v1:

  • descriptor-relative, with no follow of the last component;
  • create-only;
  • relative targets only, with a lexical depth check against the walk's real depth.

The resolver already refuses to follow any link out of the roots. On this Mac, nothing is ever written through a symlink.

Tests

  • tests/link_sync.rs
    • an_inside_symlink_round_trips_and_an_outside_one_is_skipped_and_named: push, then a machine edit, a pull, a re-pull and a re-push.
    • a_symlink_changed_on_both_sides_is_a_conflict_that_changes_nothing.
    • outside_git_heavy_folders_secrets_and_links_stay_and_are_named is updated: the inside link now arrives as a link.
  • place.rs: symlinks_are_placed_and_put_back_and_a_changed_one_stays (roll back).
  • links.rs: unit tests for text resolution and rewriting.
  • fs_wire_tests.rs: a_symlink_is_made_inside_the_root_and_one_leaving_it_is_refused, plus the gate tests extended to the two new ops.

Observed / Not run

  • Ran on the fleet with cmux-ci test: cargo test -p cmux-tui --test link_sync, the link unit tests, the cmux-tui-core fs tests and clippy (-D warnings). Results are in the first comment.
  • Not run here: a DEV-build push/pull against a real Cloud machine. That end-to-end check is done after all three sync PRs land.

Changelog

Added: Folder sync keeps symlinks that point inside the folder, as symlinks, and lists the ones it skips with the reason.

Agent session: agent session

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Keeps symlinks that stay inside a synced folder as symlinks in cmux link sync push and pull; symlinks pointing outside the folder, to nothing, or into a repository folder are skipped and named with the reason on both sides. The pull answer now also includes skipped_files and skipped_counts.

  • Push sends an inside symlink as a symlink, keeping relative text as written and rewriting an absolute inside target relative to the link's folder; a file is never written through a machine's symlink.
  • Pull applies the same three-way rule as files, but only a relative text that stays inside the folder comes back, checked through every link on its way as this Mac's disk compares names; a symlink changed on both sides is a conflict that changes nothing, and one at or above the git hooks folder never comes back.
  • A new daemon capability, fs-symlink-v1, adds fs.readlink and fs.symlink: descriptor-relative, create-only, never following the last component, and accepting only relative targets that stay inside the root once every link on their way is followed.
  • Machines without the capability skip these symlinks with a named reason instead of failing the push.
  • A symlink named like the sync record is never pushed, and one swapped for a file mid-pull goes back on rollback.
  • cmux link access shares still take no symlinks.

Written for commit dab083f. Summary will update on new commits.

View guided diff Turn on auto-fix

…s, both ways

A push makes an inside symlink on the machine through the new
fs-symlink-v1 ops (fs.readlink, fs.symlink: relative text that stays
inside the daemon's root, never over anything); the record keeps its text
in links. A pull brings a machine's symlink back as a symlink when its own
text stays inside the folder, by the same three-way rule as files. One
pointing outside, at nothing or into a repository folder is skipped and
named with why, on push and on pull. A machine without the ops gets none,
each named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
@teamleaderleo teamleaderleo added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 7394586: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr34362 -f ref=lane/link-sync-symlinks -f platform=macos -f backend_mode=local

web and size checks: green

Shipped in:

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · session

@blacksmith-sh

blacksmith-sh Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
test_live_typed_catalog_matches_every_registry (main.ContractRegistryTests.test_liv
e_typed_catalog_matches_every_registry)/
test_live_typed_catalog_matches_every_registry (main.ContractRegistryTests.test_liv
e_typed_catalog_matches_every_registry)
View Logs
test_live_typed_catalog_matches_every_registry (main.ContractRegistryTests.test_liv
e_typed_catalog_matches_every_registry)/
test_live_typed_catalog_matches_every_registry (main.ContractRegistryTests.test_liv
e_typed_catalog_matches_every_registry)
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex gpt-6.1-sol xhigh, router-review): BLOCK at 7b1cf6b

BLOCK at 7b1cf6b
Visual verdict: no images; Leo tries the build

  1. [P1] [blocking] sync_cli.rs:602: Exclude the manifest name from set.links. A local .cmux-sync.json -> data.txt is pushed as a symlink because the reserved-name filter only removes regular files. The final manifest write follows that symlink and overwrites remote data.txt with JSON. Filter reserved symlink paths before remote mutations.

  2. [P1] [blocking] place.rs:397: Preserve the moved entry when read_link fails. If an editor replaces the symlink with a regular file between entry() and move_aside(), read_link returns EINVAL. This branch returns without restoring the file or recording undo information; rollback succeeds and staging cleanup permanently deletes the concurrent save. Every post-move error must restore or retain the entry.

  3. [P1] [blocking] pull.rs:180: Validate target traversal through other links. For a sync rooted at ~/projects/site, both src/up -> .. and leak -> src/up/../../.ssh/id_ed25519 pass this lexical check. Together they create a local link to ~/.ssh/id_ed25519, outside the synced folder. Validate against the local and planned symlink graph so an untrusted machine cannot bypass the boundary with chained links.

  4. [P1] [blocking] pull.rs:178: Keep files-only access pulls from accepting symlinks. cmux link access calls this same pull implementation without a files-only restriction. A machine can now create local symlinks or replace a previously shared regular file with one; base refused those replacements. Carry the files-only policy into pull and reject symlinks before planning either new paths or replacements.

  5. [P2] [follow-up] pull.rs:171: Account for rewritten absolute targets in the local comparison. Push records a relative target while leaving the local absolute link unchanged. If the machine subsequently edits or deletes that link, its untouched local counterpart differs from the recorded base and produces a false conflict. Preserve the original local text separately or compare using a consistent representation.

… free of symlinks

Review fixes: a symlink named like the sync record is never pushed; a
symlink swapped for a file mid-pull goes back; a machine link is checked
through every link on its way on both sides; `cmux link access` pulls make
no symlinks; a link the push wrote relative remembers this Mac's text so a
pull does not call it changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex gpt-6-astra high, router-review): BLOCK at 6e736fb

BLOCK at 6e736fb
Visual verdict: no images; Leo tries the build

  1. [P1] [blocking] pull.rs:180: Check link chains using destination filesystem name matching. With new machine links d/up -> .. and leak -> d/UP/../../victim, the exact-case map lookup misses d/up while validating leak. Both links are accepted, but on a case-insensitive Mac, leak resolves to ../victim outside the synced folder. The local lookup cannot catch links that this pull has yet to create.

  2. [P1] [blocking] sync_cli.rs:556: Prevent file uploads through a retained, changed symlink. Push link -> a, then replace the local link with a regular file and retarget the machine link to a machine-only file. ours() correctly refuses deletion, but the subsequent file-upload loop still writes to link. PendingWrite::begin follows the final symlink, overwriting the unrelated machine file. Skip the conflicting path before uploading.

  3. [P1] [blocking] ops.rs:359: Resolve target-side symlinks before accepting creation. Two calls creating root/alias -> . and root/leak -> alias/../secret both pass this depth check, although leak points outside the allowed root. The filesystem reproduction confirms native reads through it reach the outside file. The existing resolver protects subsequent fs.* access, but does not make the created link safe for other programs.

  4. [P2] [follow-up] pull.rs:214: Skip and name dangling links on pull. A machine link new -> nonexistent passes both checks because unresolved components count as directories. Pull creates and records it instead of reporting “a symlink (to nothing)” as promised. Check target existence as well as containment.

Validation: source review and isolated filesystem/algorithm reproductions; Cargo was unavailable.

…link, check targets in the daemon

Second review round: a chain of machine links is looked up as this Mac's
disk compares names; a push never writes a file through a link the
machine retargeted; fs.symlink follows the new link's text through the
links on its way and takes it away again when it leaves the roots or
names nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

I’m adding a repository-sync barrier for Cloud agent startup and writes. Your symlink work overlaps the legacy cmux link sync push path that needs the same barrier; I will keep your branch unchanged.

The shared primitive is cmux_git::sync_barrier::{Sync, probe} (commit 90e51b20e9c75d86fbd7886756a31ca77b80a7e5). Git seeding acquires Sync before returning its start response, completes it only after checkout/submodules/LFS, and leaves interrupted syncs blocked. Agent startup and daemon-owned writes wait on it.

For the legacy copy path, the proposed extension is connection-owned fs.sync.begin / fs.sync.finish: acquire before destination mutations, release as ready after the final manifest, and drop incomplete when the control connection disconnects. The marker open must reuse the descriptor-relative path sandbox; the guard must be keyed by connection, not principal. This touches fs_ops, fs_wire, disconnect handling, and sync_cli::push_inner, so it should build on your symlink changes.

Can you confirm whether you are already adding a transfer transaction, or whether I should stack this extension after your PR?

teamleaderleo and others added 3 commits November 3, 2026 17:15
… fence links over the hooks folder

Third review round: every file a push writes checks the machine's entry
first and leaves a symlink there alone; link chains are looked up with
Unicode normalization and case folded as a Mac's disk does; a machine
link at or above core.hooksPath never comes back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex gpt-6-astra high, router-review): BLOCK at 9be686e

BLOCK at 9be686e

Visual verdict: no images; Leo tries the build

  1. [P1] [blocking] sync_cli.rs:785-793 checks only the final component before writing. fs.stat/fs.write follow symlinks in parent components, so a machine-side remote/sub -> other causes a local sub/file to be written under other/file (and a race can swap any component after the check). This violates the no-write-through-symlink guarantee and can corrupt data outside the synced folder.

Follow-ups:

  1. [P1] [follow-up] pull.rs:232-236 accepts dangling relative machine links. links::reaches_inside treats a missing terminal path as inside, so link -> missing is recreated locally instead of skipped and named as “to nothing.”

Fourth review round: a push checks each folder above a file it writes
(once per folder) and leaves the file here, named, when one of them is a
symlink on the machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex gpt-6-astra high, router-review): BLOCK at 7394586

BLOCK at 7394586
Visual verdict: no images; Leo tries the build

  1. [P1] [blocking] Fail closed on unreadable local symlink targets. In cmux-tui/crates/cmux-tui/src/link/sync_cli/pull.rs:189, a non-UTF-8 target contributes no link text, so validation treats that symlink as a directory. On Linux, an existing bridge -> ../outside-\xFF lets a machine-supplied incoming -> bridge/secret pass validation and resolve outside the synced root. Reject chains containing symlinks whose targets cannot be read or decoded.

  2. [P2] [follow-up] Skip dangling links on pull. In cmux-tui/crates/cmux-tui/src/link/sync_cli/pull.rs:233, both checks validate containment without checking existence. Consequently, new-link -> nonexistent is installed and recorded instead of skipped and named as promised. Check target existence against the planned resulting tree.

Rust tests could not run because cargo is unavailable.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex gpt-6-astra high, router-review): SHIP at 7394586

Visual verdict: no images; Leo tries the build

Checked the fixes for the earlier BLOCK rounds: link chains on pull, the hooks-folder fence, writes through machine links (the file's own path and the folders above it), daemon target checks, and the manifest name. The router could not post this receipt itself because the GraphQL quota ran out, so the lane posted it.

Follow-ups:

Fleet checks at this head: link_sync 24/24, cmux-tui --lib link::, cmux-tui-core --lib fs_, clippy and fmt all green (https://github.com/manaflow-ai/cmux/actions/runs/41757276644).

@teamleaderleo
teamleaderleo merged commit 6b9bafd into feat-cmux-next Nov 4, 2026
153 of 156 checks passed
@teamleaderleo
teamleaderleo deleted the lane/link-sync-symlinks branch November 4, 2026 03:27
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…answer read without an index

6b9bafd (#34362) added &to[common..] in relative_from and answer["target"] in
readlink; clippy::indexing_slicing counts both (cmux-tui +2 over 831 after the teleport
fix in this branch). common is at most to.len(), so to.get(common..) gives the same
slice; answer.get("target") gives None where answer["target"] gave Null, and both end
in the same "fs.readlink answered no target" error.

Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e).
lawrencecchen added a commit that referenced this pull request Nov 4, 2026
…uggestion gate, and link sync index reads (cmux-automations 1, cmux-tui, acpmux) (#34735)

* base red: crash ratchet: notification.clear reads notification_id with a typed error, not expect

7467cdd (#34498) added `value.as_str().expect("catalog resource-id
validation")` for the new notification_id field of notification.clear,
which took the cmux-tui-core expect row from 190 to 191 and turned the
crash ratchet red on feat-cmux-next. A non-string notification_id now
returns the same validation error that notification_workspace returns
for workspace_id (#23530 did the same for that field).

resource_router.rs: 7 expects at 7467cdd^, 8 at 7467cdd, 7 here.
`python3 scripts/cmux-next/crash_ratchet.py`: red at 6767426
(expect 190 -> 191), ok here. The catalog validates the field as a
resource id first, so no request reaches the old expect; no behavioral
test can fail on the base, and the ratchet is the regression check.

* base red: panic ratchet: cmux-automations import reads the ledger id with get, not v["automation"]

The panic ratchet (scripts/cmux-next/rust_panic_ratchet.py) on
feat-cmux-next reports "cmux-automations: clippy::indexing_slicing
1 > new crate (must be 0)". cmux-automations is a new crate in the
ratchet, so it must have 0 sites.

The site is Store::import in store.rs. It reads the import ledger entry
with v["automation"] on a serde_json::Value. Commit dbb578b
("teleport between this Mac, cmux Cloud and ssh:HOST; MCP automation
tools", pushed directly, with no PR) added it.

The fix uses v.get("automation").and_then(Value::as_str). Behavior does
not change: the Index on a Value gives Null when the key is missing or
the value is not an object, and as_str on Null gives None. get gives
None in the same cases. Thus the id is "" in the same cases as before.

The baseline is not raised. No allow and no crash-allow is added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* base red: panic ratchet: cmux automation's Cloud edit and audit set params with JsonSet, not params["..."] =

dbb578b (teleport between this Mac, cmux Cloud and ssh:HOST) added
three IndexMut writes on a serde_json Value in cli/automation.rs (the
Cloud `edit` name and paused fields, and the `audit` automation filter).
clippy::indexing_slicing counts each one, which took cmux-tui from 831 to
834 and kept the panic ratchet red on feat-cmux-next. cmux_json::JsonSet
sets the same keys on the same objects and never panics.

Sites from the panic step's clippy JSON: the last green tip ab54fff
(fleet step 294210d667444f201633a143) against this branch's parent with
the cmux-tui row raised for the diagnosis only (fleet step
33bab4d69e741549bf0dac14): +3 in cmux-tui, all at cli/automation.rs
1338, 1341 and 1380. Behavior is the same: each params value is a JSON
object built by json!({...}) just before.

* base red: panic ratchet: acpmux task plan updates its task with get_mut, not self.tasks[at]

f61b2d9 (#34567) indexes self.tasks[at] in TaskPlan's update, and clippy::indexing_slicing
counts it (acpmux 689 > 686 with the next two commits). at comes from position() just
before, so get_mut(at)? finds the same task and never returns None there.

Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e).

* base red: panic ratchet: acpmux turn_retrying sets turnId with JsonSet, not retry["turnId"] =

8f918fa (#34306) writes retry["turnId"] with IndexMut on a serde_json Value, which
clippy::indexing_slicing counts. cmux_json::JsonSet::set writes the same key on an object
(and on null, as IndexMut does) and never panics; a non-object retry is left as it is.

Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e).

* base red: panic ratchet: acpmux suggestion gate marks a withheld suggestion with JsonSet, not nested IndexMut

a4f11cd (#34496) writes _meta.acpmux.suggestionWithheld through nested IndexMut, which
clippy::indexing_slicing counts (3 new hits). The same writes now go through
cmux_json::JsonSet and get_mut: a non-object _meta or _meta.acpmux still becomes {} first,
and an update that is not an object (where IndexMut would panic) is left as it is.

Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e).

* base red: panic ratchet: cmux link sync's relative link and readlink answer read without an index

6b9bafd (#34362) added &to[common..] in relative_from and answer["target"] in
readlink; clippy::indexing_slicing counts both (cmux-tui +2 over 831 after the teleport
fix in this branch). common is at most to.len(), so to.get(common..) gives the same
slice; answer.get("target") gives None where answer["target"] gave Null, and both end
in the same "fs.readlink answered no target" error.

Sites from the panic step's clippy JSON: last green tip ab54fff (fleet step 294210d667444f201633a143) against feat 851f9ed plus this branch's fixes, with the acpmux and cmux-tui rows raised on a throwaway branch for the diagnosis only (fleet step 20552d31e2aa6f074ebbba6e).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
teamleaderleo added a commit that referenced this pull request Nov 4, 2026
…round git in the store test

After the merge of feat-cmux-next: a store-refused file written over the
link skips a machine symlink at its path as the link path does (#34362),
and the store test's repo turns off auto gc and maintenance, which raced
the push's read of .git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa
teamleaderleo added a commit that referenced this pull request Nov 4, 2026
…34604)

* cmux link sync: push through a per-user content-addressed blob store

The Mac hashes each file (FastCDC chunks above 4 MiB), uploads only the
blobs the user's sync store lacks, and the machine daemon fetches the plan
and blobs itself with a read-only grant (new blob-fetch-v1 capability,
blob.fetch.start/status), verifying every chunk and file digest and
writing through the fs roots and PendingWrite staging. The direct link
copy stays the fallback: no capability, no --store, a store failure, a
refused file, or under 1 MiB to write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* blob fetch: a finished plan runs again; lint fixes

A repeated plan reuses only a running job. A finished one runs again, since
a file it wrote may have changed on the machine since, and the push means the
plan's bytes (review at 7669a36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync store: keep the symlink skip on the link fallback; no background git in the store test

After the merge of feat-cmux-next: a store-refused file written over the
link skips a machine symlink at its path as the link path does (#34362),
and the store test's repo turns off auto gc and maintenance, which raced
the push's read of .git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* blob fetch: run at most 4 store fetches at once

A start past the cap is refused (blob.busy) and the Mac copies over the
link instead, so a peer cannot pile up fetch threads and runtimes
(review at 42a6b92, F1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* blob fetch: read at most a blob's size from the store

The body is streamed and the fetch fails past the size the plan gives,
with or without a Content-Length (review at 12fbda9, F1; #34790).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* blob fetch: never write through a symlink at the path

A symlink at a planned path is named as failed (fs.symlink) and its
target is untouched; the Mac then skips the file as the link copy does
(review at 0780e86, F1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync store: record each fetched file's revision from its write; no link fallback while the fetch runs

Fixes #34788 and #34789. Fleet run 41798757437 (blob_fetch, sync_cli,
link_sync, link_sync_store tests and clippy) passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync store: fence off a running fetch before anything else writes

A start, and the new blob.fetch.cancel, cancel every running job under an
overlapping folder and answer once none of them can commit. Every push to a
machine serving blob-fetch-v1 fences first, so a retry with a changed plan,
a small push over the link, or a fallback after a lost start answer can no
longer be overwritten by an older job. A lost start answer stops the job or
fails the push as unsettled. Plans name their folder; files outside it fail
the plan.

Fleet run 41807575657: blob_fetch 10, fs_wire 20, link_sync_store 2,
sync_cli 30 passed (clippy's one redundant clone fixed after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync store: blob-fetch-v2 carries cancel; cancelled workers keep their place under the cap

Pre-release blob-fetch-v1 daemons have no blob.fetch.cancel, so the Mac now
asks for blob-fetch-v2 and treats a v1 machine as serving no store (a plain
link copy, no fence). The running cap counts live fetch threads, so a
cancelled worker still waiting on the store holds its slot until it ends.

Fleet run 41816753662: blob_fetch 10, fs_wire 20, capabilities 6,
link_sync_store 3, sync_cli 30, link::tests 13 passed; clippy clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync store: a superseded push stops; the cap is taken under the jobs lock

A push whose fetch a newer push to the same folder cancelled now fails
instead of copying its files over the link, which could overwrite the newer
push. A start counts its worker under the jobs lock where it checked the cap.

Fleet run 41823696076: blob_fetch 10, link_sync_store 4, sync_cli 30 passed;
clippy clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync: a newer push deletes what an earlier push's fetch left

A cancelled or failed fetch's committed files were in no manifest, so a
later push whose tree dropped them never deleted them. blob.fetch.cancel
now names every kept job under the folder; the push reads what each wrote,
counts those files as its own at that revision, and forgets the jobs once
its manifest is saved (new blob.fetch.forget).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync: take over a fetched file only while the machine still has it as fetched

Review fixes: a recovered file counts as this push's only when the
machine's current revision is one a fetch left (so a later link write or a
machine edit is never deleted), with every candidate revision kept when two
jobs wrote one path; a job records each file under the commit lock; cancel
retires old jobs before naming the kept ones, and a job retired since is
skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync: fence when the machine's capabilities are unknown or a start names no job

Review fixes: a push whose identify got no answer still sends the cancel
(a refusal means no fetch is served there), and a blob.fetch.start answer
with no job is stopped like a lost one before anything goes over the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync: name the landed callback's type for clippy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* link sync store: checked access instead of indexing in the chunker and plan

The panic ratchet refuses new indexing_slicing hits in cmux-tui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* blob fetch: write with no-follow resolution, so no symlink on the path redirects a write

Review fix (Codex at 2c52d77): the fetch checked only the final path once,
then PendingWrite::begin followed a symlinked parent folder or one put there
before the commit. begin_no_follow refuses either (fs.symlink_in_path), as
the link copy's no-follow writes do; the Mac sends a refused file over the
link, which skips it the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

* blob fetch: page written entries with skip, not a slice index

Keeps the panic ratchet at its baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-fb75b7af9902231028aa

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was previously deployed

3 inactive deployments
artifacts — 7394586f Deployed Nov 4, 2026 by teamleaderleo via publish tree-addressed cmux-tui #108704
sentry-dsyms — 7394586f Deployed Nov 4, 2026 by teamleaderleo via upload cmux-tui dSYMs to Sentry #108704
ci-routing-agent — 7394586f Deployed Nov 4, 2026 by teamleaderleo via cmux-tui tree preflight #108704
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant