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,