perf(session): share journal entry bodies and read a resumed document once (#6842)

Snapshot: `SessionEntry.kind` is now `Arc<SessionEntryKind>` (serde `rc`
feature on codewhale-runtime; the JSON is unchanged), so the journal clone
that `build_session_snapshot` takes on the UI task for every save copies
ids, timestamps and a pointer per entry instead of every message body.
The persistence actor then serializes the shared bodies. Entry mutation
goes through `Arc::make_mut` (one test fixture).

Resume: `promote_interrupted_checkpoint` compares a crash checkpoint with
the saved document's metadata (the bounded prefix read) instead of parsing
the whole document, and returns whether the checkpoint is stale. attach
then reads the document once in `resume_session` and clears a stale
checkpoint only after that read succeeds; `-r` does the same after its
bounded load. Only when the checkpoint is newer is the old document still
read in full first, so an unreadable document is never replaced (the
existing guard). Before this, `-r` parsed the document twice.

Not done: `usage_source_fingerprints` stays unbounded. It is the money
replay-dedupe set: late-usage ledger records are never removed and every
metadata load re-checks them against it, and the set has no insertion
order, so any cap could evict a live entry and double-count spend. A safe
bound needs an ordered set that always keeps ledger fingerprints. The
snapshot is still built on the UI task (now without deep copies), and the
lifecycle metadata read in it is still UI-task I/O.

Evidence (macOS arm64):
- Clone cost, throwaway release bench (not committed) on a synthetic
  journal of 4,500 entries with 4 KB bodies (1,500 active, 3,000
  superseded): SessionJournal::clone 0.19 ms vs 1.24 ms with deep bodies.
- Resume of that 25.3 MB session through a loopback provider, `-r`, time
  to the first frame showing the last message: installed 0.10.2 19.32 s
  (2.82 s CPU); release build of this tree 15.72 s (3.45 s CPU). Per-turn
  process CPU was ~0.7 s on both, because it includes the persistence
  actor writing the 25 MB document. RSS is not compared: the after binary
  carries all of 0.10.3, so it is not an A/B of this change.
- scripts/dev-test.sh runtime session_tree: 22 tests run: 22 passed.
- scripts/dev-test.sh tui idle_poll_tests terminal_input session_manager
  persistence_actor thread_history runtime_store_convergence tui::approval
  snapshot checkpoint resume motion: 784 tests run: 784 passed, 13351
  skipped (session_manager 137, including
  attach_promotes_the_sessions_newer_crash_checkpoint, which covers the
  newer and stale branches; runtime_store_convergence 14).

Refs #6842

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 14:19:02 -07:00
1 parent ccda707286
commit d9ccf8f3e4
8 files changed
+45 -29

No files matched your search

+5
View File
@@ -182,6 +182,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
on macOS with release builds: an approval wait went from 29 to 12 timer
wakeups per second, a background job from 32 to 16 and an idle composer
from 25 to 11 (#6728).
- Saving a session no longer copies every message body: journal entries
share their content, so the snapshot taken on each update copies ids and
pointers. Resuming a session compares a leftover crash checkpoint against
the saved document's metadata instead of parsing the whole document first,
so `--resume` and attach read the document once (#6842).
- `write` over an existing file now says when it kept that file's CRLF or LF
line endings, so the agent no longer reports a line-ending conversion that
did not happen (#7070).
+1 -1
View File
@@ -63,7 +63,7 @@ colored = "3.0.0"
fd-lock = "4.0.4"
lru = "0.18"
regex = "1.11"
serde.workspace = true
serde = { workspace = true, features = ["rc"] }
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
sha2.workspace = true
tempfile.workspace = true
+5 -4
View File
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
use codewhale_models::{ContentBlock, Message, Role};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
pub const CURRENT_JOURNAL_SCHEMA_VERSION: u32 = 1;
pub type EntryId = String;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -106,7 +107,7 @@ pub struct SessionEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<EntryId>,
#[serde(flatten)]
pub kind: SessionEntryKind,
pub kind: Arc<SessionEntryKind>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub spawn_depth: u32,
@@ -116,7 +117,7 @@ impl SessionEntry {
Self {
id: uuid::Uuid::new_v4().to_string(),
parent_id,
kind,
kind: Arc::new(kind),
created_at: Utc::now(),
spawn_depth,
}
@@ -402,7 +403,7 @@ impl SessionJournal {
.collect();
let summary = |e: &SessionEntry| {
matches!(
e.kind,
*e.kind,
SessionEntryKind::Compaction { .. } | SessionEntryKind::BranchSummary { .. }
)
};
@@ -815,7 +816,7 @@ mod tests {
let id = j.append_compaction("summary".into(), Some(1000), Some(100), None);
assert!(j.contains(&id));
assert!(matches!(
j.leaf().unwrap().kind,
*j.leaf().unwrap().kind,
SessionEntryKind::Compaction { .. }
));
}
+5
View File
@@ -182,6 +182,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
on macOS with release builds: an approval wait went from 29 to 12 timer
wakeups per second, a background job from 32 to 16 and an idle composer
from 25 to 11 (#6728).
- Saving a session no longer copies every message body: journal entries
share their content, so the snapshot taken on each update copies ids and
pointers. Resuming a session compares a leftover crash checkpoint against
the saved document's metadata instead of parsing the whole document first,
so `--resume` and attach read the document once (#6842).
- `write` over an existing file now says when it kept that file's CRLF or LF
line endings, so the agent no longer reports a line-ending conversion that
did not happen (#7070).
+4 -1
View File
@@ -10027,8 +10027,11 @@ async fn prepare_mounted_session(
// later attach would then discard the checkpoint as stale. Then
// capture the complete document and separate local goal.
let _source_lease = manager.reserve_session_for_external_write(&id)?;
manager.promote_interrupted_checkpoint(&id);
let stale_checkpoint = manager.promote_interrupted_checkpoint(&id);
let saved = manager.load_session_snapshot_bounded(&id, MAX_CANONICAL_HISTORY_BYTES)?;
if stale_checkpoint {
let _ = manager.clear_session_checkpoint(&id);
}
// Project grants were captured for this launch before awaiting. The
// existing restore changes App workspace, so it cannot silently apply
// that snapshot to a different saved project. Match the same physical/
@@ -2404,7 +2404,8 @@ async fn canonical_resume_offered_suffix_settles_once_and_full_graph_witness_ref
.as_mut()
.context("tampered full graph")?
.entries[3];
let codewhale_runtime::session_tree::SessionEntryKind::Message { message } = &mut entry.kind
let codewhale_runtime::session_tree::SessionEntryKind::Message { message } =
std::sync::Arc::make_mut(&mut entry.kind)
else {
anyhow::bail!("inactive fixture entry lost its message");
};
+1 -1
View File
@@ -158,7 +158,7 @@ fn legacy_journal(history: &LegacyThreadHistory) -> Result<SessionJournal> {
entries.push(SessionEntry {
id: id(row.id),
parent_id: row.parent_entry_id.map(id),
kind: SessionEntryKind::Message { message },
kind: std::sync::Arc::new(SessionEntryKind::Message { message }),
created_at: DateTime::<Utc>::from_timestamp(row.created_at, 0)
.ok_or_else(|| anyhow!("invalid legacy history timestamp"))?,
spawn_depth: 0,
+22 -21
View File
@@ -1306,7 +1306,7 @@ impl SavedSession {
.iter()
.filter(|entry| {
matches!(
entry.kind,
*entry.kind,
codewhale_runtime::session_tree::SessionEntryKind::Message { .. }
)
})
@@ -3700,10 +3700,11 @@ impl SessionManager {
/// it while the turn's file edits stayed on disk.
pub fn attach_session(&self, id: &str) -> std::io::Result<(SessionRecovery, SessionLease)> {
let lease = self.reserve_session_for_attach(id)?;
let recovery = match self.promote_interrupted_checkpoint(id) {
Some(kept) => self.persist_resumed_repair(kept),
None => self.resume_session(id)?,
};
let stale_checkpoint = self.promote_interrupted_checkpoint(id);
let recovery = self.resume_session(id)?;
if stale_checkpoint {
let _ = self.clear_session_checkpoint(id);
}
Ok((recovery, lease))
}
@@ -3711,35 +3712,35 @@ impl SessionManager {
/// checkpoint is the newer of the two (or there is no document yet), then
/// consume it. Callers hold the session's attach lease. A stale checkpoint
/// never replaces a newer document; an unreadable document and a failed
/// save leave both files as they were.
pub(crate) fn promote_interrupted_checkpoint(&self, id: &str) -> Option<SessionRecovery> {
/// save leave both files as they were. Only a document about to be
/// replaced is read in full; otherwise the comparison reads its metadata.
/// Returns true when the checkpoint is stale: it is left for the caller to
/// clear once the full document has been read.
pub(crate) fn promote_interrupted_checkpoint(&self, id: &str) -> bool {
let Ok(Some(checkpoint)) = self.load_session_checkpoint(id) else {
return None;
return false;
};
let kept = match self.recover_session_for_resume(id) {
Ok(saved) if saved.session.metadata.updated_at >= checkpoint.metadata.updated_at => {
Some(saved)
}
match self.load_session_metadata_by_id(id) {
Ok(saved) if saved.updated_at >= checkpoint.metadata.updated_at => return true,
Ok(_) => {
if self.load_session_snapshot(id).is_err() {
return false;
}
let mut promoted = checkpoint;
self.merge_persisted_lifecycle(&mut promoted.metadata);
if self.save_session(&promoted).is_err() {
return None;
return false;
}
None
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if self.save_session(&checkpoint).is_err() {
return None;
return false;
}
None
}
// A document that exists but cannot be read is not ours to
// replace here; the attach reports it and the checkpoint stays.
Err(_) => return None,
};
Err(_) => return false,
}
let _ = self.clear_session_checkpoint(id);
kept
false
}
/// [`Self::attach_session`] with a partial-ID prefix.