Skip to content

fix(minimald): persist session composition across daemon restarts - #979

Merged
0chroma merged 1 commit into
mainfrom
fix/persist-session-composition-849
Jul 27, 2026
Merged

fix(minimald): persist session composition across daemon restarts#979
0chroma merged 1 commit into
mainfrom
fix/persist-session-composition-849

Conversation

@0chroma

@0chroma 0chroma commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The daemon held session composition (loadout, project, and package contributions) only in memory. After a restart, the launcher fell back to the baseline package and var set. This dropped all loadout contributions for existing sessions.
  • This change persists the finalized Composition as a composition.json sidecar alongside record.json at composition-assembly time. On spawn-from-disk, the daemon reads the sidecar back. The launcher re-applies the exact composition approved at min activate time. This preserves the bake-in guarantee across daemon lifecycle events.
  • Composition I/O goes through the Store actor and SessionRecordHandle, mirroring how the record itself is read and written. The Loader trait gains load_composition and store_composition, so the store owns the serialization, not the session actor.
  • A missing or corrupt sidecar logs a warning and falls back to baseline. Attach still works, but the operator sees the problem instead of a silent drop.

Fixes #849.

Approach

Investigated two strategies (see #849 for the full analysis):

  1. Persist the composition snapshot (chosen). This serializes the Composition to a sidecar via the existing wire types, which already have serde and bidirectional conversions. It preserves the bake-in guarantee exactly: client-side Inherit vars keep their already-resolved values, so no re-resolution is needed. No re-gating of user policy. No patch or home drift.
  2. Recompose at spawn from loadout names (rejected). This would break the documented bake-in contract. It cannot reproduce client env-derived vars without re-architecting attach into activate. It creates patch and home drift.

Approach 3 (loud baseline fallback) ships alongside. The load_composition helper on the session actor logs a warn! on missing or corrupt files, instead of silently falling back.

Changes

File What
sessions/src/store.rs load_composition and store_composition on the Loader trait. DiskLoader implements them with the same atomic tmp and rename pattern as write_record.
sessions/src/wire/request.rs WireComposition envelope (serde, versioned) plus From<&Composition> and 2 round-trip tests
sessions/src/core/compose.rs TryFrom<WireComposition> for Composition reconstructs from the persisted snapshot
minimald/src/store.rs CompositionLoad and CompositionStore messages on the Store actor. load_composition and store_composition on SessionRecordHandle.
minimald/src/session.rs load_composition helper at spawn-from-disk (loud fallback). store_composition calls at both assembly sites. 2 restart tests.
sessions/docs/COMPOSITION.md Updated the "Materializing records do not survive restart" invariant to reflect the sidecar

Verification

  • cargo test -p sessions -p minimald: all tests pass (4 new)
  • cargo clippy -p sessions -p minimald --all-targets -- -D warnings: clean
  • cargo fmt --all -- --check: clean
  • cargo check -p minvmd --tests: compiles (no broken downstream)

New tests

  • composition_survives_actor_restart: stops a session actor, evicts it, then re-resolves (spawning from disk). Asserts the sidecar restores the composition.
  • missing_sidecar_falls_back_to_baseline: deletes the sidecar, restarts the actor. Asserts the composition is None (baseline fallback).
  • composition_snapshot_round_trips and composition_snapshot_defaults_version_to_one: wire-level serde round-trip and version default.

Notes

  • JSON sidecar matches the existing on-disk convention (record.json, index.json).
  • The store actor writes the sidecar atomically (tmp and rename) before the record transitions to Materializing. A crash leaves either a reaped session or an Active session with its sidecar intact. The daemon reaps Pending or Materializing records at startup.
  • WireComposition carries a version field (defaulting to 1) so the on-disk shape can evolve without ambiguity.

Note

Persist session composition across daemon restarts in minimald

  • Adds a WireComposition wire format with versioning to serialize/deserialize Composition snapshots to/from composition.json per session.
  • Extends the Loader trait and DiskLoader with load_composition/store_composition, writing atomically via temp-file-and-rename.
  • The minimald session actor now stores the composition at assembly time and restores it on restart; missing or corrupt files log a warning and fall back to None (baseline).
  • Adds load_composition/store_composition to SessionRecordHandle, wired through new StoreMessage variants.
  • Risk: errors from store_composition propagate as io::Error and will surface at composition assembly time.

Macroscope summarized 8522f91.

Summary by CodeRabbit

  • New Features
    • Persisted session compositions now survive daemon restarts for active sessions.
    • Compositions are saved to and restored from sidecar snapshots during session creation and resume.
  • Bug Fixes
    • When a composition snapshot is missing or invalid, the system no longer reuses stale in-memory data and instead falls back safely to a baseline composition.
  • Documentation
    • Updated the composition/materializing record invariants and restart behavior details, including behavior when an actor lacks an in-memory composition.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Composition persistence

Layer / File(s) Summary
Composition snapshot contract
crates/sessions/src/wire/request.rs, crates/sessions/src/core/compose.rs
Adds a versioned WireComposition format, conversion from Composition, deserialization support, and serde coverage.
Composition sidecar storage
crates/sessions/src/store.rs, crates/minimald/src/store.rs
Adds optional loading and atomic persistence of composition.json through the disk loader and store actor APIs.
Session restart integration
crates/minimald/src/session.rs, crates/sessions/docs/COMPOSITION.md
Persists compositions before materialization, restores them for active sessions, documents fallback behavior, and tests restart and missing-sidecar cases.

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

Sequence Diagram(s)

sequenceDiagram
  participant SessionActor
  participant SessionRecordHandle
  participant StoreActor
  participant DiskLoader
  SessionActor->>SessionRecordHandle: store composition before Materializing
  SessionRecordHandle->>StoreActor: send composition request
  StoreActor->>DiskLoader: write composition.json atomically
  DiskLoader-->>StoreActor: storage result
  StoreActor-->>SessionRecordHandle: return result
  SessionActor->>SessionRecordHandle: load composition for Active record
  SessionRecordHandle->>StoreActor: send load request
  StoreActor->>DiskLoader: read composition.json
  DiskLoader-->>StoreActor: composition or missing result
  StoreActor-->>SessionRecordHandle: return restored composition or None
Loading

Possibly related issues

  • gominimal/inbox#321: Directly addresses persisting and restoring session Composition state across daemon restarts.

Possibly related PRs

Suggested reviewers: norrietaylor, evanspearman, twitchyliquid64

Poem

A rabbit tucked a snapshot tight,
Beside the record through the night.
Restart the daemon, hop and see—
The loadout springs back faithfully.
If sidecars fade, logs thump the ground,
And baseline hops back around.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #849 by persisting composition, restoring it after restart, and warning on missing or corrupt sidecars.
Out of Scope Changes check ✅ Passed The modified files stay focused on composition persistence, restore logic, docs, and supporting serialization/storage plumbing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title is concise, conventional, and accurately describes persisting session composition across restarts.
Description check ✅ Passed The description is mostly complete with summary, approach, changes, and verification, though it lacks the explicit Checklist section.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@0chroma
0chroma force-pushed the fix/persist-session-composition-849 branch 3 times, most recently from 5c7b615 to fda5378 Compare July 27, 2026 19:34
@0chroma
0chroma marked this pull request as ready for review July 27, 2026 20:42
The daemon held session composition (loadout + project + package
contributions) only in memory. After a restart, the launcher silently
fell back to the baseline package/var set, dropping all loadout
contributions for existing sessions.

Persist the finalized Composition as a composition.json sidecar
alongside record.json at composition-assembly time. On spawn-from-disk,
read the sidecar back so the launcher re-applies the exact composition
approved at `min activate` time, preserving the bake-in guarantee across
daemon lifecycle events.

A missing or corrupt sidecar logs a warning and falls back to baseline
(loud fallback) so attach still works, but the operator sees the
problem instead of a silent drop.

Fixes #849.
@0chroma
0chroma force-pushed the fix/persist-session-composition-849 branch from fda5378 to 8522f91 Compare July 27, 2026 20:46

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

🧹 Nitpick comments (3)
crates/sessions/src/core/compose.rs (2)

1244-1265: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

version is deserialized but never validated.

WireComposition::version is documented as gating the on-disk shape ("a future format can evolve without ambiguity"), but try_from ignores wire.version entirely. If a future COMPOSITION_SNAPSHOT_VERSION bump changes field semantics (not just adds new optional fields), a mismatched version would deserialize silently rather than being rejected, defeating the field's stated purpose.

Consider matching on wire.version here (or in DiskLoader::load_composition) and returning ComposeError::InvalidWireItem for unrecognized versions.

♻️ Proposed version gate
     fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
+        if wire.version != crate::wire::request::COMPOSITION_SNAPSHOT_VERSION {
+            return Err(ComposeError::InvalidWireItem {
+                what: "unsupported composition snapshot version",
+                context: format!(
+                    "found {}, expected {}",
+                    wire.version,
+                    crate::wire::request::COMPOSITION_SNAPSHOT_VERSION
+                ),
+            });
+        }
         let hooks: Vec<ProvenancedHook> = wire
🤖 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/sessions/src/core/compose.rs` around lines 1244 - 1265, Validate
wire.version in Composition::try_from before converting the composition fields,
accepting only the current COMPOSITION_SNAPSHOT_VERSION and returning
ComposeError::InvalidWireItem for unrecognized versions. Preserve the existing
conversion behavior for supported versions.

1247-1257: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate hook-conversion error mapping.

This map_err closure ("lifecycle hook with no callbacks") is identical to the one in extend_from_wire (lines 1139-1150). Extracting a shared helper (e.g. fn convert_wire_hooks(hooks: Vec<WireProvenancedHook>) -> Result<Vec<ProvenancedHook>, ComposeError>) would remove the duplication now that there are two call sites.

🤖 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/sessions/src/core/compose.rs` around lines 1247 - 1257, The
WireComposition conversion in try_from duplicates the lifecycle-hook error
mapping already used by extend_from_wire. Extract a shared helper for converting
Vec<WireProvenancedHook> to Result<Vec<ProvenancedHook>, ComposeError>,
preserving the existing "lifecycle hook with no callbacks" context, then reuse
it from both try_from and extend_from_wire.
crates/minimald/src/session.rs (1)

2040-2133: 🧹 Nitpick | 🔵 Trivial

Run the daemon integration harness for this restart/lifecycle change.

These new tests are solid in-crate coverage for the sidecar-restore and fallback paths, but they're unit/integration-style #[tokio::test]s, not the repository's dedicated daemon/VM harnesses.

As per coding guidelines, "When changing VM or daemon paths, run the relevant integration coverage: just e2e and/or just test-vm" and "Do not rely only on unit tests for VM/networking behavior; preserve and run the applicable integration and root-integration harnesses."

🤖 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 2040 - 2133, Run the
repository’s applicable daemon/VM integration coverage for the composition
restart changes, including just e2e and/or just test-vm as appropriate, in
addition to the in-crate tests composition_survives_actor_restart and
missing_sidecar_falls_back_to_baseline. Record or address any failures before
completing the change.

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.

Nitpick comments:
In `@crates/minimald/src/session.rs`:
- Around line 2040-2133: Run the repository’s applicable daemon/VM integration
coverage for the composition restart changes, including just e2e and/or just
test-vm as appropriate, in addition to the in-crate tests
composition_survives_actor_restart and missing_sidecar_falls_back_to_baseline.
Record or address any failures before completing the change.

In `@crates/sessions/src/core/compose.rs`:
- Around line 1244-1265: Validate wire.version in Composition::try_from before
converting the composition fields, accepting only the current
COMPOSITION_SNAPSHOT_VERSION and returning ComposeError::InvalidWireItem for
unrecognized versions. Preserve the existing conversion behavior for supported
versions.
- Around line 1247-1257: The WireComposition conversion in try_from duplicates
the lifecycle-hook error mapping already used by extend_from_wire. Extract a
shared helper for converting Vec<WireProvenancedHook> to
Result<Vec<ProvenancedHook>, ComposeError>, preserving the existing "lifecycle
hook with no callbacks" context, then reuse it from both try_from and
extend_from_wire.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1bf65520-5e55-4d01-8756-aae232763d89

📥 Commits

Reviewing files that changed from the base of the PR and between b1cde76 and fda5378.

📒 Files selected for processing (6)
  • crates/minimald/src/session.rs
  • crates/minimald/src/store.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/compose.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs

@0chroma
0chroma enabled auto-merge (squash) July 27, 2026 20:52

@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: 2

🤖 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/core/compose.rs`:
- Around line 1247-1263: Update the WireComposition conversion in try_from to
validate wire.version before converting any fields, accepting only the currently
supported snapshot version and returning the appropriate ComposeError for
unsupported versions so callers can fall back instead of interpreting them as
v1.

In `@crates/sessions/src/store.rs`:
- Around line 161-174: The store_composition implementation must sync the
temporary composition sidecar file to durable storage before renaming it into
place, matching the crash-safety behavior of write_record. Update the temp-file
write flow behind the store_composition trait method to flush and sync the file,
then perform the existing atomic rename.
🪄 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: 1198d1ae-89f1-4a2e-9ae2-7564ab57c844

📥 Commits

Reviewing files that changed from the base of the PR and between fda5378 and 8522f91.

📒 Files selected for processing (6)
  • crates/minimald/src/session.rs
  • crates/minimald/src/store.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/compose.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/minimald/src/store.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/wire/request.rs
  • crates/minimald/src/session.rs

Comment on lines +1247 to +1263
fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
let hooks: Vec<ProvenancedHook> = wire
.lifecycle_hooks
.into_iter()
.map(|h| {
h.try_into().map_err(|e| ComposeError::InvalidWireItem {
what: "lifecycle hook with no callbacks",
context: format!("{e}"),
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
vars: wire.vars.into_iter().map(Into::into).collect(),
patches: wire.patches.into_iter().map(Into::into).collect(),
packages: wire.packages.into_iter().map(Into::into).collect(),
lifecycle_hooks: hooks,
})

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

Reject unsupported snapshot versions.

WireComposition.version is never checked, so an older daemon will silently interpret a future snapshot using v1 semantics rather than treating it as corrupt and falling back. That can restore the wrong loadout after restart.

Proposed fix
 fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
+    if wire.version != crate::wire::request::COMPOSITION_SNAPSHOT_VERSION {
+        return Err(ComposeError::InvalidWireItem {
+            what: "unsupported composition snapshot version",
+            context: wire.version.to_string(),
+        });
+    }
+
     let hooks: Vec<ProvenancedHook> = wire
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
let hooks: Vec<ProvenancedHook> = wire
.lifecycle_hooks
.into_iter()
.map(|h| {
h.try_into().map_err(|e| ComposeError::InvalidWireItem {
what: "lifecycle hook with no callbacks",
context: format!("{e}"),
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
vars: wire.vars.into_iter().map(Into::into).collect(),
patches: wire.patches.into_iter().map(Into::into).collect(),
packages: wire.packages.into_iter().map(Into::into).collect(),
lifecycle_hooks: hooks,
})
fn try_from(wire: crate::wire::request::WireComposition) -> Result<Self, Self::Error> {
if wire.version != crate::wire::request::COMPOSITION_SNAPSHOT_VERSION {
return Err(ComposeError::InvalidWireItem {
what: "unsupported composition snapshot version",
context: wire.version.to_string(),
});
}
let hooks: Vec<ProvenancedHook> = wire
.lifecycle_hooks
.into_iter()
.map(|h| {
h.try_into().map_err(|e| ComposeError::InvalidWireItem {
what: "lifecycle hook with no callbacks",
context: format!("{e}"),
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
vars: wire.vars.into_iter().map(Into::into).collect(),
patches: wire.patches.into_iter().map(Into::into).collect(),
packages: wire.packages.into_iter().map(Into::into).collect(),
lifecycle_hooks: hooks,
})
🤖 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/sessions/src/core/compose.rs` around lines 1247 - 1263, Update the
WireComposition conversion in try_from to validate wire.version before
converting any fields, accepting only the currently supported snapshot version
and returning the appropriate ComposeError for unsupported versions so callers
can fall back instead of interpreting them as v1.

Comment on lines +161 to +174
/// Atomically persists the composition snapshot for the session
/// at `key` (tmp + rename, same crash-safety as [`Self::save`]).
/// Called at composition-assembly time so a restart can re-apply
/// the exact composition that was approved at `min activate` time.
///
/// # Errors
///
/// - `NotFound` if `key` is stale (same semantics as [`Self::save`]).
/// - I/O or serialization errors if the sidecar cannot be written.
fn store_composition(
&self,
key: &Self::Key,
composition: &crate::core::compose::Composition,
) -> Result<(), std::io::Error>;

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

Sync the sidecar before publishing it.

Unlike write_record, this path renames an unsynced temp file. A power loss can durably retain the Active record while losing composition.json, causing the restart path to fall back to baseline and lose approved contributions.

Proposed fix
 let json = serde_json::to_vec_pretty(&wire)
     .map_err(|e| std::io::Error::other(format!("serializing composition snapshot: {e}")))?;
 std::fs::write(&tmp, &json)?;
+std::fs::OpenOptions::new()
+    .write(true)
+    .open(&tmp)?
+    .sync_all()?;
 
 #[cfg(target_os = "linux")]
 common::renameat2::renameat2_cwd(tmp.as_std_path(), dest.as_std_path(), 0)?;

Also applies to: 956-964

🤖 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/sessions/src/store.rs` around lines 161 - 174, The store_composition
implementation must sync the temporary composition sidecar file to durable
storage before renaming it into place, matching the crash-safety behavior of
write_record. Update the temp-file write flow behind the store_composition trait
method to flush and sync the file, then perform the existing atomic rename.

@0chroma
0chroma merged commit f633a2a into main Jul 27, 2026
29 checks passed
@0chroma
0chroma deleted the fix/persist-session-composition-849 branch July 27, 2026 21:31
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.

Session composition state is in-memory only: daemon restart drops loadout packages/vars for existing sessions

2 participants