Skip to content

feat(antares): Libra Worktree v2 — pinned lower, effective diff, commit-finalize, chain forks - #65

Open
Ivanbeethoven wants to merge 6 commits into
mainfrom
feat/libra-worktree-v2
Open

Ivanbeethoven wants to merge 6 commits into
mainfrom
feat/libra-worktree-v2

Conversation

@Ivanbeethoven

@Ivanbeethoven Ivanbeethoven commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the Worktree Control Protocol v2 between ScorpioFS and Libra (spec: docs/scorpiofs-libra-complete-spec-v1.md, docs/scorpiofs-libra-protocol-v2.md):

  • v2 attach (POST /antares/worktrees): mounting pins the lower projection to the current trunk revision; one per-revision Dicfuse store per (base_path, refs) with a -rev- suffixed directory.
  • Effective diff (GET /worktrees/{id}/state): upper vs pinned lower compared per-path by git blob OID; FNV-1a generation for optimistic concurrency.
  • commit-finalize (POST /worktrees/{id}/commit-finalize): two-phase transaction — validate generation/base/committed content hashes, then quiesce, clear exactly the committed upper entries, remount over the new pinned lower, with rollback; retries are idempotent.
  • refresh (POST /worktrees/{id}/refresh): switch the pinned lower to a new revision.
  • chain forks: seal the source upper into a shared read-only frozen layer (atomic rename, zero copy), stack it under the child, flatten on finalize/refresh; unpinned sources downgrade to materialize.
  • upper_fork: reflink-first delta copy with fstat consistency checks for materialize forks; whiteout-aware; never hardlinks.
  • upper ownership: directories created by a root daemon are chowned to SUDO_USER — the passthrough write path executes with the requesting user's credentials, so a root-owned upper rejected every user write (EACCES on first write).

Fixes found by end-to-end testing (real mega2 + FUSE)

  • Bogus empty reads broke libra status: scorpio.toml shipped antares_dicfuse_stat_mode = "fast". Fast mode made getattr report size 0 for files never read in this revision; the kernel cached that attr and truncated every subsequent read to empty (verified via strace: read(...) = 0 with no FUSE_READ reaching the daemon), so status computed empty-blob hashes and reported thousands of bogus modified files after a finalize/refresh. Stat mode is now accurate everywhere (one probe per file per revision, persisted in size.db), size probes retry once, and commit-finalize pre-warms the committed paths.
  • Pinned stores skip deep prewarm: warming a large monorepo tree saturates the remote and starves the concurrent user fetches a finalize/remount needs.

Test plan

  • cargo test --lib — 124 passed
  • End-to-end against real mega2 (docker) + FUSE in WSL2: deploy → seed mega/rk8s → v2 attach → edit → libra sync (add+commit+push+finalize) → clone-back verification → refresh; two consecutive rounds green
  • Effective-diff correctness: sync-after-refresh reports zero fake modifications
  • CI

Depends only on the published libfuse-fs 0.2.0 from crates.io — no path/patch dependencies. (0.2.0 already exposes the Antares layer API; the layer helper now lives in scorpiofs.)

MST/2 snapshot view as an overlay lower layer (spec 12 §1)

The same branch now also serves a resolved MST/2 snapshot view in the Dicfuse slot of the Antares overlay — the convergence step mst2-impl/CONVERGENCE-ROADMAP.md describes. Opt-in via mst2_lower_enabled; per spec 15 §3 the legacy reader and the snapshot reader are explicit modes and neither silently falls back to the other.

  • impl Layer for Mst2Fuse (src/snapshot/layer.rs): the read semantics were already T10-verified, so the adapter is the Layer impl plus the two contract points the union filesystem actually consults:
    • getattr_with_mapping — copy-up asks the lower layer for a raw stat64; the trait default (ENOSYS) fails every write that needs a copy-up.
    • EROFS for every mutation, including create_with_context / mkdir_with_context / symlink_with_context. Answering the trait default ENOSYS there makes copy-up fall back to a daemon-owned (root) creation, after which the user cannot write into the copied-up directory.
  • AntaresFuse::with_lower_override + config (mst2_lower_enabled / base_url / auth_token / scope); create_mount resolves the view and injects it.

Measured (local monoengine MST/2 stack, mega+rk8s monorepo, 5160 files)

Dicfuse per-directory MST/2 snapshot view
Cold full-tree metadata walk 22.2 s (2743 HTTP round-trips) 2.13 s
Content vs git clone of the same commit byte-identical (ALL MATCH)
stat sizes probed per file from MTP2 entries (never a 0 placeholder)

Also fixes a pre-existing copy-up ownership bug (both backends)

The overlay preserves the lower layer's uid/gid when it materializes a node in the upper layer (create_upper_dir / copy_regfile_up). Both Dicfuse and the snapshot view reported uid=0/gid=0 (empty_file_attr), so copied-up directories were root-owned and the user could not create files inside them (EACCES) — a bug on the production Dicfuse path too, simply not covered by the earlier E2E (which only created files at the mount root or overwrote existing files). Attributes now report the mount owner (src/util/mount_owner.rs: SUDO_USER with an euid fallback). Verified on both backends: creating a file inside a lower directory, mkdir inside a lower directory, and truncating an existing lower file all succeed, with upper/<mount>/mega owned by the invoking user.

Known boundary: appending to a lower file whose copy-up has not happened

echo x >> <lower file> fails with ENOENT under the MST/2 lower (the Dicfuse path is unaffected). The evidence chain, all measured:

  1. strace: the openat(..., O_WRONLY|O_CREAT|O_APPEND) succeeds; the write returns ENOENT.
  2. daemon logs at trace level around the operation: only lookup, no OPEN, no WRITE — the FUSE_WRITE never reaches the daemon, so neither unionfs's real_handle path nor Mst2Fuse::write (which answers EROFS) is involved.
  3. The copy-up itself is fine — the upper file appears with the right bytes — and once the file is in the upper layer, subsequent writes succeed. O_TRUNC opens work because the kernel is forced to send OPEN for them, which is what triggers the copy-up.
  4. Excluded by experiment: the no_open negotiation (zero "open is not supported" records; Config.no_open defaults to false with do_import: true), daemon crashes, missing inodes in getattr_with_mapping, and attr TTL length (60 s → 2 s reproduced identically; reverted).

Current hypothesis: copy-up is triggered by the kernel's OPEN, and for some write modes (append, r+) the kernel does not send one — so the write lands on a lower file that was never copied up. Next steps need kernel-side visibility (/sys/kernel/debug/fuse, dmesg) or a libfuse-fs change that performs the copy-up from the write fallback. Full write-up: mst2-impl/MST2-LOWER-KNOWN-ISSUES.md.

…it-finalize, chain forks

Worktree Control Protocol v2 for ScorpioFS-backed Libra worktrees:

- POST /antares/worktrees (v2 attach): mounting pins the lower to the
  current trunk revision (DicfuseManager::for_base_path_and_refs, one
  per-revision store with a `-rev-` suffixed directory)
- GET /worktrees/{id}/state: effective diff (upper vs pinned lower,
  per-path git blob OID compare) + FNV-1a generation for optimistic
  concurrency
- POST /worktrees/{id}/commit-finalize: two-phase transaction — validate
  generation/base/committed content, then quiesce, clear exactly the
  committed upper entries, and remount over the new pinned lower with
  rollback; retries are idempotent
- POST /worktrees/{id}/refresh: switch the pinned lower to a new revision
- chain forks: seal the source upper into a shared read-only frozen layer
  (atomic rename, zero copy), stack it under the child, flatten on
  finalize/refresh; unpinned sources downgrade to materialize
- upper_fork: reflink-first delta copy with fstat consistency checks for
  materialize forks; whiteout-aware; never hardlinks

Fixes found by end-to-end testing against real mega2 + FUSE:

- Antares stat mode defaults to accurate everywhere (scorpio.toml shipped
  "fast", which made getattr report size 0 for unread files; the kernel
  then cached that attr and truncated every read to empty, so `libra
  status` showed thousands of bogus modified files). Size probes now also
  retry once, and commit-finalize pre-warms the committed paths.
- Upper layer directories created by a root daemon are chowned to
  SUDO_USER: the passthrough write path executes with the requesting
  user's credentials, so a root-owned upper rejected every user write.
- Pinned stores skip the deep directory prewarm (a large monorepo
  prewarm saturates the remote and starves the concurrent user fetches a
  finalize needs).
- Daemon-created FUSE mountpoints are cleaned up on teardown; stale
  mount DELETEs unmount the FUSE session.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f76972345

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/daemon/worktree_v2.rs
.join(parent)
.join(format!("{WHITEOUT_PREFIX}{file_name}"))
}
EffectiveKind::Added | EffectiveKind::Modified => upper_dir.join(rel),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject committed paths outside the upper root

When committed_paths contains an absolute path or .. component, upper_dir.join(rel) can resolve outside the worktree and remove_file/remove_dir_all will delete that host path. This is directly reachable when new_base_revision already equals the current pin, because that finalize branch performs cleanup without tree verification; a daemon running as root could therefore delete arbitrary files. Validate every path as a normalized mount-relative path before joining it.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3637 to +3638
entry.state = MountLifecycle::Quiescing;
if let Err(e) = entry.fuse.unmount().await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate the generation after quiescing

The generation is checked before resolving and warming the new lower, while the FUSE mount remains writable until this unmount. A user edit to a committed path during that potentially long interval is therefore not reflected in the checked generation and is then unconditionally removed by the cleanup below, losing the concurrent edit. Quiesce first and rescan before deleting, or otherwise prevent writes across validation and cleanup.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3681 to +3687
let _ = Self::remount_with_lower(
&mountpoint,
old_dicfuse,
&upper_dir,
cl_dir.as_deref(),
&sealed_chain,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore cleaned upper entries when remount fails

If mounting the verified new lower fails after remove_committed_upper_entries succeeds, this rollback only remounts the old lower; it cannot restore the upper files and whiteouts that were already deleted. Added files disappear and modified/deleted paths revert to the old projection even though the response reports a failed switch. Stage or rename the entries transactionally and restore them before remounting the previous projection.

Useful? React with 👍 / 👎.

Comment thread src/daemon/worktree_v2.rs Outdated
Comment on lines +290 to +293
for entry in scan_upper_entries(upper_dir)? {
// The base is the chain first (nearest layer wins, whiteouts honored);
// the Dicfuse projection answers only what the chain does not.
let base = match chain_base_for(chain, &entry.rel_path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include sealed-layer changes in the effective diff

For a chain fork with a fresh empty upper, this loop scans no entries at all and reports the worktree clean even though the sealed layers contain the inherited modifications, additions, and deletions relative to Dicfuse. The chain is only consulted as a base for upper entries, so its own delta is absent from changes and from the optimistic generation. Build the effective view of all chain layers against the pinned projection before overlaying the live upper.

Useful? React with 👍 / 👎.

Comment thread src/daemon/worktree_v2.rs
Comment on lines +402 to +406
if meta.file_type().is_symlink() {
let target = fs::read_link(&layer_entry)?;
std::os::unix::fs::symlink(target, &upper_target)?;
} else {
fs::copy(&layer_entry, &upper_target)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve whiteout names while flattening chains

For a sealed .wh.foo entry, layer_entry correctly names the physical whiteout but the copy destination remains upper_target, which is the logical foo path. Flattening during finalize or refresh therefore converts a deletion into an empty added file, and the new lower exposes that empty file instead of keeping the path deleted. Whiteouts must be copied to the corresponding .wh.<name> destination.

Useful? React with 👍 / 👎.

Comment thread src/daemon/worktree_v2.rs
Comment on lines +373 to +377
for layer in chain.iter().rev() {
for entry in scan_upper_entries(layer)? {
let upper_target = upper_dir.join(&entry.rel_path);
if upper_target.exists() || upper_target.is_symlink() {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep nearest-layer precedence during flattening

The loop visits farthest layers first, but this existence check also treats files copied from those layers as pre-existing upper entries. When two sealed layers modify the same path, the farther copy is installed first and the nearer copy is skipped, reversing the live overlay's nearest-first precedence; the same problem prevents a nearer whiteout from replacing a farther file. Track entries that existed in the upper before flattening, or process nearest-first while skipping later duplicates.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3272 to +3275
// The child inherits the source's pin: a fork of a pinned worktree
// must not silently fall back to the moving trunk tip.
pinned_refs: source_pinned,
sealed_chain: Vec::new(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Materialize the source's existing sealed chain

When the source was itself created by a chain fork, fork_upper copies only its current writable upper, but the child is created with an empty sealed_chain. All inherited edits that still reside in the source's older sealed layers consequently vanish from a materialized child. The materialize path must flatten/copy the complete effective delta or carry the existing chain into the child.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +2073 to +2077
if let Err(e) = std::fs::rename(&source_upper, &frozen) {
entry.state = MountLifecycle::Ready;
return Err(ServiceError::FuseFailure(format!(
"chain fork: failed to seal the source upper: {e}"
)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the source mount when sealing fails

At this point the source FUSE session has already been successfully unmounted. If the atomic rename fails, the code merely marks the entry Ready and returns, leaving the source mountpoint unmounted while control-plane state advertises it as usable. The failure path must remount the original upper/lower stack before restoring Ready.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3808 to +3813
let target = match request.target_revision.as_deref().map(str::trim) {
Some(r) if !r.is_empty() => r.to_string(),
_ => resolve_latest_revision(config::base_url(), &path)
.await
.map_err(|error| {
ServiceError::Internal(format!(
"Antares worktree-state scan task failed for mount {}: {}",
mount_id, error
))
})??;
.map_err(ServiceError::Internal)?,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate the expected base before refreshing

RefreshRequest.expected_base_revision is never inspected in this implementation. A stale client that believes the worktree is based on an older revision can therefore switch the lower anyway, defeating the advertised optimistic base check and potentially changing the projection underneath newer client state. Compare the supplied expectation with the current bound base and return BaseMismatch before flattening or switching.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3709 to +3712
entry.fuse = new_fuse;
entry.pinned_refs = Some(new_refs.clone());
// Flattened above: the chain no longer applies to the new revision.
entry.sealed_chain = Vec::new();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Advance the bound base after a successful finalize

The successful switch updates pinned_refs but never updates entry.base_revision, and the response likewise returns the old value. After the client commits from B to C, a subsequent finalize using C as expected_base_revision is rejected against stale B, so the protocol cannot progress beyond one commit. Persist and return the new bound revision as part of the successful transaction.

Useful? React with 👍 / 👎.

crates.io's libfuse-fs 0.2.0 already exposes everything Antares needs
(`Config::whiteout_format`, `PassthroughFs::new`, `import`); the previous
`new_antares_passthroughfs_layer_with` helper only wrapped those. The layer
builder now lives in scorpiofs (`new_antares_passthrough_layer`) and the
`[patch.crates-io]` section is gone.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 96be7e6f5c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/daemon/antares.rs
));
}
(EffectiveKind::Added | EffectiveKind::Modified, Some(item)) => {
if let Some(expected) = &path.content_hash {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require content hashes before cleaning committed files

When a client omits content_hash for an added or modified path, this branch accepts any blob merely because the path exists in the target revision. Finalize then removes the upper entry, so a target revision containing different content silently replaces the user's committed bytes despite the advertised per-path optimistic lock. Reject missing hashes for non-deletions rather than skipping the comparison.

Useful? React with 👍 / 👎.

Comment thread src/daemon/upper_fork.rs
Comment on lines +104 to +109
// Re-list: a file added or removed while we walked would otherwise be lost.
// (Content changes within a stable path set are caught per-file instead.)
let after = list_delta(src_upper)?;
if after == delta {
stats.retries += total.retries;
return Ok(stats);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve a coherent snapshot during materialized forks

In a materialized fork while the source remains writable, equality here only proves that the set of path names stayed constant. For example, the copier can capture old a, then the parent updates a and b, and then capture new b; each per-file check passes and the final listing is unchanged, although the child's {old a, new b} state never existed in the parent. Quiesce the source or validate a whole-layer metadata generation before accepting the copy.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3881 to +3888
let _ = Self::remount_with_lower(
&mountpoint,
old_dicfuse,
&upper_dir,
cl_dir.as_deref(),
&sealed_chain,
)
.await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install the rollback mount after a failed refresh

If mounting the target lower fails, the rollback AntaresFuse returned here is immediately discarded instead of being stored back in the mount entry. The entry consequently remains Quiescing and still contains the old fuse whose mount handle was taken during unmount, even though the response claims the previous projection was remounted; subsequent operations reject the mount and it can no longer be managed correctly.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3527 to +3541
match &base_revision {
Some(actual) if actual != expected => {
return Ok(CommitFinalizeResponse {
state: "conflict".into(),
code: Some("BASE_MISMATCH".into()),
detail: Some(format!(
"bound base revision is {actual}, client expected {expected}"
)),
base_revision: base_revision.unwrap_or_default(),
lower_revision: pinned_refs,
generation,
cleaned_paths: Vec::new(),
});
}
_ => {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat an unbound base as an explicit mismatch

When expected_base_revision is supplied but the mount's base_revision is None, the wildcard arm accepts the request and proceeds with finalize. Thus a client explicitly expecting revision B can switch and clean an unbound or incorrectly restored worktree instead of receiving BASE_MISMATCH; only an actual base equal to the expectation should pass this check.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +3908 to +3911
entry.fuse = new_fuse;
entry.pinned_refs = Some(target.clone());
// Flattened above: the chain no longer applies to the new revision.
entry.sealed_chain = Vec::new();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim orphaned layers when clearing a sealed chain

After a chained mount refreshes, clearing sealed_chain removes the daemon's only record of those directories when no other mount still references them. The later mount-delete collector only examines the entry's current chain, so this common sequence—delete the sibling, refresh the remaining mount, then delete it—leaves every sealed layer permanently on disk. Check remaining references and remove newly orphaned layers when the chain is cleared.

Useful? React with 👍 / 👎.

… §1)

Serve a resolved MST/2 snapshot view in the Dicfuse slot of the Antares
overlay, opt-in via `mst2_lower_enabled` (spec 15 §3: the legacy reader and
the snapshot reader are explicit modes, never a silent fallback):

- `impl Layer for Mst2Fuse` (src/snapshot/layer.rs): root inode, OCI whiteout
  convention, and `getattr_with_mapping` — the union filesystem's copy-up asks
  the lower layer for a raw stat64, and the trait default (ENOSYS) makes every
  write that needs a copy-up fail.
- Mst2Fuse answers EROFS for every mutation, including the `*_with_context`
  variants the copy-up path consults, so the union filesystem routes creation
  to the upper layer with the requesting user's credentials instead of falling
  back to a daemon-owned (root) creation.
- `AntaresFuse::with_lower_override` plus config
  (`mst2_lower_enabled`/`base_url`/`auth_token`/`scope`); `create_mount`
  resolves the view and injects it when enabled.

Also fixes a pre-existing copy-up ownership bug affecting both backends: the
overlay preserves the *lower* layer's uid/gid when it materializes a node in
the upper layer, and both Dicfuse and the snapshot view reported uid=0/gid=0,
so copied-up directories were root-owned and the user could not create files
inside them. Attributes now report the mount owner
(src/util/mount_owner.rs, derived from SUDO_USER with an euid fallback).

Verified against a local monoengine MST/2 stack: mounted content is
byte-identical to a git clone of the same commit; cold full-tree metadata walk
2.13s vs 22.2s for the per-directory Dicfuse path (10.4x); writes at the mount
root, inside copied-up lower directories, and over existing lower files
(truncate) all work on both backends. One boundary remains — appending to a
lower file whose copy-up has not happened yet fails with ENOENT; the full
evidence chain and the remaining hypotheses are in
mst2-impl/MST2-LOWER-KNOWN-ISSUES.md.
The lower projection answers content identity in its own domain — the Dicfuse
path speaks the mega content-hash (git blob OID), an MST/2 snapshot view speaks
sha256(raw) (spec 03 §1) — so the effective diff must hash the upper layer in
the same domain or it cannot tell "edited back to base" from "modified".

Adds LowerHashKind, hash_content(), the LowerView trait and DicfuseLower (the
legacy implementation). Pure addition: no call site changes yet, so behaviour
is unchanged; the MST/2 view and the effective_changes rewire follow.

Design: mst2-impl/P3-HASH-DOMAIN-DESIGN.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a96dec096a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/daemon/antares.rs
for path in committed {
let item = lower_item_for(new_store, &path.path).await;
match (&path.kind, item) {
(EffectiveKind::Deleted, None) => {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate lookup failures when verifying deletions

When the target revision's directory lookup fails due to a timeout, HTTP error, or store I/O error, lower_item_for discards every ensure_dir_loaded error and returns None, so this arm treats the failure as proof that the committed deletion exists. Finalize can then remove the upper whiteout and expose a file that is actually still present in the target lower. Preserve an absent-versus-error distinction and accept a deletion only after a successful lookup confirms absence.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs Outdated
Comment on lines +2797 to +2798
if let Some(lower) = mst2_lower_layer().await? {
fuse = fuse.with_lower_override(lower);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the mounted MST/2 view for control-plane comparisons

When mst2_lower_enabled is true, this replaces only the FUSE lower; worktree_state_v2, generation calculation, and committed-path verification still query the mount's Dicfuse store. In particular, an attach with an explicit historical lower_revision can expose the latest MST/2 snapshot while reporting changes and validating cleanup against the historical Dicfuse revision, so clean files can be reported dirty and finalize can remove upper entries based on content the mounted lower does not serve. Route effective-diff and verification through the same lower view installed here.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +2058 to +2065
let mut fuse = AntaresFuse::new(
mountpoint.to_path_buf(),
dicfuse,
upper_dir.to_path_buf(),
cl_dir.map(PathBuf::from),
)
.await
.and_then(|fuse| fuse.with_frozen_layers(frozen))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the MST/2 lower during remounts

With MST/2 mode enabled, every successful commit-finalize or refresh calls this helper after unmounting, but it constructs a plain AntaresFuse and never reapplies with_lower_override. The operation therefore silently changes the live filesystem from the configured MST/2 snapshot to Dicfuse; recovery follows the same plain construction path. Carry the selected lower mode and fixed snapshot identity through remount and recovery instead of dropping the override.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
cl: None,
mountpoint: Some(mountpoint.to_string_lossy().to_string()),
upper_dir: None,
pinned_refs: Some(lower_revision.clone()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the pinned revision in attach idempotency

When an attach retries with an existing job_id and the same repository path, create_mount's idempotent fast path compares only path/CL fields and ignores this pinned_refs value. If latest advanced between retries, or the caller supplies a different lower revision while retaining the same base or omitting it, attach returns the old mount but echoes the newly resolved revision in AttachWorktreeResponse, causing the client to believe it received a projection that is not mounted. Validate the pin and mountpoint on an idempotent hit, or report the existing mount's actual revision.

Useful? React with 👍 / 👎.

… (P3 steps 2-3)

`effective_changes` now takes a `&dyn LowerView` instead of the Dicfuse store:
the comparison base and the upper-side hashing both use the lower's own
identity domain (git blob OID for Dicfuse, sha256 raw bytes for an MST/2
snapshot view), so "edited back to base" and "modified" stay tellable apart
regardless of which projection is being served.

- Mst2Fuse::digest_for_path: content digest of a path in the fixed view, with
  the same on-demand page loads a lookup would make.
- Mst2Lower / DicfuseLower: the two LowerView implementations.
- All seven effective_changes call sites route through lower_view_for(entry)
  or an explicit DicfuseLower, so the Dicfuse path is behaviour-identical.

MST/2-lowered mounts refuse commit-finalize and refresh with a typed error
(their lower moves by resolving a new snapshot, not by re-pinning Dicfuse —
that finalize path is P3 step 5), are skipped on daemon restart recovery, and
are persisted with a marker so recovery never silently serves the Dicfuse
projection instead (spec 15 §3).

Verified end to end on both backends: Dicfuse reports git-oid changes and a
working finalize; MST/2 reports sha256-domain changes for copied-up content
and refuses finalize with a typed error.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1fc51bf7ef

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/daemon/upper_fork.rs
Comment on lines +266 to +274
let cloned = try_reflink(src, dst, before.size());
let written_bytes = if cloned {
stats.reflink_used += 1;
before.size()
} else {
fs::copy(src, dst).map_err(io_err(dst))?
};
fs::set_permissions(dst, fs::Permissions::from_mode(before.mode() & 0o7777))
.map_err(io_err(dst))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve user ownership in materialized forks

When the daemon runs through sudo, both the reflink destination and fs::copy destination are created as root, and this code preserves only the mode bits. AntaresFuse::new subsequently chowns only the staging directory itself, not these descendants, so a materialized child inherits root-owned 0644 files and 0755 directories that the invoking user cannot modify through the passthrough mount. Copy or recursively assign the source/invoking-user ownership before mounting the child.

Useful? React with 👍 / 👎.

Comment thread src/antares/fuse.rs
xattr: true,
do_import: true,
writeback: false,
whiteout_format: ANTARES_WHITEOUT_FORMAT,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Generate OCI whiteouts for CL deletions

This configures every passthrough layer, including the CL layer, to recognize OCI .wh.<name> whiteouts, but build_cl_layer still handles a deleted CL entry by calling create_whiteout, which on Linux creates a character device at the logical target path. With CAP_MKNOD this entry is not interpreted under the selected OCI format and therefore does not mask the Dicfuse file; without that capability, creating any CL containing a deletion fails outright. Emit an OCI marker for CL deletions as well.

Useful? React with 👍 / 👎.

Comment thread src/daemon/antares.rs
Comment on lines +2271 to +2275
std::os::unix::fs::symlink(target, source_new_upper.join(".libra")).map_err(|e| {
ServiceError::FuseFailure(format!(
"chain fork: failed to re-create the parent VCS pointer: {e}"
))
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Roll back when recreating the source pointer fails

If recreating .libra fails after the replacement source FUSE session has been mounted, ? returns before that session is installed in the mount entry. Dropping the local source_fuse tears down the replacement, while the table still contains the old unmounted fuse in Quiescing state and the original upper remains renamed to the sealed path, leaving the source worktree unusable after a failed fork. This error path needs the same rename/remount and lifecycle rollback as the earlier rebuild failures.

Useful? React with 👍 / 👎.

…ing (P3 step 5)

MST/2-lowered mounts move their lower by resolving the latest snapshot, not by
re-pinning the Dicfuse projection. Both worktree-v2 mutations gain an MST/2
branch:

- refresh: dirty-check against the current view, resolve latest, idempotent
  AlreadyAtTarget on the same snapshot, quiesce, remount over the new view,
  roll back to the previous view on failure.
- commit-finalize: generation optimistic lock, resolve latest, verify the
  committed set in the view's own domain (upper content sha256 vs the snapshot
  entry digest — the client's git-OID content_hash is not comparable across
  domains), clear exactly the committed upper entries, remount, roll back.
- `remount_with_mst2_lower`: rebuilds the overlay with the snapshot view in
  the Dicfuse slot (AntaresFuse::with_lower_override).

Entry updates: mst2_lower holds the view, pinned_refs records the snapshot id
for observability. persist_state records mst2_lower and recovery skips such
mounts with a warning — the snapshot view is resolved per process, and
rebuilding with Dicfuse would silently serve a different base (spec 15 §3).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant