Skip to main content

vtcode_core/utils/session_archive/
archive.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex};
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7
8use super::identifier::{generate_unique_archive_path, reserve_new_session_archive_path};
9use super::message::SessionMessage;
10use super::metadata::{SessionArchiveMetadata, SessionContinuationMetadata};
11use super::query::{find_session_by_identifier, resolve_sessions_dir_for_archive_writes, write_private_json_file_sync};
12use super::retention::apply_session_retention_best_effort;
13use super::settings::history_persistence_enabled;
14use super::shrink::prepare_snapshot_for_write;
15use super::snapshot::{
16    SessionListing, SessionProgress, SessionProgressArgs, SessionProgressPersistenceStatus, SessionSnapshot,
17    VerifiedSessionArchiveIdentifier,
18};
19use super::transcript::{clean_transcript_lines, progress_transcript_from_recent_messages};
20use crate::config::constants::defaults;
21use crate::core::agent::snapshots::SnapshotTurnDiagnostics;
22use crate::telemetry::perf::PerfSpan;
23use crate::utils::session_transcript_norm::normalize_distinct_tools_for_summary;
24use vtcode_commons::fs::write_private_json_file;
25
26#[derive(Debug, Clone)]
27pub struct SessionArchive {
28    path: PathBuf,
29    metadata: SessionArchiveMetadata,
30    started_at: DateTime<Utc>,
31    progress_throttle: Arc<Mutex<ProgressThrottle>>,
32}
33
34#[derive(Debug)]
35struct ProgressThrottle {
36    last_written: Instant,
37    last_turn: usize,
38}
39
40impl ProgressThrottle {
41    fn new() -> Self {
42        let min_interval = Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
43        let last_written = Instant::now().checked_sub(min_interval).unwrap_or_else(Instant::now);
44        Self { last_written, last_turn: 0 }
45    }
46}
47
48impl SessionArchive {
49    fn from_path(path: PathBuf, metadata: SessionArchiveMetadata, started_at: DateTime<Utc>) -> Self {
50        Self {
51            path,
52            metadata,
53            started_at,
54            progress_throttle: Arc::new(Mutex::new(ProgressThrottle::new())),
55        }
56    }
57
58    fn build_snapshot(
59        &self,
60        total_messages: usize,
61        distinct_tools: Vec<String>,
62        transcript: Vec<String>,
63        messages: Vec<SessionMessage>,
64        progress: Option<SessionProgress>,
65    ) -> SessionSnapshot {
66        use crate::utils::error_log_collector::drain_error_logs;
67
68        SessionSnapshot {
69            metadata: self.metadata.clone(),
70            started_at: self.started_at,
71            ended_at: Utc::now(),
72            total_messages,
73            distinct_tools,
74            transcript,
75            messages,
76            progress: progress.map(Box::new),
77            error_logs: drain_error_logs(),
78        }
79    }
80
81    /// Records the active primary agent (session "mode") so a later resume can
82    /// restore it. Called once the active agent is selected, after the archive
83    /// and thread have been started.
84    pub fn set_primary_agent(&mut self, primary_agent: impl Into<String>) {
85        self.metadata.primary_agent = Some(primary_agent.into());
86    }
87
88    pub fn metadata(&self) -> &SessionArchiveMetadata {
89        &self.metadata
90    }
91
92    fn build_final_snapshot(
93        &self,
94        transcript: Vec<String>,
95        total_messages: usize,
96        distinct_tools: Vec<String>,
97        messages: Vec<SessionMessage>,
98        turn_diagnostics: Option<SnapshotTurnDiagnostics>,
99    ) -> SessionSnapshot {
100        self.build_snapshot(
101            total_messages,
102            normalize_distinct_tools_for_summary(&distinct_tools),
103            clean_transcript_lines(&transcript),
104            messages,
105            turn_diagnostics.map(|turn_diagnostics| SessionProgress {
106                turn_diagnostics: Some(turn_diagnostics),
107                ..SessionProgress::default()
108            }),
109        )
110    }
111
112    fn build_progress_snapshot(&self, args: SessionProgressArgs) -> SessionSnapshot {
113        let SessionProgressArgs {
114            total_messages,
115            distinct_tools,
116            messages,
117            recent_messages,
118            turn_number,
119            token_usage,
120            max_context_tokens,
121            loaded_skills,
122            turn_diagnostics,
123        } = args;
124
125        let transcript = progress_transcript_from_recent_messages(&recent_messages);
126        let distinct_tools = normalize_distinct_tools_for_summary(&distinct_tools);
127        let tool_summaries = distinct_tools.clone();
128
129        self.build_snapshot(
130            total_messages,
131            distinct_tools,
132            transcript,
133            messages,
134            Some(SessionProgress {
135                turn_number,
136                recent_messages,
137                tool_summaries,
138                token_usage,
139                max_context_tokens,
140                loaded_skills: loaded_skills.unwrap_or_default(),
141                turn_diagnostics,
142            }),
143        )
144    }
145
146    pub async fn new(metadata: SessionArchiveMetadata, custom_suffix: Option<String>) -> Result<Self> {
147        let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
148        let started_at = Utc::now();
149        let path = generate_unique_archive_path(&sessions_dir, &metadata, started_at, custom_suffix.as_deref());
150
151        Ok(Self::from_path(path, metadata, started_at))
152    }
153
154    /// Create a session archive using an explicitly reserved session identifier.
155    pub async fn new_with_identifier(metadata: SessionArchiveMetadata, session_identifier: String) -> Result<Self> {
156        let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
157        let path = reserve_new_session_archive_path(&sessions_dir, &session_identifier)?;
158
159        Ok(Self::from_path(path, metadata, Utc::now()))
160    }
161
162    /// Reopen an existing archive file so follow-up runs can overwrite the snapshot in place.
163    pub fn resume_from_listing(listing: &SessionListing, metadata: SessionArchiveMetadata) -> Self {
164        Self::from_path(listing.path.clone(), metadata, listing.snapshot.started_at)
165    }
166
167    pub fn finalize(
168        &self,
169        transcript: Vec<String>,
170        total_messages: usize,
171        distinct_tools: Vec<String>,
172        messages: Vec<SessionMessage>,
173    ) -> Result<PathBuf> {
174        self.finalize_with_diagnostics(transcript, total_messages, distinct_tools, messages, None)
175    }
176
177    /// Finalize the archive while retaining lightweight diagnostics for the
178    /// last turn. The final snapshot still omits the heavy progress history.
179    pub fn finalize_with_diagnostics(
180        &self,
181        transcript: Vec<String>,
182        total_messages: usize,
183        distinct_tools: Vec<String>,
184        messages: Vec<SessionMessage>,
185        turn_diagnostics: Option<SnapshotTurnDiagnostics>,
186    ) -> Result<PathBuf> {
187        let snapshot =
188            self.build_final_snapshot(transcript, total_messages, distinct_tools, messages, turn_diagnostics);
189
190        let path = self.write_snapshot(snapshot)?;
191        if let Some(parent) = path.parent() {
192            apply_session_retention_best_effort(parent);
193        }
194        Ok(path)
195    }
196
197    pub fn persist_progress(&self, args: SessionProgressArgs) -> Result<PathBuf> {
198        let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
199        perf.tag("mode", "sync");
200
201        let snapshot = self.build_progress_snapshot(args);
202
203        self.write_snapshot(snapshot)
204    }
205
206    /// Persist progress asynchronously and report whether this call wrote a
207    /// new durable snapshot.
208    ///
209    /// A throttled checkpoint still returns the archive path for diagnostics,
210    /// but it must not be treated as durable by callers that acknowledge
211    /// state only after history has been written.
212    pub async fn persist_progress_async_with_status(
213        &self,
214        args: SessionProgressArgs,
215    ) -> Result<SessionProgressPersistenceStatus> {
216        self.persist_progress_async_with_status_inner(args, false).await
217    }
218
219    async fn persist_progress_async_with_status_inner(
220        &self,
221        args: SessionProgressArgs,
222        bypass_throttle: bool,
223    ) -> Result<SessionProgressPersistenceStatus> {
224        let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
225        perf.tag("mode", if bypass_throttle { "async_forced" } else { "async" });
226
227        if !history_persistence_enabled() {
228            return Ok(SessionProgressPersistenceStatus::Disabled(self.path.clone()));
229        }
230        if !bypass_throttle && !self.should_persist_progress(args.turn_number)? {
231            return Ok(SessionProgressPersistenceStatus::Throttled(self.path.clone()));
232        }
233
234        let snapshot = self.build_progress_snapshot(args);
235        let path = self.write_snapshot_async(snapshot).await?;
236        Ok(SessionProgressPersistenceStatus::Persisted(path))
237    }
238
239    /// Persist progress immediately, bypassing the normal progress throttle.
240    ///
241    /// This is intended for terminal handoff checkpoints where the latest
242    /// conversation must be durable before an external artifact is written.
243    pub async fn persist_progress_async_with_status_forced(
244        &self,
245        args: SessionProgressArgs,
246    ) -> Result<SessionProgressPersistenceStatus> {
247        self.persist_progress_async_with_status_inner(args, true).await
248    }
249
250    pub async fn persist_progress_async(&self, args: SessionProgressArgs) -> Result<PathBuf> {
251        Ok(self.persist_progress_async_with_status(args).await?.path().to_path_buf())
252    }
253
254    fn write_snapshot(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
255        let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
256            return Ok(self.path.clone());
257        };
258
259        write_private_json_file_sync(&self.path, &snapshot)?;
260        Ok(self.path.clone())
261    }
262
263    async fn write_snapshot_async(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
264        let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
265            return Ok(self.path.clone());
266        };
267
268        write_private_json_file(&self.path, &snapshot).await?;
269        Ok(self.path.clone())
270    }
271
272    fn should_persist_progress(&self, turn_number: usize) -> Result<bool> {
273        let min_interval = Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
274        let min_turns = defaults::DEFAULT_SESSION_PROGRESS_MIN_TURN_DELTA;
275
276        let mut throttle = self
277            .progress_throttle
278            .lock()
279            .map_err(|err| anyhow::anyhow!("session progress throttle lock poisoned: {err}"))
280            .context("Failed to evaluate session progress persistence throttle")?;
281        if turn_number <= throttle.last_turn {
282            return Ok(false);
283        }
284        if throttle.last_written.elapsed() < min_interval && turn_number.saturating_sub(throttle.last_turn) < min_turns
285        {
286            return Ok(false);
287        }
288
289        throttle.last_written = Instant::now();
290        throttle.last_turn = turn_number;
291        Ok(true)
292    }
293    /// Update loaded skills in the archive metadata
294    pub fn set_loaded_skills(&mut self, skills: Vec<String>) {
295        self.metadata.loaded_skills = skills;
296    }
297
298    /// Update continuation metadata in the archive metadata.
299    pub fn set_continuation_metadata(&mut self, continuation_metadata: Option<SessionContinuationMetadata>) {
300        self.metadata.continuation_metadata = continuation_metadata;
301    }
302
303    pub fn path(&self) -> &Path {
304        &self.path
305    }
306
307    /// Verify that a persisted snapshot resolves back to this archive and
308    /// return its typed resume identifier.
309    pub async fn verify_persisted_resume_identifier(
310        &self,
311        persisted_path: &Path,
312    ) -> Result<Option<VerifiedSessionArchiveIdentifier>> {
313        if self.path != persisted_path {
314            return Ok(None);
315        }
316
317        let Some(identifier) = persisted_path.file_stem().and_then(|stem| stem.to_str()) else {
318            return Ok(None);
319        };
320        let Some(listing) = find_session_by_identifier(identifier).await? else {
321            return Ok(None);
322        };
323        if listing.path != self.path {
324            return Ok(None);
325        }
326
327        Ok(Some(listing.verified_identifier()?))
328    }
329
330    /// Create a forked session from an existing session snapshot
331    ///
332    /// This creates a new session archive that inherits metadata from the source
333    /// session but operates independently. The forked session will have a new
334    /// archive file with a custom suffix if provided.
335    ///
336    /// # Arguments
337    /// * `source_snapshot` - The snapshot of the session to fork from
338    /// * `custom_suffix` - Optional custom suffix for the new session ID
339    ///
340    /// # Returns
341    /// A new SessionArchive instance for the forked session
342    pub async fn fork(source_snapshot: &SessionSnapshot, custom_suffix: Option<String>) -> Result<Self> {
343        create_fork_archive(source_snapshot, custom_suffix, None).await
344    }
345}
346
347async fn create_fork_archive(
348    source_snapshot: &SessionSnapshot,
349    custom_suffix: Option<String>,
350    explicit_identifier: Option<String>,
351) -> Result<SessionArchive> {
352    let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
353    let started_at = Utc::now();
354
355    let forked_metadata = source_snapshot.metadata.fork_seed();
356
357    let path = if let Some(session_identifier) = explicit_identifier {
358        reserve_new_session_archive_path(&sessions_dir, &session_identifier)?
359    } else {
360        generate_unique_archive_path(&sessions_dir, &forked_metadata, started_at, custom_suffix.as_deref())
361    };
362
363    Ok(SessionArchive::from_path(path, forked_metadata, started_at))
364}