From 8da54e6bb051a8828758130351da4435dfdc7932 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 7 Oct 2026 00:25:03 +0800 Subject: [PATCH] perf: open long threads from their tail, drop superseded diffs, bound client history Storage performance on the Turso store, three parts. Superseded turn-change snapshots lose their diffs (#522): when a turn's new cumulative diff is appended, the previous snapshot of that turn is emptied in the same transaction, as the fold itself resolves the turn; a one-time pass does the same for existing threads, marking each as done. Rows and positions never change, so nothing on the wire moves. Cold threads no longer stall the host, and long ones open from their tail (#524): one hydration per session reads and folds the log off the mailbox at a writer-ordered snapshot, holding the records that arrive meanwhile and answering waiting subscriptions first; a stored turn index, maintained at append time and built lazily after a full read, lets the first window come from the last rows only. Cursors are stored row positions. The client keeps history for the selected thread and the four left most recently, and drops pages read far above the tail once the reader has stayed at the tail (#525). Closes #522 Closes #524 Closes #525 --- crates/app/src/main.rs | 1 + crates/core/src/session.rs | 45 +- crates/core/src/session/superseded.rs | 251 ++++ crates/headless/src/main.rs | 1 + crates/protocol/src/event.rs | 8 +- crates/protocol/src/lib.rs | 4 + crates/runtime/src/app/diff_pass.rs | 136 +++ crates/runtime/src/app/events.rs | 50 +- crates/runtime/src/app/history.rs | 966 ++++++++++++---- crates/runtime/src/app/lifecycle.rs | 1 + crates/runtime/src/app/mod.rs | 25 +- crates/runtime/src/app/orchestrate.rs | 93 +- crates/runtime/src/app/sessions.rs | 118 +- crates/runtime/src/app/snapshots.rs | 3 +- crates/runtime/src/app/store_write.rs | 97 +- crates/runtime/src/app/test_support.rs | 27 + crates/runtime/src/app/tests.rs | 1385 ++++++++++++++++++++--- crates/runtime/src/pipe.rs | 37 +- crates/services/src/store/mod.rs | 235 +++- crates/services/src/store/superseded.rs | 253 +++++ crates/services/src/store/tests.rs | 157 +++ crates/services/src/store/turn_index.rs | 80 ++ crates/ui/src/chat/mod.rs | 317 +++++- crates/ui/src/chat/model.rs | 49 +- crates/ui/src/store/history.rs | 165 ++- crates/ui/src/store/intents.rs | 39 +- crates/ui/src/store/mod.rs | 489 ++++++-- 27 files changed, 4314 insertions(+), 718 deletions(-) create mode 100644 crates/core/src/session/superseded.rs create mode 100644 crates/runtime/src/app/diff_pass.rs create mode 100644 crates/services/src/store/superseded.rs create mode 100644 crates/services/src/store/turn_index.rs diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 3024d92fa..22c9f5f07 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -192,6 +192,7 @@ fn start_local(store: SessionStore) -> std::io::Result<(SpawnedHost, HostMux)> { let mut host_services = HostServices { background_startup_probes: true, ai_title_generation: true, + drop_superseded_diffs: true, ..HostServices::default() }; match mcp_host::Host::bind() { diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index f543ca53b..a5215e5c1 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -15,6 +15,10 @@ use serde::{Deserialize, Serialize}; use crate::git::merge_file_changes_by_path; +mod superseded; + +pub use superseded::{TurnSnapshots, drop_turn_diffs}; + /// Claude Code's own prompt for resuming work after a usage-window reset. pub const RESUME_PROMPT: &str = "Continue from where you left off."; @@ -169,7 +173,7 @@ impl From for StoredEvent { } /// One renderable row in the chat timeline. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct TimelineEntry { /// Provider item id (or a synthetic id for errors). pub id: String, @@ -181,7 +185,7 @@ pub struct TimelineEntry { } /// Per-turn ("Work Log" section) metadata folded from turn lifecycle events. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct TurnMeta { /// Provider-native id for this turn, used to attach replacement turn-diff /// snapshots without relying on ambient "current turn" state during replay. @@ -243,7 +247,7 @@ impl TurnTiming { /// turn, intersected with the turn's own `TurnStarted`..`TurnCompleted` bounds. /// Only the current turn can have items in flight, so one accumulator is /// enough; it resets whenever a turn opens. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] struct ToolClock { /// Item ids currently known to be in progress. open: HashSet, @@ -408,13 +412,13 @@ fn tool_is_active(lifecycle: ToolLifecycle, state: ToolState) -> bool { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct TurnChangeSet { pub changes: Vec, pub completeness: ChangeCompleteness, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum EntryContent { Item(ItemContent), /// A user message injected into an already-open turn. Provider-originated @@ -472,7 +476,7 @@ pub enum SteeringStatus { /// A proposed plan captured this session (Codex plan item / Claude /// `ExitPlanMode`). Streaming deltas accumulate into `markdown`; a `ProposedPlan` /// event replaces it with the final text. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ProposedPlan { pub item_id: String, pub markdown: String, @@ -502,8 +506,10 @@ pub struct RunningTurn { pub started_at: Option, } -/// Folded view of a session's event history. -#[derive(Debug, Clone, Default)] +/// Folded view of a session's event history. Two timelines are equal only +/// when every later event folds the same onto both, so equality covers the +/// private state the fold continues from, not just what renders. +#[derive(Debug, Clone, Default, PartialEq)] pub struct Timeline { /// Top-level entries are shared so virtualized UI snapshots can retain a /// turn without cloning its potentially large message, command-output, and @@ -763,10 +769,7 @@ impl Timeline { completeness, } => { let turn = self - .turns - .iter() - .position(|turn| turn.provider_turn_id.as_deref() == Some(turn_id.as_str())) - .or(self.current_turn) + .provider_turn(turn_id) .unwrap_or_else(|| self.push_turn(ts)); if !turn_id.is_empty() { self.turns[turn].provider_turn_id = Some(turn_id.clone()); @@ -781,10 +784,7 @@ impl Timeline { checkpoint_id, } => { let turn = self - .turns - .iter() - .position(|turn| turn.provider_turn_id.as_deref() == Some(turn_id.as_str())) - .or(self.current_turn) + .provider_turn(turn_id) .unwrap_or_else(|| self.push_turn(ts)); self.turns[turn].provider_turn_id = Some(turn_id.clone()); self.turns[turn].provider_checkpoint_id = Some(checkpoint_id.clone()); @@ -924,7 +924,9 @@ impl Timeline { } self.usage = Some(usage); } - AgentEvent::Warning { message } => log::warn!("provider warning: {message}"), + // Logged once where the live provider event arrives; a fold also + // replays stored records, which would log every old warning again. + AgentEvent::Warning { .. } => {} AgentEvent::ProviderStartFailed { error } => { let turn = self.ensure_turn(ts); let id = self.synthetic_id("error", ts); @@ -1078,6 +1080,15 @@ impl Timeline { } } + /// The turn a provider-addressed record lands on: the first turn carrying + /// `turn_id`, else the current one. `None` means the record opens a turn. + fn provider_turn(&self, turn_id: &str) -> Option { + self.turns + .iter() + .position(|turn| turn.provider_turn_id.as_deref() == Some(turn_id)) + .or(self.current_turn) + } + /// Whether the current turn is still accumulating. A turn is finished once /// a `TurnCompleted` has been folded, which records a status even when the /// event carried no timestamp to store as `end_ts`; both must be checked or diff --git a/crates/core/src/session/superseded.rs b/crates/core/src/session/superseded.rs new file mode 100644 index 000000000..27c896659 --- /dev/null +++ b/crates/core/src/session/superseded.rs @@ -0,0 +1,251 @@ +use agent::AgentEvent; + +use super::Timeline; + +/// Follows a fold to name the turn-changes snapshot each new one supersedes. +/// +/// A snapshot replaces its turn's changes wholesale, and nothing the fold does +/// between two snapshots reads a turn's diffs, so once a later snapshot lands +/// on the same turn the earlier one's diffs never reach a timeline again. The +/// turn is the one the fold itself resolves the snapshot to, in the same +/// lifetime: a rewind ends a turn's lifetime even when its index is reused. +#[derive(Debug, Clone)] +pub struct TurnSnapshots { + /// The latest snapshot on each turn the fold holds. + latest: Vec>, +} + +impl Default for TurnSnapshots { + fn default() -> Self { + Self { latest: Vec::new() } + } +} + +impl TurnSnapshots { + /// Fold the event recorded at `ts`, which the caller knows as `key`, into + /// `fold`. When it is a snapshot that supersedes an earlier one, the + /// earlier one's key is returned. + pub fn apply_at( + &mut self, + fold: &mut Timeline, + ts: Option, + event: &AgentEvent, + key: K, + ) -> Option { + let target = match event { + AgentEvent::TurnChangesUpdated { turn_id, .. } => Some(fold.provider_turn(turn_id)), + _ => None, + }; + fold.apply_at(ts, event); + self.latest.truncate(fold.turns.len()); + self.latest.resize_with(fold.turns.len(), || None); + let turn = target?.unwrap_or(fold.turns.len() - 1); + self.latest[turn].replace(key) + } +} + +/// Drop the diffs of a turn-changes snapshot, keeping its paths, kinds and +/// completeness. Returns whether there was any diff to drop. +pub fn drop_turn_diffs(event: &mut AgentEvent) -> bool { + let AgentEvent::TurnChangesUpdated { changes, .. } = event else { + return false; + }; + let mut dropped = false; + for change in changes { + dropped |= change.diff.take().is_some(); + } + dropped +} + +#[cfg(test)] +mod tests { + use agent::{ + ChangeCompleteness, FileChange, FileChangeKind, ItemContent, RewindMode, ThreadItem, + TurnStatus, + }; + + use super::*; + use crate::session::StoredEvent; + + fn at(ts: u64, event: AgentEvent) -> StoredEvent { + StoredEvent { + ts: Some(ts), + event, + elided: None, + } + } + + fn started(turn: &str) -> AgentEvent { + AgentEvent::TurnStarted { + turn_id: turn.into(), + } + } + + fn completed(turn: &str) -> AgentEvent { + AgentEvent::TurnCompleted { + turn_id: turn.into(), + status: TurnStatus::Completed, + usage: None, + } + } + + fn checkpoint(turn: &str, checkpoint: &str) -> AgentEvent { + AgentEvent::TurnCheckpoint { + turn_id: turn.into(), + checkpoint_id: checkpoint.into(), + } + } + + fn rewind(checkpoint: &str) -> AgentEvent { + AgentEvent::RewindCompleted { + checkpoint_id: checkpoint.into(), + mode: RewindMode::Conversation, + prefill: None, + } + } + + /// A snapshot of the turn's net changes: one file whose diff is `diff`. + fn changes(turn: &str, diff: &str) -> AgentEvent { + AgentEvent::TurnChangesUpdated { + turn_id: turn.into(), + changes: vec![FileChange { + path: "f".into(), + kind: FileChangeKind::Modify, + diff: Some(diff.into()), + }], + completeness: ChangeCompleteness::Exact, + } + } + + fn message(id: &str, text: &str) -> AgentEvent { + AgentEvent::ItemCompleted(ThreadItem { + id: id.into(), + parent_item_id: None, + content: ItemContent::AssistantMessage { text: text.into() }, + }) + } + + fn user(id: &str) -> AgentEvent { + AgentEvent::ItemCompleted(ThreadItem { + id: id.into(), + parent_item_id: None, + content: ItemContent::UserMessage { + text: id.into(), + context_len: None, + attachments: Vec::new(), + }, + }) + } + + /// The indices of the snapshots a later one supersedes, ascending. + fn superseded(log: &[StoredEvent]) -> Vec { + let mut fold = Timeline::default(); + let mut snapshots = TurnSnapshots::default(); + let mut superseded: Vec = log + .iter() + .enumerate() + .filter_map(|(index, record)| { + snapshots.apply_at(&mut fold, record.ts, &record.event, index) + }) + .collect(); + superseded.sort_unstable(); + superseded + } + + #[test] + fn superseded_snapshots_follow_the_turn_the_fold_lands_them_on() { + let reused_id = vec![ + at(1, started("x")), + at(2, changes("x", "a")), + at(3, message("m1", "one")), + at(4, completed("x")), + at(5, started("x")), + // Lands on the first turn named x, superseding record 1 there. + at(6, changes("x", "b")), + // Names no turn, so lands on the current one; superseded by record 8. + at(7, changes("", "c")), + at(8, message("m2", "two")), + at(9, changes("", "d")), + at(10, message("m3", "three")), + at(11, completed("x")), + ]; + let opens_turn = vec![ + at(1, changes("x", "a")), + at(2, message("m1", "one")), + at(3, changes("x", "b")), + at(4, message("m2", "two")), + ]; + let names_turn = vec![ + at(1, started("t")), + // No turn is named x yet: lands on the current turn and names it. + at(2, changes("x", "a")), + at(3, message("m1", "one")), + at(4, completed("t")), + at(5, started("u")), + // Finds the first turn by the name record 1 gave it. + at(6, changes("x", "b")), + at(7, message("m2", "two")), + at(8, completed("u")), + ]; + let rewound = vec![ + at(1, user("u1")), + at(2, started("t1")), + at(3, checkpoint("t1", "c1")), + at(4, completed("t1")), + at(5, user("u2")), + at(6, started("t2")), + at(7, checkpoint("t2", "c2")), + at(8, changes("t2", "a")), + at(9, message("m1", "one")), + at(10, completed("t2")), + // Drops the second turn; the next user message reopens index 1. + at(11, rewind("c2")), + at(12, user("u3")), + at(13, started("t3")), + at(14, changes("t3", "b")), + at(15, message("m2", "two")), + at(16, changes("t3", "c")), + at(17, message("m3", "three")), + at(18, completed("t3")), + ]; + let late_changes = vec![ + at(1, started("t")), + at(2, changes("t", "a")), + at(3, message("m", "done")), + at(4, completed("t")), + at(5, changes("t", "b")), + ]; + let cases = [ + ( + "a reused turn id lands on its first turn", + reused_id, + vec![1, 6], + ), + ("a snapshot that opened its turn", opens_turn, vec![0]), + ("a snapshot that named its turn", names_turn, vec![1]), + ( + "a rewind ends a turn's lifetime though its index is reused", + rewound, + vec![13], + ), + ("a snapshot after its turn completed", late_changes, vec![1]), + ]; + for (label, log, expected) in cases { + assert_eq!(superseded(&log), expected, "{label}"); + let mut stubbed = log.clone(); + for &index in &expected { + assert!(drop_turn_diffs(&mut stubbed[index].event), "{label}"); + } + assert_eq!( + Timeline::fold_stored(&stubbed), + Timeline::fold_stored(&log), + "{label}: the fold is unchanged" + ); + assert_eq!( + superseded(&stubbed), + expected, + "{label}: a stubbed log names the same snapshots" + ); + } + } +} diff --git a/crates/headless/src/main.rs b/crates/headless/src/main.rs index b389b7d21..9b4b6b8e0 100644 --- a/crates/headless/src/main.rs +++ b/crates/headless/src/main.rs @@ -126,6 +126,7 @@ fn serve_command(args: &[String]) -> Result<(), String> { let mut services = HostServices { background_startup_probes: true, ai_title_generation: true, + drop_superseded_diffs: true, ..HostServices::default() }; if let Ok(mut mcp_host) = mcp_host::Host::bind() { diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index aa4f450a6..71154ea49 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -114,9 +114,11 @@ pub enum ServerEvent { /// the reviewer judged the flag not a false positive. draft: String, }, - /// Records standing for the log cursors `from..end`. The host may merge - /// or drop records whose effect a later record in the log repeats, so - /// `records` can be shorter than the range. + /// Records standing for the log cursors `from..end`. A cursor is the + /// position of a stored row of the log, and a row without a readable + /// record stands for none; the host may also merge or drop records whose + /// effect a later record in the log repeats, so `records` can be shorter + /// than the range. SessionSnapshot { from: u64, end: u64, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 4b7c75665..f705f0627 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -59,6 +59,10 @@ pub use wire::{ // Unreleased: host-authored session activity, action availability, full usage and // meter capacity; a separate session plan topic; shared worktree facts and // archived-list revisions. SessionStatus working/turn_running move into activity. +// Unreleased: session log cursors (window from/end/total, page before/from/end, +// Subscription.after) are stored row positions, so in a log holding blank or +// undecodable rows they run ahead of the records; a subscription to a thread +// whose log is not loaded is acknowledged after its window. pub const PROTOCOL_VERSION: u32 = 9; #[cfg(test)] diff --git a/crates/runtime/src/app/diff_pass.rs b/crates/runtime/src/app/diff_pass.rs new file mode 100644 index 000000000..d72293391 --- /dev/null +++ b/crates/runtime/src/app/diff_pass.rs @@ -0,0 +1,136 @@ +use std::collections::VecDeque; + +use tcode_services::store::DiffPass; + +use super::*; + +/// The background pass that drops the diffs of superseded turn-changes +/// snapshots from logs stored before appends dropped them themselves. Each +/// thread is one store-writer operation, queued only once the one before it +/// finished, so the pass never holds more than one log in memory and never +/// delays the writes queued behind it by more than one thread. +pub(super) struct DiffPassRun { + remaining: VecDeque, + started: Instant, + dropped: usize, + unchanged: usize, + undecodable: usize, + failed: usize, + rows: usize, + bytes_before: u64, + bytes_after: u64, + /// The longest any one thread held the store writer. + longest: Duration, +} + +impl AppState { + /// List the threads the pass has not dealt with, off the mailbox, then + /// pass them one at a time. A resident thread is passed like any other: + /// its log already holds the snapshots without their diffs. + pub(crate) fn start_diff_pass(&mut self, cx: &mut HostCx) { + let store = self.store.clone(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let threads = host_cx + .unblock(move || store.threads_without_diff_pass()) + .await; + host_cx.enqueue(move |state, cx| match threads { + Ok(threads) => { + state.diff_pass = Some(DiffPassRun { + remaining: threads.into(), + started: Instant::now(), + dropped: 0, + unchanged: 0, + undecodable: 0, + failed: 0, + rows: 0, + bytes_before: 0, + bytes_after: 0, + longest: Duration::ZERO, + }); + state.continue_diff_pass(cx); + } + Err(error) => log::warn!("could not list the threads to pass: {error}"), + }); + }); + } + + pub(super) fn stop_diff_pass(&mut self) { + self.diff_pass = None; + } + + fn continue_diff_pass(&mut self, cx: &mut HostCx) { + let Some(run) = self.diff_pass.as_mut() else { + return; + }; + let Some(session_id) = run.remaining.pop_front() else { + let run = self.diff_pass.take().expect("checked above"); + log::info!( + "dropped superseded diffs in {:.1?}: {} thread(s) changed ({} rows, {} -> {} bytes), \ + {} unchanged, {} left with an undecodable row, {} failed; the writer was held \ + at most {:.1?}", + run.started.elapsed(), + run.dropped, + run.rows, + run.bytes_before, + run.bytes_after, + run.unchanged, + run.undecodable, + run.failed, + run.longest, + ); + return; + }; + let (completion, completed) = smol::channel::bounded(1); + self.enqueue_store_write( + StoreWrite::DropSupersededDiffs { + id: session_id.clone(), + completion, + }, + cx, + ); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let outcome = completed.recv().await; + host_cx.enqueue(move |state, cx| { + let Some(run) = state.diff_pass.as_mut() else { + return; + }; + match outcome { + Ok(Ok((outcome, held))) => { + run.longest = run.longest.max(held); + match outcome { + DiffPass::Dropped { + rows, + before, + after, + } => { + run.dropped += 1; + run.rows += rows; + run.bytes_before += before; + run.bytes_after += after; + } + DiffPass::Unchanged => run.unchanged += 1, + DiffPass::Undecodable { position } => { + run.undecodable += 1; + log::warn!( + "kept the superseded diffs of {session_id}: event {position} \ + does not decode" + ); + } + } + } + Ok(Err(error)) => { + run.failed += 1; + log::warn!("could not drop the superseded diffs of {session_id}: {error}"); + } + Err(_) => { + state.stop_diff_pass(); + return; + } + } + state.continue_diff_pass(cx); + }); + }); + } +} diff --git a/crates/runtime/src/app/events.rs b/crates/runtime/src/app/events.rs index 3da674add..400d4f63e 100644 --- a/crates/runtime/src/app/events.rs +++ b/crates/runtime/src/app/events.rs @@ -297,6 +297,7 @@ impl AppState { } } AgentEvent::Warning { message } => { + log::warn!("provider warning in {session_id}: {message}"); // Provider warnings (config problems, deprecations, failed // mode switches) explain later misbehavior: a log line alone // hides them from the person who needs to act on them. @@ -522,29 +523,37 @@ impl AppState { event: event.clone(), elided: None, }; - self.emit_domain( - Topic::SessionEvents { - session_id: session_id.to_string(), - }, - ServerEvent::SessionEvent(history::wire_record(&record).into_owned()), - cx, - ); - // A log that cannot be read is not cached: an empty stand-in would - // replace the conversation. The append is still queued. - match self.event_records.entry(session_id.to_string()) { - std::collections::hash_map::Entry::Occupied(mut log) => log.get_mut().push(record), - std::collections::hash_map::Entry::Vacant(slot) => { - match SessionLog::load(&self.store, session_id) { - Ok(log) => slot.insert(log).push(record), - Err(error) => log::error!("cannot load the event log of {session_id}: {error}"), - } + let topic = Topic::SessionEvents { + session_id: session_id.to_string(), + }; + let joined = if self.event_records.contains_key(session_id) { + self.emit_domain( + topic, + ServerEvent::SessionEvent(history::wire_record(&record).into_owned()), + cx, + ); + self.event_records + .get_mut(session_id) + .expect("checked above") + .push(record) + } else { + // A session nobody holds needs no row named: the append alone + // keeps the record, and any later read of the log is queued + // behind it. + if self.log_hydrations.contains_key(session_id) + || self.resident(session_id).is_some() + || self.subscriptions.contains(&topic) + { + self.hold_record(session_id, record, cx); } - } + Joined::Unfolded + }; self.enqueue_store_write( StoreWrite::AppendEvent { id: session_id.to_string(), ts, event: Box::new(event.clone()), + joined, }, cx, ); @@ -639,11 +648,13 @@ impl AppState { let previous_title = meta.title; let title_meta = title_session_meta(&self.settings, meta.cwd); let regenerate = first_message.is_none(); - // The cache includes accepted messages whose disk writes are still queued. + // The cache includes accepted messages whose writes are still queued; + // without it the store is read once they are written. let records = regenerate .then(|| self.event_records.get(&session_id)) .flatten() .map(|log| log.records().to_vec()); + let written = (regenerate && records.is_none()).then(|| self.store_write_barrier(cx)); let store = self.store.clone(); let settings = self.settings.clone(); let settings_store = self.settings_store.clone(); @@ -652,6 +663,9 @@ impl AppState { let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { + if let Some(written) = written { + let _ = written.recv().await; + } let read_id = session_id.clone(); let old_title = previous_title.clone(); let input = host_cx diff --git a/crates/runtime/src/app/history.rs b/crates/runtime/src/app/history.rs index 143776c38..1eca3c8cd 100644 --- a/crates/runtime/src/app/history.rs +++ b/crates/runtime/src/app/history.rs @@ -2,10 +2,12 @@ use super::*; use agent::DeltaKind; use std::borrow::Cow; use std::ops::Range; +use tcode_core::session::{TurnSnapshots, drop_turn_diffs}; use tcode_protocol::{ - HostMessage, MAX_SESSION_HISTORY_BYTES, OUTPUT_PREVIEW_BYTES, SESSION_HISTORY_RECORDS, - SESSION_WINDOW_BYTES, + CommandResponse, HostMessage, MAX_SESSION_HISTORY_BYTES, OUTPUT_PREVIEW_BYTES, + SESSION_HISTORY_RECORDS, SESSION_WINDOW_BYTES, Subscription, }; +use tcode_services::store::{EventLog, TurnIndex}; /// Count serialized bytes without allocating a copy of a large record. struct ByteCount(usize); @@ -93,32 +95,6 @@ pub(super) fn wire_record(record: &SessionEventRecord) -> Cow<'_, SessionEventRe Cow::Owned(record) } -/// Indices from `from` on of turn-change records that a later record for the -/// same turn replaces. Both land on the same turn and nothing reads a turn's -/// diffs between them, so the earlier one crosses the wire without diffs. -fn superseded_turn_changes(records: &[SessionEventRecord], from: usize) -> HashSet { - let mut later = HashSet::new(); - let mut superseded = HashSet::new(); - for index in (from..records.len()).rev() { - if let AgentEvent::TurnChangesUpdated { turn_id, .. } = &records[index].event - && !turn_id.is_empty() - && !later.insert(turn_id.as_str()) - { - superseded.insert(index); - } - } - superseded -} - -fn without_diffs(mut record: SessionEventRecord) -> SessionEventRecord { - if let AgentEvent::TurnChangesUpdated { changes, .. } = &mut record.event { - for change in changes { - change.diff = None; - } - } - record -} - /// The records that stand for the log cursors in `range`. struct Window { range: Range, @@ -136,14 +112,6 @@ fn wire_window( backwards: bool, overhead: usize, ) -> Result { - let superseded = superseded_turn_changes(records, requested.start); - let prepared = |index: usize| -> Cow<'_, SessionEventRecord> { - if superseded.contains(&index) { - Cow::Owned(without_diffs(wire_record(&records[index]).into_owned())) - } else { - wire_record(&records[index]) - } - }; let budget = SESSION_WINDOW_BYTES.saturating_sub(overhead); let mut used = 0; let mut count = 0; @@ -154,7 +122,7 @@ fn wire_window( requested.start + offset }; // Separator; also leaves room for an empty array. - let size = wire_len(&prepared(index)) + 1; + let size = wire_len(&wire_record(&records[index])) + 1; if count == 0 && size > MAX_SESSION_HISTORY_BYTES.saturating_sub(overhead) { return Err(tcode_protocol::ProtocolError { code: "history_record_too_large".into(), @@ -193,11 +161,7 @@ fn wire_window( last_text.push_str(text); continue; } - merged.push(if superseded.contains(&index) { - without_diffs(record.clone()) - } else { - record.clone() - }); + merged.push(record.clone()); } let records = merged .iter() @@ -206,101 +170,812 @@ fn wire_window( Ok(Window { range, records }) } +/// A stored turn-changes snapshot that an append supersedes. +pub(super) struct SupersededRow { + pub(super) position: u64, + pub(super) turn_id: String, +} + +/// What an appended record does to the session's other stored rows, as the +/// fold of the log it joins tells. +pub(super) enum Joined { + /// It joined a resident log. + Folded { + superseded: Option, + /// The turn index after it, when it changed the turns. + turn_index: Option, + }, + /// No log was resident to fold it into, so neither is known. + Unfolded, +} + +/// What a window is cut from: the records of the rows before `end` of a log, +/// all of them or only its last ones, with the turns of the whole log. +/// +/// Cursors are row positions. A blank or undecodable row holds no record, so +/// a range of cursors can hold fewer records than it spans. +struct LogView<'a> { + records: &'a [SessionEventRecord], + /// The row each record is in, ascending. + rows: &'a [u64], + end: u64, + turn_starts: &'a [u64], + turns: u64, +} + +impl LogView<'_> { + /// Move a window start back to the row that opens its turn. The client + /// folds only the records it holds, so a window that begins mid-turn + /// renders a partial first turn whose entries shift as earlier pages + /// arrive, while a window that begins where a turn begins opens its turns + /// exactly where the full log does. Rows that open no turn leave the + /// start unchanged. + fn turn_aligned_start(&self, start: u64) -> u64 { + let opened_before = self.turn_starts.partition_point(|&row| row <= start); + opened_before + .checked_sub(1) + .map_or(start, |last| self.turn_starts[last]) + } + + /// The index of the first record at or after row `position`. + fn index(&self, position: u64) -> usize { + self.rows.partition_point(|row| *row < position) + } + + /// The row a window whose first record is `index` starts at, when it + /// was asked to start at `requested`, the row of record `first`. + fn start(&self, index: usize, first: usize, requested: u64) -> u64 { + if index == first { + requested + } else { + self.rows[index] + } + } + + /// The window answering a subscription: the records from its cursor on, + /// or without one a baseline: the tail from a turn start about + /// [`BASELINE_RECORDS`] rows back, cut to the byte budget from its newest + /// end. + fn events_window(&self, subscription: &Subscription) -> ServerEvent { + let empty = HostMessage::Event(EventEnvelope { + request_id: Some(u64::MAX), + topic: subscription.topic.clone(), + event: ServerEvent::SessionSnapshot { + from: u64::MAX, + end: u64::MAX, + records: vec![], + total: u64::MAX, + total_turns: u64::MAX, + truncated: false, + }, + }); + let total = self.end; + let after = subscription.after.filter(|after| *after <= total); + let from = after + .unwrap_or_else(|| self.turn_aligned_start(total.saturating_sub(BASELINE_RECORDS))); + let first = self.index(from); + match wire_window( + self.records, + first..self.records.len(), + after.is_none(), + wire_overhead(&empty), + ) { + Ok(window) => { + let start = self.start(window.range.start, first, from); + let end = if window.range.end == self.records.len() { + total + } else { + self.rows[window.range.end] + }; + ServerEvent::SessionSnapshot { + from: start, + end, + truncated: start > from || end < total, + records: window.records, + total, + total_turns: self.turns, + } + } + Err(error) => ServerEvent::SessionHistoryError(error), + } + } + + fn history_page( + &self, + before: u64, + limit: u32, + ) -> Result { + let end = before.min(self.end); + let count = (limit as u64).clamp(1, SESSION_HISTORY_RECORDS as u64); + let start = self.turn_aligned_start(end.saturating_sub(count)); + let requested = self.index(start)..self.index(end); + let empty = HostMessage::QueryResult { + id: u64::MAX, + result: Ok(QueryResponse::SessionHistoryPage { + records: vec![], + from: u64::MAX, + end: u64::MAX, + truncated: false, + }), + }; + let window = wire_window(self.records, requested.clone(), true, wire_overhead(&empty))?; + Ok(QueryResponse::SessionHistoryPage { + from: self.start(window.range.start, requested.start, start), + end, + truncated: window.range.len() < requested.len(), + records: window.records, + }) + } +} + +/// The bytes a reply spends besides its records, measured on `empty`, a reply +/// without records whose numbers are as long as any reply's. +fn wire_overhead(empty: &HostMessage) -> usize { + serde_json::to_vec(empty).expect("serializable reply").len() + 1 +} + +/// How far back a baseline reaches before its turn start and byte budget. +const BASELINE_RECORDS: u64 = 400; + /// The complete event log of one session, held in memory while the session /// is resident so history windows cost the page rather than a re-read of the /// stored log, plus the fold that decides where each window may start. /// /// Memory policy: [`AppState::event_records`] holds a log for every live or /// parked session (bounded by the resident LRU) and for nothing else. A log -/// is loaded when a client opens the session or when the session first -/// appends in this process, and is dropped once the session leaves residency -/// and the store writer has flushed every append queued from it -/// ([`AppState::release_stale_session_logs`]): until then the log, not the -/// store, is the whole conversation. +/// is read off the mailbox ([`Hydration`]) when a client opens the session or +/// when the session first appends in this process, and is dropped once the +/// session leaves residency and the store writer has flushed every append +/// queued from it ([`AppState::release_stale_session_logs`]): until then the +/// log, not the store, is the whole conversation. +/// +/// A turn-changes snapshot that a later one supersedes is held without its +/// diffs, as the store keeps it, so no window sends them. #[derive(Clone)] pub(super) struct SessionLog { records: Vec, - /// Indices of the records that opened a turn in `fold`, ascending. - turn_starts: Vec, + /// The stored row each record is in, ascending. + rows: Vec, + /// The rows of the records that opened a turn in `fold`, ascending. + turn_starts: Vec, /// `Timeline::fold_events(records)`, extended by every push so an append /// can tell whether it opens a turn, and cloned by timeline loads instead /// of folding the records again. fold: Timeline, - /// The length flushed by the release barrier in flight, if any. - release_barrier: Option, + /// The snapshot each turn of `fold` holds, as its record index and the + /// stored row it is in. + snapshots: TurnSnapshots<(usize, u64)>, + /// The stored row the next append takes. + next_row: u64, + /// Every stored row decoded when the log was read, so `fold` names the + /// same superseded snapshots as the store's own pass would. + decoded: bool, + /// The end flushed by the release barrier in flight, if any. + release_barrier: Option, } impl SessionLog { - pub(super) fn load(store: &SessionStore, session_id: &str) -> std::io::Result { - Ok(Self::from_records(store.read_events(session_id)?)) - } - - pub(super) fn from_records(records: impl IntoIterator) -> Self { - let mut log = Self { + pub(super) fn new(log: EventLog) -> Self { + let mut session_log = Self { records: Vec::new(), + rows: Vec::new(), turn_starts: Vec::new(), fold: Timeline::default(), + snapshots: TurnSnapshots::default(), + next_row: log.next_row, + decoded: log.undecodable == 0, release_barrier: None, }; - for record in records { - log.push(record); + for (record, row) in log.records.into_iter().zip(log.rows) { + session_log.push_at(record, row); + } + session_log + } + + /// Append a record, returning what the store should change besides + /// appending it. + pub(super) fn push(&mut self, record: SessionEventRecord) -> Joined { + let (turns, starts) = (self.fold.turns.len(), self.turn_starts.len()); + let row = self.next_row; + self.next_row += 1; + let superseded = + self.push_at(record, row) + .filter(|_| self.decoded) + .map(|(index, position)| { + let AgentEvent::TurnChangesUpdated { turn_id, .. } = &self.records[index].event + else { + unreachable!("only snapshots are superseded") + }; + SupersededRow { + position, + turn_id: turn_id.clone(), + } + }); + let turn_index = (self.fold.turns.len() != turns || self.turn_starts.len() != starts) + .then(|| self.turn_index()); + Joined::Folded { + superseded, + turn_index, } - log } - pub(super) fn push(&mut self, record: SessionEventRecord) { + fn push_at(&mut self, record: SessionEventRecord, row: u64) -> Option<(usize, u64)> { let turns = self.fold.turns.len(); - self.fold.apply_at(record.ts, &record.event); + let index = self.records.len(); + let superseded = + self.snapshots + .apply_at(&mut self.fold, record.ts, &record.event, (index, row)); if self.fold.turns.len() > turns { - self.turn_starts.push(self.records.len()); + self.turn_starts.push(row); } self.records.push(record); + self.rows.push(row); + if let Some((earlier, _)) = superseded { + drop_turn_diffs(&mut self.records[earlier].event); + } + superseded } pub(super) fn records(&self) -> &[SessionEventRecord] { &self.records } + /// The records in rows from `position` on. + pub(super) fn records_from(&self, position: u64) -> &[SessionEventRecord] { + &self.records[self.rows.partition_point(|row| *row < position)..] + } + /// The pure fold of every record, before any `mark_idle`. pub(super) fn fold(&self) -> &Timeline { &self.fold } - /// Move a window start back to the record that opens its turn. The client - /// folds only the records it holds, so a window that begins mid-turn - /// renders a partial first turn whose entries shift as earlier pages - /// arrive, while a window that begins where a turn begins opens its turns - /// exactly where the full log does. Records that open no turn leave the - /// start unchanged. - fn turn_aligned_start(&self, start: usize) -> usize { - let opened_before = self.turn_starts.partition_point(|&index| index <= start); - opened_before - .checked_sub(1) - .map_or(start, |last| self.turn_starts[last]) + /// The row just past the last record: the log's end cursor. + pub(super) fn end(&self) -> u64 { + self.next_row + } + + pub(super) fn turn_index(&self) -> TurnIndex { + TurnIndex { + turns: self.fold.turns.len() as u64, + starts: self.turn_starts.clone(), + } + } + + fn view(&self) -> LogView<'_> { + LogView { + records: &self.records, + rows: &self.rows, + end: self.next_row, + turn_starts: &self.turn_starts, + turns: self.fold.turns.len() as u64, + } + } + + pub(super) fn events_window(&self, subscription: &Subscription) -> ServerEvent { + self.view().events_window(subscription) + } + + pub(super) fn history_page( + &self, + before: u64, + limit: u32, + ) -> Result { + self.view().history_page(before, limit) + } + + /// The whole output of one item, as the full log folds it. + pub(super) fn item_output( + &self, + session_id: &str, + item_id: &str, + ) -> Result { + let output = self + .fold + .entries + .iter() + .rev() + .find(|entry| entry.id == item_id) + .and_then(|entry| match &entry.content { + EntryContent::Item(ItemContent::ToolCall { output, .. }) => output.clone(), + EntryContent::Item(ItemContent::CommandExecution { output, .. }) => { + Some(output.clone()) + } + _ => None, + }) + .ok_or_else(|| tcode_protocol::ProtocolError { + code: "unknown_item_output".into(), + message: format!("no output for item {item_id} in {session_id}"), + })?; + if output.len() > MAX_SESSION_HISTORY_BYTES { + return Err(tcode_protocol::ProtocolError { + code: "item_output_too_large".into(), + message: "The output exceeds the 8 MiB response limit.".into(), + }); + } + Ok(QueryResponse::ItemOutput(output)) } } +/// The last rows of a log, as many as a baseline window can use, read with +/// the turn index of the whole log so the window is the one the whole log +/// would give. +pub(super) struct Tail { + records: Vec, + rows: Vec, + end: u64, + index: TurnIndex, +} + +/// Rows read per step while a tail is read backwards. +const TAIL_ROWS: u64 = 256; + +impl Tail { + /// Read the rows before `end` backwards from it until they reach the + /// baseline's turn start or hold more than a window's byte budget, past + /// which no baseline reaches. + fn read( + store: &SessionStore, + session_id: &str, + end: u64, + index: TurnIndex, + ) -> std::io::Result { + let start = LogView { + records: &[], + rows: &[], + end, + turn_starts: &index.starts, + turns: index.turns, + } + .turn_aligned_start(end.saturating_sub(BASELINE_RECORDS)); + let mut tail = Self { + records: Vec::new(), + rows: Vec::new(), + end, + index, + }; + let (mut low, mut bytes) = (end, 0); + while low > start && bytes <= SESSION_WINDOW_BYTES { + let from = low.saturating_sub(TAIL_ROWS).max(start); + let read = store.read_rows(session_id, from..low)?; + bytes += read + .records + .iter() + .map(|record| wire_len(&wire_record(record)) + 1) + .sum::(); + tail.records.splice(0..0, read.records); + tail.rows.splice(0..0, read.rows); + low = from; + } + Ok(tail) + } + + fn view(&self) -> LogView<'_> { + LogView { + records: &self.records, + rows: &self.rows, + end: self.end, + turn_starts: &self.index.starts, + turns: self.index.turns, + } + } +} + +/// A session's log being read and folded off the mailbox, and what waits for +/// it. A session has at most one, and none while its log is resident. +/// +/// The read covers the rows before the position the store writer found next +/// in its queue order, so it holds every record whose append was queued +/// before the hydration began and none queued after. Every record accepted +/// for the session from then on is held in `pending`, in order, and takes the +/// rows after that end. +/// +/// A long log with a turn index first has its tail read, and a baseline +/// window cut from it answers the subscriptions waiting then and made later, +/// while nothing is pending. Once one has, the log is `live`: records it +/// accepts go out as they arrive, and a subscription that has to wait gets a +/// window that ends after them. +pub(super) struct Hydration { + tail: Option, + live: bool, + pending: Vec, + /// Subscription replies; one without a request id answers no request. + replies: Vec<(Option, Subscription)>, + /// Queries answered from the whole log. + queries: Vec, + /// The timeline load waiting for the log, with how many `pending` records + /// had been accepted when it was requested. + timeline: Option<(TimelineLoad, usize)>, +} + +type LogQuery = Box) + Send>; + impl AppState { - /// The session's log, cached for the resident session it belongs to. A - /// non-resident session is read cold and not retained, so paging it never - /// grows the cache. - fn history_log( + /// The session's hydration, begun unless its log is being read already. + /// Callers check first that the log is not resident. + pub(super) fn hydrate_log( &mut self, session_id: &str, - ) -> Result, tcode_protocol::ProtocolError> { - if !self.event_records.contains_key(session_id) { - let log = SessionLog::load(&self.store, session_id).map_err(|error| { - tcode_protocol::ProtocolError { + timeline: Option, + cx: &mut HostCx, + ) -> &mut Hydration { + if !self.log_hydrations.contains_key(session_id) { + let (snapshot, taken) = smol::channel::bounded(1); + self.enqueue_store_write( + StoreWrite::SnapshotLog { + id: session_id.to_string(), + end: snapshot, + }, + cx, + ); + let store = self.store.clone(); + let read_id = session_id.to_string(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let end = match taken.recv().await { + Ok(Ok(end)) => end, + Ok(Err(error)) => { + host_cx.enqueue(move |state, cx| { + state.finish_hydration(read_id, Err(error), cx) + }); + return; + } + Err(_) => { + host_cx.enqueue(move |state, cx| { + state.finish_hydration( + read_id, + Err("the session store writer has stopped".into()), + cx, + ) + }); + return; + } + }; + let tail = { + let store = store.clone(); + let read_id = read_id.clone(); + host_cx + .unblock(move || { + if end <= BASELINE_RECORDS { + return None; + } + let index = store.turn_index(&read_id).ok().flatten()?; + Tail::read(&store, &read_id, end, index) + .inspect_err(|error| { + log::warn!("could not read the tail of {read_id}: {error}") + }) + .ok() + }) + .await + }; + if let Some(tail) = tail { + let tail_id = read_id.clone(); + host_cx.enqueue(move |state, cx| state.serve_tail(&tail_id, tail, cx)); + } + let log = { + let read_id = read_id.clone(); + host_cx + .unblock(move || { + store + .read_log_until(&read_id, end) + .map(SessionLog::new) + .map_err(|error| error.to_string()) + }) + .await + }; + host_cx.enqueue(move |state, cx| state.finish_hydration(read_id, log, cx)); + }); + self.log_hydrations.insert( + session_id.to_string(), + Hydration { + tail: None, + live: false, + pending: Vec::new(), + replies: Vec::new(), + queries: Vec::new(), + timeline: None, + }, + ); + } + let hydration = self + .log_hydrations + .get_mut(session_id) + .expect("inserted above"); + if let Some(load) = timeline { + hydration.timeline = Some((load, hydration.pending.len())); + } + hydration + } + + /// Answer the baseline subscriptions waiting for the log from its tail, + /// unless a record arrived since the read began: the tail ends before it. + fn serve_tail(&mut self, session_id: &str, tail: Tail, cx: &mut HostCx) { + let Some(hydration) = self.log_hydrations.get_mut(session_id) else { + return; + }; + if !hydration.pending.is_empty() { + return; + } + let (baselines, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut hydration.replies) + .into_iter() + .partition(|(_, subscription)| subscription.after.is_none()); + hydration.replies = waiting; + hydration.live |= !baselines.is_empty(); + let view = tail.view(); + for (request_id, subscription) in &baselines { + self.reply_with( + view.events_window(subscription), + *request_id, + subscription, + cx, + ); + } + if let Some(hydration) = self.log_hydrations.get_mut(session_id) { + hydration.tail = Some(tail); + } + } + + /// Serve everything that waited for the log, in the order of its + /// records, and keep the log only if its session is resident. + fn finish_hydration( + &mut self, + session_id: String, + log: Result, + cx: &mut HostCx, + ) { + let Some(hydration) = self.log_hydrations.remove(&session_id) else { + return; + }; + let mut log = match log { + Ok(log) => log, + Err(error) => { + log::error!("could not read the log of {session_id}: {error}"); + let failure = ServerEvent::SessionHistoryError(tcode_protocol::ProtocolError { code: "history_unavailable".into(), message: format!("could not read the history of {session_id}: {error}"), + }); + for (request_id, subscription) in &hydration.replies { + self.reply_with(failure.clone(), *request_id, subscription, cx); } - })?; - if self.resident(session_id).is_none() { - return Ok(std::borrow::Cow::Owned(log)); + for query in hydration.queries { + query(Err(&error)); + } + if hydration.timeline.is_some() { + self.report_error( + RuntimeError::External(format!( + "could not load thread {session_id}: {error}" + )), + cx, + ); + } + return; + } + }; + let retained = self.resident(&session_id).is_some(); + let topic = Topic::SessionEvents { + session_id: session_id.clone(), + }; + // Records a live log accepted are out already: a waiting subscription + // gets a window that holds them. Otherwise they follow the windows. + if !hydration.live { + self.reply_from_log(&log, &hydration.replies, cx); + } + let timeline = hydration.timeline.filter(|_| retained); + let fold_at = timeline.as_ref().map(|(_, accepted)| *accepted); + let mut folded = None; + for (index, record) in hydration.pending.into_iter().enumerate() { + if fold_at == Some(index) { + folded = Some((log.end(), log.fold().clone())); + } + if !hydration.live { + self.emit_domain( + topic.clone(), + ServerEvent::SessionEvent(wire_record(&record).into_owned()), + cx, + ); } - self.event_records.insert(session_id.to_string(), log); + log.push(record); + } + if hydration.live { + self.reply_from_log(&log, &hydration.replies, cx); } - Ok(std::borrow::Cow::Borrowed(&self.event_records[session_id])) + for query in hydration.queries { + query(Ok(&log)); + } + // Queued after every append the log holds, so it covers all of them. + self.enqueue_store_write( + StoreWrite::SetTurnIndex { + id: session_id.clone(), + index: log.turn_index(), + }, + cx, + ); + if !retained { + return; + } + let folded = timeline.map(|(load, _)| { + let (cursor, fold) = folded.unwrap_or_else(|| (log.end(), log.fold().clone())); + (load, cursor, fold) + }); + self.event_records.insert(session_id.clone(), log); + if let Some((load, cursor, fold)) = folded { + self.fold_timeline(session_id, load, cursor, fold, cx); + } + } + + fn reply_from_log( + &self, + log: &SessionLog, + replies: &[(Option, Subscription)], + cx: &mut HostCx, + ) { + for (request_id, subscription) in replies { + self.reply_with( + log.events_window(subscription), + *request_id, + subscription, + cx, + ); + } + } + + /// Send `window` as the reply to a subscription unless nobody is + /// subscribed any more, and acknowledge the request. + fn reply_with( + &self, + window: ServerEvent, + request_id: Option, + subscription: &Subscription, + cx: &mut HostCx, + ) { + if self.subscriptions.contains(&subscription.topic) { + cx.emit(HostEvent::Domain(EventEnvelope { + request_id, + topic: subscription.topic.clone(), + event: window, + })); + } + if let Some(id) = request_id { + cx.send_message(HostMessage::Ack { + id, + result: Ok(CommandResponse::Unit), + }); + } + } + + /// Answer a subscription with its snapshot and acknowledge the request. + /// A session's events are answered from its log: now when it is + /// resident, from its tail when a baseline can be and nothing arrived + /// since the tail was read, otherwise once the log has been read; the + /// acknowledgement waits with it, so the window is the request's first + /// reply. + pub(crate) fn reply_to_subscription( + &mut self, + request_id: Option, + subscription: Subscription, + cx: &mut HostCx, + ) { + if let Topic::SessionEvents { session_id } = &subscription.topic { + if let Some(log) = self.event_records.get(session_id) { + self.reply_with( + log.events_window(&subscription), + request_id, + &subscription, + cx, + ); + return; + } + let session_id = session_id.clone(); + let hydration = self.hydrate_log(&session_id, None, cx); + let window = hydration + .tail + .as_ref() + .filter(|_| hydration.pending.is_empty() && subscription.after.is_none()) + .map(|tail| tail.view().events_window(&subscription)); + match window { + Some(window) => { + hydration.live = true; + self.reply_with(window, request_id, &subscription, cx); + } + None => hydration.replies.push((request_id, subscription)), + } + return; + } + if let Some(mut snapshot) = self.subscription_snapshot(&subscription) { + snapshot.request_id = request_id; + cx.emit(HostEvent::Domain(snapshot)); + } + if let Some(id) = request_id { + cx.send_message(HostMessage::Ack { + id, + result: Ok(CommandResponse::Unit), + }); + } + } + + /// Accept a record for a session whose log is not resident. It is held + /// while the log is read, since its row comes after whatever the read + /// holds, and sent at once when the log is already live. + pub(super) fn hold_record( + &mut self, + session_id: &str, + record: SessionEventRecord, + cx: &mut HostCx, + ) { + let hydration = self.hydrate_log(session_id, None, cx); + let live = hydration.live; + let wire = live.then(|| wire_record(&record).into_owned()); + hydration.pending.push(record); + if let Some(wire) = wire { + self.emit_domain( + Topic::SessionEvents { + session_id: session_id.to_string(), + }, + ServerEvent::SessionEvent(wire), + cx, + ); + } + } + + /// Answer a query from the session's log: now when it is resident, + /// otherwise once it has been read. + fn query_session_log( + &mut self, + session_id: &str, + query: impl FnOnce(&SessionLog) -> Result + + Send + + 'static, + cx: &mut HostCx, + ) -> HostTask> { + if let Some(log) = self.event_records.get(session_id) { + let result = query(log); + return cx.spawn_background(async move { result }); + } + let (answer, answered) = smol::channel::bounded(1); + let read_id = session_id.to_string(); + self.hydrate_log(session_id, None, cx) + .queries + .push(Box::new(move |log| { + let _ = answer.try_send(match log { + Ok(log) => query(log), + Err(error) => Err(tcode_protocol::ProtocolError { + code: "history_unavailable".into(), + message: format!("could not read the history of {read_id}: {error}"), + }), + }); + })); + cx.spawn_background(async move { + answered.recv().await.unwrap_or_else(|_| { + Err(tcode_protocol::ProtocolError { + code: "host_stopped".into(), + message: "the host stopped before the session's log was read".into(), + }) + }) + }) + } + + pub(crate) fn session_history_page( + &mut self, + session_id: &str, + before: u64, + limit: u32, + cx: &mut HostCx, + ) -> HostTask> { + self.query_session_log(session_id, move |log| log.history_page(before, limit), cx) + } + + pub(crate) fn item_output( + &mut self, + session_id: &str, + item_id: String, + cx: &mut HostCx, + ) -> HostTask> { + let read_id = session_id.to_string(); + self.query_session_log( + session_id, + move |log| log.item_output(&read_id, &item_id), + cx, + ) } /// Queue a store-writer barrier for every cached log whose session left @@ -310,11 +985,11 @@ impl AppState { /// reports a failed write, or a writer that is gone, keeps the log: it is /// then the only complete copy of the conversation. pub(super) fn release_stale_session_logs(&mut self, cx: &mut HostCx) { - let stale: Vec<(String, usize)> = self + let stale: Vec<(String, u64)> = self .event_records .iter() .filter(|(id, log)| log.release_barrier.is_none() && self.resident(id).is_none()) - .map(|(id, log)| (id.clone(), log.records.len())) + .map(|(id, log)| (id.clone(), log.end())) .collect(); for (session_id, flushed) in stale { self.event_records @@ -341,7 +1016,7 @@ impl AppState { return; } log.release_barrier = None; - let appended_since = log.records.len() != flushed; + let appended_since = log.end() != flushed; if state.resident(&session_id).is_some() { return; } @@ -354,115 +1029,4 @@ impl AppState { }); } } - - pub(crate) fn session_events_snapshot( - &mut self, - subscription: &tcode_protocol::Subscription, - ) -> ServerEvent { - let Topic::SessionEvents { session_id } = &subscription.topic else { - unreachable!() - }; - let log = match self.history_log(session_id) { - Ok(log) => log, - Err(error) => return ServerEvent::SessionHistoryError(error), - }; - let records = log.records(); - let total = records.len(); - let total_turns = log.fold().turns.len() as u64; - let after = subscription.after.filter(|after| *after <= total as u64); - let from = after.map_or_else( - || log.turn_aligned_start(total.saturating_sub(400)), - |after| after as usize, - ); - let empty = HostMessage::Event(EventEnvelope { - request_id: Some(u64::MAX), - topic: subscription.topic.clone(), - event: ServerEvent::SessionSnapshot { - from: u64::MAX, - end: u64::MAX, - records: vec![], - total: u64::MAX, - total_turns: u64::MAX, - truncated: false, - }, - }); - let overhead = serde_json::to_vec(&empty) - .expect("serializable snapshot") - .len() - + 1; - match wire_window(records, from..total, after.is_none(), overhead) { - Ok(window) => ServerEvent::SessionSnapshot { - from: window.range.start as u64, - end: window.range.end as u64, - truncated: window.range.len() < total - from, - records: window.records, - total: total as u64, - total_turns, - }, - Err(error) => ServerEvent::SessionHistoryError(error), - } - } - - pub(crate) fn session_history_page( - &mut self, - session_id: &str, - before: u64, - limit: u32, - ) -> Result { - let log = self.history_log(session_id)?; - let records = log.records(); - let end = before.min(records.len() as u64) as usize; - let count = (limit as usize).clamp(1, SESSION_HISTORY_RECORDS); - let requested = log.turn_aligned_start(end.saturating_sub(count))..end; - let empty = HostMessage::QueryResult { - id: u64::MAX, - result: Ok(QueryResponse::SessionHistoryPage { - records: vec![], - from: u64::MAX, - end: u64::MAX, - truncated: false, - }), - }; - let overhead = serde_json::to_vec(&empty).expect("serializable page").len() + 1; - let window = wire_window(records, requested.clone(), true, overhead)?; - Ok(QueryResponse::SessionHistoryPage { - from: window.range.start as u64, - end: window.range.end as u64, - truncated: window.range.len() < requested.len(), - records: window.records, - }) - } - - /// The whole output of one item, as the full log folds it. - pub(crate) fn item_output( - &mut self, - session_id: &str, - item_id: &str, - ) -> Result { - let log = self.history_log(session_id)?; - let output = log - .fold() - .entries - .iter() - .rev() - .find(|entry| entry.id == item_id) - .and_then(|entry| match &entry.content { - EntryContent::Item(ItemContent::ToolCall { output, .. }) => output.clone(), - EntryContent::Item(ItemContent::CommandExecution { output, .. }) => { - Some(output.clone()) - } - _ => None, - }) - .ok_or_else(|| tcode_protocol::ProtocolError { - code: "unknown_item_output".into(), - message: format!("no output for item {item_id} in {session_id}"), - })?; - if output.len() > MAX_SESSION_HISTORY_BYTES { - return Err(tcode_protocol::ProtocolError { - code: "item_output_too_large".into(), - message: "The output exceeds the 8 MiB response limit.".into(), - }); - } - Ok(QueryResponse::ItemOutput(output)) - } } diff --git a/crates/runtime/src/app/lifecycle.rs b/crates/runtime/src/app/lifecycle.rs index 66d44d46e..7178a97e3 100644 --- a/crates/runtime/src/app/lifecycle.rs +++ b/crates/runtime/src/app/lifecycle.rs @@ -334,6 +334,7 @@ impl AppState { /// Shut down every provider process before the application exits. pub fn shutdown_all(&mut self, cx: &mut HostCx) { + self.stop_diff_pass(); for id in self .mcp .computer_use_registrations diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index dc7faf37b..405cfd38a 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -204,6 +204,22 @@ enum TimelineLoadTarget { Background, } +/// One request to rebuild a resident session's timeline from its log. +#[derive(Debug, Clone, Copy)] +struct TimelineLoad { + generation: u64, + target: TimelineLoadTarget, +} + +impl TimelineLoad { + fn mark_idle(&self) -> bool { + match self.target { + TimelineLoadTarget::Active { mark_idle } => mark_idle, + TimelineLoadTarget::Background => true, + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] struct TerminalPreferences { open: bool, @@ -228,10 +244,11 @@ mod acp; mod active_session; mod approvals; mod command_validation; +mod diff_pass; mod events; mod git; mod history; -use history::SessionLog; +use history::{Hydration, Joined, SessionLog}; mod lifecycle; mod options; mod orchestrate; @@ -398,6 +415,10 @@ pub struct AppState { /// Resident sessions' event logs; see [`SessionLog`] for what is cached /// and when it is dropped. event_records: HashMap, + /// Logs being read off the mailbox, by session; never one that is in + /// `event_records`. + log_hydrations: HashMap, + diff_pass: Option, /// Composer-draft review notes, keyed by session id (in-memory only). review_comment_drafts: HashMap>, /// A restart-continuity marker taken at launch (see `tcode_services::relaunch`). @@ -551,6 +572,8 @@ impl AppState { timeline_load_generations: HashMap::new(), subscriptions: HashSet::new(), event_records: HashMap::new(), + log_hydrations: HashMap::new(), + diff_pass: None, review_comment_drafts: HashMap::new(), pending_relaunch, external_imports: HashMap::new(), diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index 52d0f4fe0..cb038f8ac 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -867,22 +867,26 @@ impl AppState { let _ = reply.try_send(Ok(result)); return; } - let store = self.store.clone(); + let folds: Vec<_> = unloaded + .into_iter() + .map(|id| { + let fold = self.folded_log(&id, cx); + (id, fold) + }) + .collect(); let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { - let timelines = host_cx - .unblock(move || { - unloaded - .into_iter() - .map(|id| { - let events = store - .read_events(&id) - .map_err(|error| format!("could not read thread {id}: {error}"))?; - Ok((id, Timeline::fold_events(events))) - }) - .collect::, String>>() - }) - .await; + let mut timelines = Ok(HashMap::new()); + for (id, fold) in folds { + match fold.await { + Ok(timeline) => { + if let Ok(timelines) = &mut timelines { + timelines.insert(id, timeline); + } + } + Err(error) => timelines = Err(format!("could not read thread {id}: {error}")), + } + } let result = match timelines { Ok(timelines) => host_cx .enqueue_and_wait(move |state, _| { @@ -915,14 +919,10 @@ impl AppState { let _ = reply.try_send(result); return; } - let store = self.store.clone(); + let fold = self.folded_log(&thread_id, cx); let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { - let read_id = thread_id.clone(); - let timeline = host_cx - .unblock(move || store.read_events(&read_id).map(Timeline::fold_events)) - .await; - let result = match timeline { + let result = match fold.await { Ok(timeline) => host_cx .enqueue_and_wait(move |state, _| { let timeline = state.loaded_child_timeline(&thread_id).unwrap_or(&timeline); @@ -936,6 +936,35 @@ impl AppState { }); } + /// The pure fold of a session's whole log. A cached log already holds + /// every record accepted for it, appends still queued for the store + /// included; otherwise the store is read once everything queued before now + /// has committed. + pub(super) fn folded_log( + &mut self, + session_id: &str, + cx: &mut HostCx, + ) -> HostTask> { + if let Some(log) = self.event_records.get(session_id) { + let fold = log.fold().clone(); + return cx.spawn_background(async move { Ok(fold) }); + } + let barrier = self.store_write_barrier(cx); + let store = self.store.clone(); + let read_id = session_id.to_string(); + let host_cx = cx.clone(); + cx.spawn_background(async move { + match barrier.recv().await { + Ok(Ok(())) => host_cx + .unblock(move || store.read_events(&read_id).map(Timeline::fold_events)) + .await + .map_err(|error| error.to_string()), + Ok(Err(error)) => Err(error), + Err(_) => Err("the session store writer has stopped".to_string()), + } + }) + } + pub(super) fn loaded_child_timeline(&self, session_id: &str) -> Option<&Timeline> { self.resident(session_id).map(|child| &child.timeline) } @@ -1060,30 +1089,10 @@ impl AppState { // attention. let auto_archive = child.archive_on_complete && matches!(status, TurnStatus::Completed); let result_max_chars = child.result_max_chars; - // The cached log holds appends the store writer may not have committed - // yet; without one, the store is read only after a barrier. - let source = match self.event_records.get(&child_id) { - Some(log) => Ok(log.fold().clone()), - None => Err(self.store_write_barrier(cx)), - }; - let store = self.store.clone(); + let fold = self.folded_log(&child_id, cx); let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { - let read_id = child_id.clone(); - let timeline = match source { - Ok(timeline) => Ok(timeline), - Err(barrier) => match barrier.recv().await { - Ok(Ok(())) => { - host_cx - .unblock(move || store.read_events(&read_id).map(Timeline::fold_events)) - .await - } - Ok(Err(error)) => Err(std::io::Error::other(error)), - Err(_) => Err(std::io::Error::other( - "the session store writer has stopped", - )), - }, - }; + let timeline = fold.await; host_cx.enqueue(move |state, cx| { let timeline = match timeline { Ok(timeline) => timeline, diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index db16868b8..545ca112e 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -980,16 +980,16 @@ impl AppState { Ok(()) => { state.upsert_session_in_memory(fork.clone()); state.select_session(&fork.id, cx); - if let Some(snapshot) = - state.subscription_snapshot(&tcode_protocol::Subscription { + state.reply_to_subscription( + None, + tcode_protocol::Subscription { topic: Topic::SessionEvents { session_id: fork.id.clone(), }, after: None, - }) - { - cx.emit(HostEvent::Domain(snapshot)); - } + }, + cx, + ); } Err(error) => { state.report_error(RuntimeError::PersistSession { error }, cx); @@ -1540,63 +1540,51 @@ impl AppState { let Some(cwd) = intended.map(|session| session.meta.cwd.clone()) else { return; }; + if matches!(target, TimelineLoadTarget::Active { .. }) { + self.refresh_session_git_branch(session_id.clone(), cwd, cx); + } + let load = TimelineLoad { generation, target }; // A cached log is the whole conversation, including appends whose - // disk writes are still queued, so the timeline derives from it and - // never from the store, which lags the writes still queued. A session opened - // for a client loads its log here, on the mailbox, because the - // snapshot that answers the subscription needs it in the same turn; - // a background session parses off the mailbox and caches on completion. - if matches!(target, TimelineLoadTarget::Active { .. }) - && !self.event_records.contains_key(&session_id) - { - match SessionLog::load(&self.store, &session_id) { - Ok(log) => { - self.event_records.insert(session_id.clone(), log); - } - Err(error) => { - self.report_error( - RuntimeError::External(format!( - "could not load thread {session_id}: {error}" - )), - cx, - ); - return; - } + // writes are still queued, so the timeline derives from it and never + // from the store, which lags the writes still queued. + match self.event_records.get(&session_id) { + Some(log) => { + let cursor = log.end(); + let fold = log.fold().clone(); + self.fold_timeline(session_id, load, cursor, fold, cx); + } + None => { + self.hydrate_log(&session_id, Some(load), cx); } } - let cached = self - .event_records - .get(&session_id) - .map(|log| (log.records().len(), log.fold().clone())); - let store = self.store.clone(); + } + + /// Mark `fold`, the cached log's fold up to row `cursor`, idle as `load` + /// asks, then make it the session's unless a later load superseded `load` + /// or the session left the residency it was loaded for. + pub(super) fn fold_timeline( + &mut self, + session_id: String, + load: TimelineLoad, + cursor: u64, + mut fold: Timeline, + cx: &mut HostCx, + ) { + let mark_idle = load.mark_idle(); let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { - let read_id = session_id.clone(); - let (timeline, folded, loaded, git_branch) = { - let (mut timeline, folded, loaded) = match cached { - Some((folded, fold)) => (fold, folded, None), - None => match SessionLog::load(&store, &read_id) { - Ok(log) => (log.fold().clone(), log.records().len(), Some(log)), - Err(error) => { - log::error!("could not load thread {read_id}: {error}"); - return; - } - }, - }; - let (mark_idle, load_branch) = match target { - TimelineLoadTarget::Active { mark_idle } => (mark_idle, true), - TimelineLoadTarget::Background => (true, false), - }; - if mark_idle { - timeline.mark_idle(); - } - let git_branch = load_branch.then(|| read_git_branch(&cwd)); - (timeline, folded, loaded, git_branch) - }; + let timeline = host_cx + .unblock(move || { + if mark_idle { + fold.mark_idle(); + } + fold + }) + .await; host_cx.enqueue(move |state, cx| { - let generation_matches = - state.timeline_load_generations.get(&session_id).copied() == Some(generation); - let target_matches = match target { + let generation_matches = state.timeline_load_generations.get(&session_id).copied() + == Some(load.generation); + let target_matches = match load.target { TimelineLoadTarget::Active { .. } => { state.residents.live.contains_key(&session_id) } @@ -1607,26 +1595,16 @@ impl AppState { if !generation_matches || !target_matches { return; } - // An append during the parse already loaded and extended its - // own copy; that one carries the newer records. - if let Some(loaded) = loaded { - state - .event_records - .entry(session_id.clone()) - .or_insert(loaded); - } let mut timeline = timeline; - // Records appended while the fold ran continue the same log. + // A resident session's log stays cached; records it accepted + // after `cursor` continue it. if let Some(log) = state.event_records.get(&session_id) { - for record in log.records().iter().skip(folded) { + for record in log.records_from(cursor) { timeline.apply_at(record.ts, &record.event); } } if let Some(session) = state.resident_mut(&session_id) { session.timeline = timeline; - if let Some(git_branch) = git_branch { - session.git_branch = git_branch; - } } state.repair_orphaned_mirror_turn(&session_id, cx); }); diff --git a/crates/runtime/src/app/snapshots.rs b/crates/runtime/src/app/snapshots.rs index d38c3b940..485d12402 100644 --- a/crates/runtime/src/app/snapshots.rs +++ b/crates/runtime/src/app/snapshots.rs @@ -328,7 +328,8 @@ impl AppState { Topic::SessionPlan { session_id } => { ServerEvent::SessionPlanReplaced(self.session_plan_snapshot(session_id)?) } - Topic::SessionEvents { .. } => self.session_events_snapshot(subscription), + // Answered from the session's log by `reply_to_subscription`. + Topic::SessionEvents { .. } => return None, Topic::RuntimeEvents => return None, Topic::Preview { .. } => return None, // Retained latest-run status, so a client that subscribes after a diff --git a/crates/runtime/src/app/store_write.rs b/crates/runtime/src/app/store_write.rs index c7e3d0d4e..c55a07e5e 100644 --- a/crates/runtime/src/app/store_write.rs +++ b/crates/runtime/src/app/store_write.rs @@ -1,5 +1,5 @@ use super::*; -use tcode_services::store::Mutation; +use tcode_services::store::{DiffPass, Mutation, TurnIndex}; /// A drain of the queue is committed in transactions of at most this many /// writes and about this many bytes; streamed deltas then share a commit @@ -8,10 +8,19 @@ const MAX_BATCH_WRITES: usize = 256; const MAX_BATCH_BYTES: usize = 4 << 20; pub(super) enum StoreWrite { + /// One appended record, committed together with what it changes in the + /// session's other rows: the diffs of the snapshot it supersedes and the + /// turn index, or, when no fold tells, forgetting both. AppendEvent { id: String, ts: u64, event: Box, + joined: Joined, + }, + /// The turn index of a log the host just read and folded whole. + SetTurnIndex { + id: String, + index: TurnIndex, }, UpsertMeta { meta: Box, @@ -45,6 +54,19 @@ pub(super) enum StoreWrite { value: Option, }, ClearProfileSecrets(String), + /// [`SessionStore::drop_superseded_diffs`] for one thread, answered with + /// what it did and how long it held the writer. + DropSupersededDiffs { + id: String, + completion: smol::channel::Sender>, + }, + /// Answered with the row the session's next append takes, once every + /// write queued before it has committed: the rows before it are the log + /// as those writes left it. + SnapshotLog { + id: String, + end: smol::channel::Sender>, + }, /// Answered once every write queued before it has committed, or with the /// first store failure since the writer started: a write that failed is /// never certified by a later flush. @@ -79,11 +101,43 @@ impl StoreWrite { /// database change. fn mutations(&self) -> Option, String>> { Some(Ok(match self { - StoreWrite::AppendEvent { id, ts, event } => { - match Mutation::append_event(id, *ts, event) { + StoreWrite::AppendEvent { + id, + ts, + event, + joined, + } => { + let mut mutations = match Mutation::append_event(id, *ts, event) { Ok(mutation) => vec![mutation], Err(error) => return Some(Err(error.to_string())), + }; + match joined { + Joined::Folded { + superseded, + turn_index, + } => { + if let Some(superseded) = superseded { + mutations.push(Mutation::drop_turn_diffs( + id, + superseded.position, + &superseded.turn_id, + )); + } + if let Some(index) = turn_index { + mutations.push(Mutation::set_turn_index(id, index.clone())); + } + } + Joined::Unfolded => { + mutations.push(Mutation::forget_turn_index(id)); + if matches!(**event, AgentEvent::TurnChangesUpdated { .. }) { + mutations.push(Mutation::forget_diff_pass(id)); + } + } } + mutations + } + StoreWrite::SetTurnIndex { id, index } => { + vec![Mutation::set_turn_index(id, index.clone())] } StoreWrite::UpsertMeta { meta, .. } => vec![Mutation::upsert_meta((**meta).clone())], StoreWrite::UpsertProject(project) => vec![Mutation::upsert_project(project.clone())], @@ -110,7 +164,9 @@ impl StoreWrite { fn member(self) -> Member { let (failure, completion): (fn(String) -> RuntimeError, _) = match self { - StoreWrite::AppendEvent { .. } => (|error| RuntimeError::PersistEvent { error }, None), + StoreWrite::AppendEvent { .. } | StoreWrite::SetTurnIndex { .. } => { + (|error| RuntimeError::PersistEvent { error }, None) + } StoreWrite::UpsertMeta { initial: true, .. } => { (|error| RuntimeError::PersistSession { error }, None) } @@ -130,6 +186,9 @@ impl StoreWrite { |error| RuntimeError::PersistEvent { error }, Some(completion), ), + StoreWrite::DropSupersededDiffs { .. } | StoreWrite::SnapshotLog { .. } => { + (|error| RuntimeError::PersistEvent { error }, None) + } StoreWrite::SaveCommands { .. } | StoreWrite::InvalidateCommands(_) | StoreWrite::WriteTerminalUi(_) @@ -148,6 +207,17 @@ impl StoreWrite { /// Fail a write the writer never accepted: its waiter hears why, and /// anything else is reported like a failed write. pub(super) fn reject(self, reason: &str) -> Option { + match self { + StoreWrite::DropSupersededDiffs { completion, .. } => { + let _ = completion.try_send(Err(reason.to_owned())); + return None; + } + StoreWrite::SnapshotLog { end, .. } => { + let _ = end.try_send(Err(reason.to_owned())); + return None; + } + _ => {} + } let reports = !matches!(self, StoreWrite::Flush(_) | StoreWrite::Fork { .. }); let member = self.member(); if let Some(completion) = member.completion { @@ -322,6 +392,24 @@ impl StoreWriter { .clear_profile_secrets(&profile_id) .err() .map(settings_failure), + StoreWrite::DropSupersededDiffs { id, completion } => { + let started = Instant::now(); + let outcome = self.store.drop_superseded_diffs(&id); + let held = started.elapsed(); + let failed = outcome.is_err() && self.store.is_failed(); + let outcome = outcome.map_err(|error| error.to_string()); + let failure = failed.then(|| { + StoreWriteFailure::StoreFailed( + outcome.as_ref().err().cloned().unwrap_or_default(), + ) + }); + let _ = completion.try_send(outcome.map(|outcome| (outcome, held))); + failure + } + StoreWrite::SnapshotLog { id, end } => { + let _ = end.try_send(self.store.next_row(&id).map_err(|error| error.to_string())); + None + } StoreWrite::Flush(completion) => { let _ = completion.try_send(match &self.unresolved { None => Ok(()), @@ -330,6 +418,7 @@ impl StoreWriter { None } StoreWrite::AppendEvent { .. } + | StoreWrite::SetTurnIndex { .. } | StoreWrite::UpsertMeta { .. } | StoreWrite::UpsertProject(_) | StoreWrite::RemoveSessions { .. } diff --git a/crates/runtime/src/app/test_support.rs b/crates/runtime/src/app/test_support.rs index 5e3147068..4ccd6f2c6 100644 --- a/crates/runtime/src/app/test_support.rs +++ b/crates/runtime/src/app/test_support.rs @@ -154,6 +154,33 @@ impl TestAppContext { } } + /// Wait for background work to queue a message on the mailbox and run + /// that one message alone, keeping what it emitted for + /// [`TestAppContext::drain_outgoing`]. + pub(super) fn run_next(&mut self) { + let state = self + .state + .as_ref() + .and_then(Weak::upgrade) + .expect("test state must outlive its context"); + let deadline = Instant::now() + Duration::from_secs(5); + let message = loop { + if let Ok(message) = self.mailbox_rx.try_recv() { + break message; + } + assert!( + Instant::now() < deadline, + "nothing reached the mailbox within five seconds" + ); + std::thread::sleep(Duration::from_millis(1)); + }; + let mut host_cx = self.host_cx(); + message(&mut state.borrow_mut(), &mut host_cx); + while let Ok(line) = self.outgoing_rx.try_recv() { + self.outgoing.push(line); + } + } + /// Drain and decode every NDJSON line emitted by the host so tests assert /// on the same serialized traffic consumed by production clients. pub(super) fn drain_outgoing(&mut self) -> Vec { diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index f55910cee..18ff01f94 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -5185,6 +5185,7 @@ fn model_fallback_stops_active_session_when_abort_on_model_fallback_is_enabled() }); assert!(matches!(receiver.try_recv(), Ok(SessionCommand::Shutdown))); + cx.run_until_parked(); let outgoing = cx.drain_outgoing(); assert!(outgoing.iter().any(|message| matches!( message, @@ -7331,11 +7332,13 @@ fn orchestrate_dispatch_resolves_cwd_before_reply() { reply, cx, ); + // A reply resolved in the update would be in the channel now; the + // background resolution sends it later, from its own task. + assert!( + response.try_recv().is_err(), + "cwd resolution must not reply from the GPUI update" + ); }); - assert!( - response.try_recv().is_err(), - "cwd resolution must not reply from the GPUI update" - ); assert_eq!( recv_dispatch_reply(cx, &response).unwrap_err(), @@ -7550,6 +7553,14 @@ fn mux_clients_target_independent_drafts_and_receive_only_their_session_tail() { after: None, }) .unwrap(); + let window = smol::block_on(smol::future::race( + async { link.events().recv().await.unwrap() }, + async { + smol::Timer::after(Duration::from_secs(5)).await; + panic!("the subscription was not answered") + }, + )); + assert!(matches!(window.event, ServerEvent::SessionSnapshot { .. })); id }; let id_one = draft(&one, "one"); @@ -7758,14 +7769,18 @@ fn plan_and_usage_projections_survive_a_partial_history_window_and_emit_only_cha cx, ); } - let snapshot = state - .subscription_snapshot(&Subscription { + }); + cx.run_until(|state| state.event_records.contains_key(id)); + state.update(cx, |state, _| { + let snapshot = events_reply( + state, + &Subscription { topic: Topic::SessionEvents { session_id: id.into(), }, after: None, - }) - .unwrap(); + }, + ); let ServerEvent::SessionSnapshot { from, records, .. } = snapshot.event else { panic!("history snapshot") }; @@ -7849,14 +7864,14 @@ fn session_history_snapshot_pages_and_absolute_tail_cursors() { .collect(); state .event_records - .insert("large".into(), SessionLog::from_records(records.clone())); + .insert("large".into(), session_log(records.clone())); let subscription = tcode_protocol::Subscription { topic: Topic::SessionEvents { session_id: "large".into(), }, after: None, }; - let snapshot = state.subscription_snapshot(&subscription).unwrap(); + let snapshot = events_reply(state, &subscription); assert!( tcode_protocol::encode_line(&HostMessage::Event(snapshot.clone())) .unwrap() @@ -7883,7 +7898,9 @@ fn session_history_snapshot_pages_and_absolute_tail_cursors() { from, end, truncated, - } = state.session_history_page("large", before, 200).unwrap() + } = state.event_records["large"] + .history_page(before, 200) + .unwrap() else { panic!("page") }; @@ -7895,12 +7912,13 @@ fn session_history_snapshot_pages_and_absolute_tail_cursors() { } assert_eq!(loaded, records); for after in [0, 17, 1800, 1999, 2000] { - let snapshot = state - .subscription_snapshot(&tcode_protocol::Subscription { + let snapshot = events_reply( + state, + &tcode_protocol::Subscription { after: Some(after), ..subscription.clone() - }) - .unwrap(); + }, + ); let ServerEvent::SessionSnapshot { from, records: tail, @@ -7974,16 +7992,17 @@ fn history_snapshot_and_pages_start_at_turn_boundaries() { assert_eq!(turn_starts, [0, 303, 606, 909, 1212]); state .event_records - .insert("streamed".into(), SessionLog::from_records(records.clone())); + .insert("streamed".into(), session_log(records.clone())); - let snapshot = state - .subscription_snapshot(&tcode_protocol::Subscription { + let snapshot = events_reply( + state, + &tcode_protocol::Subscription { topic: Topic::SessionEvents { session_id: "streamed".into(), }, after: None, - }) - .unwrap(); + }, + ); let ServerEvent::SessionSnapshot { from, .. } = snapshot.event else { panic!("snapshot") }; @@ -7997,7 +8016,9 @@ fn history_snapshot_and_pages_start_at_turn_boundaries() { end, records: page, truncated, - } = state.session_history_page("streamed", from, 200).unwrap() + } = state.event_records["streamed"] + .history_page(from, 200) + .unwrap() else { panic!("page") }; @@ -8048,11 +8069,19 @@ fn persist_streamed_turns(store: &SessionStore, id: &str, turns: u64) -> Vec = events + .iter() + .enumerate() + .map(|(offset, event)| { + tcode_services::store::Mutation::append_event( + id, + turn * 1000 + offset as u64, + event, + ) + .unwrap() + }) + .collect(); + store.apply(&appends).unwrap(); } store.read_events(id).unwrap() } @@ -8063,67 +8092,50 @@ fn persist_streamed_turns(store: &SessionStore, id: &str, turns: u64) -> Vec 0 { - let QueryResponse::SessionHistoryPage { - records: page, - from, - end, - .. - } = state.session_history_page("paged", before, 200).unwrap() - else { - panic!("page") - }; - assert_eq!(end, before); - loaded.splice(0..0, page); - before = from; - } - assert_eq!( - format!("{:?}", Timeline::fold_events(loaded).entries), - format!("{:?}", Timeline::fold_events(records).entries), - "merged deltas fold as the log does" - ); - }); + let mut loaded = window.records; + let mut before = window.from; + let mut id = 2; + while before > 0 { + let QueryResponse::SessionHistoryPage { + records: page, + from, + end, + .. + } = query_reply( + &through_pipe(&state, cx, id, history_page("paged", before)), + id, + ) + else { + panic!("page") + }; + assert_eq!(end, before); + loaded.splice(0..0, page); + before = from; + id += 1; + } + assert_eq!( + Timeline::fold_events(loaded), + Timeline::fold_events(records), + "merged deltas fold as the log does" + ); assert_eq!( store.event_reads() - reads_before_open, 1, @@ -8131,24 +8143,25 @@ fn history_pages_of_an_opened_session_parse_the_log_once() { ); } -/// The cached log follows residency: it serves appends whose disk writes -/// are still queued, survives parking, and is dropped only after the session -/// leaves residency and the store writer has flushed those appends, so the -/// next cold open replays the whole conversation. +/// The log of an opened session is read once, extended by its appends, and +/// kept while the session is resident, parked included; once it leaves +/// residency it is dropped only after the store writer flushed its appends, +/// so the next read finds every one of them. #[test] fn session_log_follows_residency_and_flushes_before_release() { let cx = &mut TestAppContext::default(); let store = TestStore::new("history-log-residency"); - let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, store.root().clone(), None); - meta.id = "resident".into(); - store.upsert_meta(&meta).unwrap(); + open_stored_session(&store, "resident"); let persisted = persist_streamed_turns(&store, "resident", 2).len() as u64; let state = cx.new_entity(TestClientState::new((*store).clone())); let reads_before_open = store.event_reads(); let (commands, _actor) = smol::channel::unbounded(); + window_reply( + &through_pipe(&state, cx, 1, subscribe_events("resident", None)), + 1, + ); state.update(cx, |state, cx| { - state.select_session("resident", cx); state.record_event( "resident", &AgentEvent::ItemCompleted(ThreadItem { @@ -8161,44 +8174,39 @@ fn session_log_follows_residency_and_flushes_before_release() { }, }), cx, - ); - let snapshot = state - .subscription_snapshot(&tcode_protocol::Subscription { - topic: Topic::SessionEvents { - session_id: "resident".into(), - }, - after: Some(persisted), - }) - .unwrap(); - let ServerEvent::SessionSnapshot { - records, - total, - total_turns, - .. - } = snapshot.event - else { - panic!("snapshot") - }; - assert_eq!((total, total_turns), (persisted + 1, 3)); - assert!( - matches!(&records[..], [record] if matches!(&record.event, AgentEvent::ItemCompleted(item) if item.id == "user-late")) - ); - let QueryResponse::SessionHistoryPage { from, records, .. } = state - .session_history_page("resident", persisted + 1, 1) - .unwrap() - else { - panic!("page") - }; - assert_eq!((from, records.len()), (persisted, 1)); - assert_eq!( - state.store.event_reads() - reads_before_open, - 1, - "the appended record is served from the log, not a re-parse" - ); + ) + }); + let window = window_reply( + &through_pipe(&state, cx, 2, subscribe_events("resident", Some(persisted))), + 2, + ); + assert_eq!((window.total, window.total_turns), (persisted + 1, 3)); + assert!( + matches!(&window.records[..], [record] if matches!(&record.event, AgentEvent::ItemCompleted(item) if item.id == "user-late")) + ); + let page = tcode_protocol::ClientPayload::Query(tcode_protocol::Query::SessionHistoryPage { + session_id: "resident".into(), + before: persisted + 1, + limit: 1, + }); + let QueryResponse::SessionHistoryPage { from, records, .. } = + query_reply(&through_pipe(&state, cx, 3, page), 3) + else { + panic!("page") + }; + assert_eq!((from, records.len()), (persisted, 1)); + assert_eq!( + store.event_reads() - reads_before_open, + 1, + "the appended record is served from the log, not a re-read" + ); - // Parked with a live provider, the session stays resident and cached. - state.selected_session_mut().unwrap().runtime = Runtime::Live(commands); - state.park_active(cx); + // Parked with a live provider, the session stays resident and cached. + state.update(cx, |state, _| { + state.residents.live.get_mut("resident").unwrap().runtime = Runtime::Live(commands); + }); + through_pipe(&state, cx, 4, unsubscribe_events("resident")); + state.update(cx, |state, cx| { assert!(state.residents.parked.contains_key("resident")); assert!(state.event_records.contains_key("resident")); @@ -8210,13 +8218,10 @@ fn session_log_follows_residency_and_flushes_before_release() { ); }); cx.run_until(|state| !state.event_records.contains_key("resident")); - state.update(cx, |state, _| { - assert!(!state.event_records.contains_key("resident")); - assert_eq!( - state.store.read_events("resident").unwrap().len() as u64, - persisted + 1 - ); - }); + assert_eq!( + store.read_events("resident").unwrap().len() as u64, + persisted + 1 + ); assert_eq!(store.event_reads() - reads_before_open, 2); } @@ -8237,14 +8242,14 @@ fn history_byte_budget_preserves_contiguous_records_and_reports_shrinking() { .collect(); state .event_records - .insert("large".into(), SessionLog::from_records(records.clone())); + .insert("large".into(), session_log(records.clone())); let subscription = tcode_protocol::Subscription { topic: Topic::SessionEvents { session_id: "large".into(), }, after: None, }; - let mut snapshot = state.subscription_snapshot(&subscription).unwrap(); + let mut snapshot = events_reply(state, &subscription); snapshot.request_id = Some(u64::MAX); assert!( tcode_protocol::encode_line(&HostMessage::Event(snapshot.clone())) @@ -8263,7 +8268,9 @@ fn history_byte_budget_preserves_contiguous_records_and_reports_shrinking() { }; assert!(truncated); assert_eq!(tail, records[from as usize..]); - let response = state.session_history_page("large", 10, u32::MAX).unwrap(); + let response = state.event_records["large"] + .history_page(10, u32::MAX) + .unwrap(); let line = tcode_protocol::encode_line(&HostMessage::QueryResult { id: u64::MAX, result: Ok(response.clone()), @@ -8281,12 +8288,13 @@ fn history_byte_budget_preserves_contiguous_records_and_reports_shrinking() { }; assert!(truncated); assert_eq!(page, records[from as usize..]); - let snapshot = state - .subscription_snapshot(&tcode_protocol::Subscription { + let snapshot = events_reply( + state, + &tcode_protocol::Subscription { after: Some(0), ..subscription - }) - .unwrap(); + }, + ); let ServerEvent::SessionSnapshot { from, records: tail, @@ -8305,9 +8313,12 @@ fn history_byte_budget_preserves_contiguous_records_and_reports_shrinking() { }; state .event_records - .insert("large".into(), SessionLog::from_records(records)); + .insert("large".into(), session_log(records)); assert_eq!( - state.session_history_page("large", 10, 1).unwrap_err().code, + state.event_records["large"] + .history_page(10, 1) + .unwrap_err() + .code, "history_record_too_large" ); }); @@ -8689,14 +8700,15 @@ fn history_paging_bench() { let opened = started.elapsed(); let (snapshot_elapsed, pages) = state.update(cx, |state, _| { let started = Instant::now(); - let snapshot = state - .subscription_snapshot(&tcode_protocol::Subscription { + let snapshot = events_reply( + state, + &tcode_protocol::Subscription { topic: Topic::SessionEvents { session_id: "bench".into(), }, after: None, - }) - .unwrap(); + }, + ); let snapshot_elapsed = started.elapsed(); let ServerEvent::SessionSnapshot { from, total, .. } = snapshot.event else { panic!("snapshot") @@ -8706,13 +8718,10 @@ fn history_paging_bench() { let mut pages = Vec::new(); while before > 0 && pages.len() < 16 { let started = Instant::now(); - let QueryResponse::SessionHistoryPage { from, records, .. } = state - .session_history_page( - "bench", - before, - tcode_protocol::SESSION_HISTORY_RECORDS as u32, - ) - .unwrap() + let QueryResponse::SessionHistoryPage { from, records, .. } = + state.event_records["bench"] + .history_page(before, tcode_protocol::SESSION_HISTORY_RECORDS as u32) + .unwrap() else { panic!("page") }; @@ -8939,15 +8948,16 @@ fn history_sends_output_previews_and_reads_whole_outputs_on_request() { state.update(cx, |state, _| { state .event_records - .insert("outputs".into(), SessionLog::from_records(records.clone())); - let snapshot = state - .subscription_snapshot(&tcode_protocol::Subscription { + .insert("outputs".into(), session_log(records.clone())); + let snapshot = events_reply( + state, + &tcode_protocol::Subscription { topic: Topic::SessionEvents { session_id: "outputs".into(), }, after: None, - }) - .unwrap(); + }, + ); let ServerEvent::SessionSnapshot { records: sent, .. } = snapshot.event else { panic!("snapshot") }; @@ -8986,15 +8996,22 @@ fn history_sends_output_previews_and_reads_whole_outputs_on_request() { ]) ); assert_eq!( - state.item_output("outputs", "tool").unwrap(), + state.event_records["outputs"] + .item_output("outputs", "tool") + .unwrap(), QueryResponse::ItemOutput(tool_output.clone()) ); assert_eq!( - state.item_output("outputs", "command").unwrap(), + state.event_records["outputs"] + .item_output("outputs", "command") + .unwrap(), QueryResponse::ItemOutput(command_output.clone()) ); assert_eq!( - state.item_output("outputs", "missing").unwrap_err().code, + state.event_records["outputs"] + .item_output("outputs", "missing") + .unwrap_err() + .code, "unknown_item_output" ); }); @@ -9037,9 +9054,9 @@ fn superseded_turn_changes_cross_without_diffs() { state.update(cx, |state, _| { state .event_records - .insert("changes".into(), SessionLog::from_records(records.clone())); + .insert("changes".into(), session_log(records.clone())); let QueryResponse::SessionHistoryPage { records: sent, .. } = - state.session_history_page("changes", 4, 200).unwrap() + state.event_records["changes"].history_page(4, 200).unwrap() else { panic!("page") }; @@ -9048,11 +9065,163 @@ fn superseded_turn_changes_cross_without_diffs() { }; assert!(changes.iter().all(|change| change.diff.is_none())); assert_eq!(sent[3], records[3]); + assert_eq!(Timeline::fold_events(sent), Timeline::fold_events(records)); + }); +} + +/// A stored turn-changes snapshot: the whole diff of `turn` so far. +fn stored_snapshot(ts: u64, turn: &str, diff: &str) -> String { + format!( + "{{\"ts\":{ts},\"event\":{{\"type\":\"turn_changes_updated\",\"turn_id\":\"{turn}\",\ + \"changes\":[{{\"path\":\"f\",\"kind\":\"modify\",\"diff\":{}}}],\ + \"completeness\":\"exact\"}}}}\n", + serde_json::to_string(&Some(diff).filter(|diff| !diff.is_empty())).unwrap() + ) +} + +/// A snapshot appended to an open thread drops the diffs of the one it +/// supersedes in the same commit, at that one's stored row even where blank or +/// undecodable rows put rows and records out of step, and the log windows are +/// served from holds it the same way. A log with a row that does not decode keeps its stored +/// rows: its fold may not name the snapshots another build's would. +#[test] +fn an_appended_snapshot_drops_the_superseded_ones_diffs_in_the_same_commit() { + for (label, gap, rewritten) in [ + ("blank", "\n", true), + ("undecodable", "{not valid json}\n", false), + ] { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("append-supersedes"); + let seeded = format!( + "{{\"ts\":1,\"event\":{{\"type\":\"turn_started\",\"turn_id\":\"t\"}}}}\n\n{}{gap}", + stored_snapshot(2, "t", "-a\n+b\n") + ); + store + .apply(&[tcode_services::store::Mutation::replace_event_log( + "thread", + seeded.clone().into_bytes(), + )]) + .unwrap(); + open_stored_session(&store, "thread"); + let mut conversation = store.read_events("thread").unwrap(); + let state = cx.new_entity(TestClientState::new((*store).clone())); + window_reply( + &through_pipe(&state, cx, 1, subscribe_events("thread", None)), + 1, + ); + let snapshot = |diff: &str| AgentEvent::TurnChangesUpdated { + turn_id: "t".into(), + changes: vec![agent::FileChange { + path: "f".into(), + kind: agent::FileChangeKind::Modify, + diff: Some(diff.into()), + }], + completeness: agent::ChangeCompleteness::Exact, + }; + let window = state.update(cx, |state, cx| { + for (ts, diff) in [(3, "-a\n+c\n"), (4, "-a\n+d\n")] { + state.record_event_for_replica_test("thread", ts, &snapshot(diff), cx); + conversation.push(SessionEventRecord { + ts: Some(ts), + event: snapshot(diff), + elided: None, + }); + } + let ServerEvent::SessionSnapshot { records, .. } = events_reply( + state, + &tcode_protocol::Subscription { + topic: Topic::SessionEvents { + session_id: "thread".into(), + }, + after: None, + }, + ) + .event + else { + panic!("snapshot") + }; + records + }); + let diffs: Vec<_> = window + .iter() + .filter_map(|record| match &record.event { + AgentEvent::TurnChangesUpdated { changes, .. } => Some(changes[0].diff.is_some()), + _ => None, + }) + .collect(); + assert_eq!(diffs, [false, false, true], "{label}: the served log"); + assert_eq!( + Timeline::fold_stored(&window), + Timeline::fold_stored(&conversation), + "{label}" + ); + cx.run_until_parked(); + + let stored = String::from_utf8(store.read_event_log("thread").unwrap()).unwrap(); + let expected = if rewritten { + format!( + "{{\"ts\":1,\"event\":{{\"type\":\"turn_started\",\"turn_id\":\"t\"}}}}\n\n{}{gap}{}{}", + stored_snapshot(2, "t", ""), + stored_snapshot(3, "t", ""), + stored_snapshot(4, "t", "-a\n+d\n"), + ) + } else { + format!( + "{seeded}{}{}", + stored_snapshot(3, "t", "-a\n+c\n"), + stored_snapshot(4, "t", "-a\n+d\n"), + ) + }; + assert_eq!(stored, expected, "{label}: the stored rows"); assert_eq!( - format!("{:?}", Timeline::fold_events(sent).turns), - format!("{:?}", Timeline::fold_events(records).turns) + Timeline::fold_events(store.read_events("thread").unwrap()), + Timeline::fold_stored(&conversation), + "{label}" ); + } +} + +/// The startup pass drops the superseded diffs of every thread stored before +/// appends did, an open one included, through the store writer. +#[test] +fn the_startup_pass_drops_superseded_diffs_of_every_stored_thread() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("diff-pass"); + let log = format!( + "{{\"ts\":1,\"event\":{{\"type\":\"turn_started\",\"turn_id\":\"t\"}}}}\n{}{}", + stored_snapshot(2, "t", "-a\n+b\n"), + stored_snapshot(3, "t", "-a\n+c\n"), + ); + let passed = format!( + "{{\"ts\":1,\"event\":{{\"type\":\"turn_started\",\"turn_id\":\"t\"}}}}\n{}{}", + stored_snapshot(2, "t", ""), + stored_snapshot(3, "t", "-a\n+c\n"), + ); + for id in ["cold", "open"] { + let mut meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); + meta.id = id.into(); + store + .apply(&[ + tcode_services::store::Mutation::upsert_meta(meta), + tcode_services::store::Mutation::replace_event_log(id, log.clone().into_bytes()), + ]) + .unwrap(); + } + let state = cx.new_entity(TestClientState::new((*store).clone())); + state.update(cx, |state, cx| { + state.select_session("open", cx); + state.start_diff_pass(cx); + }); + cx.run_until(|state| { + state.diff_pass.is_none() && state.store.threads_without_diff_pass().unwrap().is_empty() }); + for id in ["cold", "open"] { + assert_eq!( + String::from_utf8(store.read_event_log(id).unwrap()).unwrap(), + passed, + "{id}" + ); + } } /// A thread opens with about half a megabyte of history however long its @@ -9074,15 +9243,16 @@ fn history_windows_are_byte_budgeted() { state.update(cx, |state, _| { state .event_records - .insert("wide".into(), SessionLog::from_records(records.clone())); - let mut snapshot = state - .subscription_snapshot(&tcode_protocol::Subscription { + .insert("wide".into(), session_log(records.clone())); + let mut snapshot = events_reply( + state, + &tcode_protocol::Subscription { topic: Topic::SessionEvents { session_id: "wide".into(), }, after: None, - }) - .unwrap(); + }, + ); snapshot.request_id = Some(u64::MAX); let line = tcode_protocol::encode_line(&HostMessage::Event(snapshot.clone())).unwrap(); assert!(line.len() <= tcode_protocol::SESSION_WINDOW_BYTES); @@ -9105,14 +9275,12 @@ fn history_windows_are_byte_budgeted() { huge[199].event = AgentEvent::Warning { message: "w".repeat(2 * tcode_protocol::SESSION_WINDOW_BYTES), }; - state - .event_records - .insert("wide".into(), SessionLog::from_records(huge)); + state.event_records.insert("wide".into(), session_log(huge)); let QueryResponse::SessionHistoryPage { from, records: sent, .. - } = state.session_history_page("wide", 200, 200).unwrap() + } = state.event_records["wide"].history_page(200, 200).unwrap() else { panic!("page") }; @@ -9302,3 +9470,878 @@ fn store_writer_survives_sigkill() { ); assert!(checkpointed_runs > 0, "no run crossed an auto-checkpoint"); } + +/// A log as a read of densely numbered rows would load it. +fn session_log(records: impl IntoIterator) -> SessionLog { + let records: Vec<_> = records.into_iter().collect(); + let rows = (0..records.len() as u64).collect(); + SessionLog::new(tcode_services::store::EventLog { + next_row: records.len() as u64, + records, + rows, + undecodable: 0, + }) +} + +/// Threads of [`diff_pass_survives_sigkill`]'s fixture, each a run of turns +/// whose every turn-changes snapshot carries the turn's whole, growing diff. +const CRASH_PASS_THREADS: usize = 6; + +/// The process [`diff_pass_survives_sigkill`] kills: a host started with the +/// startup diff pass over the fixture in [`CRASH_CHILD_DIR`]. +#[test] +#[ignore = "the child process of diff_pass_survives_sigkill, which runs it"] +fn diff_pass_crash_child() { + let root = PathBuf::from(std::env::var_os(CRASH_CHILD_DIR).unwrap()); + let _host = crate::pipe::spawn_host( + SessionStore::open_at(root).unwrap(), + crate::pipe::HostServices { + drop_superseded_diffs: true, + ..Default::default() + }, + ) + .unwrap(); + println!("{CRASH_ACK}started"); + loop { + std::thread::park(); + } +} + +/// Kill a host while its startup pass drops superseded diffs, over and over: +/// every thread is afterwards either exactly as it was and still to be passed, +/// or exactly as a completed pass leaves it and marked passed, never a mix, +/// and the files pass sqlite3's integrity check. `TCODE_CRASH_RUNS` sets the +/// number of kills. +#[test] +#[ignore = "spawns and SIGKILLs child processes and needs the sqlite3 CLI; run deliberately"] +fn diff_pass_survives_sigkill() { + let runs: usize = std::env::var("TCODE_CRASH_RUNS") + .ok() + .and_then(|runs| runs.parse().ok()) + .unwrap_or(30); + let scratch = std::env::temp_dir().join(format!("tcode-pass-crash-{}", uuid::Uuid::new_v4())); + let template = scratch.join("template"); + let ids: Vec = (0..CRASH_PASS_THREADS) + .map(|thread| format!("thread-{thread}")) + .collect(); + { + let store = SessionStore::open_at(template.clone()).unwrap(); + for id in &ids { + let mut log = String::new(); + for turn in 0..10 { + log.push_str(&format!( + "{{\"ts\":{turn},\"event\":{{\"type\":\"turn_started\",\"turn_id\":\"{turn}\"}}}}\n" + )); + let mut diff = String::new(); + for edit in 0..40 { + diff.push_str(&format!("+line {edit} {}\n", "x".repeat(1_000))); + log.push_str(&stored_snapshot(edit, &turn.to_string(), &diff)); + } + } + let mut meta = SessionMeta::new(ProviderKind::Codex, template.clone(), None); + meta.id = id.clone(); + store + .apply(&[ + tcode_services::store::Mutation::upsert_meta(meta), + tcode_services::store::Mutation::replace_event_log(id, log.into_bytes()), + ]) + .unwrap(); + } + store.close().unwrap(); + } + let copy = |to: &Path| { + std::fs::create_dir_all(to).unwrap(); + std::fs::copy(template.join("tcode.db"), to.join("tcode.db")).unwrap(); + }; + let rows_of = |store: &SessionStore, id: &str| store.read_event_log(id).unwrap(); + let passed_dir = scratch.join("passed"); + copy(&passed_dir); + let (before, after, pass_time) = { + let store = SessionStore::open_at(passed_dir.clone()).unwrap(); + let before: Vec<_> = ids.iter().map(|id| rows_of(&store, id)).collect(); + let started = Instant::now(); + for id in &ids { + assert!(matches!( + store.drop_superseded_diffs(id).unwrap(), + tcode_services::store::DiffPass::Dropped { .. } + )); + } + let pass_time = started.elapsed(); + let after: Vec<_> = ids.iter().map(|id| rows_of(&store, id)).collect(); + store.close().unwrap(); + (before, after, pass_time) + }; + println!( + "fixture: {} bytes in {CRASH_PASS_THREADS} threads, passed in {pass_time:.1?} in process", + before.iter().map(Vec::len).sum::() + ); + let mut seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + | 1; + let mut random = move |range: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % range + }; + let (mut mixed_runs, mut partial_runs) = (0, 0); + for run in 0..runs { + let root = scratch.join(format!("run-{run}")); + copy(&root); + let mut child = tcode_services::process::command(std::env::current_exe().unwrap()) + .args([ + "--exact", + "app::tests::diff_pass_crash_child", + "--ignored", + "--nocapture", + ]) + .env(CRASH_CHILD_DIR, &root) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); + loop { + let mut line = String::new(); + assert_ne!( + std::io::BufRead::read_line(&mut stdout, &mut line).unwrap(), + 0, + "run {run}: the child exited before starting" + ); + if line.contains(CRASH_ACK) { + break; + } + } + let delay = random(pass_time.as_millis() as u64 * 3 / 2 + 1); + std::thread::sleep(Duration::from_millis(delay)); + child.kill().unwrap(); + child.wait().unwrap(); + + let preserved = root.join("preserved"); + std::fs::create_dir_all(&preserved).unwrap(); + for name in ["tcode.db", "tcode.db-wal"] { + if root.join(name).exists() { + std::fs::copy(root.join(name), preserved.join(name)).unwrap(); + } + } + let store = SessionStore::open_at(root.clone()).unwrap(); + let unpassed = store.threads_without_diff_pass().unwrap(); + let mut done = 0; + for (index, id) in ids.iter().enumerate() { + let rows = rows_of(&store, id); + if unpassed.contains(id) { + assert!( + rows == before[index], + "run {run}: {id} changed but is unpassed" + ); + } else { + assert!( + rows == after[index], + "run {run}: {id} is passed but not as a pass leaves it" + ); + done += 1; + } + } + if done > 0 && done < ids.len() { + partial_runs += 1; + } + store.close().unwrap(); + drop(store); + let check = tcode_services::process::command("sqlite3") + .arg(preserved.join("tcode.db")) + .arg("PRAGMA integrity_check;") + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&check.stdout).trim(), + "ok", + "run {run}: sqlite3 integrity_check: {}", + String::from_utf8_lossy(&check.stderr) + ); + mixed_runs += usize::from(done > 0); + println!( + "run {run}: killed after {delay} ms, {done} of {CRASH_PASS_THREADS} threads passed" + ); + std::fs::remove_dir_all(&root).unwrap(); + } + std::fs::remove_dir_all(&scratch).unwrap(); + println!("{runs} kills: {mixed_runs} after some thread passed, {partial_runs} mid-pass"); + assert!(partial_runs > 0, "no kill landed mid-pass"); +} + +/// The reply a subscription to a session whose log is resident gets. +fn events_reply(state: &AppState, subscription: &Subscription) -> EventEnvelope { + let Topic::SessionEvents { session_id } = &subscription.topic else { + panic!("a subscription to a session's events") + }; + EventEnvelope { + request_id: None, + topic: subscription.topic.clone(), + event: state.event_records[session_id].events_window(subscription), + } +} + +/// Hand `payload` to the host as client message `id`, without running +/// anything it queues. +fn send_to_host( + state: &TestEntity, + cx: &mut TestAppContext, + id: u64, + payload: tcode_protocol::ClientPayload, +) { + state.deliver(cx, id, payload); +} + +/// Run the host until it answered client message `id` and parked, and +/// return every message it serialized since the last drain. +fn answered(cx: &mut TestAppContext, id: u64) -> Vec { + let deadline = Instant::now() + Duration::from_secs(5); + let mut messages = Vec::new(); + loop { + cx.run_until_parked(); + messages.extend(cx.drain_outgoing()); + if messages.iter().any(|message| { + matches!(message, HostMessage::Ack { id: reply, .. } | HostMessage::QueryResult { id: reply, .. } if *reply == id) + }) { + return messages; + } + assert!( + Instant::now() < deadline, + "the host did not answer message {id} within five seconds" + ); + } +} + +/// Send `payload` through the host pipe as client message `id`, and return +/// every message the host serialized since the last drain, once the host +/// answered it and parked. +fn through_pipe( + state: &TestEntity, + cx: &mut TestAppContext, + id: u64, + payload: tcode_protocol::ClientPayload, +) -> Vec { + send_to_host(state, cx, id, payload); + answered(cx, id) +} + +fn subscribe_events(session_id: &str, after: Option) -> tcode_protocol::ClientPayload { + tcode_protocol::ClientPayload::Subscribe(Subscription { + topic: Topic::SessionEvents { + session_id: session_id.into(), + }, + after, + }) +} + +fn unsubscribe_events(session_id: &str) -> tcode_protocol::ClientPayload { + tcode_protocol::ClientPayload::Unsubscribe(Subscription { + topic: Topic::SessionEvents { + session_id: session_id.into(), + }, + after: None, + }) +} + +fn history_page(session_id: &str, before: u64) -> tcode_protocol::ClientPayload { + tcode_protocol::ClientPayload::Query(tcode_protocol::Query::SessionHistoryPage { + session_id: session_id.into(), + before, + limit: 200, + }) +} + +/// A session window as the host sent it. +#[derive(Debug, Clone, PartialEq)] +struct Window { + from: u64, + end: u64, + records: Vec, + total: u64, + total_turns: u64, +} + +fn window_of(event: &ServerEvent) -> Option { + match event { + ServerEvent::SessionSnapshot { + from, + end, + records, + total, + total_turns, + .. + } => Some(Window { + from: *from, + end: *end, + records: records.clone(), + total: *total, + total_turns: *total_turns, + }), + _ => None, + } +} + +/// The window answering subscription request `id`. +fn window_reply(messages: &[HostMessage], id: u64) -> Window { + messages + .iter() + .find_map(|message| match message { + HostMessage::Event(EventEnvelope { + request_id: Some(request), + event, + .. + }) if *request == id => window_of(event), + _ => None, + }) + .expect("a window answers the subscription") +} + +fn query_reply(messages: &[HostMessage], id: u64) -> QueryResponse { + messages + .iter() + .find_map(|message| match message { + HostMessage::QueryResult { id: reply, result } if *reply == id => { + Some(result.clone().expect("query succeeds")) + } + _ => None, + }) + .expect("the query is answered") +} + +/// The live records among `messages`, in order. +fn live_records(messages: &[HostMessage]) -> Vec { + messages + .iter() + .filter_map(|message| match message { + HostMessage::Event(EventEnvelope { + request_id: None, + event: ServerEvent::SessionEvent(record), + .. + }) => Some(record.clone()), + _ => None, + }) + .collect() +} + +fn open_stored_session(store: &SessionStore, id: &str) -> SessionMeta { + let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, store.root().clone(), None); + meta.id = id.into(); + store.upsert_meta(&meta).unwrap(); + meta +} + +/// The index of the first message matching `matches`. +fn first_at(messages: &[HostMessage], matches: impl Fn(&HostMessage) -> bool) -> usize { + messages + .iter() + .position(matches) + .expect("the message was sent") +} + +fn is_live_record(message: &HostMessage) -> bool { + matches!( + message, + HostMessage::Event(EventEnvelope { + request_id: None, + event: ServerEvent::SessionEvent(_), + .. + }) + ) +} + +/// Subscriptions made while a cold log is read share that one read. Each +/// gets a window ending where the read log ends, and a record accepted +/// meanwhile follows every window, once. +#[test] +fn subscriptions_made_while_a_cold_log_is_read_share_it_and_get_each_record_once() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("cold-subscriptions"); + open_stored_session(&store, "cold"); + let persisted = persist_streamed_turns(&store, "cold", 2).len() as u64; + let state = cx.new_entity(TestClientState::new((*store).clone())); + let reads_before_open = store.event_reads(); + + send_to_host(&state, cx, 1, subscribe_events("cold", None)); + send_to_host(&state, cx, 2, subscribe_events("cold", None)); + let live = AgentEvent::Warning { + message: "first live".into(), + }; + state.update(cx, |state, cx| { + assert!(state.log_hydrations.contains_key("cold")); + state.on_event("cold", live.clone(), cx); + }); + let messages = answered(cx, 2); + assert!( + messages + .iter() + .any(|message| matches!(message, HostMessage::Ack { id: 1, .. })) + ); + + let live_at = first_at(&messages, is_live_record); + for id in [1, 2] { + let window = window_reply(&messages, id); + assert_eq!((window.end, window.total), (persisted, persisted)); + let window_at = first_at( + &messages, + |message| matches!(message, HostMessage::Event(EventEnvelope { request_id: Some(request), .. }) if *request == id), + ); + assert!(window_at < live_at, "window {id} comes before the record"); + } + assert_eq!( + live_records(&messages) + .into_iter() + .map(|record| record.event) + .collect::>(), + [live] + ); + assert_eq!( + store.event_reads() - reads_before_open, + 1, + "both subscriptions were answered from one read" + ); +} + +/// The first event of a resident thread whose log is not loaded starts the +/// read of that log, which therefore does not contain it: a subscription +/// made before the read completes gets a window without it, and the event +/// follows once, at the end of the window. +#[test] +fn the_event_that_starts_a_read_follows_the_window_once() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("first-event-read"); + let state = cx.new_entity(TestClientState::new((*store).clone())); + let first = AgentEvent::Warning { + message: "first".into(), + }; + let id = state.update(cx, |state, cx| { + state.start_draft("project".into(), store.root().clone(), cx); + let id = state.active_session_id().unwrap().to_string(); + state.record_event(&id, &first, cx); + assert!(state.log_hydrations.contains_key(&id)); + id + }); + send_to_host(&state, cx, 1, subscribe_events(&id, None)); + let messages = answered(cx, 1); + + let window = window_reply(&messages, 1); + assert_eq!((window.end, window.records.len()), (0, 0)); + assert_eq!( + live_records(&messages) + .into_iter() + .map(|record| record.event) + .collect::>(), + [first] + ); + assert!( + first_at(&messages, |message| matches!( + message, + HostMessage::Event(EventEnvelope { + request_id: Some(1), + .. + }) + )) < first_at(&messages, is_live_record) + ); +} + +/// A subscriber that leaves a cold thread before its log is read gets no +/// window for it, and a log read for a session that is no longer resident is +/// not kept. +#[test] +fn a_cold_thread_left_before_its_log_is_read_sends_no_window_and_keeps_no_log() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("cold-left"); + open_stored_session(&store, "left"); + persist_streamed_turns(&store, "left", 2); + let state = cx.new_entity(TestClientState::new((*store).clone())); + + send_to_host(&state, cx, 1, subscribe_events("left", None)); + send_to_host(&state, cx, 2, unsubscribe_events("left")); + state.read(|state| assert!(state.log_hydrations.contains_key("left"))); + let messages = answered(cx, 1); + + assert!( + messages + .iter() + .any(|message| matches!(message, HostMessage::Ack { id: 2, .. })) + ); + assert!( + !messages.iter().any(|message| matches!( + message, + HostMessage::Event(EventEnvelope { + topic: Topic::SessionEvents { .. }, + .. + }) + )), + "nothing of the thread reaches the client that left it" + ); + state.read(|state| { + assert!(state.resident("left").is_none()); + assert!(state.log_hydrations.is_empty()); + assert!(state.event_records.is_empty()); + }); +} + +/// History pages and whole outputs of a thread no client holds open are +/// answered from its whole log, read off the mailbox and not kept. +#[test] +fn pages_and_outputs_of_a_thread_nobody_opened_are_read_and_not_kept() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("cold-queries"); + open_stored_session(&store, "cold"); + let mut records = persist_streamed_turns(&store, "cold", 2); + let output = "o".repeat(2 * tcode_protocol::OUTPUT_PREVIEW_BYTES); + let tool = tool_call("tool", output.clone()); + store.append_event("cold", 5000, &tool.event).unwrap(); + records.push(tool); + let total = records.len() as u64; + let state = cx.new_entity(TestClientState::new((*store).clone())); + + let QueryResponse::SessionHistoryPage { + end, records: page, .. + } = query_reply(&through_pipe(&state, cx, 1, history_page("cold", total)), 1) + else { + panic!("page") + }; + assert_eq!(end, total); + assert!( + matches!(&page.last().unwrap().event, AgentEvent::ItemCompleted(item) if item.id == "tool") + ); + let read_output = tcode_protocol::ClientPayload::Query(tcode_protocol::Query::ReadItemOutput { + session_id: "cold".into(), + item_id: "tool".into(), + }); + assert_eq!( + query_reply(&through_pipe(&state, cx, 2, read_output), 2), + QueryResponse::ItemOutput(output) + ); + state.read(|state| { + assert!(state.resident("cold").is_none()); + assert!(state.log_hydrations.is_empty()); + assert!(state.event_records.is_empty()); + }); +} + +/// A child whose turn completes while its log is still being read reports +/// that turn: its status and result, and the callback to its parent, see the +/// completion and the final message accepted just before it. +#[test] +fn a_child_completing_while_its_log_is_read_reports_that_turn() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("child-completing-while-read"); + let mut child = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); + child.id = "child".into(); + child.parent_session_id = Some("parent".into()); + child.archive_on_complete = false; + store.upsert_meta(&child).unwrap(); + persist_streamed_turns(&store, "child", 1); + let state = cx.new_entity(TestClientState::new((*store).clone())); + let (parent_commands, parent_receiver) = smol::channel::unbounded(); + + state.update(cx, |state, cx| { + let mut parent = live_session(ProviderKind::Codex, parent_commands); + parent.meta.id = "parent".into(); + parent.turn_in_flight = true; + state + .residents + .parked + .insert(parent.meta.id.clone(), parent); + state.load_background_session(child, cx); + for event in [ + AgentEvent::TurnStarted { + turn_id: "turn-new".into(), + }, + persisted_assistant_event("the new answer"), + AgentEvent::TurnCompleted { + turn_id: "turn-new".into(), + status: TurnStatus::Completed, + usage: None, + }, + ] { + state.on_event("child", event, cx); + } + assert!(state.log_hydrations.contains_key("child")); + + let (reply, response) = smol::channel::bounded(1); + state.handle_orchestrate_op( + orchestrate_mcp::OrchestrateOp::Status { + parent_id: "parent".into(), + thread_id: Some("child".into()), + }, + reply, + cx, + ); + let status = response.try_recv().unwrap().unwrap(); + assert_eq!(status[0]["state"], "completed"); + assert_eq!(status[0]["last_output_tail"], "the new answer"); + let (reply, response) = smol::channel::bounded(1); + state.handle_orchestrate_op( + orchestrate_mcp::OrchestrateOp::Result { + parent_id: "parent".into(), + thread_id: "child".into(), + }, + reply, + cx, + ); + let result = response.try_recv().unwrap().unwrap(); + assert_eq!(result["state"], "completed"); + assert_eq!(result["final_message"], "the new answer"); + }); + + cx.run_until(|state| state.callback_last_turn.contains_key("child")); + let callback = std::iter::from_fn(|| parent_receiver.try_recv().ok()) + .find_map(|command| match command { + SessionCommand::Steer { text, .. } => Some(text), + _ => None, + }) + .expect("the parent is called back"); + assert!(callback.starts_with("[orchestrate] thread child")); + assert!(callback.ends_with("\nthe new answer")); +} + +/// Deliver `event` as the session's provider would, as soon as a read of the +/// session's log is in flight. Queued on the mailbox before that read began, +/// it runs before the read's completion, which is queued only after. +fn record_while_hydrating( + state: &mut AppState, + cx: &mut HostCx, + session_id: &'static str, + event: AgentEvent, + recorded: smol::channel::Sender<()>, +) { + assert!(!state.event_records.contains_key(session_id)); + if state.log_hydrations.contains_key(session_id) { + state.on_event(session_id, event, cx); + let _ = recorded.try_send(()); + } else { + cx.enqueue(move |state, cx| record_while_hydrating(state, cx, session_id, event, recorded)); + } +} + +/// Two clients of a real host, through the multiplexer, open a cold thread +/// whose first live event arrives while its log is read: each gets its own +/// window, routed to it although it comes long after the request, then the +/// event; every record reaches each client once, and the log is read once. +#[test] +fn clients_opening_a_cold_thread_through_the_mux_get_each_record_once() { + use crate::pipe::{HostServices, spawn_host}; + use tcode_client::HostLink; + + let root = TestStore::new("cold-open-mux"); + open_stored_session(&root, "cold"); + let persisted = persist_streamed_turns(&root, "cold", 3).len() as u64; + let reads_before_open = root.event_reads(); + let host = spawn_host((*root).clone(), HostServices::default()).unwrap(); + let mux = tcode_traverse::HostMux::new(host.to_host.clone(), host.from_host.clone()); + let clients: Vec = (0..2) + .map(|_| { + let connection = mux.attach(); + let link = HostLink::new(connection.to_host, connection.from_host); + smol::spawn({ + let link = link.clone(); + async move { link.pump().await } + }) + .detach(); + link + }) + .collect(); + + // Hold the host until the event is queued ahead of anything the + // subscriptions start. + let (entered, host_held) = smol::channel::bounded(1); + let (release, released) = smol::channel::bounded::<()>(1); + let (recorded, event_recorded) = smol::channel::bounded(1); + let holder = host.clone(); + let held = smol::spawn(async move { + holder + .update_state_for_test(move |_, cx| { + entered.try_send(()).unwrap(); + released.recv_blocking().unwrap(); + let event = AgentEvent::Warning { + message: "first live".into(), + }; + cx.enqueue(move |state, cx| { + record_while_hydrating(state, cx, "cold", event, recorded) + }); + }) + .await + }); + host_held.recv_blocking().unwrap(); + for link in &clients { + link.subscribe(Subscription { + topic: Topic::SessionEvents { + session_id: "cold".into(), + }, + after: None, + }) + .unwrap(); + } + release.try_send(()).unwrap(); + smol::block_on(held).unwrap(); + + fn within_five_seconds(future: impl std::future::Future) -> T { + smol::block_on(smol::future::race(future, async { + smol::Timer::after(Duration::from_secs(5)).await; + panic!("the host stopped sending the thread") + })) + } + within_five_seconds(event_recorded.recv()).unwrap(); + let next_event = |link: &HostLink| within_five_seconds(link.events().recv()).unwrap(); + for link in &clients { + let mut window = None; + let mut end = 0; + let mut seen = 0; + while end < persisted + 1 { + let event = next_event(link).event; + if let Some(received) = window_of(&event) { + assert!(window.is_none(), "one window per subscription"); + end = received.end; + seen += received + .records + .iter() + .filter(|record| matches!(record.event, AgentEvent::Warning { .. })) + .count(); + window = Some(received); + continue; + } + let ServerEvent::SessionEvent(record) = event else { + panic!("unexpected {event:?}") + }; + // The mux routes a topic to a connection once it forwards the + // subscription, so a record can precede the window of a + // subscription the host answers later; clients drop it. + if window.is_none() { + continue; + } + end += 1; + seen += usize::from(matches!(record.event, AgentEvent::Warning { .. })); + } + assert_eq!(end, persisted + 1); + assert_eq!(seen, 1, "the live record reaches the client once"); + } + assert_eq!( + root.event_reads() - reads_before_open, + 1, + "every subscriber was served by one read" + ); + clients[0].shutdown_blocking().unwrap(); + host.to_host.close(); + host.stopped.recv_blocking().unwrap(); +} + +/// A long thread with a turn index opens with a baseline window cut from its +/// last rows, sent while the whole log is still being read, and equal to the +/// window the whole log gives: the index the first open built, kept current +/// by the appends of a resident log, a rewind included, serves it. An append +/// to a log nobody holds forgets the index, and the next open waits for the +/// whole log, with the same result. +#[test] +fn a_long_thread_opens_from_its_tail_with_the_whole_logs_window() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("tail-window"); + open_stored_session(&store, "long"); + persist_streamed_turns(&store, "long", 3); + let state = cx.new_entity(TestClientState::new((*store).clone())); + let mut id = 0; + // Open the thread cold, returning its window, whether it went out while + // the whole log was still being read, and the window the whole log gives. + let mut open = |state: &TestEntity, cx: &mut TestAppContext| { + id += 1; + send_to_host(state, cx, id, subscribe_events("long", None)); + let (window, early) = loop { + cx.run_next(); + let messages = cx.drain_outgoing(); + if let Some(window) = messages.iter().find_map(|message| match message { + HostMessage::Event(EventEnvelope { + request_id: Some(request), + event, + .. + }) if *request == id => window_of(event), + _ => None, + }) { + break ( + window, + state.read(|state| state.log_hydrations.contains_key("long")), + ); + } + }; + cx.run_until(|state| state.event_records.contains_key("long")); + let whole = SessionLog::new(store.read_log("long").unwrap()).events_window(&Subscription { + topic: Topic::SessionEvents { + session_id: "long".into(), + }, + after: None, + }); + (window, early, window_of(&whole).unwrap()) + }; + let release = |state: &TestEntity, cx: &mut TestAppContext, id: u64| { + through_pipe(state, cx, id, unsubscribe_events("long")); + state.update(cx, |state, cx| state.drop_background("long", cx)); + cx.run_until(|state| !state.event_records.contains_key("long")); + }; + + let (window, early, whole) = open(&state, cx); + assert!( + !early, + "without an index the first open reads the whole log" + ); + assert_eq!(window, whole); + release(&state, cx, 100); + + let (window, early, whole) = open(&state, cx); + assert!(early, "the index the first open built serves the tail"); + assert_eq!(window, whole); + assert!(window.from > 0 && window.total_turns == 3); + let turn = |index: usize| { + vec![ + AgentEvent::TurnStarted { + turn_id: format!("late-{index}"), + }, + AgentEvent::TurnCheckpoint { + turn_id: format!("late-{index}"), + checkpoint_id: format!("checkpoint-{index}"), + }, + persisted_assistant_event(&format!("late answer {index}")), + AgentEvent::TurnCompleted { + turn_id: format!("late-{index}"), + status: TurnStatus::Completed, + usage: None, + }, + ] + }; + state.update(cx, |state, cx| { + // The rewind drops the last late turn and is the last change to + // the turns. + for event in + turn(0) + .into_iter() + .chain(turn(1)) + .chain(turn(2)) + .chain([AgentEvent::RewindCompleted { + checkpoint_id: "checkpoint-2".into(), + mode: agent::RewindMode::Conversation, + prefill: None, + }]) + { + state.record_event("long", &event, cx); + } + }); + release(&state, cx, 101); + + let (window, early, whole) = open(&state, cx); + assert!(early, "appends to the resident log kept the index current"); + assert_eq!(window, whole); + assert_eq!( + window.total_turns, 5, + "three, and three late ones less the rewound one" + ); + release(&state, cx, 102); + + state.update(cx, |state, cx| { + state.record_event("long", &persisted_assistant_event("nobody held it"), cx) + }); + cx.run_until_parked(); + let (window, early, whole) = open(&state, cx); + assert!(!early, "an append nobody folded forgot the index"); + assert_eq!(window, whole); +} diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 32bb3a6b5..7597fc46c 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -12,7 +12,7 @@ use tcode_protocol::{EventEnvelope, ServerEvent, Subscription, Topic}; use tcode_services::store::SessionStore; use crate::app::{AppState, DomainDiff}; -use crate::host::{HostCx, HostEvent, HostFn}; +use crate::host::{HostCx, HostFn}; /// Optional process-local services attached before the host starts accepting /// client traffic. @@ -22,6 +22,9 @@ pub struct HostServices { pub background_startup_probes: bool, /// Generate AI-authored titles for new threads and explicit regeneration. pub ai_title_generation: bool, + /// Drop the diffs of superseded turn-changes snapshots from logs stored + /// before appends dropped them, one thread at a time in the background. + pub drop_superseded_diffs: bool, /// URL/tokens and the broker receiver stay host-side. Requests reach /// subscribed WebViews through the preview reverse-RPC topic. pub preview: Option, @@ -148,6 +151,9 @@ pub fn spawn_host(store: SessionStore, mut services: HostServices) -> std::io::R state.refresh_provider_usage(&mut cx); state.refresh_provider_status(&mut cx); } + if services.drop_superseded_diffs { + state.start_diff_pass(&mut cx); + } state.sync_terminal_handles(); let _ = ready_tx.send(Ok(())); let mut state = smol::block_on(host_loop(state, cx, client_rx, mailbox_rx)); @@ -285,14 +291,7 @@ pub(crate) fn handle_client_message(state: &mut AppState, cx: &mut HostCx, messa } ClientPayload::Subscribe(subscription) => { state.subscribe(&subscription, cx); - if let Some(mut snapshot) = state.subscription_snapshot(&subscription) { - snapshot.request_id = Some(id); - cx.emit(HostEvent::Domain(snapshot)); - } - cx.send_message(HostMessage::Ack { - id, - result: Ok(CommandResponse::Unit), - }); + state.reply_to_subscription(Some(id), subscription, cx); } ClientPayload::Unsubscribe(subscription) => { state.unsubscribe(&subscription, cx); @@ -696,10 +695,7 @@ fn dispatch_query( session_id, before, limit, - } => { - let result = app.session_history_page(&session_id, before, limit); - cx.spawn_background(async move { result }) - } + } => app.session_history_page(&session_id, before, limit, cx), Query::Hosting { .. } => cx.spawn_background(async { Err(ProtocolError { code: "unsupported".into(), @@ -828,10 +824,7 @@ fn dispatch_query( Query::ReadItemOutput { session_id, item_id, - } => { - let result = app.item_output(&session_id, &item_id); - cx.spawn_background(async move { result }) - } + } => app.item_output(&session_id, item_id, cx), Query::ArchivedSessions => { let archived = app.archived_sessions(); cx.spawn_background(async move { Ok(QueryResponse::ArchivedSessions(archived)) }) @@ -1652,10 +1645,16 @@ mod tests { let snapshot = next_event(&stream, |event| { event.topic == topic && matches!(event.event, ServerEvent::SessionSnapshot { .. }) }); - let ServerEvent::SessionSnapshot { records, total, .. } = snapshot.event else { + let ServerEvent::SessionSnapshot { + records, + end, + total, + .. + } = snapshot.event + else { unreachable!("filtered to snapshots") }; - assert_eq!(total, 5); + assert_eq!(end, total, "the window reaches the end of the log"); assert_eq!( format!("{:?}", Timeline::fold_events(records)), format!( diff --git a/crates/services/src/store/mod.rs b/crates/services/src/store/mod.rs index cf7a0cf37..a56ab8ad1 100644 --- a/crates/services/src/store/mod.rs +++ b/crates/services/src/store/mod.rs @@ -6,7 +6,12 @@ //! JSON serde produces for it. //! * `events` holds each thread's log as raw byte segments, one per line //! including its `\n`, densely numbered from 0: the bytes of a `{ ts, event }` -//! record, or whatever a migrated or imported log contained. +//! record, or whatever a migrated or imported log contained. A turn-changes +//! snapshot that a later one supersedes is kept without its diffs +//! ([`tcode_core::session::TurnSnapshots`]). +//! * `diff_pass` names the threads whose superseded snapshots +//! [`SessionStore::drop_superseded_diffs`] has dealt with. +//! * `turn_index` holds a thread's [`TurnIndex`], while the host knows it. //! * `kept_worktrees` holds the path of every worktree whose thread was //! deleted with the worktree kept. //! @@ -18,10 +23,14 @@ mod db; mod migrate; mod relocate; +mod superseded; #[cfg(test)] mod tests; +mod turn_index; pub use migrate::{Migration, MigrationPhase, MigrationProgress}; +pub use superseded::DiffPass; +pub use turn_index::TurnIndex; use std::collections::HashMap; use std::fs::{self, File}; @@ -197,9 +206,29 @@ pub struct Mutation(Op); #[derive(Debug, Clone)] enum Op { - AppendEvent { session_id: String, line: Vec }, - ReplaceEventLog { session_id: String, bytes: Vec }, - CloneEvents { src: String, dst: String }, + AppendEvent { + session_id: String, + line: Vec, + }, + ReplaceEventLog { + session_id: String, + bytes: Vec, + }, + CloneEvents { + src: String, + dst: String, + }, + DropTurnDiffs { + session_id: String, + position: u64, + turn_id: String, + }, + ForgetDiffPass(String), + SetTurnIndex { + session_id: String, + index: TurnIndex, + }, + ForgetTurnIndex(String), UpsertMeta(Box), UpsertProject(Box), RemoveSession(String), @@ -236,6 +265,39 @@ impl Mutation { }) } + /// Drop the diffs of the turn-changes snapshot of `turn_id` stored at + /// `position` of the session's log, which a later snapshot supersedes. A + /// row that holds anything else is left as it is. + pub fn drop_turn_diffs(session_id: &str, position: u64, turn_id: &str) -> Self { + Self(Op::DropTurnDiffs { + session_id: session_id.to_owned(), + position, + turn_id: turn_id.to_owned(), + }) + } + + /// Have the next [`SessionStore::drop_superseded_diffs`] pass cover the + /// session again: an append superseded a snapshot whose row could not be + /// named. + pub fn forget_diff_pass(session_id: &str) -> Self { + Self(Op::ForgetDiffPass(session_id.to_owned())) + } + + /// Record the turns of the fold of the session's whole log, as of this + /// change. + pub fn set_turn_index(session_id: &str, index: TurnIndex) -> Self { + Self(Op::SetTurnIndex { + session_id: session_id.to_owned(), + index, + }) + } + + /// Forget the session's turn index: this change may alter its turns + /// without anyone having folded it. + pub fn forget_turn_index(session_id: &str) -> Self { + Self(Op::ForgetTurnIndex(session_id.to_owned())) + } + /// Insert or replace a meta (by id). pub fn upsert_meta(meta: SessionMeta) -> Self { Self(Op::UpsertMeta(Box::new(meta))) @@ -275,12 +337,15 @@ impl Mutation { /// The session whose event log this change rewrites, if any. fn event_log(&self) -> Option<&str> { match &self.0 { - Op::AppendEvent { session_id, .. } | Op::ReplaceEventLog { session_id, .. } => { - Some(session_id) - } + Op::AppendEvent { session_id, .. } + | Op::ReplaceEventLog { session_id, .. } + | Op::DropTurnDiffs { session_id, .. } => Some(session_id), Op::CloneEvents { dst, .. } => Some(dst), Op::RemoveSession(id) => Some(id), - Op::UpsertMeta(_) + Op::ForgetDiffPass(_) + | Op::SetTurnIndex { .. } + | Op::ForgetTurnIndex(_) + | Op::UpsertMeta(_) | Op::UpsertProject(_) | Op::RemoveProject(_) | Op::KeepWorktree(_) => None, @@ -601,23 +666,25 @@ impl SessionStore { Ok(icons) }) })?; - { - let mut generations = self - .shared - .generations - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - for id in mutations.iter().filter_map(Mutation::event_log) { - let generation = self.shared.next_generation.fetch_add(1, Ordering::Relaxed); - generations.insert(id.to_owned(), generation); - } - } + self.advance_event_generations(mutations.iter().filter_map(Mutation::event_log)); for icon in icons { self.remove_project_icon(icon); } Ok(()) } + fn advance_event_generations<'a>(&self, ids: impl IntoIterator) { + let mut generations = self + .shared + .generations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for id in ids { + let generation = self.shared.next_generation.fetch_add(1, Ordering::Relaxed); + generations.insert(id.to_owned(), generation); + } + } + /// A value that changes whenever a committed write changes `id`'s event /// log through this store, so a cache of something derived from the log /// can tell it is stale. 0 means unchanged since the store was opened. @@ -809,38 +876,79 @@ impl SessionStore { /// (`{"ts":…,"event":…}`) or a legacy bare event (`{"type":…}`), so logs /// written before the envelope format still replay (with `ts == None`). pub fn read_events(&self, id: &str) -> io::Result> { + self.read_log(id).map(|log| log.records) + } + + /// [`SessionStore::read_events`] with the stored row each record came + /// from and what the read skipped. + pub fn read_log(&self, id: &str) -> io::Result { + self.read_log_until(id, u64::MAX) + } + + /// [`SessionStore::read_log`] of the rows before position `end` alone: + /// what the log held when [`SessionStore::next_row`] returned `end`, since + /// rows are only ever appended. + pub fn read_log_until(&self, id: &str, end: u64) -> io::Result { #[cfg(any(test, feature = "test-support"))] self.shared .event_reads .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.read_rows(id, 0..end) + } + + /// The records of the rows at `positions` of a session's log, without + /// reading the rest of it. + pub fn read_rows(&self, id: &str, positions: std::ops::Range) -> io::Result { + let bound = |position: u64| i64::try_from(position).unwrap_or(i64::MAX); self.run("read events", |db| { db.read(|db, connection| { - let mut events = Vec::new(); + let mut log = EventLog::default(); db.query( connection, - "SELECT position, line FROM events WHERE session_id = ?1 ORDER BY position", - (id,), + "SELECT position, line FROM events \ + WHERE session_id = ?1 AND position >= ?2 AND position < ?3 ORDER BY position", + (id, bound(positions.start), bound(positions.end)), |row| { let position = integer(row, 0)?; let line = blob(row, 1)?; - let Ok(line) = std::str::from_utf8(&line) else { - log::warn!("skipping event {position} of {id}: not UTF-8"); - return Ok(()); - }; - let trimmed = line.trim(); - if trimmed.is_empty() { - return Ok(()); - } - match parse_stored_line(trimmed) { - Ok(stored) => events.push(stored), - Err(err) => { - log::warn!("skipping unparseable event {position} of {id}: {err}") + log.next_row = position as u64 + 1; + match decode_row(&line) { + Row::Record(stored) => { + log.records.push(stored); + log.rows.push(position as u64); + } + Row::Blank => {} + Row::Undecodable(reason) => { + log.undecodable += 1; + log::warn!("skipping event {position} of {id}: {reason}"); } } Ok(()) }, )?; - Ok(events) + Ok(log) + }) + }) + } + + /// The position the next row appended to a session's log takes. + pub fn next_row(&self, id: &str) -> io::Result { + self.run("read the end of an event log", |db| { + db.read(|db, connection| { + let mut last = None; + db.query( + connection, + "SELECT max(position) FROM events WHERE session_id = ?1", + (id,), + |row| { + last = match row.get_value(0) { + Ok(turso::Value::Integer(position)) => Some(position as u64), + _ => None, + }; + Ok(()) + }, + )?; + Ok(last.map_or(0, |last| last + 1)) }) }) } @@ -986,7 +1094,12 @@ fn open_live(root: &Path, previous: Option<&Path>, ownership: File) -> io::Resul )); } } - db.write(|db, connection| db.execute(connection, KEPT_WORKTREES, ()))?; + db.write(|db, connection| { + db.execute(connection, KEPT_WORKTREES, ())?; + db.execute(connection, superseded::DIFF_PASS_TABLE, ())?; + db.execute(connection, turn_index::TURN_INDEX_TABLE, ()) + .map(drop) + })?; migrate::warn_about_stray_sources(root); migrate::remove_legacy(root); Ok(Live { @@ -996,6 +1109,40 @@ fn open_live(root: &Path, previous: Option<&Path>, ownership: File) -> io::Resul }) } +/// A session's event log as one read of its rows saw it. +#[derive(Debug, Default)] +pub struct EventLog { + pub records: Vec, + /// The position of the row each record was read from. + pub rows: Vec, + /// The position after the last row read. + pub next_row: u64, + /// Rows that hold no record this build reads and are not blank. + pub undecodable: usize, +} + +/// What one stored row holds. +enum Row { + Record(StoredEvent), + /// Only whitespace: no record in any build. + Blank, + Undecodable(String), +} + +fn decode_row(line: &[u8]) -> Row { + let Ok(line) = std::str::from_utf8(line) else { + return Row::Undecodable("not UTF-8".into()); + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + return Row::Blank; + } + match parse_stored_line(trimmed) { + Ok(stored) => Row::Record(stored), + Err(error) => Row::Undecodable(format!("unparseable: {error}")), + } +} + fn acquire_ownership(root: &Path) -> io::Result { let path = root.join(LOCK_FILE); let file = fs::OpenOptions::new() @@ -1147,6 +1294,8 @@ fn apply_op( (session_id.as_str(),), )?; insert_segments(db, connection, session_id, 0, segments(bytes))?; + superseded::forget_pass(db, connection, session_id)?; + turn_index::forget(db, connection, session_id)?; } Op::CloneEvents { src, dst } => { db.execute( @@ -1160,7 +1309,19 @@ fn apply_op( SELECT ?2, position, line FROM events WHERE session_id = ?1", (src.as_str(), dst.as_str()), )?; + superseded::forget_pass(db, connection, dst)?; + turn_index::forget(db, connection, dst)?; + } + Op::DropTurnDiffs { + session_id, + position, + turn_id, + } => superseded::drop_row_diffs(db, connection, session_id, *position, turn_id)?, + Op::ForgetDiffPass(session_id) => superseded::forget_pass(db, connection, session_id)?, + Op::SetTurnIndex { session_id, index } => { + turn_index::set(db, connection, session_id, index)? } + Op::ForgetTurnIndex(session_id) => turn_index::forget(db, connection, session_id)?, Op::UpsertMeta(meta) => { let body = serde_json::to_vec(meta.as_ref()).map_err(invalid_data)?; db.execute( @@ -1195,6 +1356,8 @@ fn apply_op( "DELETE FROM sessions WHERE id = ?1", (id.as_str(),), )?; + superseded::forget_pass(db, connection, id)?; + turn_index::forget(db, connection, id)?; } Op::RemoveProject(id) => { if let Some(previous) = stored_project(db, connection, id)? { diff --git a/crates/services/src/store/superseded.rs b/crates/services/src/store/superseded.rs new file mode 100644 index 000000000..f226d28ea --- /dev/null +++ b/crates/services/src/store/superseded.rs @@ -0,0 +1,253 @@ +//! Turn-changes snapshots that a later snapshot supersedes are stored without +//! their diffs. A row keeps its position; only its bytes shrink. +//! +//! The pass over rows stored before appends did this themselves covers the +//! threads in `sessions`: event rows without a session are never served, and +//! are left as they are. + +use std::io; + +use agent::AgentEvent; +use tcode_core::session::{StoredEvent, Timeline, TurnSnapshots, drop_turn_diffs}; +use turso::Connection; + +use super::db::{Db, blob, integer, text}; +use super::{EventEnvelopeRef, Row, SessionStore, decode_row, invalid_data}; + +/// A thread named here has had [`SessionStore::drop_superseded_diffs`] run on +/// it; `undecodable_position` is the row that made it leave the thread as it +/// was, if any. Later appends keep the thread's rows that way themselves. +pub(super) const DIFF_PASS_TABLE: &str = "CREATE TABLE IF NOT EXISTS diff_pass (\ + session_id TEXT PRIMARY KEY, \ + undecodable_position INTEGER\ +)"; + +/// Every row holding a snapshot contains this; a thread without one is left +/// unread. +const SNAPSHOT_TAG: &[u8] = b"\"turn_changes_updated\""; + +/// What [`SessionStore::drop_superseded_diffs`] did to one thread. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiffPass { + /// The diffs of `rows` superseded snapshots were dropped; those rows went + /// from `before` to `after` bytes. + Dropped { + rows: usize, + before: u64, + after: u64, + }, + /// No row held a superseded snapshot's diff. + Unchanged, + /// The row at `position` holds no record this build reads, so which + /// snapshots are superseded is unknown; no row was changed. + Undecodable { position: u64 }, +} + +impl SessionStore { + /// The sessions that [`SessionStore::drop_superseded_diffs`] has not run + /// on. + pub fn threads_without_diff_pass(&self) -> io::Result> { + self.run("list threads without a diff pass", |db| { + db.read(|db, connection| { + let mut ids = Vec::new(); + db.query( + connection, + "SELECT id FROM sessions \ + WHERE id NOT IN (SELECT session_id FROM diff_pass)", + (), + |row| { + ids.push(text(row, 0)?); + Ok(()) + }, + )?; + Ok(ids) + }) + }) + } + + /// Drop the diffs of every turn-changes snapshot in the thread's log that + /// a later one supersedes, as the fold of its rows decides, and record that + /// the thread was dealt with, all in one transaction. Any row that is + /// neither a record nor blank leaves every row as it is: a tolerant read + /// would fold without it and could name the wrong snapshots. + pub fn drop_superseded_diffs(&self, id: &str) -> io::Result { + let outcome = self.run("drop superseded diffs", |db| { + db.write(|db, connection| { + let outcome = pass(db, connection, id)?; + let undecodable = match outcome { + DiffPass::Undecodable { position } => Some(position as i64), + _ => None, + }; + db.execute( + connection, + "INSERT INTO diff_pass (session_id, undecodable_position) VALUES (?1, ?2) \ + ON CONFLICT (session_id) DO UPDATE \ + SET undecodable_position = excluded.undecodable_position", + (id, undecodable), + )?; + Ok(outcome) + }) + })?; + if matches!(outcome, DiffPass::Dropped { .. }) { + self.advance_event_generations([id]); + } + Ok(outcome) + } +} + +fn pass(db: &Db, connection: &Connection, id: &str) -> io::Result { + let mut tagged = false; + db.query( + connection, + "SELECT 1 FROM events WHERE session_id = ?1 AND instr(line, ?2) > 0 LIMIT 1", + (id, SNAPSHOT_TAG), + |_| { + tagged = true; + Ok(()) + }, + )?; + if !tagged { + return Ok(DiffPass::Unchanged); + } + let mut fold = Timeline::default(); + let mut snapshots = TurnSnapshots::default(); + let mut superseded = Vec::new(); + let mut undecodable = None; + db.query( + connection, + "SELECT position, line FROM events WHERE session_id = ?1 ORDER BY position", + (id,), + |row| { + if undecodable.is_some() { + return Ok(()); + } + let position = integer(row, 0)? as u64; + match decode_row(&blob(row, 1)?) { + Row::Record(stored) => superseded.extend(snapshots.apply_at( + &mut fold, + stored.ts, + &stored.event, + position, + )), + Row::Blank => {} + Row::Undecodable(_) => undecodable = Some(position), + } + Ok(()) + }, + )?; + if let Some(position) = undecodable { + return Ok(DiffPass::Undecodable { position }); + } + let (mut rows, mut before, mut after) = (0, 0, 0); + for position in superseded { + if let Some((old, new)) = rewrite_without_diffs(db, connection, id, position, |_| true)? { + rows += 1; + before += old; + after += new; + } + } + Ok(if rows == 0 { + DiffPass::Unchanged + } else { + DiffPass::Dropped { + rows, + before, + after, + } + }) +} + +/// Drop the diffs of the snapshot of `turn_id` at `position`, which the +/// appending host's fold found superseded. A row holding anything else is left +/// as it is. +pub(super) fn drop_row_diffs( + db: &Db, + connection: &Connection, + session_id: &str, + position: u64, + turn_id: &str, +) -> io::Result<()> { + let expected = |stored: &StoredEvent| matches!(&stored.event, AgentEvent::TurnChangesUpdated { turn_id: stored, .. } if stored == turn_id); + if rewrite_without_diffs(db, connection, session_id, position, expected)?.is_none() { + log::debug!("event {position} of {session_id} has no diff of {turn_id} to drop"); + } + Ok(()) +} + +/// Rewrite the row at `position` without the diffs of the snapshot it holds, +/// when it holds one with diffs that `expected` accepts. Returns the row's +/// byte length before and after. +fn rewrite_without_diffs( + db: &Db, + connection: &Connection, + session_id: &str, + position: u64, + expected: impl Fn(&StoredEvent) -> bool, +) -> io::Result> { + let mut line = None; + db.query( + connection, + "SELECT line FROM events WHERE session_id = ?1 AND position = ?2", + (session_id, position as i64), + |row| { + line = Some(blob(row, 0)?); + Ok(()) + }, + )?; + let Some(line) = line else { + return Ok(None); + }; + let Some((rewritten, stored)) = without_diffs(&line)? else { + return Ok(None); + }; + if !expected(&stored) { + return Ok(None); + } + let lengths = (line.len() as u64, rewritten.len() as u64); + db.execute( + connection, + "UPDATE events SET line = ?3 WHERE session_id = ?1 AND position = ?2", + (session_id, position as i64, rewritten), + )?; + Ok(Some(lengths)) +} + +/// `line` rewritten without the diffs of the snapshot it holds, in the form it +/// was stored in, and the record that reads back from it; `None` when the row +/// holds no snapshot with a diff. +fn without_diffs(line: &[u8]) -> io::Result, StoredEvent)>> { + let Row::Record(mut stored) = decode_row(line) else { + return Ok(None); + }; + if !drop_turn_diffs(&mut stored.event) { + return Ok(None); + } + let mut rewritten = match stored.ts { + Some(ts) => serde_json::to_vec(&EventEnvelopeRef { + ts, + event: &stored.event, + }), + None => serde_json::to_vec(&stored.event), + } + .map_err(invalid_data)?; + if line.ends_with(b"\n") { + rewritten.push(b'\n'); + } + if !matches!(decode_row(&rewritten), Row::Record(read) if read == stored) { + return Err(invalid_data( + "a turn-changes snapshot without its diffs does not read back as itself", + )); + } + Ok(Some((rewritten, stored))) +} + +/// Forget that the thread's rows were dealt with, for a write that replaced +/// them or left a superseded snapshot's diff behind. +pub(super) fn forget_pass(db: &Db, connection: &Connection, session_id: &str) -> io::Result<()> { + db.execute( + connection, + "DELETE FROM diff_pass WHERE session_id = ?1", + (session_id,), + ) + .map(drop) +} diff --git a/crates/services/src/store/tests.rs b/crates/services/src/store/tests.rs index 034bf6c6e..ca38e76d0 100644 --- a/crates/services/src/store/tests.rs +++ b/crates/services/src/store/tests.rs @@ -1055,3 +1055,160 @@ fn migration_survives_sigkill() { } println!("{runs} kills over a {window} ms migration: {outcomes:?}"); } + +/// A Codex log as stored: every turn-changes snapshot carries the turn's whole +/// diff so far, so each one supersedes the one before it on its turn. One +/// legacy bare snapshot and a blank row are among them. +const SNAPSHOT_LOG: &[u8] = b"{\"ts\":1,\"event\":{\"type\":\"turn_started\",\"turn_id\":\"t1\"}}\n\ +{\"type\":\"turn_changes_updated\",\"turn_id\":\"t1\",\"changes\":[{\"path\":\"f\",\"kind\":\"modify\",\"diff\":\"-a\\n+b\\n\"}],\"completeness\":\"exact\"}\n\ +\n\ +{\"ts\":3,\"event\":{\"type\":\"turn_changes_updated\",\"turn_id\":\"t1\",\"changes\":[{\"path\":\"f\",\"kind\":\"modify\",\"diff\":\"-a\\n+c\\n\"}],\"completeness\":\"exact\"}}\n\ +{\"ts\":4,\"event\":{\"type\":\"turn_completed\",\"turn_id\":\"t1\",\"status\":\"completed\",\"usage\":null}}\n\ +{\"ts\":5,\"event\":{\"type\":\"turn_started\",\"turn_id\":\"t2\"}}\n\ +{\"ts\":6,\"event\":{\"type\":\"turn_changes_updated\",\"turn_id\":\"t2\",\"changes\":[{\"path\":\"g\",\"kind\":\"create\",\"diff\":\"+d\\n\"}],\"completeness\":\"exact\"}}\n\ +{\"ts\":7,\"event\":{\"type\":\"turn_changes_updated\",\"turn_id\":\"t2\",\"changes\":[{\"path\":\"g\",\"kind\":\"create\",\"diff\":\"+e\\n\"}],\"completeness\":\"exact\"}}\n"; + +fn rows(store: &SessionStore, id: &str) -> Vec> { + store + .read_event_log(id) + .unwrap() + .split_inclusive(|byte| *byte == b'\n') + .map(<[u8]>::to_vec) + .collect() +} + +/// The pass drops the diffs of exactly the snapshots a later one supersedes, +/// in the form each was stored in, without moving a row; the thread folds as +/// before, and it is not passed again until its rows are replaced or an +/// append leaves a superseded diff it could not name. Rows without a session +/// are never listed. +#[test] +fn superseded_snapshots_lose_their_diffs_in_place_once() { + let dir = DataDir::new(); + let store = dir.store(); + let mut meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/w"), None); + meta.id = "codex".into(); + store + .apply(&[ + Mutation::upsert_meta(meta), + Mutation::replace_event_log("codex", SNAPSHOT_LOG.to_vec()), + Mutation::replace_event_log("orphan", SNAPSHOT_LOG.to_vec()), + ]) + .unwrap(); + let before = rows(&store, "codex"); + let folded = tcode_core::session::Timeline::fold_events(store.read_events("codex").unwrap()); + assert_eq!(store.threads_without_diff_pass().unwrap(), ["codex"]); + + let DiffPass::Dropped { rows: dropped, .. } = store.drop_superseded_diffs("codex").unwrap() + else { + panic!("superseded snapshots lose their diffs") + }; + assert_eq!(dropped, 2); + let after = rows(&store, "codex"); + let mut expected = before.clone(); + expected[1] = b"{\"type\":\"turn_changes_updated\",\"turn_id\":\"t1\",\"changes\":[{\"path\":\"f\",\"kind\":\"modify\",\"diff\":null}],\"completeness\":\"exact\"}\n".to_vec(); + expected[6] = b"{\"ts\":6,\"event\":{\"type\":\"turn_changes_updated\",\"turn_id\":\"t2\",\"changes\":[{\"path\":\"g\",\"kind\":\"create\",\"diff\":null}],\"completeness\":\"exact\"}}\n".to_vec(); + assert_eq!(after, expected); + assert_eq!( + tcode_core::session::Timeline::fold_events(store.read_events("codex").unwrap()), + folded + ); + assert!(store.threads_without_diff_pass().unwrap().is_empty()); + assert_eq!( + store.drop_superseded_diffs("codex").unwrap(), + DiffPass::Unchanged + ); + + store + .apply(&[Mutation::replace_event_log("codex", SNAPSHOT_LOG.to_vec())]) + .unwrap(); + assert_eq!(store.threads_without_diff_pass().unwrap(), ["codex"]); + store.drop_superseded_diffs("codex").unwrap(); + store.apply(&[Mutation::forget_diff_pass("codex")]).unwrap(); + assert_eq!(store.threads_without_diff_pass().unwrap(), ["codex"]); +} + +/// A tolerant read folds without a row it cannot decode, so it could name +/// the wrong snapshots: such a thread keeps every row as it is. +#[test] +fn a_thread_with_an_undecodable_row_keeps_every_row() { + let dir = DataDir::new(); + let store = dir.store(); + let mut meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/w"), None); + meta.id = "codex".into(); + store.upsert_meta(&meta).unwrap(); + for (label, bad) in [ + ("unparseable", &b"{not valid json}\n"[..]), + ("not UTF-8", &b"\xff\xfe{}\n"[..]), + ] { + let mut log = SNAPSHOT_LOG.to_vec(); + let at: usize = SNAPSHOT_LOG + .split_inclusive(|byte| *byte == b'\n') + .take(5) + .map(<[u8]>::len) + .sum(); + log.splice(at..at, bad.iter().copied()); + store + .apply(&[Mutation::replace_event_log("codex", log.clone())]) + .unwrap(); + assert_eq!(store.threads_without_diff_pass().unwrap(), ["codex"]); + assert_eq!( + store.drop_superseded_diffs("codex").unwrap(), + DiffPass::Undecodable { position: 5 }, + "{label}" + ); + assert_eq!(store.read_event_log("codex").unwrap(), log, "{label}"); + assert!( + store.threads_without_diff_pass().unwrap().is_empty(), + "{label}" + ); + } +} + +/// A turn index stands for rows the host folded: an append keeps it, since +/// the host writes the index that follows it in the same transaction, while +/// a write that replaces the rows, copies another thread's over them or +/// removes the thread forgets it. +#[test] +fn writes_the_host_did_not_fold_forget_the_turn_index() { + let dir = DataDir::new(); + let store = dir.store(); + let index = TurnIndex { + turns: 2, + starts: vec![0, 5], + }; + let event = agent::AgentEvent::TurnStarted { + turn_id: "next".into(), + }; + for (label, write, kept) in [ + ( + "appended", + Mutation::append_event("thread", 9, &event).unwrap(), + true, + ), + ( + "replaced", + Mutation::replace_event_log("thread", SNAPSHOT_LOG.to_vec()), + false, + ), + ( + "cloned over", + Mutation::clone_events("other", "thread"), + false, + ), + ("removed", Mutation::remove_session("thread"), false), + ] { + store + .apply(&[ + Mutation::replace_event_log("thread", SNAPSHOT_LOG.to_vec()), + Mutation::set_turn_index("thread", index.clone()), + ]) + .unwrap(); + store.apply(&[write]).unwrap(); + assert_eq!( + store.turn_index("thread").unwrap(), + kept.then(|| index.clone()), + "{label}" + ); + } +} diff --git a/crates/services/src/store/turn_index.rs b/crates/services/src/store/turn_index.rs new file mode 100644 index 000000000..bb98e5a62 --- /dev/null +++ b/crates/services/src/store/turn_index.rs @@ -0,0 +1,80 @@ +//! A thread's turn index: how many turns the fold of its whole log holds and +//! the rows that opened them, so a window can be cut from the log's last rows +//! without reading the rest. It is written whenever the host folds the log +//! and the turns change, in the transaction of the append that changed them, +//! and forgotten by any write the host did not fold; a thread without one is +//! read whole. + +use std::io; + +use turso::Connection; + +use super::db::{Db, blob, integer}; +use super::{SessionStore, invalid_data}; + +pub(super) const TURN_INDEX_TABLE: &str = "CREATE TABLE IF NOT EXISTS turn_index (\ + session_id TEXT PRIMARY KEY, \ + turns INTEGER NOT NULL, \ + starts BLOB NOT NULL\ +)"; + +/// The turns of the fold of a thread's whole log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TurnIndex { + /// How many turns the fold holds. + pub turns: u64, + /// The position of every row that opened a turn while the log was + /// folded, ascending. A rewind ends turns without removing their starts: + /// each is still where a turn began. + pub starts: Vec, +} + +impl SessionStore { + /// The session's turn index, if the host has one that covers its whole + /// log. + pub fn turn_index(&self, id: &str) -> io::Result> { + self.run("read a turn index", |db| { + db.read(|db, connection| { + let mut index = None; + db.query( + connection, + "SELECT turns, starts FROM turn_index WHERE session_id = ?1", + (id,), + |row| { + index = Some(TurnIndex { + turns: integer(row, 0)? as u64, + starts: serde_json::from_slice(&blob(row, 1)?).map_err(invalid_data)?, + }); + Ok(()) + }, + )?; + Ok(index) + }) + }) + } +} + +pub(super) fn set( + db: &Db, + connection: &Connection, + session_id: &str, + index: &TurnIndex, +) -> io::Result<()> { + let starts = serde_json::to_vec(&index.starts).map_err(invalid_data)?; + db.execute( + connection, + "INSERT INTO turn_index (session_id, turns, starts) VALUES (?1, ?2, ?3) \ + ON CONFLICT (session_id) DO UPDATE SET turns = excluded.turns, starts = excluded.starts", + (session_id, index.turns as i64, starts), + ) + .map(drop) +} + +pub(super) fn forget(db: &Db, connection: &Connection, session_id: &str) -> io::Result<()> { + db.execute( + connection, + "DELETE FROM turn_index WHERE session_id = ?1", + (session_id,), + ) + .map(drop) +} diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index 36aec071d..bcafd580e 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -3132,8 +3132,9 @@ impl Render for ChatView { && let Some(screens) = history_screens_covered(&chat.list_state, chat.leading_space()) { + let following_tail = chat.list_state.is_following_tail(); chat.workspace_store.update(cx, |store, cx| { - store.update_history_window(screens, cx); + store.update_history_window(screens, following_tail, cx); }); } }); @@ -5561,6 +5562,307 @@ mod tests { ); } + /// A reader who scrolls far up a long thread and comes back sees rows at + /// every step. Once they have stayed at the tail, the pages fetched above + /// it are released without moving the tail: the conversation is again the + /// one the tail needed, nothing is fetched back while they stay, and the + /// next scroll up asks for the page above that window. + #[gpui::test] + fn pages_read_far_up_are_released_once_the_reader_stays_at_the_tail(cx: &mut TestAppContext) { + use gpui::{VisualTestContext, point, px}; + use tcode_core::session::StoredEvent; + use tcode_protocol::{ + ClientPayload, EventEnvelope, HostMessage, Query, QueryResponse, ServerEvent, Topic, + }; + use tcode_runtime::pipe::{HostServices, spawn_host}; + use tcode_services::store::SessionStore; + + cx.update(crate::theme::init); + cx.update(crate::markdown::init); + let data_root = std::env::temp_dir().join(format!( + "tcode-chat-release-test-{}-{}", + std::process::id(), + NEXT_RESIDENCY_TEST_ID.fetch_add(1, Ordering::Relaxed) + )); + let host = spawn_host( + SessionStore::open_at(data_root.clone()).expect("test session store"), + HostServices::default(), + ) + .expect("spawn test host"); + let mut status = smol::block_on(host.update_state_for_test(|state, cx| { + let id = state.start_draft("long".into(), std::env::temp_dir(), cx); + state.session_status_snapshot(&id).unwrap() + })) + .unwrap(); + host.shutdown_blocking().unwrap(); + let _ = std::fs::remove_dir_all(data_root); + status.session_id = "long".into(); + status.draft = false; + let mut meta = tcode_core::project::SessionMeta::new( + agent::ProviderKind::Codex, + std::env::temp_dir(), + None, + ); + meta.id = "long".into(); + + // Turn `t` is the four records at `4 * t` of a 400-turn log. + let turns = |range: std::ops::Range| -> Vec { + range + .flat_map(|turn| { + [ + agent::AgentEvent::TurnStarted { + turn_id: turn.to_string(), + }, + agent::AgentEvent::ItemCompleted(agent::ThreadItem { + id: format!("user-{turn}"), + parent_item_id: None, + content: ItemContent::UserMessage { + text: format!("Question {turn}"), + context_len: None, + attachments: vec![], + }, + }), + agent::AgentEvent::ItemCompleted(agent::ThreadItem { + id: format!("answer-{turn}"), + parent_item_id: None, + content: ItemContent::AssistantMessage { + text: format!("Answer {turn}.\n\n").repeat(3), + }, + }), + agent::AgentEvent::TurnCompleted { + turn_id: turn.to_string(), + status: agent::TurnStatus::Completed, + usage: None, + }, + ] + .map(StoredEvent::from) + }) + .collect() + }; + + let (to_host, outgoing) = async_channel::unbounded::(); + let (incoming, from_host) = async_channel::unbounded::(); + let link = tcode_client::HostLink::new(to_host, from_host); + let executor = cx.background_executor.clone(); + let _pump = cx.background_executor.spawn({ + let link = link.clone(); + async move { + link.pump_with_timer(|| executor.timer(Duration::from_millis(25))) + .await; + } + }); + let send = |message: HostMessage| { + incoming + .try_send(tcode_protocol::encode_line(&message).unwrap()) + .unwrap(); + }; + let event = |topic: Topic, event: ServerEvent| { + send(HostMessage::Event(EventEnvelope { + request_id: None, + topic, + event, + })); + }; + let store = cx.new(|cx| WorkspaceStore::new(link, cx)); + store.update(cx, |store, _| store.select_session("long".into())); + event( + Topic::Settings, + ServerEvent::SettingsSnapshot(Default::default()), + ); + event( + Topic::Index, + ServerEvent::IndexSnapshot(tcode_protocol::IndexSnapshot { + summary: Default::default(), + sessions: vec![meta], + projects: vec![], + }), + ); + event( + Topic::SessionStatus { + session_id: "long".into(), + }, + ServerEvent::SessionStatusReplaced(Box::new(status)), + ); + event( + Topic::SessionEvents { + session_id: "long".into(), + }, + ServerEvent::SessionSnapshot { + from: 4 * 390, + end: 4 * 400, + records: turns(390..400), + total: 4 * 400, + total_turns: 400, + truncated: false, + }, + ); + let window_state = cx.new(|_| WindowState::new(false)); + let (view, cx) = + cx.add_window_view(|window, cx| ChatView::new(store.clone(), window_state, window, cx)); + cx.simulate_resize(gpui::size(px(393.), px(852.))); + + // Present a frame, as the display link does, and answer the page it + // asked for, ten turns above `before`; then let the store's single + // request gate reopen. + let frame = |cx: &mut VisualTestContext| -> Option { + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + cx.run_until_parked(); + let pages: Vec<(u64, u64)> = std::iter::from_fn(|| outgoing.try_recv().ok()) + .map(|line| tcode_protocol::decode_client_line(&line).unwrap()) + .filter_map(|message| match message.payload { + ClientPayload::Query(Query::SessionHistoryPage { before, .. }) => { + Some((message.id, before)) + } + _ => None, + }) + .collect(); + assert!(pages.len() <= 1, "one page in flight: {pages:?}"); + let (id, before) = *pages.first()?; + send(HostMessage::QueryResult { + id, + result: Ok(QueryResponse::SessionHistoryPage { + records: turns(before / 4 - 10..before / 4), + from: before - 40, + end: before, + truncated: false, + }), + }); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_millis(250)); + Some(before) + }; + let settle = |cx: &mut VisualTestContext| -> Vec { + let pages: Vec = std::iter::from_fn(|| frame(cx)).take(40).collect(); + assert!(frame(cx).is_none(), "the prefetch settles"); + pages + }; + let replica = |cx: &mut VisualTestContext| { + store.read_with(cx, |store, _| store.with_active_timeline(Timeline::clone)) + }; + let list = view.read_with(cx, |chat, _| chat.list_state.clone()); + // One trackpad gesture. + let scroll = |distance: gpui::Pixels, cx: &mut VisualTestContext| { + let position = list.viewport_bounds().center(); + for (touch_phase, distance) in [ + (gpui::TouchPhase::Started, px(0.)), + (gpui::TouchPhase::Moved, distance), + (gpui::TouchPhase::Ended, px(0.)), + ] { + cx.simulate_event(gpui::ScrollWheelEvent { + position, + delta: gpui::ScrollDelta::Pixels(point(px(0.), distance)), + touch_phase, + ..Default::default() + }); + } + }; + + let tail_pages = settle(cx); + assert!(list.is_following_tail()); + assert!(!tail_pages.is_empty(), "ten turns do not fill six screens"); + let tail = replica(cx).expect("the tail window"); + let tail_from = tail_pages.last().unwrap() - 40; + + let height = list.viewport_bounds().size.height; + let mut pages_away = 0; + while pages_away < 6 { + scroll(height * 3., cx); + while frame(cx).is_some() { + pages_away += 1; + view.read_with(cx, |chat, cx| { + assert!(!chat.workspace_store.read(cx).chat_loading()); + assert!(!chat.rows.is_empty(), "the conversation stays on screen"); + }); + } + assert!(!list.is_following_tail()); + } + let read = replica(cx).unwrap(); + assert!(read.turns.len() > tail.turns.len()); + + let back = |cx: &mut VisualTestContext| { + // The pill shows once a frame has seen the reader away. + draw(cx); + let pill = cx.debug_bounds("scroll-to-end").expect("jump to latest"); + cx.simulate_click(pill.center(), gpui::Modifiers::none()); + assert!(frame(cx).is_none()); + assert!(list.is_following_tail()); + }; + back(cx); + cx.executor().advance_clock(Duration::from_secs(20)); + assert!(frame(cx).is_none()); + assert_eq!( + replica(cx).unwrap(), + read, + "a glance at the tail keeps them" + ); + scroll(height * 1.5, cx); + assert!(frame(cx).is_none()); + assert!(!list.is_following_tail()); + cx.executor().advance_clock(Duration::from_secs(20)); + assert!(frame(cx).is_none()); + assert_eq!(replica(cx).unwrap(), read, "reading above keeps them"); + + back(cx); + let last_row = |cx: &mut VisualTestContext| { + cx.debug_bounds(format!("timeline-row-{}", list.item_count() - 1).leak()) + .expect("the last row is on screen") + }; + let rows = list.item_count(); + let bottom = last_row(cx).bottom(); + cx.executor().advance_clock(Duration::from_secs(30)); + assert!(frame(cx).is_none(), "the tail's own pages are still held"); + assert_eq!(replica(cx).unwrap(), tail); + assert!(list.is_following_tail()); + assert!(list.item_count() < rows); + assert_eq!(last_row(cx).bottom(), bottom, "the tail stays where it was"); + assert!(settle(cx).is_empty()); + + scroll(height * 3., cx); + assert_eq!(frame(cx), Some(tail_from), "the page above the tail window"); + + // Left with that page held, the thread reopens from its cursor with + // the window its tail needed. + while outgoing.try_recv().is_ok() {} + store.update(cx, |store, _| { + store.select_session("other".into()); + store.select_session("long".into()); + }); + let events_topic = Topic::SessionEvents { + session_id: "long".into(), + }; + let resumed: Vec<_> = std::iter::from_fn(|| outgoing.try_recv().ok()) + .filter_map( + |line| match tcode_protocol::decode_client_line(&line).unwrap().payload { + ClientPayload::Subscribe(subscription) + if subscription.topic == events_topic => + { + Some(subscription.after) + } + _ => None, + }, + ) + .collect(); + assert_eq!(resumed, [Some(4 * 400)]); + event( + events_topic, + ServerEvent::SessionSnapshot { + from: 4 * 400, + end: 4 * 400, + records: vec![], + total: 4 * 400, + total_turns: 400, + truncated: false, + }, + ); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + assert_eq!(replica(cx).unwrap(), tail); + } + #[gpui::test] fn pan_packet_after_a_page_lands_keeps_the_walk_back_into_it(cx: &mut TestAppContext) { use gpui::{FollowMode, ListOffset, point, px}; @@ -6232,6 +6534,19 @@ This begins after the hard break."#; (active.meta.id.clone(), active.timeline.clone()) })) .expect("seed markdown host"); + if paged { + // The history is the thread's before a client opens it: the host + // answers a page only once it has read the log the records joined. + smol::block_on( + host.link() + .query(tcode_protocol::Query::SessionHistoryPage { + session_id: session_id.clone(), + before: u64::MAX, + limit: 1, + }), + ) + .expect("the seeded history is read"); + } let workspace_store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); workspace_store.update(cx, |store, cx| { store.set_session_replica_for_test(session_id.clone(), timeline, cx); diff --git a/crates/ui/src/chat/model.rs b/crates/ui/src/chat/model.rs index fe68ff591..e26dd8e04 100644 --- a/crates/ui/src/chat/model.rs +++ b/crates/ui/src/chat/model.rs @@ -1402,7 +1402,8 @@ pub(crate) enum ListSync { /// spliced in where they stand, which is how the list keeps the reader's /// anchor across a steer landing mid-turn or a page arriving above. Rows /// that vanish or trade places reset the list, except inside the partial -/// first turn, whose entries a page may merge or shift. +/// first turn, whose entries a page may merge or shift, and above every row +/// that survives: dropping the pages read above the tail removes rows there. fn list_sync_with<'a>( old: &[TimelineRow], new_len: usize, @@ -1438,11 +1439,13 @@ fn list_sync_with<'a>( let mut splices: Vec<(Range, usize)> = Vec::new(); let mut remeasure = Vec::new(); let mut carried = Vec::new(); + let mut aligned = false; let (mut i, mut j) = (0, 0); while i < old.len() || j < new_len { if i < old.len() && j < new_len { let (old_row, new_row) = (&old[i], new_at(j)); if old_row.identity == new_row.identity { + aligned = true; if old_row.content != new_row.content || old_row.entry_count != new_row.entry_count { remeasure.push(j); @@ -1476,7 +1479,21 @@ fn list_sync_with<'a>( || row.part != RowPart::Segment }; if !replaceable(&old[i]) { - return ListSync::Reset { count: new_len }; + let below_survives = old[i..] + .iter() + .any(|row| new_pos.contains_key(&row.identity)); + if aligned || j > 0 || !below_survives { + return ListSync::Reset { count: new_len }; + } + let start = i; + while !new_pos.contains_key(&old[i].identity) { + i += 1; + } + match splices.last_mut() { + Some((dropped, 0)) if dropped.end == start => dropped.end = i, + _ => splices.push((start..i, 0)), + } + continue; } let start = i; while i < old.len() && !new_pos.contains_key(&old[i].identity) && replaceable(&old[i]) { @@ -1887,6 +1904,34 @@ mod tests { ) }], }); + scenarios.push(Scenario { + name: "pages dropped above the tail", + initial: Snapshot::new( + 4, + vec![ + entry("paged", assistant("an earlier page")), + at_turn(entry("paged-later", assistant("a later page")), 1), + at_turn(entry("kept-user", user_item("question")), 2), + at_turn(entry("kept", assistant("answer")), 2), + at_turn(entry("newest", assistant("latest")), 3), + ], + ), + steps: vec![Step { + continuity: TimelineContinuity::PartialFirstTurn, + ..step( + "drop the pages above the tail's window", + Snapshot::new( + 2, + vec![ + entry("kept-user", user_item("question")), + entry("kept", assistant("answer")), + at_turn(entry("newest", assistant("latest")), 1), + ], + ), + incremental(vec![(0..2, 0)], vec![]), + ) + }], + }); for (name, continuity, expected) in [ ( "merge inside partial turn", diff --git a/crates/ui/src/store/history.rs b/crates/ui/src/store/history.rs index 86439cbad..61946b3ac 100644 --- a/crates/ui/src/store/history.rs +++ b/crates/ui/src/store/history.rs @@ -2,12 +2,87 @@ use super::*; pub(crate) const HISTORY_WINDOW_SCREENS: f32 = 6.; +/// How long the reader stays at the tail before the pages fetched above it +/// are dropped, so a glance down and back up does not fetch them again. +pub(super) const HISTORY_TRIM_DELAY: std::time::Duration = std::time::Duration::from_secs(30); + +/// The records of one thread's log the client holds: they stand for the +/// cursors `from..end`. The host may merge records, and a row may hold none, +/// so the range can be longer than the records. +pub(super) struct HeldHistory { + pub(super) from: u64, + pub(super) end: u64, + pub(super) records: Vec, + /// The earlier pages put in front of the window since it arrived, in the + /// order they were fetched, so the oldest records come from the last. + pages: Vec, +} + +struct HeldPage { + records: usize, + /// Where the window started before the page: `from` again once the page + /// is dropped. + end: u64, + /// Fetched while the reader followed the tail, which therefore needs it. + tail: bool, +} + +impl HeldHistory { + pub(super) fn new(from: u64, end: u64, records: &[StoredEvent]) -> Self { + Self { + from, + end, + records: records.to_vec(), + pages: Vec::new(), + } + } + + /// Records that continue the held cursor up to `end`. + pub(super) fn extend(&mut self, records: &[StoredEvent], end: u64) { + self.records.extend(records.iter().cloned()); + self.end = end; + } + + fn prepend(&mut self, records: Vec, from: u64, tail: bool) { + self.pages.push(HeldPage { + records: records.len(), + end: self.from, + tail, + }); + self.records.splice(0..0, records); + self.from = from; + } + + fn holds_pages_above_tail(&self) -> bool { + self.pages.last().is_some_and(|page| !page.tail) + } + + /// Drop the pages fetched while the reader was away from the tail, back + /// to the last page the tail needed. What remains is a window the client + /// already held and showed, plus what arrived live since, so it folds as + /// that window did; the tail's own pages keep the prefetch satisfied, so + /// no page dropped here is fetched again while the reader stays there. + pub(super) fn drop_pages_above_tail(&mut self) -> bool { + let mut dropped = 0; + while let Some(page) = self.pages.pop_if(|page| !page.tail) { + dropped += page.records; + self.from = page.end; + } + self.records.drain(..dropped); + dropped > 0 + } +} + impl WorkspaceStore { - pub(crate) fn history_available(&self) -> bool { - self.selected_session_id + fn selected_history(&self) -> Option<&HeldHistory> { + self.threads + .get(self.selected_session_id.as_ref()?)? + .history .as_ref() - .and_then(|id| self.session_from.get(id)) - .is_some_and(|from| *from > 0) + } + + pub(crate) fn history_available(&self) -> bool { + self.selected_history().is_some_and(|held| held.from > 0) } pub(crate) fn history_loading(&self) -> bool { @@ -37,15 +112,16 @@ impl WorkspaceStore { /// Geometry is reported after layout, including the first frame on restore. /// Event counts cannot predict the height of folded turns. - pub(crate) fn update_history_window(&mut self, screens: f32, cx: &mut Context) { + pub(crate) fn update_history_window( + &mut self, + screens: f32, + following_tail: bool, + cx: &mut Context, + ) { if self.session_loading() || !screens.is_finite() { return; } - let records = self - .selected_session_id - .as_ref() - .and_then(|id| self.session_records.get(id)) - .map_or(0, Vec::len); + let records = self.selected_history().map_or(0, |held| held.records.len()); if self.history_logged_records != Some(records) { log::debug!( "history-window session={:?} records_loaded={} screens_covered={:.2} pages_fetched={}", @@ -57,20 +133,70 @@ impl WorkspaceStore { self.history_logged_records = Some(records); } if screens < HISTORY_WINDOW_SCREENS && self.history_error.is_none() { - self.load_earlier_messages(cx); + self.load_history_pages(following_tail, cx); + } + if !following_tail { + self.history_trim = None; + } else if self.history_trim.is_none() + && self + .selected_history() + .is_some_and(HeldHistory::holds_pages_above_tail) + { + let generation = self.selection_generation; + self.history_trim = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(HISTORY_TRIM_DELAY).await; + let _ = this.update(cx, |store, cx| { + if store.selection_generation == generation { + store.history_trim = None; + store.drop_selected_pages_above_tail(); + cx.notify(); + } + }); + })); } } + /// The rows the dropped pages rendered are above the reader, who follows + /// the tail, and the list removes them without moving what is on screen. + fn drop_selected_pages_above_tail(&mut self) { + // A page in flight continues the cursor it was asked from; the next + // report from the tail schedules the drop again. + if self.history_task.is_some() || self.session_catching_up { + return; + } + let Some(session_id) = self.selected_session_id.clone() else { + return; + }; + let Some(held) = self + .threads + .get_mut(&session_id) + .and_then(|thread| thread.history.as_mut()) + else { + return; + }; + if !held.drop_pages_above_tail() { + return; + } + let previous_turns = self + .session_replica + .as_ref() + .map_or(0, |(_, timeline)| timeline.turns.len()); + let mut timeline = self.fold_held_records(&session_id); + self.session_turn_offset += previous_turns.saturating_sub(timeline.turns.len()); + self.settle_running_turn(&mut timeline); + self.session_replica = Some((session_id, timeline)); + } + pub(crate) fn load_earlier_messages(&mut self, cx: &mut Context) { - self.load_history_pages(cx); + self.load_history_pages(false, cx); } - fn load_history_pages(&mut self, cx: &mut Context) { + fn load_history_pages(&mut self, tail: bool, cx: &mut Context) { if self.history_task.is_some() || !self.history_available() || self.session_loading() { return; } let session_id = self.selected_session_id.clone().expect("selected history"); - let before = self.session_from[&session_id]; + let before = self.selected_history().expect("selected history").from; let generation = self.selection_generation; let host = self.host.clone(); self.history_error = None; @@ -91,16 +217,19 @@ impl WorkspaceStore { records, from, end, .. }) if from < before && end == before - && store.session_from.get(&session_id) == Some(&before) => + && store.selected_history().map(|held| held.from) == Some(before) => { store.history_pages_fetched += 1; let previous_turns = store .session_replica .as_ref() .map_or(0, |(_, timeline)| timeline.turns.len()); - let held = store.session_records.entry(session_id.clone()).or_default(); - held.splice(0..0, records); - store.session_from.insert(session_id.clone(), from); + store + .threads + .get_mut(&session_id) + .and_then(|thread| thread.history.as_mut()) + .expect("held window checked above") + .prepend(records, from, tail); let mut timeline = store.fold_held_records(&session_id); store.session_turn_offset = store .session_turn_offset diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index 77db7064a..2e618e324 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -15,7 +15,7 @@ use tcode_core::{ }; use tcode_protocol::{Command, CommandResponse, ProtocolError, RuntimeOperationId, SettingsPatch}; -use super::{ArchivedDeletion, StoreChange, TopicKind, WorkspaceStore}; +use super::{ArchivedDeletion, KEPT_THREADS, StoreChange, TopicKind, WorkspaceStore}; impl WorkspaceStore { pub(super) fn dispatch(&mut self, command: Command) { @@ -232,6 +232,7 @@ impl WorkspaceStore { pub(crate) fn leave_session(&mut self) { self.selection_generation = self.selection_generation.wrapping_add(1); self.history_task = None; + self.history_trim = None; self.history_error = None; self.history_pages_fetched = 0; self.history_logged_records = None; @@ -239,6 +240,14 @@ impl WorkspaceStore { self.session_catching_up = false; self.clear_terminal_topics(); if let Some(session_id) = self.selected_session_id.take() { + if let Some(thread) = self.threads.get_mut(&session_id) { + thread.left_at = self.selection_generation; + // The thread reopens at its tail. + if let Some(held) = &mut thread.history { + held.drop_pages_above_tail(); + } + } + self.release_left_threads(); for topic in [ tcode_protocol::Topic::SessionEvents { session_id: session_id.clone(), @@ -266,6 +275,22 @@ impl WorkspaceStore { self.git_status_replica = Default::default(); } + /// Keep the replicas of the [`KEPT_THREADS`] threads left most recently. + fn release_left_threads(&mut self) { + if self.threads.len() <= KEPT_THREADS { + return; + } + let mut left: Vec<(u64, String)> = self + .threads + .iter() + .map(|(id, thread)| (thread.left_at, id.clone())) + .collect(); + left.sort_unstable_by(|a, b| b.cmp(a)); + for (_, id) in &left[KEPT_THREADS..] { + self.threads.remove(id); + } + } + pub fn select_session(&mut self, session_id: String) { if self.selected_session_id.as_ref() == Some(&session_id) { return; @@ -291,15 +316,11 @@ impl WorkspaceStore { .remove(&tcode_protocol::Topic::SessionEvents { session_id: session_id.clone(), }); - self.session_status_replica = self.session_statuses.get(&session_id).cloned(); - self.git_status_replica = self - .git_statuses - .get(&session_id) - .cloned() - .unwrap_or_default(); - self.session_records.entry(session_id.clone()).or_default(); + let thread = self.threads.entry(session_id.clone()).or_default(); + self.session_status_replica = thread.status.clone(); + self.git_status_replica = thread.git.clone().unwrap_or_default(); self.session_replica = None; - let after = self.session_end.get(&session_id).copied(); + let after = thread.history.as_ref().map(|held| held.end); for topic in [ tcode_protocol::Topic::SessionStatus { session_id: session_id.clone(), diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 065d47878..1ff04c575 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -171,7 +171,6 @@ pub struct WorkspaceStore { settings_hydrated: bool, baseline_topics: HashSet, index_hydrated: bool, - hydrated_sessions: HashSet, /// The `updated_at` this view last reported read, so an acknowledgement /// is sent once per change rather than once per event until the host's /// visit echo arrives. @@ -180,21 +179,19 @@ pub struct WorkspaceStore { /// window keeps the thread selected on the thread list it returned to. conversation_on_screen: bool, selected_session_id: Option, - session_records: HashMap>, - /// The log cursors `session_records` stands for. The host may merge - /// records, so the range can be longer than the records. - session_from: HashMap, - session_end: HashMap, + /// The selected thread's replicas, and those of the threads left most + /// recently ([`KEPT_THREADS`]). + threads: HashMap, selection_generation: u64, session_turn_offset: usize, history_task: Option>, + /// Drops the pages fetched above the tail once the reader has stayed + /// there ([`history::HISTORY_TRIM_DELAY`]). + history_trim: Option>, history_error: Option, history_pages_fetched: usize, history_logged_records: Option, session_catching_up: bool, - session_statuses: HashMap, - session_plans: HashMap, - git_statuses: HashMap, session_replica: Option<(String, Timeline)>, session_status_replica: Option, providers_replica: ProvidersStatus, @@ -211,6 +208,26 @@ pub struct WorkspaceStore { draft_fallback_pending: bool, } +/// How many threads the user left keep their replicas besides the selected +/// one. Re-selecting a kept thread sends its cursor and gets only what it +/// missed; any other thread gets a baseline, as on first open. A thread +/// receives nothing while it is not selected, so keeping one longer saves no +/// more than that baseline: the bound is on how many are kept, not for how +/// long. +const KEPT_THREADS: usize = 4; + +/// What the client holds of one thread. +#[derive(Default)] +struct ThreadReplica { + /// Absent until the first window of the thread's log arrives. + history: Option, + status: Option, + plan: Option, + git: Option, + /// The `selection_generation` the user left the thread at. + left_at: u64, +} + /// A turn stopped by Claude Code's safety classifier, kept per session so the /// composer can offer recovery after the turn already ended. #[derive(Debug, Clone)] @@ -359,23 +376,18 @@ impl WorkspaceStore { settings_hydrated: false, baseline_topics: HashSet::new(), index_hydrated: false, - hydrated_sessions: HashSet::new(), read_acknowledged: None, conversation_on_screen: false, selected_session_id: None, - session_records: HashMap::new(), - session_from: HashMap::new(), - session_end: HashMap::new(), + threads: HashMap::new(), selection_generation: 0, session_turn_offset: 0, history_task: None, + history_trim: None, history_error: None, history_pages_fetched: 0, history_logged_records: None, session_catching_up: false, - session_statuses: HashMap::new(), - session_plans: HashMap::new(), - git_statuses: HashMap::new(), session_replica: None, session_status_replica: None, providers_replica: ProvidersStatus::default(), @@ -696,16 +708,21 @@ impl WorkspaceStore { .into_iter() .filter_map(|entry| { let text = message(&entry.command)?; - let queued = self.session_statuses.get(&active).is_some_and(|status| { - status.queued_messages.iter().any(|message| { - message.delivery_key.as_deref() == Some(entry.key.as_str()) - }) - }); - let recorded = self.session_records.get(&active).is_some_and(|records| { - records.iter().any(|record| { - Self::record_delivery_key(record) == Some(entry.key.as_str()) - }) - }); + let thread = self.threads.get(&active); + let queued = thread + .and_then(|thread| thread.status.as_ref()) + .is_some_and(|status| { + status.queued_messages.iter().any(|message| { + message.delivery_key.as_deref() == Some(entry.key.as_str()) + }) + }); + let recorded = thread + .and_then(|thread| thread.history.as_ref()) + .is_some_and(|held| { + held.records.iter().any(|record| { + Self::record_delivery_key(record) == Some(entry.key.as_str()) + }) + }); if queued || recorded { // Once adopted by the host replica, a later rewind must not // resurrect the acknowledged placeholder. @@ -805,10 +822,8 @@ impl WorkspaceStore { if !self.index_hydrated || !self.settings_hydrated { return true; } - if let Some(id) = &self.selected_session_id { + if self.selected_session_id.is_some() { self.session_loading() - || !self.hydrated_sessions.contains(id) - || !self.session_statuses.contains_key(id) } else { self.threads_loading() } @@ -1008,6 +1023,10 @@ impl WorkspaceStore { snapshot.sessions.iter().any(|meta| meta.id == *session_id) } }); + self.threads.retain(|session_id, _| { + self.selected_session_id.as_ref() == Some(session_id) + || snapshot.sessions.iter().any(|meta| meta.id == *session_id) + }); self.apply_index_summary(&snapshot.summary, cx); if self.archived_requested { self.load_archived_sessions(cx); @@ -1033,7 +1052,9 @@ impl WorkspaceStore { self.providers_replica = status.clone(); } (Topic::GitStatus { session_id }, ServerEvent::GitStatusReplaced(status)) => { - self.git_statuses.insert(session_id.clone(), status.clone()); + if let Some(thread) = self.threads.get_mut(session_id) { + thread.git = Some(status.clone()); + } if self.selected_session_id.as_ref() == Some(session_id) { self.git_status_replica = status.clone(); } @@ -1042,8 +1063,9 @@ impl WorkspaceStore { if status.session_id == *session_id => { self.baseline_topics.insert(envelope.topic.clone()); - self.session_statuses - .insert(session_id.clone(), status.as_ref().clone()); + if let Some(thread) = self.threads.get_mut(session_id) { + thread.status = Some(status.as_ref().clone()); + } if self.selected_session_id.as_ref() == Some(session_id) { let mut status = status.as_ref().clone(); status.native_rewind_prefill_available = @@ -1063,7 +1085,9 @@ impl WorkspaceStore { if plan.session_id == *session_id => { self.baseline_topics.insert(envelope.topic.clone()); - self.session_plans.insert(session_id.clone(), plan.clone()); + if let Some(thread) = self.threads.get_mut(session_id) { + thread.plan = Some(plan.clone()); + } } (Topic::SessionEvents { session_id }, ServerEvent::SessionHistoryError(error)) if self.selected_session_id.as_ref() == Some(session_id) => @@ -1084,34 +1108,31 @@ impl WorkspaceStore { if self.selected_session_id.as_ref() != Some(session_id) { return; } - let held = self.session_records.entry(session_id.clone()).or_default(); - let start = self.session_from.entry(session_id.clone()).or_insert(*from); - let held_end = self.session_end.entry(session_id.clone()).or_insert(*from); - if *from == 0 { - held.clear(); - *start = 0; - *held_end = 0; - } else if *from != *held_end { - held.clear(); - self.session_from.remove(session_id); - self.session_end.remove(session_id); - self.session_replica = None; - self.hydrated_sessions.remove(session_id); - self.baseline_topics.remove(&envelope.topic); - self.session_catching_up = false; - let _ = self.host.subscribe(Subscription { - topic: envelope.topic.clone(), - after: None, - }); - return; - } - if records.is_empty() && *from != 0 && self.session_replica.is_some() { - self.baseline_topics.insert(envelope.topic.clone()); - self.hydrated_sessions.insert(session_id.clone()); - return; + let held = &mut self.threads.entry(session_id.clone()).or_default().history; + match held { + // Continues the held cursor. + Some(held) if *from != 0 && held.end == *from => { + if records.is_empty() && self.session_replica.is_some() { + self.baseline_topics.insert(envelope.topic.clone()); + return; + } + held.extend(records, *end); + } + // Neither a baseline nor what follows the held records: + // ask for a baseline. + Some(_) if *from != 0 => { + *held = None; + self.session_replica = None; + self.baseline_topics.remove(&envelope.topic); + self.session_catching_up = false; + let _ = self.host.subscribe(Subscription { + topic: envelope.topic.clone(), + after: None, + }); + return; + } + _ => *held = Some(history::HeldHistory::new(*from, *end, records)), } - held.extend(records.iter().cloned()); - *held_end = *end; let after = *end; self.session_catching_up = after < *total; let _ = self.host.update_after(&envelope.topic, after); @@ -1120,7 +1141,6 @@ impl WorkspaceStore { } let mut timeline = self.fold_held_records(session_id); self.baseline_topics.insert(envelope.topic.clone()); - self.hydrated_sessions.insert(session_id.clone()); self.session_turn_offset = (*total_turns as usize).saturating_sub(timeline.turns.len()); self.settle_running_turn(&mut timeline); @@ -1130,15 +1150,22 @@ impl WorkspaceStore { if self.selected_session_id.as_ref() != Some(session_id) { return; } - if self.session_catching_up || !self.session_from.contains_key(session_id) { + // Until its window arrives the thread may hold an earlier + // visit's records, which the window continues: a record sent + // before the window is part of it. + if self.session_catching_up || self.session_replica.is_none() { return; } - self.session_records - .entry(session_id.clone()) - .or_default() - .push(record.clone()); - let after = self.session_end.get(session_id).map_or(0, |end| end + 1); - self.session_end.insert(session_id.clone(), after); + let Some(held) = self + .threads + .get_mut(session_id) + .and_then(|thread| thread.history.as_mut()) + else { + return; + }; + held.records.push(record.clone()); + held.end += 1; + let after = held.end; let _ = self.host.update_after(&envelope.topic, after); // A new turn means the user moved on; the recovery card for the // stopped one is stale. @@ -1215,6 +1242,13 @@ impl WorkspaceStore { if envelope.topic == Topic::Index { self.reconcile_destination(cx); self.removed_session = None; + // A thread kept on screen (its failed send still offers Retry) + // keeps its replicas until the user leaves it. + if let ServerEvent::IndexRemoveSession { session_id } = &envelope.event + && self.selected_session_id.as_ref() != Some(session_id) + { + self.threads.remove(session_id); + } } self.acknowledge_read(); } @@ -1455,7 +1489,13 @@ impl WorkspaceStore { } fn fold_held_records(&self, session_id: &str) -> Timeline { - Timeline::fold_stored(self.session_records.get(session_id).into_iter().flatten()) + Timeline::fold_stored( + self.threads + .get(session_id) + .and_then(|thread| thread.history.as_ref()) + .into_iter() + .flat_map(|held| &held.records), + ) } /// Records folded after their provider stopped still end running, and a @@ -2687,12 +2727,35 @@ impl WorkspaceStore { timeline: Timeline, cx: &mut Context, ) { + if self.selected_session_id.as_ref() == Some(&session_id) { + self.session_replica = Some((session_id, timeline)); + return; + } self.select_session(session_id.clone()); - self.host - .command_blocking(tcode_protocol::Command::ClearRelaunchMarker) - .expect("subscription fence"); - while let Ok(envelope) = self.host.events().try_recv() { + // The host answers the subscription once it has read the thread's + // log, after anything an acknowledgement could fence. + let topic = Topic::SessionEvents { + session_id: session_id.clone(), + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let envelope = match self.host.events().try_recv() { + Ok(envelope) => envelope, + Err(_) => { + assert!( + std::time::Instant::now() < deadline, + "the session's window did not arrive" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + continue; + } + }; self.apply_domain_event(&envelope, cx); + if envelope.topic == topic + && matches!(envelope.event, ServerEvent::SessionSnapshot { .. }) + { + break; + } } self.session_replica = Some((session_id, timeline)); } @@ -2975,7 +3038,10 @@ impl WorkspaceStore { } pub fn session_plan(&self) -> Option<&SessionPlan> { - self.session_plans.get(self.selected_session_id.as_deref()?) + self.threads + .get(self.selected_session_id.as_deref()?)? + .plan + .as_ref() } pub fn session_status(&self) -> Option<&SessionStatus> { @@ -3117,6 +3183,7 @@ mod tests { use super::{ ConversationDestination, WorkspaceAttachment, WorkspaceStore, effective_client_settings, + history::HeldHistory, }; #[cfg(all( @@ -3312,7 +3379,7 @@ mod tests { }, cx, ); - store.session_records.remove("scripted"); + store.threads.remove("scripted"); store.selected_session_id = Some("scripted".into()); assert!(store.delivery_messages().is_empty()); }); @@ -3924,7 +3991,10 @@ mod tests { cx, ); assert_eq!( - (store.session_from["merged"], store.session_end["merged"]), + ( + held_history(store, "merged").from, + held_history(store, "merged").end + ), (10, 20) ); assert_eq!( @@ -3940,7 +4010,7 @@ mod tests { )), cx, ); - assert_eq!(store.session_end["merged"], 21); + assert_eq!(held_history(store, "merged").end, 21); store.apply_domain_event( &EventEnvelope { request_id: None, @@ -4086,7 +4156,7 @@ mod tests { ); }); while outgoing.try_recv().is_ok() {} - workspace.update(cx, |store, cx| store.update_history_window(1., cx)); + workspace.update(cx, |store, cx| store.update_history_window(1., true, cx)); cx.run_until_parked(); let mut request = tcode_protocol::decode_client_line(&outgoing.try_recv().unwrap()).unwrap(); @@ -4193,7 +4263,9 @@ mod tests { ) .unwrap(); wait_until(cx, &workspace, "prefetched page applied", |cx| { - workspace.read_with(cx, |store, _| store.session_from["large"] == before - 200) + workspace.read_with(cx, |store, _| { + held_window(store, "large").is_some_and(|held| held.from == before - 200) + }) }); assert!( outgoing.try_recv().is_err(), @@ -4203,7 +4275,7 @@ mod tests { .advance_clock(std::time::Duration::from_millis(250)); cx.run_until_parked(); workspace.update(cx, |store, cx| { - store.update_history_window(if page < 3 { 2. + page as f32 } else { 6. }, cx); + store.update_history_window(if page < 3 { 2. + page as f32 } else { 6. }, true, cx); }); cx.run_until_parked(); if page < 3 { @@ -4219,8 +4291,8 @@ mod tests { "stop when six screens are covered, without a scroll event" ); workspace.read_with(cx, |store, _| { - assert_eq!(store.session_from["large"], 1000); - assert_eq!(store.session_records["large"].len(), 1000); + assert_eq!(held_history(store, "large").from, 1000); + assert_eq!(held_history(store, "large").records.len(), 1000); assert!(!store.history_loading()); }); } @@ -4455,6 +4527,14 @@ mod tests { ); } + fn held_window<'a>(store: &'a WorkspaceStore, session_id: &str) -> Option<&'a HeldHistory> { + store.threads.get(session_id)?.history.as_ref() + } + + fn held_history<'a>(store: &'a WorkspaceStore, session_id: &str) -> &'a HeldHistory { + held_window(store, session_id).expect("a held window") + } + fn test_host(store: SessionStore) -> SpawnedHost { spawn_host(store, HostServices::default()).expect("spawn test host") } @@ -4588,6 +4668,191 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + /// A client that visits many threads keeps the replicas of the selected + /// one and of the few it left last. Re-selecting a released thread asks + /// for a baseline and holds exactly what a client opening it fresh holds; + /// re-selecting a kept one sends its cursor and receives only what it + /// missed. Records appended while a thread was away and live afterwards + /// are each held once, and a thread deleted from the index is released at + /// once. + #[gpui::test] + fn visiting_many_threads_keeps_the_replicas_of_the_last_few(cx: &mut TestAppContext) { + let root = scratch_root("visited-threads"); + let disk = SessionStore::open_at(root.clone()).unwrap(); + let ids: Vec = (0..50).map(|index| format!("thread-{index:02}")).collect(); + let answer = |id: &str, turn: usize| { + AgentEvent::ItemCompleted(ThreadItem { + id: format!("{id}-answer-{turn}"), + parent_item_id: None, + content: ItemContent::AssistantMessage { + text: format!("answer {turn} in {id}"), + }, + }) + }; + let turn = |id: &str, turn: usize| { + [ + AgentEvent::TurnStarted { + turn_id: turn.to_string(), + }, + answer(id, turn), + AgentEvent::TurnCompleted { + turn_id: turn.to_string(), + status: TurnStatus::Completed, + usage: None, + }, + ] + }; + disk.upsert_project(&project_at("p", &root)).unwrap(); + for id in &ids { + disk.upsert_meta(&thread(&root, id, "p", None)).unwrap(); + let appends: Vec<_> = (0..3) + .flat_map(|index| turn(id, index)) + .enumerate() + .map(|(ts, event)| { + tcode_services::store::Mutation::append_event(id, ts as u64 + 1, &event) + .unwrap() + }) + .collect(); + disk.apply(&appends).unwrap(); + } + let host = test_host(disk); + let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + let open = |cx: &mut TestAppContext, workspace: &gpui::Entity, id: &str| { + workspace.update(cx, |store, _| store.select_session(id.into())); + wait_until(cx, workspace, id, |cx| { + workspace.read_with(cx, |store, _| { + store.baseline_ready() && !store.session_loading() && !store.session_catching_up + }) + }); + }; + let events_cursor = |store: &WorkspaceStore| { + store + .host + .subscriptions() + .into_iter() + .find(|subscription| matches!(subscription.topic, Topic::SessionEvents { .. })) + .map(|subscription| subscription.after) + }; + let held_count = |store: &WorkspaceStore, id: &str, event: &AgentEvent| { + held_history(store, id) + .records + .iter() + .filter(|record| record.event == *event) + .count() + }; + for id in &ids { + open(cx, &workspace, id); + } + workspace.read_with(cx, |store, _| { + let mut held: Vec<&String> = store.threads.keys().collect(); + held.sort(); + assert_eq!(held, ids[45..].iter().collect::>()); + }); + + // A kept thread continues from its cursor even past what a baseline + // would carry: the baseline would begin hundreds of records later. + let kept = &ids[46]; + let end = workspace.read_with(cx, |store, _| held_history(store, kept).end); + let missed: Vec = (3..153).flat_map(|index| turn(kept, index)).collect(); + update_host!(&host, { + let (kept, missed) = (kept.clone(), missed.clone()); + move |state, cx| { + for (offset, event) in missed.iter().enumerate() { + state.record_event_for_replica_test(&kept, 100 + offset as u64, event, cx); + } + } + }); + workspace.update(cx, |store, _| { + store.select_session(kept.clone()); + assert_eq!(events_cursor(store), Some(Some(end))); + }); + let total = end + missed.len() as u64; + wait_until(cx, &workspace, "the kept thread's continuation", |cx| { + workspace.read_with(cx, |store, _| { + held_window(store, kept).is_some_and(|held| held.end == total) + && store.session_replica.is_some() + }) + }); + let after_reselect = answer(kept, 153); + update_host!(&host, { + let (kept, after_reselect) = (kept.clone(), after_reselect.clone()); + move |state, cx| state.record_event_for_replica_test(&kept, 1000, &after_reselect, cx) + }); + wait_until(cx, &workspace, "the kept thread's live record", |cx| { + workspace.read_with(cx, |store, _| { + held_window(store, kept).is_some_and(|held| held.end == total + 1) + }) + }); + workspace.read_with(cx, |store, _| { + let held = held_history(store, kept); + assert_eq!(held.from, 0, "a continuation, not a baseline"); + for event in [answer(kept, 0), answer(kept, 152), after_reselect.clone()] { + assert_eq!(held_count(store, kept, &event), 1); + } + }); + + // A released thread opens as on a fresh client, with what was + // appended while it was away and what arrives live once it is open. + let released = &ids[3]; + let away = answer(released, 3); + let live = answer(released, 4); + update_host!(&host, { + let (released, away) = (released.clone(), away.clone()); + move |state, cx| state.record_event_for_replica_test(&released, 100, &away, cx) + }); + workspace.update(cx, |store, _| { + store.select_session(released.clone()); + assert_eq!(events_cursor(store), Some(None)); + }); + wait_until(cx, &workspace, "the released thread's baseline", |cx| { + workspace.read_with(cx, |store, _| { + store.baseline_ready() && !store.session_loading() + }) + }); + update_host!(&host, { + let (released, live) = (released.clone(), live.clone()); + move |state, cx| state.record_event_for_replica_test(&released, 101, &live, cx) + }); + wait_until(cx, &workspace, "the live record", |cx| { + workspace.read_with(cx, |store, _| { + held_window(store, released).is_some_and(|held| held.end == 11) + }) + }); + let fresh = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + open(cx, &fresh, released); + let fresh = fresh.read_with(cx, |store, _| { + (store.session_replica.clone(), store.session_turn_offset) + }); + workspace.read_with(cx, |store, _| { + assert_eq!( + (store.session_replica.clone(), store.session_turn_offset), + fresh + ); + for event in [&away, &live] { + assert_eq!(held_count(store, released, event), 1); + } + }); + + let deleted = ids[47].clone(); + assert!(workspace.read_with(cx, |store, _| store.threads.contains_key(&deleted))); + command( + &host, + Command::DeleteSession { + session_id: deleted.clone(), + remove_worktree: false, + }, + ); + wait_until(cx, &workspace, "the deletion", |cx| { + workspace.read_with(cx, |store, _| { + !store.index_replica.0.iter().any(|meta| meta.id == deleted) + }) + }); + assert!(!workspace.read_with(cx, |store, _| store.threads.contains_key(&deleted))); + + shutdown_test_host(&host); + let _ = std::fs::remove_dir_all(&root); + } + fn scratch_root(label: &str) -> std::path::PathBuf { static NEXT_ROOT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); std::env::temp_dir().join(format!( @@ -4891,9 +5156,12 @@ mod tests { let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); workspace.update(cx, |store, _| store.select_session("parent".into())); - wait_until(cx, &workspace, "parent selected", |cx| { + wait_until(cx, &workspace, "parent on screen", |cx| { selected_status(cx, &workspace, "parent") + && workspace.read_with(cx, |store, _| held_window(store, "parent").is_some()) }); + let parent_cursor = + workspace.read_with(cx, |store, _| Some(held_history(store, "parent").end)); workspace.update(cx, |store, _| store.select_session("child".into())); wait_until(cx, &workspace, "child selected", |cx| { selected_status(cx, &workspace, "child") @@ -4910,9 +5178,20 @@ mod tests { }); workspace.read_with(cx, |store, _| { assert!( - store.session_records.contains_key("parent"), + held_window(store, "parent").is_some(), "the parent's replicated records were dropped on the way back" ); + let events_cursor = store + .host + .subscriptions() + .into_iter() + .find(|subscription| matches!(subscription.topic, Topic::SessionEvents { .. })) + .map(|subscription| subscription.after); + assert_eq!( + events_cursor, + Some(parent_cursor), + "the parent resumes from its cursor, not from nothing" + ); }); // The index and its summary replicate on their own topics: the // return to the parent follows the index, the archived count the @@ -5097,7 +5376,9 @@ mod tests { } }); wait_until(cx, &workspace, "three records", |cx| { - workspace.read_with(cx, |store, _| store.session_records["reconnect"].len() == 3) + workspace.read_with(cx, |store, _| { + held_window(store, "reconnect").is_some_and(|held| held.records.len() == 3) + }) }); host.link() .set_connection_state(tcode_client::ConnectionState::Reconnecting { @@ -5109,7 +5390,7 @@ mod tests { command(&host, Command::ClearRelaunchMarker); workspace.update(cx, |store, cx| { store.drain_host_events_for_test(cx); - assert_eq!(store.session_records["reconnect"].len(), 3); + assert_eq!(held_history(store, "reconnect").records.len(), 3); store.apply_domain_event( &EventEnvelope { request_id: None, @@ -5136,11 +5417,16 @@ mod tests { cx, &workspace, "full replacement after invalid tail", - |cx| workspace.read_with(cx, |store, _| store.session_records["reconnect"].len() == 3), + |cx| { + workspace.read_with(cx, |store, _| { + held_window(store, "reconnect").is_some_and(|held| held.records.len() == 3) + }) + }, ); workspace.read_with(cx, |store, _| { assert_eq!( - store.session_records["reconnect"] + held_history(store, "reconnect") + .records .iter() .map(|record| record.ts) .collect::>(), @@ -5287,10 +5573,8 @@ mod tests { "incremental session timeline replica", |cx| { workspace.read_with(cx, |store, _| { - store - .session_records - .get(&session_id) - .and_then(|records| records.last()) + held_window(store, &session_id) + .and_then(|held| held.records.last()) .is_some_and(|record| { matches!( &record.event, @@ -5697,20 +5981,15 @@ mod tests { }); let session_id = meta.id.clone(); - update_host!(&host, move |_state, cx| { - cx.emit(HostEvent::Domain(EventEnvelope { - request_id: None, - topic: Topic::SessionEvents { - session_id: session_id.clone(), + update_host!(&host, move |state, cx| { + state.record_event_for_replica_test( + &session_id, + 1, + &AgentEvent::TurnStarted { + turn_id: "turn-next".into(), }, - event: ServerEvent::SessionEvent(SessionEventRecord { - ts: None, - event: AgentEvent::TurnStarted { - turn_id: "turn-next".into(), - }, - elided: None, - }), - })); + cx, + ); }); wait_until( cx,