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 mut sessions = retention_candidates(&root, preserve_path.as_deref())?;
68    let mut removed = 0usize;
69
70    // Phase 1: evict oldest sessions beyond the count cap.
71    if sessions.len() > policy.max_sessions {
72        sessions.sort_by(|a, b| a.summary.updated_at.cmp(&b.summary.updated_at));
73        let to_remove = sessions.len() - policy.max_sessions;
74        for s in sessions.iter().take(to_remove) {
75            remove_session(&root, &s.path)?;
76            removed += 1;
77        }
78        // Drop evicted entries so phase 2 doesn't double-remove.
79        sessions.drain(..to_remove);
80    }
81
82    // Phase 2: evict sessions older than max_age_days (regardless of count).
83    let cutoff = age_cutoff(policy.max_age_days);
84    for s in &sessions {
85        if older_than(s.summary.updated_at.as_str(), cutoff) {
86            remove_session(&root, &s.path)?;
87            removed += 1;
88        }
89    }
90
91    Ok(removed)
92}
93
94/// Sidecar marker written when a session must outlive ordinary retention
95/// because an unresolved blocker archive references it.
96pub const RETENTION_PIN_FILE: &str = "retention-pin.json";
97
98/// Whether a session directory is pinned against ordinary retention eviction.
99#[must_use]
100pub fn session_retention_pinned(session_dir: &Path) -> bool {
101    session_dir.join(RETENTION_PIN_FILE).is_file()
102}
103
104/// Write a retention pin for a session (best-effort path check by caller).
105pub fn pin_session_retention(session_dir: &Path, reason: &str) -> Result<(), SessionStoreError> {
106    let path = session_dir.join(RETENTION_PIN_FILE);
107    let body = serde_json::json!({
108        "reason": reason,
109        "pinned_at": chrono::Utc::now().to_rfc3339(),
110    });
111    std::fs::write(&path, body.to_string()).map_err(|e| SessionStoreError::io(path, e))
112}
113
114/// Remove a retention pin if present.
115pub fn unpin_session_retention(session_dir: &Path) -> Result<bool, SessionStoreError> {
116    let path = session_dir.join(RETENTION_PIN_FILE);
117    if !path.is_file() {
118        return Ok(false);
119    }
120    std::fs::remove_file(&path).map_err(|e| SessionStoreError::io(path, e))?;
121    Ok(true)
122}
123
124/// Enumerate session stores from their filesystem entries, never from the
125/// session ID contained in a manifest. This keeps retention confined to
126/// validated direct children of the sessions root.
127fn retention_candidates(
128    root: &Path,
129    preserve_path: Option<&Path>,
130) -> Result<Vec<RetentionCandidate>, SessionStoreError> {
131    let entries = std::fs::read_dir(root).map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
132    let mut candidates = Vec::new();
133    for entry in entries {
134        let entry = entry.map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
135        let file_type = entry.file_type().map_err(|e| SessionStoreError::io(entry.path(), e))?;
136        if !file_type.is_dir() || file_type.is_symlink() {
137            continue;
138        }
139        let path = entry.path();
140        if preserve_path.is_some_and(|preserve_path| preserve_path == path) {
141            continue;
142        }
143        // Unresolved blocker forensics must survive count/age eviction.
144        if session_retention_pinned(&path) {
145            continue;
146        }
147        let manifest_path = path.join("manifest.json");
148        let Ok(bytes) = std::fs::read(&manifest_path) else {
149            continue;
150        };
151        let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
152            continue;
153        };
154        if summary.status == "active" {
155            continue;
156        }
157        candidates.push(RetentionCandidate { path, summary });
158    }
159    Ok(candidates)
160}
161
162/// Flip `active` manifests that have been idle past `max_age_days` to
163/// `completed` so ordinary retention can evict them.
164///
165/// A crashed or killed thread never emits `thread.completed`, so its manifest
166/// stays `active` forever and would otherwise pin the store. Live sessions are
167/// younger than the cutoff and are left untouched. `max_age_days == 0` disables
168/// the sweep (the hard "never evict active" contract used by force-evict tests).
169/// Returns how many manifests were marked abandoned.
170pub fn mark_abandoned_active_sessions(
171    workspace: &Path,
172    max_age_days: u64,
173    preserve_session_id: Option<&str>,
174) -> Result<usize, SessionStoreError> {
175    if max_age_days == 0 {
176        return Ok(0);
177    }
178    let root = sessions_root(workspace);
179    let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
180    let Ok(entries) = std::fs::read_dir(&root) else {
181        return Ok(0);
182    };
183    let cutoff = age_cutoff(max_age_days);
184    let mut marked = 0usize;
185    for entry in entries.flatten() {
186        let path = entry.path();
187        let file_type = match entry.file_type() {
188            Ok(file_type) => file_type,
189            Err(_) => continue,
190        };
191        // Same symlink-safe enumeration as `retention_candidates`: never
192        // rewrite a manifest through a planted symlink.
193        if !file_type.is_dir() || file_type.is_symlink() {
194            continue;
195        }
196        if preserve_path.as_deref() == Some(path.as_path()) {
197            continue;
198        }
199        if session_retention_pinned(&path) {
200            continue;
201        }
202        let manifest_path = path.join("manifest.json");
203        let Ok(bytes) = std::fs::read(&manifest_path) else {
204            continue;
205        };
206        let Ok(mut summary) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
207            continue;
208        };
209        let is_active = summary.get("status").and_then(serde_json::Value::as_str) == Some("active");
210        if !is_active {
211            continue;
212        }
213        let updated_at = summary
214            .get("updated_at")
215            .and_then(serde_json::Value::as_str)
216            .unwrap_or_default();
217        if !older_than(updated_at, cutoff) {
218            continue;
219        }
220        if let Some(object) = summary.as_object_mut() {
221            object.insert("status".to_string(), serde_json::Value::String("completed".to_string()));
222        } else {
223            continue;
224        }
225        let body = serde_json::to_vec_pretty(&summary)
226            .map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
227        // Unique temp + rename so a crash cannot leave a truncated manifest and
228        // concurrent writers cannot collide (same pattern as checkpoint atomic_json).
229        let temp = manifest_path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4()));
230        std::fs::write(&temp, &body).map_err(|e| SessionStoreError::io(temp.clone(), e))?;
231        if let Err(error) = std::fs::rename(&temp, &manifest_path) {
232            let _ = std::fs::remove_file(&temp);
233            return Err(SessionStoreError::io(manifest_path.clone(), error));
234        }
235        marked += 1;
236    }
237    Ok(marked)
238}
239
240/// Remove the legacy `history/` and `logs/` directories after they have been
241/// imported into the unified store by [`crate::migrate_legacy`].
242///
243/// Returns the number of bytes freed. The legacy `checkpoints/` directory is
244/// intentionally left in place until `/revert` is rewired to the unified
245/// store; callers should confirm revert behavior before deleting it manually.
246pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
247    let vt = workspace.join(".vtcode");
248    let mut freed = 0u64;
249    for name in ["history", "logs"] {
250        let dir = vt.join(name);
251        if dir.exists() {
252            freed += dir_size(&dir);
253            std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
254        }
255    }
256    Ok(freed)
257}
258
259fn remove_session(root: &Path, dir: &Path) -> Result<(), SessionStoreError> {
260    // Retention is allowed to remove only one validated child of the sessions
261    // root. Never trust a manifest-controlled identifier or follow a symlink.
262    if dir.parent() != Some(root) {
263        return Ok(());
264    }
265    let metadata = match std::fs::symlink_metadata(dir) {
266        Ok(metadata) => metadata,
267        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
268        Err(error) => return Err(SessionStoreError::io(dir.to_path_buf(), error)),
269    };
270    if metadata.file_type().is_symlink() || !metadata.is_dir() {
271        return Ok(());
272    }
273    std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
274    Ok(())
275}
276
277fn dir_size(dir: &Path) -> u64 {
278    WalkDir::new(dir)
279        .into_iter()
280        .filter_map(Result::ok)
281        .filter(|e| e.file_type().is_file())
282        .filter_map(|e| e.metadata().ok())
283        .map(|m| m.len())
284        .sum()
285}
286
287fn age_cutoff(max_age_days: u64) -> SystemTime {
288    let seconds = max_age_days.saturating_mul(24 * 3600);
289    SystemTime::now() - Duration::from_secs(seconds)
290}
291
292fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
293    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
294        return false;
295    };
296    let cutoff_secs = cutoff
297        .duration_since(UNIX_EPOCH)
298        .map(|d| d.as_secs() as i64)
299        .unwrap_or(i64::MAX);
300    dt.timestamp() < cutoff_secs
301}