Skip to content

Commit 6a3e60d

Browse files
committed
feat(store): support invariant-safe session forks
1 parent 88fba0a commit 6a3e60d

3 files changed

Lines changed: 64 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added an invariant-checked session snapshot fork operation that rebinds the
13+
session, workspace, run ownership, and subagent parent ownership while
14+
preserving the complete persisted generation.
15+
1016
## [5.3.5] - 2026-07-17
1117

1218
### Added

core/src/store/session_snapshot.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,39 @@ impl SessionSnapshotV1 {
6262
)
6363
}
6464

65+
/// Rebind a complete snapshot to a new session and workspace.
66+
///
67+
/// Session forks retain historical artifacts, traces, run ids, and child
68+
/// session ids. Top-level run ownership and subagent parent ownership must
69+
/// move with the new session or the aggregate would no longer be loadable.
70+
pub fn fork_for_session(
71+
mut self,
72+
session_id: impl Into<String>,
73+
workspace: impl Into<String>,
74+
) -> Result<Self> {
75+
let source_session_id = self.session.id.clone();
76+
self.validate_for_session(&source_session_id)?;
77+
78+
let session_id = session_id.into();
79+
if session_id.trim().is_empty() {
80+
bail!("forked session id cannot be empty");
81+
}
82+
83+
self.session.id = session_id.clone();
84+
self.session.config.workspace = workspace.into();
85+
for record in &mut self.run_records {
86+
record.snapshot.session_id.clone_from(&session_id);
87+
}
88+
for task in &mut self.subagent_tasks {
89+
if !task.parent_session_id.is_empty() {
90+
task.parent_session_id.clone_from(&session_id);
91+
}
92+
}
93+
94+
self.validate_for_session(&session_id)?;
95+
Ok(self)
96+
}
97+
6598
pub fn artifact_store(&self) -> ArtifactStore {
6699
artifact_store_from(&self.artifacts)
67100
}

core/src/store/tests.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,31 @@ async fn create_test_snapshot() -> SessionSnapshotV1 {
155155
)
156156
}
157157

158+
#[tokio::test]
159+
async fn snapshot_fork_rebinds_every_top_level_session_owner() {
160+
let mut snapshot = create_test_snapshot().await;
161+
for record in &mut snapshot.run_records {
162+
record.snapshot.session_id = snapshot.session.id.clone();
163+
}
164+
snapshot.validate_for_session("test-session-1").unwrap();
165+
166+
let fork = snapshot
167+
.fork_for_session("fork-session", "/tmp/fork-workspace")
168+
.unwrap();
169+
170+
assert_eq!(fork.session.id, "fork-session");
171+
assert_eq!(fork.session.config.workspace, "/tmp/fork-workspace");
172+
assert!(fork
173+
.run_records
174+
.iter()
175+
.all(|record| record.snapshot.session_id == "fork-session"));
176+
assert!(fork
177+
.subagent_tasks
178+
.iter()
179+
.all(|task| task.parent_session_id == "fork-session"));
180+
fork.validate_for_session("fork-session").unwrap();
181+
}
182+
158183
// ========================================================================
159184
// FileSessionStore Tests
160185
// ========================================================================

0 commit comments

Comments
 (0)