perf(snapshot): drop old undo snapshots half a window at a time

Native acceptance of a 910-turn session showed every turn from about the
sixteenth starting 2.8-3.0 s late (0.45-0.6 s before), constant afterwards.
Cause: the prune that follows every snapshot. Once the store held more than
the 50-snapshot window (about three snapshots per turn), each new snapshot
removed one old one by rebuilding the whole survivor chain, two git processes
per survivor, on the path before the provider request.

The per-snapshot prune now waits until half a window is due to go and drops
it in one rebuild, so the rebuild runs about once every eight turns instead of
every turn. The store holds at most half a window more than before; the
size-pressure prune and the turn-boundary retention rule are unchanged. The
unbatched form remains for tests.

Local macOS, Rust 1.99: snapshot, turn and prune selection 129 passed /
0 failed, including the new batched-prune test (one over the cap: no rebuild;
half a window over: dropped together, newest kept). CI-policy Clippy, whole
workspace: exit 0. In a larger parallel selection six runtime_threads tests
timed out once and passed 3 of 3 alone; recorded as load-sensitive, not
explained. The turn-start delay itself has not been re-measured natively with
this change.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-10-01 10:49:18 -07:00
1 parent 15f79ed6cc
commit 2d1971debe
4 files changed
+51 -4

No files matched your search

+6
View File
@@ -103,6 +103,12 @@ quieter, and Fleet runs can be checked before they spend anything.
under the stream retry budget when nothing had streamed; authentication and
invalid-model frames still fail at once
([#6795](https://github.com/Hmbown/Codewhale/issues/6795)).
- Long sessions no longer start every turn late. Once a workspace held more
than 50 undo snapshots (around the sixteenth turn), each new snapshot rebuilt
the whole snapshot history before the provider request, about 2.8 s per turn
in a small workspace. Old snapshots are now dropped half a window at a time.
- Resuming a crashed session from inside the TUI recovers its interrupted
turn too.
- Release builds compile on Rust 1.99.
- TUI undo and retry rewind the Engine conversation and saved session before
replacement inference. If the conversation or its settings change while
+6
View File
@@ -103,6 +103,12 @@ quieter, and Fleet runs can be checked before they spend anything.
under the stream retry budget when nothing had streamed; authentication and
invalid-model frames still fail at once
([#6795](https://github.com/Hmbown/Codewhale/issues/6795)).
- Long sessions no longer start every turn late. Once a workspace held more
than 50 undo snapshots (around the sixteenth turn), each new snapshot rebuilt
the whole snapshot history before the provider request, about 2.8 s per turn
in a small workspace. Old snapshots are now dropped half a window at a time.
- Resuming a crashed session from inside the TUI recovers its interrupted
turn too.
- Release builds compile on Rust 1.99.
- TUI undo and retry rewind the Engine conversation and saved session before
replacement inference. If the conversation or its settings change while
+1 -1
View File
@@ -740,7 +740,7 @@ fn snapshot_with_label_since(
_ => None,
};
// Prune oldest snapshots to cap disk usage (#1112).
if let Err(e) = repo.prune_keep_last_n(max_snapshots_for(workspace)) {
if let Err(e) = repo.prune_keep_last_n_batched(max_snapshots_for(workspace)) {
tracing::warn!(target: "snapshot", "snapshot prune failed: {e}");
}
id.map(|taken| (taken, changed))
+38 -3
View File
@@ -1897,11 +1897,22 @@ impl SnapshotRepo {
/// The survivors are rebuilt as a fresh orphan chain; each keeps its
/// tree, label, session id and timestamp, and the dropped ones become
/// unreachable for gc to reclaim.
#[cfg(test)]
pub fn prune_keep_last_n(&self, max_count: usize) -> io::Result<usize> {
self.with_write_lock(|| self.prune_keep_last_n_locked(max_count))
self.with_write_lock(|| self.prune_keep_last_n_locked(max_count, 1))
}
fn prune_keep_last_n_locked(&self, max_count: usize) -> io::Result<usize> {
/// [`Self::prune_keep_last_n`] for the prune that follows every snapshot:
/// it waits until half a window of snapshots is due to go and drops them
/// together. Rebuilding the survivor chain costs two git processes per
/// survivor, and doing that after every snapshot past the cap put seconds
/// in front of every later turn's provider request. The store then holds
/// at most half a window more than [`Self::prune_keep_last_n`] would keep.
pub fn prune_keep_last_n_batched(&self, max_count: usize) -> io::Result<usize> {
self.with_write_lock(|| self.prune_keep_last_n_locked(max_count, (max_count / 2).max(1)))
}
fn prune_keep_last_n_locked(&self, max_count: usize, min_removed: usize) -> io::Result<usize> {
let snapshots = self.list(usize::MAX)?;
if snapshots.len() <= max_count {
return Ok(0);
@@ -1923,7 +1934,7 @@ impl SnapshotRepo {
.map(|(_, snapshot)| snapshot.clone())
.collect();
let removed = snapshots.len() - survivors.len();
if removed == 0 || survivors.is_empty() {
if removed < min_removed || survivors.is_empty() {
return Ok(0);
}
self.rebuild_survivor_chain(&survivors, &snapshots[0].id)?;
@@ -3800,6 +3811,30 @@ mod tests {
assert_eq!(repo.list(usize::MAX).unwrap().len(), 2);
}
/// The per-snapshot prune drops half a window at once instead of
/// rebuilding the chain for every snapshot past the cap.
#[test]
fn batched_prune_waits_for_half_a_window_then_drops_it_together() {
let tmp = tempdir().unwrap();
let (repo, _home) = make_repo(tmp.path());
let max = 4;
for n in 0..max + 1 {
std::fs::write(repo.work_tree().join("f.txt"), format!("{n}")).unwrap();
repo.snapshot(&format!("tool:{n}")).unwrap();
}
// One over the cap: the plain prune would rebuild now, the batched
// one waits.
assert_eq!(repo.prune_keep_last_n_batched(max).unwrap(), 0);
assert_eq!(repo.list(usize::MAX).unwrap().len(), max + 1);
std::fs::write(repo.work_tree().join("f.txt"), "last").unwrap();
repo.snapshot("tool:last").unwrap();
assert_eq!(repo.prune_keep_last_n_batched(max).unwrap(), 2);
let kept = repo.list(usize::MAX).unwrap();
assert_eq!(kept.len(), max);
assert_eq!(kept[0].label, "tool:last", "the newest snapshots survive");
}
#[test]
fn prune_keep_last_n_preserves_multiple_snapshots_in_order() {
let tmp = tempdir().unwrap();