feat(sessions): upload patch files from loadouts and friends - #914
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe session lifecycle now distinguishes ChangesSession activation pipeline
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
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
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 winFinalize the session before opening the exec channel.
Materializedmeans composition is assembled, not that the session is attachable. Even with no patches, the client must callFinalizeSession; the empty-composition path only bypasses the marker check inside that RPC. As written, the subsequent exec is rejected while the record is stillMaterializing. Also update the staleReadywording 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 winFinalize the session before executing the command.
ConfigureLoadoutResponse::Materializedleaves the on-disk record inMaterializing. This test must upload patches if applicable and callFinalizeSessionbefore 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 valuePatch-unpack failures aren't logged server-side.
serve_stream_workspace_filesemitstracing::debug!(error = %msg, ...)before relaying to the client, butserve_stream_workspace_patchesonly relays over extended-data. The#[tracing::instrument]onunpack_workspace_patchesdoesn't record theErrreturn 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 winOptional: 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 inclient.rs::upload_patches(thematch (build_result, finish_result)block). Both encode the same invariant:finish()must run to avoid theasync_tar::Builderdrop-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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (22)
Cargo.tomlcrates/minimal/Cargo.tomlcrates/minimal/src/attach.rscrates/minimal/src/client.rscrates/minimal/src/file_upload.rscrates/minimal/src/lib.rscrates/minimal/tests/cli.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/rpc.rscrates/minimald/src/session.rscrates/minimald/src/session_host.rscrates/minimald/src/sessions.rscrates/minimald/src/test_harness.rscrates/minvmd/examples/exec.rscrates/minvmd/tests/minimald_session_integration.rscrates/ot/src/indicatif_shim.rscrates/ot/src/lib.rscrates/sessions/docs/COMPOSITION.mdcrates/sessions/src/core/primitives.rscrates/sessions/src/lib.rscrates/sessions/src/store.rscrates/sessions/src/wire/request.rs
| } | ||
| loop_result?; | ||
|
|
||
| // Atomic swap: remove any prior `patches/`, rename the fully- |
There was a problem hiding this comment.
🟡 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_patchesfirst removes the existingpatches/directory and only then renamespatches.tmp/. During a retry,FinalizeSessioncan 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 = { |
There was a problem hiding this comment.
🟡 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).
| let marker = patches_dir | ||
| .as_utf8_path() | ||
| .join(crate::rpc::PATCHES_READY_MARKER); | ||
| if !tokio::fs::try_exists(&marker).await.unwrap_or(false) { |
There was a problem hiding this comment.
🟡 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.
| 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 |
There was a problem hiding this comment.
🟠 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| { |
There was a problem hiding this comment.
🟡 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( |
There was a problem hiding this comment.
🟡 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.
|
|
||
| // 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); |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
🟡 Medium
minimal/crates/minimald/src/session.rs
Line 893 in 21ce5fe
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 { |
There was a problem hiding this comment.
🟡 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.
21ce5fe to
0e7a277
Compare
There was a problem hiding this comment.
🟠 High
minimal/crates/minimald/src/session.rs
Line 1089 in 0e7a277
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.
| /// 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) { |
There was a problem hiding this comment.
🟡 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) => { |
There was a problem hiding this comment.
🟠 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 { |
There was a problem hiding this comment.
🔴 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.
There was a problem hiding this comment.
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 liftMake the log-directory walk race-safe.
The
symlink_metadatacheck is TOCTOU: a guest task can replacelogs/with a symlink beforenewest_rotatedenumerates it.add_file_tailprevents 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 liftDo not detach the bundle builder on copy failure.
When the channel copy times out or fails,
copy_result...?returns after droppingrx, whilebuildis 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 winMove the
disk_usageprobe off the async worker.
diagnostics::disk_usage(path)is a synchronouslibc::statvfscall, andcollect_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 inspawn_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_patchesdoesn't track/log bytes received, unlike itsserve_stream_workspace_filessibling.
serve_stream_workspace_fileswraps its reader inCountingReaderand logsbytes_receivedon both success and failure (lines 634-650).serve_stream_workspace_patches/unpack_workspace_patchesnever wrap the reader this way, so a stalled/failed patch upload has no wire-byte tally in the logs — only the outerserved()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_dirabsent.The docstring above (lines 754-761) and this comment describe the swap as atomic, but
remove_dir_all(&patches_dir)followed byrename(&staging_dir, &patches_dir)is two separate filesystem operations. A daemon crash/OOM-kill/host restart between them leaves neither the old nor the newpatches/tree in place. This is likely bounded by the PR's "reaps unrecoverable records after restart" behavior (a still-Materializingsession 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 winNo visible test for
FinalizeSessionagainst an unknown session id, unlike its RPC siblings.
serve_rename_session,serve_destroy_session, andserve_abort_sessionall have a companion*_errors_for_unknown_idtest.serve_finalize_sessionhas the same "no session with ID" error path (lines 311-315) but no matching test in the provided test module. GivenFinalizeSessiongates theMaterializing → Activepromotion, a regression here would silently let attach-gating logic drift.As per coding guidelines,
**/*.rs: "During Rust development, frequently runcargo test -p <crate name>to catch compilation errors and test failures" — worth runningcargo test -p minimaldafter 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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (23)
Cargo.tomlcrates/minimal/Cargo.tomlcrates/minimal/src/attach.rscrates/minimal/src/client.rscrates/minimal/src/file_upload.rscrates/minimal/src/lib.rscrates/minimal/tests/cli.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/diag.rscrates/minimald/src/rpc.rscrates/minimald/src/session.rscrates/minimald/src/session_host.rscrates/minimald/src/sessions.rscrates/minimald/src/test_harness.rscrates/minvmd/examples/exec.rscrates/minvmd/tests/minimald_session_integration.rscrates/ot/src/indicatif_shim.rscrates/ot/src/lib.rscrates/sessions/docs/COMPOSITION.mdcrates/sessions/src/core/primitives.rscrates/sessions/src/lib.rscrates/sessions/src/store.rscrates/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
| 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>(()) | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ 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 || trueRepository: 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:
- 1: https://docs.rs/tar/latest/tar/struct.Builder.html
- 2: https://docs.rs/tar/latest/src/tar/builder.rs.html
- 3: https://codebrowser.dev/rust/crates/tar/src/builder.rs.html
- 4: https://docs.rs/tar/0.4.25/tar/struct.Builder.html
- 5: https://docs.rs/tar/latest/tar/struct.Header.html
- 6: https://docs.rs/tar/latest/src/tar/header.rs.html
- 7: https://codebrowser.dev/rust/crates/tar/src/header.rs.html
🏁 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.rsRepository: 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\\(" cratesRepository: 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.
0e7a277 to
1d0ce99
Compare
| /// 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( |
There was a problem hiding this comment.
🟠 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.
There was a problem hiding this comment.
🟡 Medium
minimal/crates/minimald/src/session.rs
Line 548 in 1d0ce99
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.
There was a problem hiding this comment.
🟡 Medium
minimal/crates/sessions/src/core/primitives.rs
Lines 1015 to 1017 in 1d0ce99
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`.
There was a problem hiding this comment.
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 winGuard the empty-loadout attach shortcut on persisted
Pendingonly.SessionInner::Draft { pending: None }also represents a restart-orphanedMaterializingrecord, so this branch can silently re-compose it withWireContribution::default()and even auto-finalize it back toActive, replacing the intended resume path with an empty loadout. Check the stored record is stillPendingbefore 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 winTreat
Materializingas busy.IsBusyonly checkshost.is_some(), but afterComposeOutcome::Ready/handle_verdictthe actor isActive { composition: Some(_), host: None }while the record isMaterializing.shutdown(false)only consultsis_busy(), so an unforced stop can interruptFinalizeSessionand 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 liftConsider a dedicated
SessionInnervariant instead of overloadingActivefor Materializing.
finalize(),attach(), and the restart-orphan guard all have to independently re-derive "is this really finalized" by cross-checking the in-memoryActive{composition, host}shape against the on-diskRecord.status, becauseSessionInner::Activeis used for both theMaterializingandActiveon-disk states. This ambiguity is exactly what enables the attach()-gating gap flagged below — a variant likeComposed { composition, host }(kept separate from a trueActive) 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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (23)
Cargo.tomlcrates/minimal/Cargo.tomlcrates/minimal/src/attach.rscrates/minimal/src/client.rscrates/minimal/src/file_upload.rscrates/minimal/src/lib.rscrates/minimal/tests/cli.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/diag.rscrates/minimald/src/rpc.rscrates/minimald/src/session.rscrates/minimald/src/session_host.rscrates/minimald/src/sessions.rscrates/minimald/src/test_harness.rscrates/minvmd/examples/exec.rscrates/minvmd/tests/minimald_session_integration.rscrates/ot/src/indicatif_shim.rscrates/ot/src/lib.rscrates/sessions/docs/COMPOSITION.mdcrates/sessions/src/core/primitives.rscrates/sessions/src/lib.rscrates/sessions/src/store.rscrates/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
1d0ce99 to
57569b2
Compare
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (22)
Cargo.tomlcrates/minimal/Cargo.tomlcrates/minimal/src/attach.rscrates/minimal/src/client.rscrates/minimal/src/file_upload.rscrates/minimal/src/lib.rscrates/minimal/tests/cli.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/rpc.rscrates/minimald/src/session.rscrates/minimald/src/session_host.rscrates/minimald/src/sessions.rscrates/minimald/src/test_harness.rscrates/minvmd/examples/exec.rscrates/minvmd/tests/minimald_session_integration.rscrates/ot/src/indicatif_shim.rscrates/ot/src/lib.rscrates/sessions/docs/COMPOSITION.mdcrates/sessions/src/core/primitives.rscrates/sessions/src/lib.rscrates/sessions/src/store.rscrates/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
57569b2 to
7fb73ba
Compare
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (22)
Cargo.tomlcrates/minimal/Cargo.tomlcrates/minimal/src/attach.rscrates/minimal/src/client.rscrates/minimal/src/file_upload.rscrates/minimal/src/lib.rscrates/minimal/tests/cli.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/rpc.rscrates/minimald/src/session.rscrates/minimald/src/session_host.rscrates/minimald/src/sessions.rscrates/minimald/src/test_harness.rscrates/minvmd/examples/exec.rscrates/minvmd/tests/minimald_session_integration.rscrates/ot/src/indicatif_shim.rscrates/ot/src/lib.rscrates/sessions/docs/COMPOSITION.mdcrates/sessions/src/core/primitives.rscrates/sessions/src/lib.rscrates/sessions/src/store.rscrates/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
7fb73ba to
062db2e
Compare
f52bdca to
c16b1e7
Compare
c16b1e7 to
61f4f35
Compare
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
SessionPatchentries andsession_hostlogged them asdeferred = true— the sandbox home was empty when the shell was minted.New
WorkspacePatchesTarZstSSH subsystem streams the finalizedcomposition's approved patch files (loadout-contributed + Phase 3
daemon-approved) into
<workspace>/patches/on the daemon viaatomic staging-dir + rename +
.patches_readymarker. Body writesfan out across
available_parallelism()tasks via aJoinSet.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_capacityto OOM.New
SessionStatus::Materializingstate sits betweenPendingandActive.ConfigureLoadout/SubmitVerdictpromote the record toMaterializing(notActive); a newFinalizeSessionRPC checksthe marker, materializes patches into the sandbox home via
materialize_patches_into_home, and promotes toActive. Attachis refused while
Materializing, so a client that dies mid-flownever lands the operator on an empty-home shell.
Wire renames (pre-prod, no back-compat aliases):
ConfigureLoadoutResponse::Ready → MaterializedandSessionStep::Active → Materialized.Restart safety:
Manager::initreaps unresumable records(
PendingandMaterializing) at startup — those states rely onin-memory compose state that dies with the actor. If a race lets a
Materializingrecord through,finalizerefuses with anInvalidInputfault so the operator sees the problem instead of asilently-empty sandbox home.
Client progress bars via indicatif — spinner for the workspace-files
upload, patch-count bar for the composition-patches upload.
add_spinner_barandadd_patches_barregister withot's globalMultiProgressso tracing output and bars don't stomp on eachother. Includes a hidden
min spindemo command.Perf: several structural wins found while profiling the patches
upload path — 1 MiB
BufReaderon the source file, customcopy_buf-based fast path bypassingasync_tar's 8 KiB internalbuffer for short archive paths, zstd level 1 with N-1 worker
threads (feature
zstdmt), and the parallel daemon-side unpackdescribed above. End-to-end wall time for a 1.3 GiB
~/.claudepayload dropped from 117 s (cold cache, default settings) to ~9 s.
PatchDest::try_newnormalizes a leading~/in destinations(mfile authors write
path = "~/.claude"meaning "under sandboxhome"; the tilde was previously kept literal and landed patches at
/home/~/.claude/...).COMPOSITION.mdrewritten to cover the new Phase 4a/4b/4c split,the
Materializingstate, and the new invariants (client isauthoritative 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
BREAKING CHANGE:footer present if this is a breaking changeNote
Add patch file upload and
FinalizeSessionRPC to session activation flowMaterializingsession state betweenConfigureLoadoutandActive. Sessions now follow:CreateSession→ConfigureLoadout→ (client uploads patches) →FinalizeSession→Active.FinalizeSessionRPC and aWorkspacePatchesTarZstSSH subsystem. The daemon atomically unpacks uploaded tar.zst patch archives with path-traversal validation and in-flight byte budgets before marking the session active.cmd_activate) collects approved patches from the contribution verdict, uploads them viaClient::upload_patcheswith a determinate progress bar, and callsFinalizeSession; on failure it attempts a best-effort session destroy.PatchDestPrefixCollisionconflict detection at compose time, rejecting patches whose destinations are component-boundary prefixes of each other (e.g.foovsfoo/bar).PendingandMaterializingsession records are reaped automatically.PatchDest::try_newnow strips a leading~/from patch destinations.ConfigureLoadoutResponse::Readyis renamed toMaterializedandSessionStep::ActivebecomesSessionStep::Materializedon the wire; all RPC clients and tests must be updated.Macroscope summarized 61f4f35.
Summary by CodeRabbit
spincommand for a stderr spinner with--seconds.