Skip to main content

vtcode_memory/
retention.rs

1//! Retention and garbage-collection for the unified session store.
2
3use std::path::Path;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use walkdir::WalkDir;
7
8use crate::error::SessionStoreError;
9use crate::query::SessionSummary;
10use crate::sessions_root;
11
12#[derive(Debug)]
13struct RetentionCandidate {
14    path: std::path::PathBuf,
15    summary: SessionSummary,
16}
17
18/// Retention policy applied to the set of per-session stores.
19#[derive(Debug, Clone, Copy)]
20pub struct RetentionPolicy {
21    /// Maximum number of sessions to keep (oldest evicted first).
22    pub max_sessions: usize,
23    /// Maximum age of a session in days before eviction.
24    pub max_age_days: u64,
25}
26
27impl Default for RetentionPolicy {
28    fn default() -> Self {
29        Self { max_sessions: 50, max_age_days: 30 }
30    }
31}
32
33/// Apply the retention policy, removing the oldest / stale sessions.
34///
35/// Returns the number of sessions removed. This bounds the otherwise
36/// unbounded growth of `.vtcode/sessions/` so overhead does not accumulate
37/// on disk across a long-lived agent.
38pub fn apply_retention(workspace: &Path, policy: RetentionPolicy) -> Result<usize, SessionStoreError> {
39    apply_retention_preserving(workspace, policy, None)
40}
41
42/// Apply retention while preserving one session directory, even when its
43/// existing manifest still says `completed` (for example, a resumed session).
44///
45/// The preserved path is resolved with the same session-id sanitization as the
46/// canonical store and is compared against the direct child discovered on
47/// disk. It is never taken from a manifest.
48pub fn apply_retention_preserving(
49    workspace: &Path,
50    policy: RetentionPolicy,
51    preserve_session_id: Option<&str>,
52) -> Result<usize, SessionStoreError> {
53    let root = sessions_root(workspace);
54    let root_metadata = match std::fs::symlink_metadata(&root) {
55        Ok(metadata) => metadata,
56        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
57        Err(error) => return Err(SessionStoreError::io(root.clone(), error)),
58    };
59    if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
60        return Ok(0);
61    }
62    // Crashed/killed threads never emit thread.completed; surface those
63    // abandoned `active` stores as completed so the eviction phases can
64    // reclaim them. Live sessions stay `active` and remain unpinned.
65    mark_abandoned_active_sessions(workspace, policy.max_age_days, preserve_session_id)?;
66    let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
67    let sessions = retention_candidates(&root, preserve_path.as_deref())?;
68    let mut removed = 0usize;
69
70    // Phase 0: drop empty completed shells immediately. A 0-turn store is only
71    // `thread.started` + `thread.completed` noise; it never held work the user
72    // can resume. Age/count caps would otherwise keep these around for weeks.
73    let (empty, mut sessions): (Vec<_>, Vec<_>) = sessions
74        .into_iter()
75        .partition(|s| s.summary.turn_count == 0 && s.summary.status != "active");
76    for s in &empty {
77        remove_session(&root, &s.path)?;
78        removed += 1;
79    }
80
81    // Phase 1: evict oldest sessions beyond the count cap.
82    if sessions.len() > policy.max_sessions {
83        sessions.sort_by(|a, b| a.summary.updated_at.cmp(&b.summary.updated_at));
84        let to_remove = sessions.len() - policy.max_sessions;
85        for s in sessions.iter().take(to_remove) {
86            remove_session(&root, &s.path)?;
87            removed += 1;
88        }
89        // Drop evicted entries so phase 2 doesn't double-remove.
90        sessions.drain(..to_remove);
91    }
92
93    // Phase 2: evict sessions older than max_age_days (regardless of count).
94    let cutoff = age_cutoff(policy.max_age_days);
95    for s in &sessions {
96        if older_than(s.summary.updated_at.as_str(), cutoff) {
97            remove_session(&root, &s.path)?;
98            removed += 1;
99        }
100    }
101
102    Ok(removed)
103}
104
105/// Sidecar marker written when a session must outlive ordinary retention
106/// because an unresolved blocker archive references it.
107pub const RETENTION_PIN_FILE: &str = "retention-pin.json";
108
109/// Delete a 0-turn completed session store immediately (close-path hygiene).
110///
111/// Empty shells (`thread.started` + `thread.completed` only) never held work
112/// the user can resume; keeping them pollutes `.vtcode/sessions/` and hides
113/// real sessions. No-op when the store has turns, is still active, is pinned,
114/// is live in another process, or the id is not a validated direct child.
115/// Returns whether the store was removed.
116pub fn evict_zero_turn_completed_store(workspace: &Path, session_id: &str) -> Result<bool, SessionStoreError> {
117    let root = sessions_root(workspace);
118    let dir = crate::session_dir(workspace, session_id);
119    if dir.parent() != Some(&root) {
120        return Ok(false);
121    }
122    if session_retention_pinned(&dir) || session_dir_is_live(&dir) {
123        return Ok(false);
124    }
125    let manifest_path = dir.join("manifest.json");
126    let Ok(bytes) = std::fs::read(&manifest_path) else {
127        return Ok(false);
128    };
129    let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
130        return Ok(false);
131    };
132    if summary.turn_count > 0 || summary.status == "active" {
133        return Ok(false);
134    }
135    remove_session(&root, &dir)?;
136    Ok(true)
137}
138
139/// Whether a session directory is pinned against ordinary retention eviction.
140#[must_use]
141pub fn session_retention_pinned(session_dir: &Path) -> bool {
142    session_dir.join(RETENTION_PIN_FILE).is_file()
143}
144
145/// Whether a live process still holds the session's event-log handles open.
146///
147/// `session.lock` is flock-held for as long as any event-log handle to the
148/// session exists (see `event_log::acquire_liveness_lock`). `WouldBlock`
149/// proves a live holder; a missing lock file (older or crashed sessions) or
150/// an acquirable lock means nothing alive keeps the session open. Unreadable
151/// lock files count as live: deletion paths must not evict what they cannot
152/// inspect.
153pub(crate) fn session_dir_is_live(session_dir: &Path) -> bool {
154    use crate::event_log::SESSION_LOCK_FILE;
155
156    let lock_path = session_dir.join(SESSION_LOCK_FILE);
157    let file = match std::fs::OpenOptions::new().read(true).write(true).open(&lock_path) {
158        Ok(file) => file,
159        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
160        Err(_) => return true,
161    };
162    file.try_lock()
163        .map_err(std::io::Error::from)
164        .is_err_and(|error| error.kind() == std::io::ErrorKind::WouldBlock)
165}
166
167/// Session ids whose stores are retention-pinned against ordinary eviction.
168///
169/// Companion to [`session_retention_pinned`]: a blocked session pins its store
170/// so ordinary retention cannot erase its evidence. Callers pruning other
171/// session-derived data (e.g. legacy history envelopes) must exclude these ids
172/// or they would erase through a different path what the pin protects.
173#[must_use]
174pub fn retention_pinned_session_ids(workspace: &Path) -> Vec<String> {
175    let Ok(entries) = std::fs::read_dir(sessions_root(workspace)) else {
176        return Vec::new();
177    };
178    entries
179        .flatten()
180        .filter(|entry| entry.file_type().map(|file_type| file_type.is_dir()).unwrap_or(false))
181        .filter(|entry| session_retention_pinned(&entry.path()))
182        .filter_map(|entry| entry.file_name().into_string().ok())
183        .collect()
184}
185
186/// Write a retention pin for a session (best-effort path check by caller).
187pub fn pin_session_retention(session_dir: &Path, reason: &str) -> Result<(), SessionStoreError> {
188    let path = session_dir.join(RETENTION_PIN_FILE);
189    let body = serde_json::json!({
190        "reason": reason,
191        "pinned_at": chrono::Utc::now().to_rfc3339(),
192    });
193    std::fs::write(&path, body.to_string()).map_err(|e| SessionStoreError::io(path, e))
194}
195
196/// Remove a retention pin if present.
197pub fn unpin_session_retention(session_dir: &Path) -> Result<bool, SessionStoreError> {
198    let path = session_dir.join(RETENTION_PIN_FILE);
199    if !path.is_file() {
200        return Ok(false);
201    }
202    std::fs::remove_file(&path).map_err(|e| SessionStoreError::io(path, e))?;
203    Ok(true)
204}
205
206/// Enumerate session stores from their filesystem entries, never from the
207/// session ID contained in a manifest. This keeps retention confined to
208/// validated direct children of the sessions root.
209fn retention_candidates(
210    root: &Path,
211    preserve_path: Option<&Path>,
212) -> Result<Vec<RetentionCandidate>, SessionStoreError> {
213    let entries = std::fs::read_dir(root).map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
214    let mut candidates = Vec::new();
215    for entry in entries {
216        let entry = entry.map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
217        let file_type = entry.file_type().map_err(|e| SessionStoreError::io(entry.path(), e))?;
218        if !file_type.is_dir() || file_type.is_symlink() {
219            continue;
220        }
221        let path = entry.path();
222        if preserve_path.is_some_and(|preserve_path| preserve_path == path) {
223            continue;
224        }
225        // Unresolved blocker forensics must survive count/age eviction.
226        if session_retention_pinned(&path) {
227            continue;
228        }
229        // A session still open in a live process must not be evicted even
230        // when its manifest says completed: the user may resume or keep
231        // reading it.
232        if session_dir_is_live(&path) {
233            continue;
234        }
235        let manifest_path = path.join("manifest.json");
236        let Ok(bytes) = std::fs::read(&manifest_path) else {
237            continue;
238        };
239        let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
240            continue;
241        };
242        if summary.status == "active" {
243            continue;
244        }
245        candidates.push(RetentionCandidate { path, summary });
246    }
247    Ok(candidates)
248}
249
250/// Flip `active` manifests that have been idle past `max_age_days` to
251/// `completed` so ordinary retention can evict them.
252///
253/// A crashed or killed thread never emits `thread.completed`, so its manifest
254/// stays `active` forever and would otherwise pin the store. Live sessions are
255/// younger than the cutoff and are left untouched. `max_age_days == 0` disables
256/// the sweep (the hard "never evict active" contract used by force-evict tests).
257/// Returns how many manifests were marked abandoned.
258pub fn mark_abandoned_active_sessions(
259    workspace: &Path,
260    max_age_days: u64,
261    preserve_session_id: Option<&str>,
262) -> Result<usize, SessionStoreError> {
263    if max_age_days == 0 {
264        return Ok(0);
265    }
266    let root = sessions_root(workspace);
267    let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
268    let Ok(entries) = std::fs::read_dir(&root) else {
269        return Ok(0);
270    };
271    let cutoff = age_cutoff(max_age_days);
272    let mut marked = 0usize;
273    for entry in entries.flatten() {
274        let path = entry.path();
275        let file_type = match entry.file_type() {
276            Ok(file_type) => file_type,
277            Err(_) => continue,
278        };
279        // Same symlink-safe enumeration as `retention_candidates`: never
280        // rewrite a manifest through a planted symlink.
281        if !file_type.is_dir() || file_type.is_symlink() {
282            continue;
283        }
284        if preserve_path.as_deref() == Some(path.as_path()) {
285            continue;
286        }
287        if session_retention_pinned(&path) {
288            continue;
289        }
290        // A live process still holds this session open (open-but-idle):
291        // marking it completed would let phase-2 evict a session the user
292        // may still resume.
293        if session_dir_is_live(&path) {
294            continue;
295        }
296        let manifest_path = path.join("manifest.json");
297        let Ok(bytes) = std::fs::read(&manifest_path) else {
298            continue;
299        };
300        let Ok(mut summary) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
301            continue;
302        };
303        let is_active = summary.get("status").and_then(serde_json::Value::as_str) == Some("active");
304        if !is_active {
305            continue;
306        }
307        let updated_at = summary
308            .get("updated_at")
309            .and_then(serde_json::Value::as_str)
310            .unwrap_or_default();
311        if !older_than(updated_at, cutoff) {
312            continue;
313        }
314        if let Some(object) = summary.as_object_mut() {
315            object.insert("status".to_string(), serde_json::Value::String("completed".to_string()));
316        } else {
317            continue;
318        }
319        let body = serde_json::to_vec_pretty(&summary)
320            .map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
321        // Same primitive as ManifestStore: 0600 private temp + fsync + rename,
322        // so a crash cannot leave a truncated manifest, the rewritten manifest
323        // keeps session-file permissions, and a planted symlink cannot be
324        // followed to an outside destination.
325        vtcode_commons::VtCodePaths::write_private_file_atomic(&manifest_path, &body)
326            .map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
327        marked += 1;
328    }
329    Ok(marked)
330}
331
332/// Remove the legacy `history/` and `logs/` directories after they have been
333/// imported into the unified store by [`crate::migrate_legacy`].
334///
335/// Returns the number of bytes freed. The legacy `checkpoints/` directory is
336/// intentionally left in place until `/revert` is rewired to the unified
337/// store; callers should confirm revert behavior before deleting it manually.
338pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
339    let vt = workspace.join(".vtcode");
340    let mut freed = 0u64;
341    for name in ["history", "logs"] {
342        let dir = vt.join(name);
343        if dir.exists() {
344            freed += dir_size(&dir);
345            std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
346        }
347    }
348    Ok(freed)
349}
350
351fn remove_session(root: &Path, dir: &Path) -> Result<(), SessionStoreError> {
352    // Retention is allowed to remove only one validated child of the sessions
353    // root. Never trust a manifest-controlled identifier or follow a symlink.
354    if dir.parent() != Some(root) {
355        return Ok(());
356    }
357    let metadata = match std::fs::symlink_metadata(dir) {
358        Ok(metadata) => metadata,
359        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
360        Err(error) => return Err(SessionStoreError::io(dir.to_path_buf(), error)),
361    };
362    if metadata.file_type().is_symlink() || !metadata.is_dir() {
363        return Ok(());
364    }
365    std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
366    Ok(())
367}
368
369fn dir_size(dir: &Path) -> u64 {
370    WalkDir::new(dir)
371        .into_iter()
372        .filter_map(Result::ok)
373        .filter(|e| e.file_type().is_file())
374        .filter_map(|e| e.metadata().ok())
375        .map(|m| m.len())
376        .sum()
377}
378
379fn age_cutoff(max_age_days: u64) -> SystemTime {
380    let seconds = max_age_days.saturating_mul(24 * 3600);
381    SystemTime::now() - Duration::from_secs(seconds)
382}
383
384fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
385    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
386        return false;
387    };
388    let cutoff_secs = cutoff
389        .duration_since(UNIX_EPOCH)
390        .map(|d| d.as_secs() as i64)
391        .unwrap_or(i64::MAX);
392    dt.timestamp() < cutoff_secs
393}