Skip to content

feat(sessions): upload patch files from loadouts and friends - #914

Merged
evanspearman merged 1 commit into
mainfrom
evan/upload
Jul 23, 2026
Merged

feat(sessions): upload patch files from loadouts and friends#914
evanspearman merged 1 commit into
mainfrom
evan/upload

Conversation

@evanspearman

@evanspearman evanspearman commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds the missing piece of the composition pipeline: actual patch file
    streaming from client to daemon
    so composition-declared patches land
    in the sandbox home at attach time. Before this, the composer produced
    SessionPatch entries and session_host logged them as deferred = true — the sandbox home was empty when the shell was minted.

  • New WorkspacePatchesTarZst SSH subsystem streams the finalized
    composition's approved patch files (loadout-contributed + Phase 3
    daemon-approved) into <workspace>/patches/ on the daemon via
    atomic staging-dir + rename + .patches_ready marker. Body writes
    fan out across available_parallelism() tasks via a JoinSet.
    Untrusted per-entry validation on the daemon side: traversal check,
    marker-filename collision reject, and a 1 GiB per-entry size cap so
    a forged header can't drive Vec::with_capacity to OOM.

  • New SessionStatus::Materializing state sits between Pending and
    Active. ConfigureLoadout / SubmitVerdict promote the record to
    Materializing (not Active); a new FinalizeSession RPC checks
    the marker, materializes patches into the sandbox home via
    materialize_patches_into_home, and promotes to Active. Attach
    is refused while Materializing, so a client that dies mid-flow
    never lands the operator on an empty-home shell.

  • Wire renames (pre-prod, no back-compat aliases):
    ConfigureLoadoutResponse::Ready → Materialized and
    SessionStep::Active → Materialized.

  • Restart safety: Manager::init reaps unresumable records
    (Pending and Materializing) at startup — those states rely on
    in-memory compose state that dies with the actor. If a race lets a
    Materializing record through, finalize refuses with an
    InvalidInput fault so the operator sees the problem instead of a
    silently-empty sandbox home.

  • Client progress bars via indicatif — spinner for the workspace-files
    upload, patch-count bar for the composition-patches upload.
    add_spinner_bar and add_patches_bar register with ot's global
    MultiProgress so tracing output and bars don't stomp on each
    other. Includes a hidden min spin demo command.

  • Perf: several structural wins found while profiling the patches
    upload path — 1 MiB BufReader on the source file, custom
    copy_buf-based fast path bypassing async_tar's 8 KiB internal
    buffer for short archive paths, zstd level 1 with N-1 worker
    threads (feature zstdmt), and the parallel daemon-side unpack
    described above. End-to-end wall time for a 1.3 GiB ~/.claude
    payload dropped from 117 s (cold cache, default settings) to ~9 s.

  • PatchDest::try_new normalizes a leading ~/ in destinations
    (mfile authors write path = "~/.claude" meaning "under sandbox
    home"; the tilde was previously kept literal and landed patches at
    /home/~/.claude/...).

  • COMPOSITION.md rewritten to cover the new Phase 4a/4b/4c split,
    the Materializing state, and the new invariants (client is
    authoritative for the upload; restart-orphaned records are reaped
    or refused; unpack is atomic and marker-gated; peer sizes are
    capped).

Testing

Torture tested it by uploading my .claude directory many many times.

Checklist

  • Docs updated if behavior changed
  • [We're pre-prod so not necessary] BREAKING CHANGE: footer present if this is a breaking change

Note

Add patch file upload and FinalizeSession RPC to session activation flow

  • Introduces a Materializing session state between ConfigureLoadout and Active. Sessions now follow: CreateSessionConfigureLoadout → (client uploads patches) → FinalizeSessionActive.
  • Adds the FinalizeSession RPC and a WorkspacePatchesTarZst SSH subsystem. The daemon atomically unpacks uploaded tar.zst patch archives with path-traversal validation and in-flight byte budgets before marking the session active.
  • Client-side (cmd_activate) collects approved patches from the contribution verdict, uploads them via Client::upload_patches with a determinate progress bar, and calls FinalizeSession; on failure it attempts a best-effort session destroy.
  • Adds PatchDestPrefixCollision conflict detection at compose time, rejecting patches whose destinations are component-boundary prefixes of each other (e.g. foo vs foo/bar).
  • On daemon startup, stale Pending and Materializing session records are reaped automatically.
  • PatchDest::try_new now strips a leading ~/ from patch destinations.
  • Risk: ConfigureLoadoutResponse::Ready is renamed to Materialized and SessionStep::Active becomes SessionStep::Materialized on the wire; all RPC clients and tests must be updated.

Macroscope summarized 61f4f35.

Summary by CodeRabbit

  • New Features
    • Added a two-step activation flow: sessions become materialized after composition, then require finalization to become attachable.
    • Approved workspace patches are uploaded with progress and materialized atomically during finalization.
    • Added a hidden CLI spin command for a stderr spinner with --seconds.
  • Bug Fixes
    • Improved resilience for sessions interrupted during activation, including cleanup/reaping of unresumable states.
    • Safer patch extraction rejects unsafe paths and prevents oversized/marker-colliding entries.
    • Attachment picker now shows the correct “not-yet-ready” status for materializing/pending.
  • Documentation
    • Refreshed session composition/activation docs with the new phased lifecycle and finalization semantics.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The session lifecycle now distinguishes Materializing from attachable Active. Clients upload approved patches through a dedicated tar+zstd stream, then call FinalizeSession; the daemon validates, stages, materializes, and activates the session.

Changes

Session activation pipeline

Layer / File(s) Summary
Lifecycle and RPC contracts
crates/sessions/src/*, crates/minimald-rpc/src/lib.rs
Adds Materializing, replaces Ready/Active completion responses with Materialized, and introduces FinalizeSession.
Daemon finalization and patch staging
crates/minimald/src/rpc.rs, crates/minimald/src/session.rs, crates/minimald/src/sessions.rs
Stages validated patch archives atomically, requires readiness before finalization, materializes patches into the session home, and promotes sessions to Active.
Client upload and archive pipeline
crates/minimal/src/client.rs, crates/minimal/src/file_upload.rs, crates/minimal/src/lib.rs
Shares upload handling, collects approved patches, streams tar+zstd data, finalizes sessions, and cleans up failed activations.
CLI, tests, and documentation
crates/minimal/tests/*, crates/minimald/src/test_harness.rs, crates/minvmd/*, crates/sessions/docs/*
Updates helpers, examples, tests, status rendering, path normalization, and lifecycle documentation for the new flow.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MinimaldRPC
  participant PatchStaging
  participant SessionActor
  Client->>MinimaldRPC: ConfigureLoadout
  MinimaldRPC-->>Client: Materialized
  Client->>PatchStaging: Upload validated tar+zstd patches
  PatchStaging-->>Client: .patches_ready
  Client->>MinimaldRPC: FinalizeSession
  MinimaldRPC->>SessionActor: Finalize
  SessionActor->>PatchStaging: Materialize patches into session home
  SessionActor-->>Client: Active session
Loading

Possibly related PRs

Suggested reviewers: twitchyliquid64

Poem

I’m a rabbit with patches tucked neat,
Through zstd pipes they hop to their seat.
“Materialized!” rings clear,
Then finalize draws near—
And Active blooms under swift feet.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is conventional, concise, and matches the main change: uploading patch files during session activation.
Description check ✅ Passed The description includes Summary, Testing, and Checklist sections and covers the main behavior changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/minvmd/examples/exec.rs (1)

229-240: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Finalize the session before opening the exec channel.

Materialized means composition is assembled, not that the session is attachable. Even with no patches, the client must call FinalizeSession; the empty-composition path only bypasses the marker check inside that RPC. As written, the subsequent exec is rejected while the record is still Materializing. Also update the stale Ready wording in the error message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minvmd/examples/exec.rs` around lines 229 - 240, Update the
ConfigureLoadout handling around ConfigureLoadoutResponse::Materialized to call
FinalizeSession before opening the exec channel, including the empty-composition
path. Preserve the existing Pending error flow, but change its stale “Ready”
wording to accurately describe the Materialized-only case.
crates/minvmd/tests/minimald_session_integration.rs (1)

359-369: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Finalize the session before executing the command.

ConfigureLoadoutResponse::Materialized leaves the on-disk record in Materializing. This test must upload patches if applicable and call FinalizeSession before opening the exec channel; otherwise the daemon correctly refuses attachment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minvmd/tests/minimald_session_integration.rs` around lines 359 - 369,
Update the session setup after handling ConfigureLoadoutResponse in the
integration test: when the loadout is Materialized, upload any applicable
patches and invoke FinalizeSession before opening the exec channel. Preserve the
existing Pending and error handling, and ensure command execution starts only
after finalization succeeds.
🧹 Nitpick comments (2)
crates/minimald/src/rpc.rs (1)

587-596: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Patch-unpack failures aren't logged server-side.

serve_stream_workspace_files emits tracing::debug!(error = %msg, ...) before relaying to the client, but serve_stream_workspace_patches only relays over extended-data. The #[tracing::instrument] on unpack_workspace_patches doesn't record the Err return value, so daemon logs carry no trace of patch-unpack failures. Consider mirroring the files handler's debug log for symmetry.

🔍 Suggested parity with the files handler
 async fn serve_stream_workspace_patches(
     s: ServerStateHandle,
     config: ChannelConfig,
     mut c: RuChannel<Msg>,
 ) {
     if let Err(msg) = unpack_workspace_patches(&s, &config, &mut c).await {
+        tracing::debug!(error = %msg, "patch unpack failed");
         let _ = c.extended_data_bytes(1, msg).await;
     }
     let _ = c.close().await;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/rpc.rs` around lines 587 - 596, Add server-side debug
logging to the Err branch of serve_stream_workspace_patches before relaying the
failure through extended_data_bytes, mirroring the tracing::debug! behavior and
fields used by serve_stream_workspace_files. Preserve the existing client relay
and channel close behavior.
crates/minimal/src/file_upload.rs (1)

369-382: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Optional: hoist the "always finalize, prefer build error" pattern into TarZstArchive.

The finalize-on-every-path dance is implemented twice — here via stream_tar_zstd (Lines 62-73) and again in client.rs::upload_patches (the match (build_result, finish_result) block). Both encode the same invariant: finish() must run to avoid the async_tar::Builder drop-time panic, and the build error is preferred. A single helper (e.g. archive.finalize_after(build_result)) would keep that safety-critical ordering in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimal/src/file_upload.rs` around lines 369 - 382, Optionally
centralize the “always finalize, prefer build error” flow currently duplicated
between stream_tar_zstd and client.rs::upload_patches. Add a TarZstArchive
helper such as finalize_after that always invokes finish(), preserves the
existing async_tar finalization safety, and returns the build error when both
building and finalization fail; update both callers to use it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimald/src/rpc.rs`:
- Around line 567-573: Reduce MAX_PATCH_ENTRY_BYTES to a substantially smaller
limit suitable for the expected KB-to-few-MB patch archives, or add an aggregate
in-flight byte budget that limits the combined memory reserved across concurrent
unpack operations. Ensure the enforcement covers all concurrent patch-entry
buffers and prevents allocations from exceeding the configured bound.

In `@crates/minimald/src/test_harness.rs`:
- Around line 403-406: Update the panic diagnostic in the
ConfigureLoadoutResponse match to refer to the current Materialized success
variant instead of the stale Ready name, while preserving the existing Pending
handling.

In `@crates/sessions/docs/COMPOSITION.md`:
- Around line 24-27: Update the “four sequential RPCs” wording in the
composition workflow description to say “up to four sequential RPCs,” or
explicitly limit the four-RPC count to sessions where ConfigureLoadout returns
Pending; preserve the surrounding explanation of the two file-tree uploads.

---

Outside diff comments:
In `@crates/minvmd/examples/exec.rs`:
- Around line 229-240: Update the ConfigureLoadout handling around
ConfigureLoadoutResponse::Materialized to call FinalizeSession before opening
the exec channel, including the empty-composition path. Preserve the existing
Pending error flow, but change its stale “Ready” wording to accurately describe
the Materialized-only case.

In `@crates/minvmd/tests/minimald_session_integration.rs`:
- Around line 359-369: Update the session setup after handling
ConfigureLoadoutResponse in the integration test: when the loadout is
Materialized, upload any applicable patches and invoke FinalizeSession before
opening the exec channel. Preserve the existing Pending and error handling, and
ensure command execution starts only after finalization succeeds.

---

Nitpick comments:
In `@crates/minimal/src/file_upload.rs`:
- Around line 369-382: Optionally centralize the “always finalize, prefer build
error” flow currently duplicated between stream_tar_zstd and
client.rs::upload_patches. Add a TarZstArchive helper such as finalize_after
that always invokes finish(), preserves the existing async_tar finalization
safety, and returns the build error when both building and finalization fail;
update both callers to use it.

In `@crates/minimald/src/rpc.rs`:
- Around line 587-596: Add server-side debug logging to the Err branch of
serve_stream_workspace_patches before relaying the failure through
extended_data_bytes, mirroring the tracing::debug! behavior and fields used by
serve_stream_workspace_files. Preserve the existing client relay and channel
close behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b619e70f-3a5b-4672-bfab-f98ca2f7446e

📥 Commits

Reviewing files that changed from the base of the PR and between c9d664c and 21ce5fe.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • Cargo.toml
  • crates/minimal/Cargo.toml
  • crates/minimal/src/attach.rs
  • crates/minimal/src/client.rs
  • crates/minimal/src/file_upload.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/session_host.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/test_harness.rs
  • crates/minvmd/examples/exec.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/ot/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs

Comment thread crates/minimald/src/rpc.rs
Comment thread crates/minimald/src/test_harness.rs
Comment thread crates/sessions/docs/COMPOSITION.md
Comment thread crates/minimald/src/rpc.rs Outdated
}
loop_result?;

// Atomic swap: remove any prior `patches/`, rename the fully-

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/rpc.rs:833

The atomic swap deletes the existing patches_dir before calling rename(staging_dir, patches_dir). If rename fails, the previously valid patch tree and its ready marker are already gone, and there is a window where no patch tree exists at all — breaking the atomicity the comment claims. The old tree should be preserved until the new one is installed, e.g. by renaming the old tree out of the way first so a failed rename can roll back.

Also found in 1 other location(s)

crates/sessions/docs/COMPOSITION.md:685

The new invariant calls the unpack an atomic rename and says the marker never lies, but serve_stream_workspace_patches first removes the existing patches/ directory and only then renames patches.tmp/. During a retry, FinalizeSession can concurrently observe the old ready marker before removal and materialize stale patches, or observe the gap; the swap is not atomic replacement. The documentation therefore gives operators an incorrect concurrency guarantee and should either document/serialize this race or the implementation should use a genuinely atomic swap.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/rpc.rs around line 833:

The atomic swap deletes the existing `patches_dir` before calling `rename(staging_dir, patches_dir)`. If `rename` fails, the previously valid patch tree and its ready marker are already gone, and there is a window where no patch tree exists at all — breaking the atomicity the comment claims. The old tree should be preserved until the new one is installed, e.g. by renaming the old tree out of the way first so a failed `rename` can roll back.

Also found in 1 other location(s):
- crates/sessions/docs/COMPOSITION.md:685 -- The new invariant calls the unpack an atomic rename and says the marker never lies, but `serve_stream_workspace_patches` first removes the existing `patches/` directory and only then renames `patches.tmp/`. During a retry, `FinalizeSession` can concurrently observe the old ready marker before removal and materialize stale patches, or observe the gap; the swap is not atomic replacement. The documentation therefore gives operators an incorrect concurrency guarantee and should either document/serialize this race or the implementation should use a genuinely atomic swap.


let paths = upload_session_paths(s, config).await?;
let patches_dir = paths.patches.as_utf8_path().as_std_path().to_path_buf();
let staging_dir = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/rpc.rs:675

Two concurrent WorkspacePatchesTarZst uploads for the same session corrupt each other's patch set. Both compute the same fixed staging_dir (patches/.tmp), so each upload's remove_dir_all/create_dir_all/writes interleave with the other's, and the final remove_dir_all(&patches_dir) + rename swaps race — one upload can wipe the tree the other just installed, install a mixed archive, or fail after the other already renamed. There is no per-session serialization or unique staging path. Consider using a unique staging directory per upload and serializing the final swap per session (e.g., a per-session mutex).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/rpc.rs around line 675:

Two concurrent `WorkspacePatchesTarZst` uploads for the same session corrupt each other's patch set. Both compute the same fixed `staging_dir` (`patches/.tmp`), so each upload's `remove_dir_all`/`create_dir_all`/writes interleave with the other's, and the final `remove_dir_all(&patches_dir)` + `rename` swaps race — one upload can wipe the tree the other just installed, install a mixed archive, or fail after the other already renamed. There is no per-session serialization or unique staging path. Consider using a unique staging directory per upload and serializing the final swap per session (e.g., a per-session mutex).

Comment thread crates/minimald/src/session.rs Outdated
let marker = patches_dir
.as_utf8_path()
.join(crate::rpc::PATCHES_READY_MARKER);
if !tokio::fs::try_exists(&marker).await.unwrap_or(false) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/session.rs:808

finalize converts every error from tokio::fs::try_exists into false via unwrap_or(false). A permissions or filesystem I/O error when checking the patches-ready marker makes the function return the misleading InvalidInput error "patches upload never completed" instead of the real I/O error, preventing correct diagnosis or retry. Propagate the try_exists error and only treat Ok(false) as a missing upload.

Suggested change
if !tokio::fs::try_exists(&marker).await.unwrap_or(false) {
if !tokio::fs::try_exists(&marker).await? {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/session.rs around line 808:

`finalize` converts every error from `tokio::fs::try_exists` into `false` via `unwrap_or(false)`. A permissions or filesystem I/O error when checking the patches-ready marker makes the function return the misleading `InvalidInput` error "patches upload never completed" instead of the real I/O error, preventing correct diagnosis or retry. Propagate the `try_exists` error and only treat `Ok(false)` as a missing upload.

host: None,
},
SessionStatus::Pending => SessionInner::Draft { pending: None },
// `Materializing` records are only meaningful across a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High src/session.rs:376

A Materializing record that survives daemon restart is mapped to SessionInner::Draft { pending: None }, so configure_loadout and the attach shortcut accept it and recompose the session as if it were Pending. The record's persisted status stays Materializing, so the session drifts to an inconsistent state, and any stale .patches_ready marker left by a prior upload can satisfy the new composition's finalize check — materializing the old session's patches for a different composition instead of requiring the client to abort and re-create.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/session.rs around line 376:

A `Materializing` record that survives daemon restart is mapped to `SessionInner::Draft { pending: None }`, so `configure_loadout` and the attach shortcut accept it and recompose the session as if it were `Pending`. The record's persisted status stays `Materializing`, so the session drifts to an inconsistent state, and any stale `.patches_ready` marker left by a prior upload can satisfy the new composition's finalize check — materializing the old session's patches for a different composition instead of requiring the client to abort and re-create.

// async_tar::Builder requires W: Sync, but the SSH channel stream
// is not Sync. Use a duplex pipe: the tar builder writes to one end
// (in a background task), and we copy from the other end to writer.
stream_via_pipe(writer, async |tx| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/file_upload.rs:62

If the future returned by stream_tar_zstd is cancelled while add_dir_entries or archive.finish() is awaiting, the TarZstArchive is dropped without finalization, and async_tar::Builder panics from its Drop impl. Unlike the previous spawned-task design, the archive builder now runs inline in the caller's future, so any task abort, timeout, or Ctrl-C shutdown triggers the drop-time panic. Consider either keeping the builder on a separate task that drains to completion on cancellation, or wrapping the archive in an Abortable/CancellationToken guard that finalizes the builder before dropping it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimal/src/file_upload.rs around line 62:

If the future returned by `stream_tar_zstd` is cancelled while `add_dir_entries` or `archive.finish()` is awaiting, the `TarZstArchive` is dropped without finalization, and `async_tar::Builder` panics from its `Drop` impl. Unlike the previous spawned-task design, the archive builder now runs inline in the caller's future, so any task abort, timeout, or Ctrl-C shutdown triggers the drop-time panic. Consider either keeping the builder on a separate task that drains to completion on cancellation, or wrapping the archive in an `Abortable`/`CancellationToken` guard that finalizes the builder before dropping it.

/// checked the patches-ready marker, so a missing file at this
/// point is a bug (the marker was written but the file it should
/// have gated on didn't land).
async fn materialize_patches_into_home(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/session.rs:39

materialize_patches_into_home can fail with an io::Error for a composition that check_patch_mismatches accepted. Two patches targeting destinations foo and foo/bar have distinct destinations so the composer's conflict check passes, but materialization tries to treat foo as both a file and a directory: whichever runs second fails. This leaves the session stuck in Materializing for a valid composition. The fix belongs in the composer: reject incoming patches whose destination is a prefix of (or equal to) an existing destination, and vice versa, so prefix collisions are caught at compose time rather than failing at finalize.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/session.rs around line 39:

`materialize_patches_into_home` can fail with an `io::Error` for a composition that `check_patch_mismatches` accepted. Two patches targeting destinations `foo` and `foo/bar` have distinct destinations so the composer's conflict check passes, but materialization tries to treat `foo` as both a file and a directory: whichever runs second fails. This leaves the session stuck in `Materializing` for a valid composition. The fix belongs in the composer: reject incoming patches whose destination is a prefix of (or equal to) an existing destination, and vice versa, so prefix collisions are caught at compose time rather than failing at finalize.

Comment thread crates/minimald/src/rpc.rs Outdated

// Read the entry body from the tar stream. Sequential —
// this is the only step that can't parallelize.
let mut body = Vec::with_capacity(entry_size as usize);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/rpc.rs:777

The per-entry size cap doesn't bound total memory: up to write_concurrency write tasks each hold a buffered body in memory, and the next entry body is read fully before the backpressure check waits for a slot. Peak live memory is therefore roughly (available_parallelism() + 1) × MAX_PATCH_ENTRY_BYTES — tens of GiB on a many-core host — which can trigger the allocator's OOM handler and abort the whole daemon. This defeats the stated purpose of MAX_PATCH_ENTRY_BYTES. The concurrency cap limits the number of writes, but since each accepted body is fully buffered into a Vec before being handed to a spawned task, the memory ceiling is the product of the cap and concurrency, not the cap alone. Consider enforcing a daemon-wide byte budget and applying backpressure before reading the next entry body, rather than multiplying a 1 GiB per-entry cap by CPU count.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/rpc.rs around line 777:

The per-entry size cap doesn't bound total memory: up to `write_concurrency` write tasks each hold a buffered body in memory, and the next entry body is read fully *before* the backpressure check waits for a slot. Peak live memory is therefore roughly `(available_parallelism() + 1) × MAX_PATCH_ENTRY_BYTES` — tens of GiB on a many-core host — which can trigger the allocator's OOM handler and abort the whole daemon. This defeats the stated purpose of `MAX_PATCH_ENTRY_BYTES`. The concurrency cap limits the number of writes, but since each accepted body is fully buffered into a `Vec` before being handed to a spawned task, the memory ceiling is the product of the cap and concurrency, not the cap alone. Consider enforcing a daemon-wide byte budget and applying backpressure *before* reading the next entry body, rather than multiplying a 1 GiB per-entry cap by CPU count.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium

async fn attach(

When attach auto-configures an unconfigured Draft session with an empty contribution and the resulting composition has patches, the record is promoted to Materializing but the attach returns SessionPending and stays blocked forever. The inline configure_loadout moved the session to Materializing, but attach only auto-finalizes when c.patches().is_empty() — otherwise it skips finalization and falls through to the SessionPending refusal. This attach path never receives the composition or a patches-upload request, so there is no way for the client to run the expected upload → FinalizeSession sequence. Every subsequent attach to the same session hits the same Materializing guard and also fails. Consider checking for a non-empty composition before calling configure_loadout and refusing the inline shortcut (or documenting that internal callers must never produce daemon-side patches through this path).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/session.rs around line 893:

When `attach` auto-configures an unconfigured `Draft` session with an empty contribution and the resulting composition has patches, the record is promoted to `Materializing` but the attach returns `SessionPending` and stays blocked forever. The inline `configure_loadout` moved the session to `Materializing`, but `attach` only auto-finalizes when `c.patches().is_empty()` — otherwise it skips finalization and falls through to the `SessionPending` refusal. This attach path never receives the composition or a patches-upload request, so there is no way for the client to run the expected upload → `FinalizeSession` sequence. Every subsequent attach to the same session hits the same `Materializing` guard and also fails. Consider checking for a non-empty composition before calling `configure_loadout` and refusing the inline shortcut (or documenting that internal callers must never produce daemon-side patches through this path).

buffered.consume(take);
written += take as u64;
}
if written < declared {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/file_upload.rs:332

When the source file shrinks between metadata() and the body copy, add_file pads the short read with NUL bytes to match the declared size and returns Ok(()). The daemon then unpacks a corrupted file containing the surviving prefix plus trailing zeros, and the client reports a successful upload. The padding keeps the tar framing valid but silently loses content integrity. Consider returning an error when written < declared instead of zero-padding so the upload fails loudly.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimal/src/file_upload.rs around line 332:

When the source file shrinks between `metadata()` and the body copy, `add_file` pads the short read with NUL bytes to match the declared size and returns `Ok(())`. The daemon then unpacks a corrupted file containing the surviving prefix plus trailing zeros, and the client reports a successful upload. The padding keeps the tar framing valid but silently loses content integrity. Consider returning an error when `written < declared` instead of zero-padding so the upload fails loudly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High

async fn context(&mut self, scaffold_if_missing: bool) -> Result<mctx::Context, String> {

configure_loadout sets the on-disk record to Materializing but stores the in-memory state as SessionInner::Active at line 654. Because Session::context only rejects SessionInner::Draft, SessionHandle::context() succeeds before patches are uploaded or FinalizeSession runs. The task-exec path calls context() directly, so tasks execute against an unmaterialized home directory, bypassing the lifecycle safety guarantee the Materializing status was added to enforce. Consider gating context() on the persisted record status being Active, as attach() already does.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/session.rs around line 1089:

`configure_loadout` sets the on-disk record to `Materializing` but stores the in-memory state as `SessionInner::Active` at line 654. Because `Session::context` only rejects `SessionInner::Draft`, `SessionHandle::context()` succeeds before patches are uploaded or `FinalizeSession` runs. The task-exec path calls `context()` directly, so tasks execute against an unmaterialized home directory, bypassing the lifecycle safety guarantee the `Materializing` status was added to enforce. Consider gating `context()` on the persisted record status being `Active`, as `attach()` already does.

Comment thread crates/minimal/src/lib.rs
/// Unlike `AbortSession`, `DestroySession` works on any status
/// past `Pending`. Errors are logged, not propagated — the caller
/// is already reporting a primary error.
async fn best_effort_destroy(client: &mut client::Client, session_id: sessions::SessionId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/lib.rs:1039

best_effort_destroy awaits Client::oneshot_rpc with no timeout, so when cleanup runs after a network blip or wedged daemon, the half-open SSH channel can make the call hang indefinitely — blocking the caller from ever seeing the original upload/finalization error it's supposed to surface. Consider bounding the cleanup with tokio::time::timeout so best-effort teardown can't stall activation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimal/src/lib.rs around line 1039:

`best_effort_destroy` awaits `Client::oneshot_rpc` with no timeout, so when cleanup runs after a network blip or wedged daemon, the half-open SSH channel can make the call hang indefinitely — blocking the caller from ever seeing the original upload/finalization error it's supposed to surface. Consider bounding the cleanup with `tokio::time::timeout` so best-effort teardown can't stall activation.

serde_json::from_slice(&resp_buf).map_err(|e| format!("decode response: {e}"))?;
match resp {
Errorable::Ok(ConfigureLoadoutResponse::Ready) => {}
Errorable::Ok(ConfigureLoadoutResponse::Materialized) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High examples/exec.rs:229

The Materialized branch skips FinalizeSession, so the session stays in Materializing status. The daemon gates exec on Active status, and the auto-finalize shortcut only applies to sessions the daemon configured itself — not this client-configured session. The exec at line 247 is therefore rejected instead of running the command. This example must call FinalizeSession (after the empty patches upload) before attempting exec.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minvmd/examples/exec.rs around line 229:

The `Materialized` branch skips `FinalizeSession`, so the session stays in `Materializing` status. The daemon gates exec on `Active` status, and the auto-finalize shortcut only applies to sessions the daemon configured itself — not this client-configured session. The exec at line 247 is therefore rejected instead of running the command. This example must call `FinalizeSession` (after the empty patches upload) before attempting exec.


let dest = staging_dir.join(&entry_path);
let path_display = entry_path.display().to_string();
inflight.spawn(async move {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Critical src/rpc.rs:905

The unpacker treats every tar entry as a regular file — tokio::fs::write is used unconditionally, ignoring the header's entry type. Directory entries (e.g. .claude/) are written as empty regular files instead of being created with mkdir, so the subsequent tokio::fs::create_dir_all(".claude") for a child like .claude/settings.json hits a file-not-directory error and the whole unpack fails. Symlink entries are likewise replaced by empty regular files instead of being created with symlink. The fix is to branch on entry.header().entry_type() and handle directories and symlinks explicitly.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/rpc.rs around line 905:

The unpacker treats every tar entry as a regular file — `tokio::fs::write` is used unconditionally, ignoring the header's entry type. Directory entries (e.g. `.claude/`) are written as empty regular files instead of being created with `mkdir`, so the subsequent `tokio::fs::create_dir_all(".claude")` for a child like `.claude/settings.json` hits a file-not-directory error and the whole unpack fails. Symlink entries are likewise replaced by empty regular files instead of being created with `symlink`. The fix is to branch on `entry.header().entry_type()` and handle directories and symlinks explicitly.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
crates/minimald/src/diag.rs (3)

429-484: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Make the log-directory walk race-safe.

The symlink_metadata check is TOCTOU: a guest task can replace logs/ with a symlink before newest_rotated enumerates it. add_file_tail prevents following a symlinked final file, but it does not prevent the replaced parent directory from exposing unrelated files. Traverse from an opened directory handle with no-follow/beneath constraints, or revalidate the directory identity during enumeration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/diag.rs` around lines 429 - 484, Make the logs traversal
in logs race-safe by avoiding reliance on the initial symlink_metadata check
before newest_rotated. Traverse from an opened logs directory handle using
no-follow/beneath constraints, or revalidate the directory identity throughout
enumeration so a guest cannot replace logs/ with a symlink and expose unrelated
files; retain the existing skip behavior for missing, inaccessible, or
non-directory paths and keep add_file_tail’s final-file protection.

161-180: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not detach the bundle builder on copy failure.

When the channel copy times out or fails, copy_result...? returns after dropping rx, while build is never awaited. The spawned task can remain alive in a blocked collector, retaining the bundle writer and filesystem state for every abandoned client. Join or otherwise coordinate the builder before returning; if cancellation is required, make the archive writer cancellation-safe first.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/diag.rs` around lines 161 - 180, The diagnostic bundle
handler must not return immediately after a failed or timed-out copy while the
spawned build task remains running. In the flow around copy_result, drop rx as
needed, then await or explicitly cancel and join build before propagating the
copy error; ensure any cancellation path safely releases the archive writer and
filesystem state.

730-748: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Move the disk_usage probe off the async worker.
diagnostics::disk_usage(path) is a synchronous libc::statvfs call, and collect_step! can only time out while the future yields. If the filesystem wedges, this blocks the Tokio worker and the collector deadline never fires. Wrap both probes in spawn_blocking, or move the helper itself to blocking code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/diag.rs` around lines 730 - 748, Update the disk
function’s diagnostics::disk_usage probes to run via Tokio blocking execution,
ensuring both filesystem checks occur off the async worker and can be bounded by
the collector deadline. Await the blocking results and preserve the existing
total_bytes/free_bytes mapping and output behavior.

Source: Learnings

🧹 Nitpick comments (3)
crates/minimald/src/rpc.rs (3)

655-669: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

serve_stream_workspace_patches doesn't track/log bytes received, unlike its serve_stream_workspace_files sibling.

serve_stream_workspace_files wraps its reader in CountingReader and logs bytes_received on both success and failure (lines 634-650). serve_stream_workspace_patches / unpack_workspace_patches never wrap the reader this way, so a stalled/failed patch upload has no wire-byte tally in the logs — only the outer served() wrapper's duration/error. Given this PR's focus on patch-upload performance and progress reporting, this asymmetry is worth closing for parity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/rpc.rs` around lines 655 - 669, The patch-upload path
does not record received wire bytes. Update serve_stream_workspace_patches and
unpack_workspace_patches to use the same CountingReader-based tracking and
logging pattern as serve_stream_workspace_files, ensuring bytes_received is
logged on both successful and failed uploads while preserving the existing error
reporting and channel close behavior.

940-946: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

"Atomic swap" is actually remove-then-rename; a crash between the two leaves patches_dir absent.

The docstring above (lines 754-761) and this comment describe the swap as atomic, but remove_dir_all(&patches_dir) followed by rename(&staging_dir, &patches_dir) is two separate filesystem operations. A daemon crash/OOM-kill/host restart between them leaves neither the old nor the new patches/ tree in place. This is likely bounded by the PR's "reaps unrecoverable records after restart" behavior (a still-Materializing session without a valid marker would presumably get reaped), but that recovery path lives outside this file, so worth confirming it actually covers "patches dir missing entirely, no marker" on restart.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/rpc.rs` around lines 940 - 946, The patches directory
replacement in the staging swap flow is not atomic because remove_dir_all
precedes rename. Update the swap logic and its surrounding
documentation/comments to use an atomic replacement strategy that preserves the
existing patches directory until the new tree is ready, or explicitly implement
and validate the restart recovery path for a missing patches directory with no
marker; ensure the behavior is consistent with the materializing-session
recovery described above.

753-957: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No visible test for FinalizeSession against an unknown session id, unlike its RPC siblings.

serve_rename_session, serve_destroy_session, and serve_abort_session all have a companion *_errors_for_unknown_id test. serve_finalize_session has the same "no session with ID" error path (lines 311-315) but no matching test in the provided test module. Given FinalizeSession gates the Materializing → Active promotion, a regression here would silently let attach-gating logic drift.

As per coding guidelines, **/*.rs: "During Rust development, frequently run cargo test -p <crate name> to catch compilation errors and test failures" — worth running cargo test -p minimald after adding this coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/rpc.rs` around lines 753 - 957, Add a companion
error-path test for serve_finalize_session covering an unknown session ID,
matching the existing serve_rename_session, serve_destroy_session, and
serve_abort_session tests. Assert it returns the expected “no session with ID”
error and does not promote any session. Run cargo test -p minimald to verify the
new coverage.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimald/src/rpc.rs`:
- Around line 821-916: Update the archive-entry handling loop around the
entry_type and write task to branch on each tar entry’s type before reading and
writing its body. Create directories with the async directory API, preserve
symlink targets using the platform-appropriate symlink operation, and retain
body reads plus file writes only for regular files; reject unsupported entry
types with a descriptive error while preserving existing path validation and
inflight-task cleanup.

---

Outside diff comments:
In `@crates/minimald/src/diag.rs`:
- Around line 429-484: Make the logs traversal in logs race-safe by avoiding
reliance on the initial symlink_metadata check before newest_rotated. Traverse
from an opened logs directory handle using no-follow/beneath constraints, or
revalidate the directory identity throughout enumeration so a guest cannot
replace logs/ with a symlink and expose unrelated files; retain the existing
skip behavior for missing, inaccessible, or non-directory paths and keep
add_file_tail’s final-file protection.
- Around line 161-180: The diagnostic bundle handler must not return immediately
after a failed or timed-out copy while the spawned build task remains running.
In the flow around copy_result, drop rx as needed, then await or explicitly
cancel and join build before propagating the copy error; ensure any cancellation
path safely releases the archive writer and filesystem state.
- Around line 730-748: Update the disk function’s diagnostics::disk_usage probes
to run via Tokio blocking execution, ensuring both filesystem checks occur off
the async worker and can be bounded by the collector deadline. Await the
blocking results and preserve the existing total_bytes/free_bytes mapping and
output behavior.

---

Nitpick comments:
In `@crates/minimald/src/rpc.rs`:
- Around line 655-669: The patch-upload path does not record received wire
bytes. Update serve_stream_workspace_patches and unpack_workspace_patches to use
the same CountingReader-based tracking and logging pattern as
serve_stream_workspace_files, ensuring bytes_received is logged on both
successful and failed uploads while preserving the existing error reporting and
channel close behavior.
- Around line 940-946: The patches directory replacement in the staging swap
flow is not atomic because remove_dir_all precedes rename. Update the swap logic
and its surrounding documentation/comments to use an atomic replacement strategy
that preserves the existing patches directory until the new tree is ready, or
explicitly implement and validate the restart recovery path for a missing
patches directory with no marker; ensure the behavior is consistent with the
materializing-session recovery described above.
- Around line 753-957: Add a companion error-path test for
serve_finalize_session covering an unknown session ID, matching the existing
serve_rename_session, serve_destroy_session, and serve_abort_session tests.
Assert it returns the expected “no session with ID” error and does not promote
any session. Run cargo test -p minimald to verify the new coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 50325d66-6367-4e81-87b4-b46fc31573e8

📥 Commits

Reviewing files that changed from the base of the PR and between 21ce5fe and 0e7a277.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • Cargo.toml
  • crates/minimal/Cargo.toml
  • crates/minimal/src/attach.rs
  • crates/minimal/src/client.rs
  • crates/minimal/src/file_upload.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/diag.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/session_host.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/test_harness.rs
  • crates/minvmd/examples/exec.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/ot/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs
🚧 Files skipped from review as they are similar to previous changes (18)
  • crates/ot/src/lib.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/minimal/src/attach.rs
  • crates/sessions/src/store.rs
  • crates/minvmd/examples/exec.rs
  • crates/sessions/src/wire/request.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/sessions/src/core/primitives.rs
  • crates/minimal/src/client.rs
  • crates/minimald/src/test_harness.rs
  • crates/sessions/src/lib.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/minimald/src/session.rs
  • crates/minimal/src/file_upload.rs
  • crates/minimald/src/sessions.rs
  • crates/minimal/src/lib.rs

Comment on lines +821 to +916
use futures::StreamExt as _;
use tokio::io::AsyncReadExt as _;
let mut entries = archive
.entries()
.map_err(|e| format!("reading patch tar entries: {e}"))?;
let write_concurrency = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
// `JoinSet` (not `FuturesUnordered<JoinHandle>`) so we can
// `abort_all` in-flight writes on any error return. Dropping a
// `JoinHandle` only detaches the task — writes queued when we
// fail would otherwise keep running under `staging_dir`,
// racing the next upload's `remove_dir_all` at the top of this
// function and burning blocking-pool slots for results nobody
// reads.
//
// `unpack_loop` returns Err on any per-entry problem;
// `abort_and_drain` afterward cancels stragglers regardless of
// outcome so no writes outlive this function.
let mut inflight: tokio::task::JoinSet<Result<(), String>> = tokio::task::JoinSet::new();
let loop_result: Result<(), String> = async {
while let Some(entry) = entries.next().await {
let mut entry = entry.map_err(|e| format!("reading patch tar entry: {e}"))?;
let entry_path = entry
.path()
.map_err(|e| format!("decoding entry path: {e}"))?
.into_owned();
if !safe_relative_path(&entry_path) {
return Err(format!(
"patch archive entry rejected: `{}` contains an absolute path or a `..` component",
entry_path.display()
));
}
// Reject a patch destination that would clobber the
// patches-ready marker. Marker + user patch would
// otherwise race, and `materialize_patches_into_home`
// would silently copy the emptied marker into the
// sandbox home, zeroing whatever the user had there.
if entry_path == StdPath::new(PATCHES_READY_MARKER) {
return Err(format!(
"patch archive entry rejected: `{}` collides with the daemon's \
patches-ready marker filename",
entry_path.display()
));
}
let entry_size = entry.header().size().unwrap_or(0);
// Cap per-entry size. Without this a peer that lies in
// the tar header can push `Vec::with_capacity` to
// `usize::MAX` (panic → task abort) or into OOM
// (allocator abort → whole daemon down). Legitimate
// patch files are small; the cap is generous but
// bounded (also multiplies against `write_concurrency`,
// so peak daemon memory stays predictable).
if entry_size > MAX_PATCH_ENTRY_BYTES {
return Err(format!(
"patch archive entry rejected: `{}` declares {entry_size} bytes, \
exceeds the {MAX_PATCH_ENTRY_BYTES}-byte per-entry cap",
entry_path.display()
));
}

// Read the entry body from the tar stream. Sequential —
// this is the only step that can't parallelize.
let mut body = Vec::with_capacity(entry_size as usize);
entry
.read_to_end(&mut body)
.await
.map_err(|e| format!("reading body of `{}`: {e}", entry_path.display()))?;

// Backpressure: if the pool is at capacity, wait for a
// slot before pushing another write task.
while inflight.len() >= write_concurrency {
match inflight.join_next().await {
Some(Ok(Ok(()))) => {}
Some(Ok(Err(e))) => return Err(e),
Some(Err(join_err)) => {
return Err(format!("write task panicked: {join_err}"))
}
None => break,
}
}

let dest = staging_dir.join(&entry_path);
let path_display = entry_path.display().to_string();
inflight.spawn(async move {
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
format!("creating parent dir for `{path_display}`: {e}")
})?;
}
tokio::fs::write(&dest, &body)
.await
.map_err(|e| format!("writing `{path_display}`: {e}"))?;
Ok::<_, String>(())
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd file_upload.rs
rg -n "append_dir_all|append_dir\(|Builder::new|EntryType" crates/minimal/src/file_upload.rs 2>/dev/null || true

Repository: gominimal/minimal

Length of output: 523


🌐 Web query:

Does the tar crate's Header::entry_type() default to EntryType::Regular for entries created via Builder::append_data?

💡 Result:

No, the tar crate's Builder::append_data method does not automatically set the EntryType of the Header to EntryType::Regular [1][2][3]. When using Builder::append_data, the user is required to provide a mutable reference to a Header [1][4]. The append_data method explicitly states that it will set the path in the provided header (and handle necessary GNU long-name extensions) and update the checksum, but it explicitly specifies that no other metadata in the header will be modified [1][3]. Consequently, the EntryType must be configured by the user on the Header object before calling append_data [1]. If the header is created via Header::new_gnu, its default entry type is typically the value set during the header's initialization, and it will remain unchanged by the append_data process unless the user explicitly calls Header::set_entry_type [5][6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- crates/minimal/src/file_upload.rs (around entry creation) ---'
sed -n '520,610p' crates/minimal/src/file_upload.rs

echo
echo '--- crates/minimald/src/rpc.rs (around unpack loop) ---'
sed -n '821,916p' crates/minimald/src/rpc.rs

Repository: gominimal/minimal

Length of output: 8662


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- search for hardlink / link emission in crates/minimal/src/file_upload.rs ---'
rg -n "Hard|hard|Link|link_name|set_entry_type\\(async_tar::EntryType::" crates/minimal/src/file_upload.rs

echo
echo '--- search for EntryType::Link anywhere in repo ---'
rg -n "EntryType::Link|is_hard_link\\(|set_link_name\\(" crates

Repository: gominimal/minimal

Length of output: 1162


Handle Directory and Symlink entries before writing bodies. The upload path emits both entry types, but this loop always reads a body and calls tokio::fs::write(...). That turns directories into empty files and symlinks into empty files too, which can break later parent creation and corrupt the extracted tree. Branch on entry.header().entry_type() to create directories, preserve links, or reject unsupported types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/rpc.rs` around lines 821 - 916, Update the archive-entry
handling loop around the entry_type and write task to branch on each tar entry’s
type before reading and writing its body. Create directories with the async
directory API, preserve symlink targets using the platform-appropriate symlink
operation, and retain body reads plus file writes only for regular files; reject
unsupported entry types with a descriptive error while preserving existing path
validation and inflight-task cleanup.

Comment thread crates/minimal/src/lib.rs
/// verdict — the daemon-side patches the client just approved and
/// now needs to upload. `Ignored`/`Denied` verdicts contribute
/// nothing to the composition, so they're not uploaded.
fn approved_patches_from_verdict(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High src/lib.rs:999

approved_patches_from_verdict returns each approved patch's host_path as a PathBuf that the CLI later tries to upload from the local machine. But WirePatchVerdict::Approved::host_path is the canonical absolute path on the daemon, not the client. Any approved daemon-side patch therefore makes activation fail with a file-not-found error when the client attempts to open a path that doesn't exist locally. The function should either skip daemon-sourced patches or resolve them back to a genuine client-local source (or stream from daemon storage) before returning them as upload sources.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimal/src/lib.rs around line 999:

`approved_patches_from_verdict` returns each approved patch's `host_path` as a `PathBuf` that the CLI later tries to upload from the local machine. But `WirePatchVerdict::Approved::host_path` is the canonical absolute path on the **daemon**, not the client. Any approved daemon-side patch therefore makes activation fail with a file-not-found error when the client attempts to open a path that doesn't exist locally. The function should either skip daemon-sourced patches or resolve them back to a genuine client-local source (or stream from daemon storage) before returning them as upload sources.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium

SessionMessage::IsBusy(r) => {

IsBusy returns false for a Materializing session (composition finalized but patches still uploading), so an unforced daemon shutdown proceeds mid-upload. On restart, the startup reaper deletes the Materializing record, discarding the in-progress session without warning. The Materializing phase maps to SessionInner::Active { host: None, composition: Some(_), .. }, which IsBusy treats as idle because it only checks host.is_some(). Consider also checking whether a composition is held and the record status is still Materializing, or track the phase explicitly.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/session.rs around line 548:

`IsBusy` returns `false` for a `Materializing` session (composition finalized but patches still uploading), so an unforced daemon shutdown proceeds mid-upload. On restart, the startup reaper deletes the `Materializing` record, discarding the in-progress session without warning. The `Materializing` phase maps to `SessionInner::Active { host: None, composition: Some(_), .. }`, which `IsBusy` treats as idle because it only checks `host.is_some()`. Consider also checking whether a composition is held and the record status is still `Materializing`, or track the phase explicitly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium

Ok(Self(
SandboxRelPath::try_new(normalized).map_err(PatchError::AbsoluteDestPath)?,
))

PatchDest::try_new accepts paths like "~/." or "~/./" and produces a PatchDest pointing at the sandbox home root, bypassing the empty-destination rejection. After ~/ is stripped and CurDir components are dropped during normalization, normalized is empty, but the code constructs SandboxRelPath::try_new(normalized) anyway instead of re-checking for emptiness. An empty SandboxRelPath is valid (it has no components), so the error is never raised. Consider checking normalized.as_str().is_empty() after the normalization loop and returning PatchError::EmptyDest.

         Ok(Self(
-            SandboxRelPath::try_new(normalized).map_err(PatchError::AbsoluteDestPath)?,
+            SandboxRelPath::try_new(normalized)
+                .map_err(PatchError::AbsoluteDestPath)
+                .and_then(|p| {
+                    if p.as_str().is_empty() {
+                        Err(PatchError::EmptyDest)
+                    } else {
+                        Ok(p)
+                    }
+                })?,
         ))
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/sessions/src/core/primitives.rs around lines 1015-1017:

`PatchDest::try_new` accepts paths like `"~/."` or `"~/./"` and produces a `PatchDest` pointing at the sandbox home root, bypassing the empty-destination rejection. After `~/` is stripped and `CurDir` components are dropped during normalization, `normalized` is empty, but the code constructs `SandboxRelPath::try_new(normalized)` anyway instead of re-checking for emptiness. An empty `SandboxRelPath` is valid (it has no components), so the error is never raised. Consider checking `normalized.as_str().is_empty()` after the normalization loop and returning `PatchError::EmptyDest`.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/minimald/src/session.rs (2)

905-941: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Guard the empty-loadout attach shortcut on persisted Pending only. SessionInner::Draft { pending: None } also represents a restart-orphaned Materializing record, so this branch can silently re-compose it with WireContribution::default() and even auto-finalize it back to Active, replacing the intended resume path with an empty loadout. Check the stored record is still Pending before auto-configuring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/session.rs` around lines 905 - 941, The empty-loadout
attach shortcut around SessionInner::Draft must only run for a persisted record
whose status is Pending. Validate the stored session record before calling
configure_loadout or the no_patches auto-finalize path, and leave
restart-orphaned Materializing records on their existing resume flow instead of
recomposing them with WireContribution::default().

254-258: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Treat Materializing as busy. IsBusy only checks host.is_some(), but after ComposeOutcome::Ready / handle_verdict the actor is Active { composition: Some(_), host: None } while the record is Materializing. shutdown(false) only consults is_busy(), so an unforced stop can interrupt FinalizeSession and force the client to re-upload and re-activate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/session.rs` around lines 254 - 258, Update the session
busy-state handling for IsBusy and its is_busy implementation to treat Active
sessions with composition present and host absent as busy when their record is
Materializing. Preserve existing busy behavior for Draft sessions holding
compose state and Active sessions with a minted host, so shutdown(false) cannot
interrupt FinalizeSession.
🧹 Nitpick comments (1)
crates/minimald/src/session.rs (1)

732-846: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider a dedicated SessionInner variant instead of overloading Active for Materializing.

finalize(), attach(), and the restart-orphan guard all have to independently re-derive "is this really finalized" by cross-checking the in-memory Active{composition, host} shape against the on-disk Record.status, because SessionInner::Active is used for both the Materializing and Active on-disk states. This ambiguity is exactly what enables the attach()-gating gap flagged below — a variant like Composed { composition, host } (kept separate from a true Active) would make the two states structurally distinguishable and remove the need to fetch/compare the on-disk record in multiple places.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/session.rs` around lines 732 - 846, Introduce a dedicated
SessionInner variant such as Composed { composition, host } for sessions still
in Materializing, keeping SessionInner::Active exclusively for finalized
sessions. Update session creation, finalize, attach, and restart-recovery logic
to use the distinct variants and transition Composed to Active only after
successful materialization and record promotion. Remove the duplicated
Active-shape checks and rely on the variant distinction for finalized-state
gating.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minvmd/tests/minimald_session_integration.rs`:
- Line 362: Update the ConfigureLoadoutResponse::Materialized branch in the
session setup to invoke the FinalizeSession RPC before opening the exec channel,
using the existing test-harness pattern and supporting the empty-patch case.
Preserve the current handling for other ConfigureLoadoutResponse variants.

---

Outside diff comments:
In `@crates/minimald/src/session.rs`:
- Around line 905-941: The empty-loadout attach shortcut around
SessionInner::Draft must only run for a persisted record whose status is
Pending. Validate the stored session record before calling configure_loadout or
the no_patches auto-finalize path, and leave restart-orphaned Materializing
records on their existing resume flow instead of recomposing them with
WireContribution::default().
- Around line 254-258: Update the session busy-state handling for IsBusy and its
is_busy implementation to treat Active sessions with composition present and
host absent as busy when their record is Materializing. Preserve existing busy
behavior for Draft sessions holding compose state and Active sessions with a
minted host, so shutdown(false) cannot interrupt FinalizeSession.

---

Nitpick comments:
In `@crates/minimald/src/session.rs`:
- Around line 732-846: Introduce a dedicated SessionInner variant such as
Composed { composition, host } for sessions still in Materializing, keeping
SessionInner::Active exclusively for finalized sessions. Update session
creation, finalize, attach, and restart-recovery logic to use the distinct
variants and transition Composed to Active only after successful materialization
and record promotion. Remove the duplicated Active-shape checks and rely on the
variant distinction for finalized-state gating.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 873e3e30-a3b5-4fb6-9f4b-65f743358621

📥 Commits

Reviewing files that changed from the base of the PR and between 0e7a277 and 1d0ce99.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • Cargo.toml
  • crates/minimal/Cargo.toml
  • crates/minimal/src/attach.rs
  • crates/minimal/src/client.rs
  • crates/minimal/src/file_upload.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/diag.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/session_host.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/test_harness.rs
  • crates/minvmd/examples/exec.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/ot/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs
🚧 Files skipped from review as they are similar to previous changes (20)
  • crates/ot/src/indicatif_shim.rs
  • crates/ot/src/lib.rs
  • crates/minimal/Cargo.toml
  • crates/minimald/src/diag.rs
  • crates/minimal/src/attach.rs
  • Cargo.toml
  • crates/sessions/src/wire/request.rs
  • crates/minvmd/examples/exec.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/session_host.rs
  • crates/minimal/src/client.rs
  • crates/minimal/tests/cli.rs
  • crates/sessions/src/core/primitives.rs
  • crates/minimald/src/sessions.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/minimald/src/rpc.rs
  • crates/minimal/src/file_upload.rs
  • crates/minimal/src/lib.rs

Comment thread crates/minvmd/tests/minimald_session_integration.rs

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minvmd/examples/exec.rs`:
- Around line 229-236: Update the ConfigureLoadoutResponse::Materialized branch
to invoke FinalizeSession for the empty-patch case before opening the exec
channel. Preserve the existing no-patches flow, but ensure finalization promotes
the session to Active before executing the command.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c4ec661a-e34c-44e7-8257-16531b217cee

📥 Commits

Reviewing files that changed from the base of the PR and between 1d0ce99 and 57569b2.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • Cargo.toml
  • crates/minimal/Cargo.toml
  • crates/minimal/src/attach.rs
  • crates/minimal/src/client.rs
  • crates/minimal/src/file_upload.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/session_host.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/test_harness.rs
  • crates/minvmd/examples/exec.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/ot/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs
🚧 Files skipped from review as they are similar to previous changes (19)
  • Cargo.toml
  • crates/minimal/Cargo.toml
  • crates/minimal/src/attach.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/sessions/src/core/primitives.rs
  • crates/ot/src/lib.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/sessions/src/store.rs
  • crates/minimald/src/test_harness.rs
  • crates/sessions/src/wire/request.rs
  • crates/minimald/src/session_host.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald/src/sessions.rs
  • crates/minimal/src/client.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/minimald/src/rpc.rs
  • crates/minimal/src/lib.rs
  • crates/minimald/src/session.rs

Comment thread crates/minvmd/examples/exec.rs

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/sessions/src/wire/request.rs`:
- Around line 70-74: Update the minvmd exec example flow so it sends
FinalizeSession after Materialized and before executing, including when the
patch set is empty. Remove the path that skips finalization, while preserving
the existing patch upload behavior for non-empty patches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8fa4ee22-fdba-42f4-aeb5-df81b6d47d09

📥 Commits

Reviewing files that changed from the base of the PR and between 57569b2 and 7fb73ba.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • Cargo.toml
  • crates/minimal/Cargo.toml
  • crates/minimal/src/attach.rs
  • crates/minimal/src/client.rs
  • crates/minimal/src/file_upload.rs
  • crates/minimal/src/lib.rs
  • crates/minimal/tests/cli.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/session_host.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/src/test_harness.rs
  • crates/minvmd/examples/exec.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/ot/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/primitives.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs
🚧 Files skipped from review as they are similar to previous changes (20)
  • crates/minimal/Cargo.toml
  • Cargo.toml
  • crates/minvmd/examples/exec.rs
  • crates/minvmd/tests/minimald_session_integration.rs
  • crates/ot/src/lib.rs
  • crates/minimal/src/attach.rs
  • crates/minimal/tests/cli.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/ot/src/indicatif_shim.rs
  • crates/minimald/src/session_host.rs
  • crates/minimald/src/test_harness.rs
  • crates/sessions/src/core/primitives.rs
  • crates/minimald/src/rpc.rs
  • crates/minimal/src/client.rs
  • crates/minimal/src/lib.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/session.rs
  • crates/minimal/src/file_upload.rs

Comment thread crates/sessions/src/wire/request.rs
@evanspearman
evanspearman force-pushed the evan/upload branch 4 times, most recently from f52bdca to c16b1e7 Compare July 23, 2026 20:49
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.

2 participants