mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
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:
1 parent
ccda707286
commit
d9ccf8f3e4
8 files changed
+45
-29
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user