From 6a3e60d2f7155f5eb7d9e7b145607b49ff87439b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 04:06:45 +0800 Subject: [PATCH] feat(store): support invariant-safe session forks --- CHANGELOG.md | 6 ++++++ core/src/store/session_snapshot.rs | 33 ++++++++++++++++++++++++++++++ core/src/store/tests.rs | 25 ++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e01a17aa..359a4cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added an invariant-checked session snapshot fork operation that rebinds the + session, workspace, run ownership, and subagent parent ownership while + preserving the complete persisted generation. + ## [5.3.5] - 2026-07-17 ### Added diff --git a/core/src/store/session_snapshot.rs b/core/src/store/session_snapshot.rs index 80c83380..97ab5d83 100644 --- a/core/src/store/session_snapshot.rs +++ b/core/src/store/session_snapshot.rs @@ -62,6 +62,39 @@ impl SessionSnapshotV1 { ) } + /// Rebind a complete snapshot to a new session and workspace. + /// + /// Session forks retain historical artifacts, traces, run ids, and child + /// session ids. Top-level run ownership and subagent parent ownership must + /// move with the new session or the aggregate would no longer be loadable. + pub fn fork_for_session( + mut self, + session_id: impl Into, + workspace: impl Into, + ) -> Result { + let source_session_id = self.session.id.clone(); + self.validate_for_session(&source_session_id)?; + + let session_id = session_id.into(); + if session_id.trim().is_empty() { + bail!("forked session id cannot be empty"); + } + + self.session.id = session_id.clone(); + self.session.config.workspace = workspace.into(); + for record in &mut self.run_records { + record.snapshot.session_id.clone_from(&session_id); + } + for task in &mut self.subagent_tasks { + if !task.parent_session_id.is_empty() { + task.parent_session_id.clone_from(&session_id); + } + } + + self.validate_for_session(&session_id)?; + Ok(self) + } + pub fn artifact_store(&self) -> ArtifactStore { artifact_store_from(&self.artifacts) } diff --git a/core/src/store/tests.rs b/core/src/store/tests.rs index 84e6dddf..b82fa3eb 100644 --- a/core/src/store/tests.rs +++ b/core/src/store/tests.rs @@ -155,6 +155,31 @@ async fn create_test_snapshot() -> SessionSnapshotV1 { ) } +#[tokio::test] +async fn snapshot_fork_rebinds_every_top_level_session_owner() { + let mut snapshot = create_test_snapshot().await; + for record in &mut snapshot.run_records { + record.snapshot.session_id = snapshot.session.id.clone(); + } + snapshot.validate_for_session("test-session-1").unwrap(); + + let fork = snapshot + .fork_for_session("fork-session", "/tmp/fork-workspace") + .unwrap(); + + assert_eq!(fork.session.id, "fork-session"); + assert_eq!(fork.session.config.workspace, "/tmp/fork-workspace"); + assert!(fork + .run_records + .iter() + .all(|record| record.snapshot.session_id == "fork-session")); + assert!(fork + .subagent_tasks + .iter() + .all(|task| task.parent_session_id == "fork-session")); + fork.validate_for_session("fork-session").unwrap(); +} + // ======================================================================== // FileSessionStore Tests // ========================================================================