fix(session): preserve branch journals through saves and synchronize the engine

Retain full journals and stable entry identities through snapshots, resume and forks. Flush queued snapshots before branch persistence and return the existing SyncSession action. Closes #6367.

Focused session/lifecycle/snapshot tests: 325 passed, 0 failed, including branch-save-reload-fork regression. npm test: 551 passed, 0 failed; check:web passed for unchanged web surfaces.
This commit is contained in:
CodeWhale Bot committed 2026-09-19 17:31:48 -07:00
1 parent 6e4bdb8b39
commit ffca963f90
12 files changed
+215 -112

No files matched your search

+1
View File
@@ -978,6 +978,7 @@ pub struct SessionSyncPayload {
pub struct SessionBranchOutcome {
pub leaf_display: String,
pub journal_entries_before: usize,
pub sync: SessionSyncPayload,
}
/// `/fork` success projection for an active-conversation fork. The handler
+39 -71
View File
@@ -321,6 +321,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
Ok(m) => m,
Err(e) => return Err(format!("could not open sessions directory: {e}")),
};
crate::tui::persistence_actor::flush_before_transition()?;
let mut session = match manager.load_session(&session_id) {
Ok(s) => s,
Err(e) => return Err(format!("could not load session {session_id}: {e}")),
@@ -334,19 +335,36 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
match session.journal_branch_to(entry_id) {
Ok(()) => {
if let Err(e) = manager.save_session(&session) {
return Err(format!("branch saved but persist failed: {e}"));
return Err(format!(
"branch could not be persisted; active conversation unchanged: {e}"
));
}
app.restore_api_messages(
session.messages.clone(),
&session.journal_message_stamps(),
);
app.restore_api_messages(session.messages.clone(), &session);
let leaf_display = session
.leaf_id
.clone()
.unwrap_or_else(|| "(none)".to_string());
app.clear_history();
app.session_artifacts = session.artifacts.clone();
app.session_context_references = session.context_references.clone();
app.extend_history(
session
.messages
.iter()
.flat_map(crate::tui::history::history_cells_from_message),
);
app.scroll_to_bottom();
Ok(SessionBranchOutcome {
leaf_display,
journal_entries_before: journal_len_before,
sync: SessionSyncPayload {
session_id: Some(session_id),
messages: session.messages,
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: to_command_mode(app.mode),
},
})
}
Err(e) => Err(format!(
@@ -477,74 +495,25 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
}
};
let parent_id = app
.current_session_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let mut parent = crate::session_manager::create_saved_session_with_id_and_mode(
parent_id,
&app.api_messages,
&app.model,
&app.workspace,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.label()),
);
parent
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
if let Some(cached) = app
.current_session_metadata
.as_ref()
.filter(|metadata| metadata.id == parent.metadata.id)
{
parent.metadata.created_at = cached.created_at;
parent.metadata.title.clone_from(&cached.title);
parent
.metadata
.parent_session_id
.clone_from(&cached.parent_session_id);
parent.metadata.forked_from_message_count = cached.forked_from_message_count;
}
app.sync_cost_to_metadata(&mut parent.metadata);
parent.context_references = app.session_context_references.clone();
parent.artifacts = app.session_artifacts.clone();
let work_state = match app.work_state_snapshot() {
Ok(state) => state,
Err(err) => return Err(format!("Failed to snapshot Work state: {err}")),
};
parent.work_state = work_state.clone();
parent.last_auto_route = app.auto_route_for_persistence();
let mut parent = crate::tui::ui::build_session_snapshot(&mut app, &manager)?;
parent.make_storage_compatible();
if let Err(err) = manager.save_session(&parent) {
return Err(format!("Failed to save parent session: {err}"));
}
let mut forked = crate::session_manager::create_saved_session_with_mode(
&app.api_messages,
&app.model,
&app.workspace,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.label()),
);
forked
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
forked.metadata.copy_cost_from(&parent.metadata);
let mut forked = parent.clone();
forked.metadata.id = uuid::Uuid::new_v4().to_string();
forked.metadata.created_at = chrono::Utc::now();
forked.metadata.updated_at = forked.metadata.created_at;
forked.metadata.archived = false;
forked.metadata.runtime_store = None;
forked.approval_receipts.clear();
forked.window_title = None;
forked.metadata.spawn_depth = parent.metadata.spawn_depth.saturating_add(1);
// Ensure journal for both sessions: parent already has one from factory, bump forked's journal depth
if let Some(j) = forked.journal.as_mut() {
j.spawn_depth = forked.metadata.spawn_depth;
}
if let Some(j) = parent.journal.as_mut() {
j.spawn_depth = parent.metadata.spawn_depth;
}
forked.metadata.mark_forked_from(&parent.metadata);
forked.context_references = app.session_context_references.clone();
forked.artifacts = app.session_artifacts.clone();
forked.work_state = work_state;
forked.last_auto_route = app.auto_route_for_persistence();
if let Some(journal) = forked.journal.as_mut() {
journal.spawn_depth = forked.metadata.spawn_depth;
}
let queue_transition =
crate::tui::ui::prepare_offline_queue_transition(&app, &forked.metadata.id)?;
@@ -560,6 +529,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
app.current_session_id = Some(forked.metadata.id.clone());
app.current_session_metadata = Some(forked.metadata.clone());
app.restore_api_messages(forked.messages.clone(), &forked);
app.session_title = Some(forked.metadata.title.clone());
// A fork starts as its own session: no inherited tab/window title.
app.window_title = None;
@@ -650,6 +620,7 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
app.current_session_id = Some(forked.metadata.id.clone());
app.current_session_metadata = Some(forked.metadata.clone());
app.restore_api_messages(forked.messages.clone(), &forked);
app.session_title = Some(forked.metadata.title.clone());
// A fork starts as its own session: no inherited tab/window title.
app.window_title = None;
@@ -1456,10 +1427,7 @@ fn import_session_container(
crate::tui::ui::install_offline_queue_transition(app, queue_transition);
app.current_session_id = Some(new_id.clone());
app.current_session_metadata = Some(imported.metadata.clone());
app.restore_api_messages(
imported.messages.clone(),
&imported.journal_message_stamps(),
);
app.restore_api_messages(imported.messages.clone(), &imported);
let picker = crate::tui::session_picker::SessionPickerView::new_selecting(
&app.workspace,
app.ui_locale,
@@ -71,10 +71,13 @@ pub(in crate::commands) fn branch_portable(
);
};
match lifecycle.branch_to(entry_id) {
Ok(outcome) => CommandResult::message(format!(
"Branched to entry {entry_id} (leaf now {}); journal entries {} (history preserved, leaf moved only)",
outcome.leaf_display, outcome.journal_entries_before
)),
Ok(outcome) => CommandResult::with_message_and_action(
format!(
"Branched to entry {entry_id} (leaf now {}); journal entries {} (history preserved, leaf moved only)",
outcome.leaf_display, outcome.journal_entries_before
),
super::sync_session_action(outcome.sync),
),
Err(error) => CommandResult::error(error),
}
}
@@ -93,6 +93,7 @@ fn branch_composes_exact_baseline_messages() {
branch: Ok(SessionBranchOutcome {
leaf_display: "entry-3".to_string(),
journal_entries_before: 5,
sync: sync_payload("branched-session"),
}),
..CannedLifecycle::default()
};
@@ -103,7 +104,9 @@ fn branch_composes_exact_baseline_messages() {
"Branched to entry entry-3 (leaf now entry-3); journal entries 5 (history preserved, leaf moved only)"
)
);
assert!(result.action.is_none());
assert!(
matches!(result.action, Some(AppAction::SyncSession { session_id: Some(ref id), .. }) if id == "branched-session")
);
assert_eq!(canned.branch_entries, ["entry-3"]);
// Host stage error passes through unchanged.
@@ -875,3 +875,78 @@ fn test_sessions_unknown_subcommand_errors() {
result.message
);
}
#[test]
fn branch_snapshot_roundtrip_preserves_siblings_ids_stamps_and_engine_projection() {
let _guard = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let mut app = create_test_app_with_tmpdir(&root);
let message = |text: &str| codewhale_models::Message {
role: Role::User,
content: vec![codewhale_models::ContentBlock::Text {
text: text.into(),
cache_control: None,
}],
};
for text in ["root", "chosen", "abandoned"] {
app.push_api_message(message(text));
}
assert!(!save(&mut app, None).is_error);
let id = app.current_session_id.clone().unwrap();
let manager = crate::session_manager::SessionManager::default_location().unwrap();
let initial = manager.load_session(&id).unwrap();
let original = initial.journal.as_ref().unwrap().entries.clone();
let chosen = original[1].id.clone();
let result = dispatch_lifecycle(&mut app, "branch", Some(&chosen));
assert!(!result.is_error, "{:?}", result.message);
let Some(AppAction::SyncSession {
messages,
session_id,
..
}) = result.action
else {
panic!("branch must synchronize the existing engine");
};
assert_eq!(session_id.as_deref(), Some(id.as_str()));
assert_eq!(messages, vec![message("root"), message("chosen")]);
assert_eq!(app.api_messages.as_ref(), &messages);
assert!(
!app.history
.iter()
.any(|cell| format!("{cell:?}").contains("abandoned"))
);
app.push_api_message(message("new path"));
for _ in 0..2 {
let snapshot = crate::tui::ui::build_session_snapshot(&mut app, &manager).unwrap();
manager.save_session_owned(snapshot).unwrap();
}
let saved = manager.load_session(&id).unwrap();
let journal = saved.journal.as_ref().unwrap();
assert_eq!(
&journal.entries[..3],
original.as_slice(),
"all old IDs, stamps and content survive"
);
assert_eq!(journal.entries.len(), 4, "a second save appends nothing");
assert_eq!(
journal.entries[3].parent_id.as_deref(),
Some(chosen.as_str())
);
assert_eq!(journal.leaves().len(), 2);
assert_eq!(
saved.messages,
vec![message("root"), message("chosen"), message("new path")]
);
// Restoring and forking must retain the tree as well.
app.restore_api_messages(saved.messages.clone(), &saved);
let forked = fork(&mut app);
assert!(!forked.is_error, "{:?}", forked.message);
let child = manager
.load_session(app.current_session_id.as_deref().unwrap())
.unwrap();
assert_eq!(child.journal.as_ref().unwrap().entries, journal.entries);
assert_eq!(child.metadata.spawn_depth, 1);
let snapshot = crate::tui::ui::build_session_snapshot(&mut app, &manager).unwrap();
assert_eq!(snapshot.journal.unwrap().entries, journal.entries);
}
+7 -7
View File
@@ -1029,6 +1029,7 @@ impl SavedSession {
journal.branch_to(entry_id)?;
self.leaf_id = journal.leaf_id.clone();
self.messages = journal.to_messages();
self.metadata.message_count = self.messages.len();
self.metadata.updated_at = Utc::now();
Ok(())
}
@@ -3232,7 +3233,7 @@ pub fn create_saved_session_with_id_mode_and_stamps(
create_saved_session_inner(
id,
messages,
message_stamps,
SessionJournal::from_messages_stamped(messages.to_vec(), message_stamps, 0),
model,
workspace,
total_tokens,
@@ -3254,7 +3255,7 @@ pub fn create_saved_session_with_id_mode_and_stamps(
pub fn create_saved_session_journal_only(
id: String,
messages: &[Message],
message_stamps: &[DateTime<Utc>],
journal: SessionJournal,
model: &str,
workspace: &Path,
total_tokens: u64,
@@ -3264,7 +3265,7 @@ pub fn create_saved_session_journal_only(
create_saved_session_inner(
id,
messages,
message_stamps,
journal,
model,
workspace,
total_tokens,
@@ -3277,7 +3278,7 @@ pub fn create_saved_session_journal_only(
fn create_saved_session_inner(
id: String,
messages: &[Message],
message_stamps: &[DateTime<Utc>],
journal: SessionJournal,
model: &str,
workspace: &Path,
total_tokens: u64,
@@ -3293,7 +3294,6 @@ fn create_saved_session_inner(
let title =
conversation_derived_title(messages).unwrap_or_else(|| DEFAULT_SESSION_TITLE.to_string());
let journal = SessionJournal::from_messages_stamped(messages.to_vec(), message_stamps, 0);
let leaf_id = journal.leaf_id.clone();
SavedSession {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
@@ -3315,7 +3315,7 @@ fn create_saved_session_inner(
runtime_store: None,
cumulative_turn_secs: 0,
archived: false,
spawn_depth: 0,
spawn_depth: journal.spawn_depth,
},
messages: if fill_messages {
messages.to_vec()
@@ -7830,7 +7830,7 @@ mod storage_compatible_tests {
let sparse = create_saved_session_journal_only(
"roundtrip".to_string(),
&messages,
&[],
SessionJournal::from_messages(messages.clone(), 0),
"test-model",
tmp.path(),
7,
+17 -2
View File
@@ -329,6 +329,16 @@ impl SessionJournal {
/// The existing active branch remains as evidence. We reuse its longest
/// unchanged prefix, then append the repaired suffix as a sibling branch.
pub fn rebranch_active_messages(&mut self, messages: &[Message]) {
self.rebranch_active_messages_stamped(messages, &[]);
}
/// Preserve existing entry identity and timestamps; only append the changed
/// suffix, keeping the previous branch reachable.
pub fn rebranch_active_messages_stamped(
&mut self,
messages: &[Message],
stamps: &[DateTime<Utc>],
) {
let active_path = self.root_to_leaf();
let shared_prefix = active_path
.iter()
@@ -338,8 +348,13 @@ impl SessionJournal {
self.leaf_id = shared_prefix
.checked_sub(1)
.map(|index| active_path[index].id.clone());
for message in &messages[shared_prefix..] {
self.append_message(message.clone());
for (index, message) in messages.iter().enumerate().skip(shared_prefix) {
self.append_stamped(
SessionEntryKind::Message {
message: message.clone(),
},
stamps.get(index).copied().unwrap_or_else(Utc::now),
);
}
}
}
+16 -3
View File
@@ -1497,6 +1497,9 @@ pub struct App {
/// length-mismatched site degrades to save-time stamps, never to a
/// dropped message.
pub api_message_stamps: Vec<DateTime<Utc>>,
/// Full saved history, including inactive branches. API messages remain
/// the active projection; snapshots reconcile it without rebuilding IDs.
pub session_journal: crate::session_tree::SessionJournal,
/// User-visible assistant text that crossed typed completion boundaries.
/// Receipts are aligned to transcript cells because provider context can
/// be compacted or purged without changing what remains visible.
@@ -4850,9 +4853,18 @@ impl App {
/// per-entry `created_at` as the stamps so a next save does not rewrite
/// history to resume time. Entries without a matching stamp fall back to
/// now.
pub fn restore_api_messages(&mut self, messages: Vec<Message>, stamps: &[DateTime<Utc>]) {
self.api_message_stamps.clear();
self.api_message_stamps.extend_from_slice(stamps);
pub fn restore_api_messages(
&mut self,
messages: Vec<Message>,
session: &crate::session_manager::SavedSession,
) {
self.session_journal = session.journal.clone().unwrap_or_else(|| {
crate::session_tree::SessionJournal::from_messages(
session.messages.clone(),
session.metadata.spawn_depth,
)
});
self.api_message_stamps = session.journal_message_stamps();
self.api_message_stamps
.resize_with(messages.len(), Utc::now);
self.api_messages = Arc::new(messages);
@@ -4894,6 +4906,7 @@ impl App {
}
pub fn clear_api_messages(&mut self) {
self.session_journal = crate::session_tree::SessionJournal::new();
self.api_messages_mut().clear();
self.api_message_stamps.clear();
}
+1
View File
@@ -786,6 +786,7 @@ impl App {
next_history_revision: 1,
api_messages: Arc::new(Vec::new()),
api_message_stamps: Vec::new(),
session_journal: crate::session_tree::SessionJournal::new(),
completed_assistant_outputs: Vec::new(),
context_token_cache: std::cell::RefCell::new(Default::default()),
remote_control: crate::remote_control::RemoteControlController::default(),
+33
View File
@@ -309,6 +309,39 @@ pub fn persist(request: PersistRequest) {
}
}
/// Order synchronous lifecycle saves after all previously queued snapshots.
/// Refuse a single-thread runtime rather than deadlocking its persistence task.
pub(crate) fn flush_before_transition() -> Result<(), String> {
let Some(handle) = ACTOR_TX.get() else {
return Ok(());
};
let runtime = tokio::runtime::Handle::try_current().ok();
if runtime
.as_ref()
.is_some_and(|r| r.runtime_flavor() != tokio::runtime::RuntimeFlavor::MultiThread)
{
return Err("session transition requires an asynchronous persistence barrier".into());
}
let (reply, receiver) = oneshot::channel();
if !handle.try_send(PersistRequest::FlushAndReport { reply }) {
return Err("session transition could not queue persistence barrier".into());
}
let receive = || receiver.blocking_recv();
let report = if runtime.is_some() {
tokio::task::block_in_place(receive)
} else {
receive()
}
.map_err(|_| "session persistence stopped before the transition".to_string())?;
if !report.failures.is_empty() {
return Err(format!(
"session transition refused after persistence failures: {:?}",
report.failures
));
}
Ok(())
}
fn request_label(request: &PersistRequest) -> &'static str {
match request {
PersistRequest::SaveCheckpoint { .. } => "SaveCheckpoint",
+1 -1
View File
@@ -3657,7 +3657,7 @@ pub(crate) fn apply_loaded_session_with_goal(
app.refresh_notification_settings(config);
app.restore_api_messages(
crate::runtime_handoff::project_messages_for_restore(&session.messages),
&session.journal_message_stamps(),
session,
);
app.clear_history();
app.tool_cells.clear();
+14 -23
View File
@@ -1077,29 +1077,20 @@ pub(crate) fn build_session_snapshot(
format!("automatic session snapshot skipped while Work state is busy: {err}")
})?,
};
let mut session = if let Some(existing_id) = app.current_session_id.as_ref() {
crate::session_manager::create_saved_session_journal_only(
existing_id.clone(),
&app.api_messages,
&app.api_message_stamps,
&model,
&app.workspace,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.as_setting()),
)
} else {
crate::session_manager::create_saved_session_journal_only(
uuid::Uuid::new_v4().to_string(),
&app.api_messages,
&app.api_message_stamps,
&model,
&app.workspace,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.as_setting()),
)
};
app.session_journal
.rebranch_active_messages_stamped(&app.api_messages, &app.api_message_stamps);
let mut session = crate::session_manager::create_saved_session_journal_only(
app.current_session_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
&app.api_messages,
app.session_journal.clone(),
&model,
&app.workspace,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.as_setting()),
);
let computed_title = session.metadata.title.clone();
if let Some(cached) = app
.current_session_metadata