Skip to main content

lean_ctx/core/cache/
warming.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use crate::core::session::SessionState;
8use crate::core::tokens::count_tokens;
9
10use super::{SessionCache, max_cache_tokens};
11
12const MAX_WARM_FILE_BYTES: usize = 50 * 1024;
13const MAX_RECENT_FILES: usize = 50;
14
15/// A file accessed by one or more previous sessions.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct RecentFile {
18    pub path: String,
19    pub last_access: SystemTime,
20    pub read_count: u32,
21    /// Largest observed token count, used to estimate the benefit of a warm hit.
22    pub tokens: usize,
23}
24
25trait WarmCache {
26    fn contains(&self, path: &str) -> bool;
27    fn total_tokens(&self) -> usize;
28    fn insert(&mut self, path: &str, content: &str);
29}
30
31impl WarmCache for SessionCache {
32    fn contains(&self, path: &str) -> bool {
33        self.get(path).is_some()
34    }
35
36    fn total_tokens(&self) -> usize {
37        self.total_cached_tokens()
38    }
39
40    fn insert(&mut self, path: &str, content: &str) {
41        self.store(path, content);
42    }
43}
44
45/// Pre-populates a session cache with the highest-priority recent files.
46///
47/// Warming stops when the cache reaches 80% of its configured token budget.
48/// Missing, oversized, unreadable, and already-cached files are ignored.
49pub fn warm_cache(cache: &mut SessionCache, history: &[RecentFile]) {
50    warm_cache_with(cache, history, max_cache_tokens(), |path| {
51        let metadata = std::fs::metadata(path).ok()?;
52        if !metadata.is_file() || metadata.len() > MAX_WARM_FILE_BYTES as u64 {
53            return None;
54        }
55        crate::core::io_boundary::read_file_lossy(path).ok()
56    });
57}
58
59fn warm_cache_with<C, F>(cache: &mut C, history: &[RecentFile], token_budget: usize, mut load: F)
60where
61    C: WarmCache,
62    F: FnMut(&str) -> Option<String>,
63{
64    let warming_limit = token_budget.saturating_mul(4) / 5;
65
66    for recent in history {
67        if cache.total_tokens() >= warming_limit {
68            break;
69        }
70        if cache.contains(&recent.path) {
71            continue;
72        }
73
74        let Some(content) = load(&recent.path) else {
75            continue;
76        };
77        if content.len() > MAX_WARM_FILE_BYTES {
78            continue;
79        }
80
81        let incoming_tokens = count_tokens(&content);
82        if cache.total_tokens().saturating_add(incoming_tokens) > warming_limit {
83            continue;
84        }
85        cache.insert(&recent.path, &content);
86    }
87}
88
89/// Collects, deduplicates, and ranks files touched by previous sessions.
90///
91/// Read counts are summed across sessions. The largest observed token count
92/// estimates the avoided read cost. A session's update time is used as the
93/// recency of its file accesses because `FileTouched` has no own timestamp.
94pub fn collect_recent_files(sessions: &[SessionState]) -> Vec<RecentFile> {
95    collect_recent_files_with(sessions, |_, file| Some(file.path.clone()))
96}
97
98/// Collect warm candidates only from the requested project and pass every
99/// persisted path through PathJail again before it can reach disk I/O.
100pub fn collect_recent_files_in_project(
101    sessions: &[SessionState],
102    project_root: &str,
103) -> Vec<RecentFile> {
104    let Some(root) = normalized_safe_root(project_root) else {
105        return Vec::new();
106    };
107    // GH #2006: canonicalize each session's root once, not once per touched
108    // file — on a network drive every canonicalization is a round trip.
109    let matching: std::collections::HashSet<&str> = sessions
110        .iter()
111        .filter(|session| {
112            session
113                .project_root
114                .as_deref()
115                .and_then(normalized_safe_root)
116                .as_ref()
117                == Some(&root)
118        })
119        .map(|session| session.id.as_str())
120        .collect();
121    collect_recent_files_with(sessions, |session, file| {
122        if file.stale || !matching.contains(session.id.as_str()) {
123            return None;
124        }
125        crate::core::pathjail::jail_path(Path::new(&file.path), &root)
126            .ok()
127            .map(|path| path.to_string_lossy().into_owned())
128    })
129}
130
131fn normalized_safe_root(project_root: &str) -> Option<PathBuf> {
132    let path = Path::new(project_root);
133    if project_root.trim().is_empty() || crate::core::pathutil::is_broad_or_unsafe_root(path) {
134        return None;
135    }
136    Some(crate::core::pathutil::safe_canonicalize_or_self(path))
137}
138
139fn collect_recent_files_with<F>(sessions: &[SessionState], resolve_path: F) -> Vec<RecentFile>
140where
141    F: Fn(&SessionState, &crate::core::session::FileTouched) -> Option<String>,
142{
143    let mut recent_by_path: HashMap<String, RecentFile> = HashMap::new();
144
145    for session in sessions {
146        let last_access = SystemTime::from(session.updated_at);
147        for file in &session.files_touched {
148            let Some(path) = resolve_path(session, file) else {
149                continue;
150            };
151            let recent = recent_by_path
152                .entry(path.clone())
153                .or_insert_with(|| RecentFile {
154                    path,
155                    last_access,
156                    read_count: 0,
157                    tokens: file.tokens,
158                });
159            recent.read_count = recent.read_count.saturating_add(file.read_count);
160            recent.tokens = recent.tokens.max(file.tokens);
161            recent.last_access = recent.last_access.max(last_access);
162        }
163    }
164
165    let mut recent: Vec<RecentFile> = recent_by_path.into_values().collect();
166    recent.sort_by(|a, b| {
167        (b.read_count as usize)
168            .saturating_mul(b.tokens)
169            .cmp(&(a.read_count as usize).saturating_mul(a.tokens))
170            .then_with(|| b.last_access.cmp(&a.last_access))
171            .then_with(|| a.path.cmp(&b.path))
172    });
173    recent.truncate(MAX_RECENT_FILES);
174    recent
175}
176
177#[cfg(test)]
178mod tests {
179    use std::collections::HashMap;
180    use std::time::{Duration, SystemTime};
181
182    use chrono::{TimeZone, Utc};
183
184    use super::*;
185
186    #[derive(Default)]
187    struct MemoryCache {
188        entries: HashMap<String, String>,
189    }
190
191    impl WarmCache for MemoryCache {
192        fn contains(&self, path: &str) -> bool {
193            self.entries.contains_key(path)
194        }
195
196        fn total_tokens(&self) -> usize {
197            self.entries
198                .values()
199                .map(|content| count_tokens(content))
200                .sum()
201        }
202
203        fn insert(&mut self, path: &str, content: &str) {
204            self.entries.insert(path.to_string(), content.to_string());
205        }
206    }
207
208    fn recent(path: &str) -> RecentFile {
209        RecentFile {
210            path: path.to_string(),
211            last_access: SystemTime::UNIX_EPOCH,
212            read_count: 1,
213            tokens: 1,
214        }
215    }
216
217    fn session_at(timestamp: i64) -> SessionState {
218        let time = Utc.timestamp_opt(timestamp, 0).unwrap();
219        SessionState {
220            canonical_checkpoint: None,
221            save_gate: Default::default(),
222            last_save_failed: false,
223            id: format!("session-{timestamp}"),
224            app_session_id: None,
225            version: 0,
226            started_at: time,
227            updated_at: time,
228            project_root: None,
229            shell_cwd: None,
230            task: None,
231            findings: Vec::new(),
232            decisions: Vec::new(),
233            files_touched: Vec::new(),
234            test_results: None,
235            progress: Vec::new(),
236            next_steps: Vec::new(),
237            evidence: Vec::new(),
238            intents: Vec::new(),
239            active_structured_intent: None,
240            stats: crate::core::session::SessionStats::default(),
241            terse_mode: false,
242            compression_level: String::new(),
243            last_consolidate_ts: None,
244            last_aaak_hash: None,
245            extra_roots: Vec::new(),
246            playbook: crate::core::session::Playbook::default(),
247            last_semantic_query: None,
248            taint: Default::default(),
249            last_flush: None,
250            live_zone: Default::default(),
251            handoff_context: Default::default(),
252        }
253    }
254
255    #[test]
256    fn warming_skips_cached_missing_and_oversized_files() {
257        let mut cache = MemoryCache::default();
258        cache
259            .entries
260            .insert("cached.rs".to_string(), "cached".to_string());
261        let history = [
262            recent("cached.rs"),
263            recent("missing.rs"),
264            recent("large.rs"),
265            recent("good.rs"),
266        ];
267        let contents = HashMap::from([
268            ("large.rs", "x".repeat(MAX_WARM_FILE_BYTES + 1)),
269            ("good.rs", "fn good() {}".to_string()),
270        ]);
271        let mut loaded = Vec::new();
272
273        warm_cache_with(&mut cache, &history, 10_000, |path| {
274            loaded.push(path.to_string());
275            contents.get(path).cloned()
276        });
277
278        assert!(!loaded.contains(&"cached.rs".to_string()));
279        assert!(!cache.entries.contains_key("missing.rs"));
280        assert!(!cache.entries.contains_key("large.rs"));
281        assert_eq!(
282            cache.entries.get("good.rs").map(String::as_str),
283            Some("fn good() {}")
284        );
285    }
286
287    #[test]
288    fn warming_stops_at_eighty_percent_of_budget() {
289        let first_content = "first file content";
290        let first_tokens = count_tokens(first_content);
291        let budget = first_tokens.saturating_mul(5).div_ceil(4);
292        let history = [recent("first.rs"), recent("second.rs")];
293        let mut cache = MemoryCache::default();
294        let mut loaded = Vec::new();
295
296        warm_cache_with(&mut cache, &history, budget, |path| {
297            loaded.push(path.to_string());
298            Some(first_content.to_string())
299        });
300
301        assert!(cache.entries.contains_key("first.rs"));
302        assert!(!cache.entries.contains_key("second.rs"));
303        assert_eq!(loaded, vec!["first.rs"]);
304        assert!(cache.total_tokens() <= budget.saturating_mul(4) / 5);
305    }
306
307    #[test]
308    fn recent_files_are_deduplicated_and_ranked_by_expected_benefit() {
309        let mut older = session_at(10);
310        older.touch_file("frequent.rs", None, "full", 10);
311        older.touch_file("frequent.rs", None, "full", 10);
312        older.touch_file("recent.rs", None, "full", 10);
313        older.updated_at = Utc.timestamp_opt(10, 0).unwrap();
314
315        let mut newer = session_at(20);
316        newer.touch_file("frequent.rs", None, "full", 10);
317        newer.touch_file("recent.rs", None, "full", 10);
318        newer.touch_file("peer.rs", None, "full", 10);
319        newer.updated_at = Utc.timestamp_opt(20, 0).unwrap();
320
321        let result = collect_recent_files(&[older, newer]);
322
323        assert_eq!(
324            result
325                .iter()
326                .map(|file| file.path.as_str())
327                .collect::<Vec<_>>(),
328            vec!["frequent.rs", "recent.rs", "peer.rs"]
329        );
330        assert_eq!(result[0].read_count, 3);
331        assert_eq!(
332            result[0].last_access,
333            SystemTime::UNIX_EPOCH + Duration::from_secs(20)
334        );
335        assert_eq!(result[0].tokens, 10);
336    }
337
338    #[test]
339    fn recent_files_prioritize_read_count_times_tokens() {
340        let mut session = session_at(10);
341        session.touch_file("frequent.rs", None, "full", 10);
342        session.touch_file("frequent.rs", None, "full", 10);
343        session.touch_file("small.rs", None, "full", 1);
344        session.touch_file("large.rs", None, "full", 100);
345
346        let result = collect_recent_files(&[session]);
347
348        assert_eq!(
349            result
350                .iter()
351                .map(|file| file.path.as_str())
352                .collect::<Vec<_>>(),
353            vec!["large.rs", "frequent.rs", "small.rs"]
354        );
355    }
356
357    #[test]
358    fn recent_files_are_capped_at_fifty() {
359        let mut session = session_at(10);
360        for index in 0..60 {
361            session.touch_file(&format!("file-{index:02}.rs"), None, "full", 1);
362        }
363
364        let result = collect_recent_files(&[session]);
365
366        assert_eq!(result.len(), MAX_RECENT_FILES);
367    }
368
369    #[cfg(not(feature = "no-jail"))]
370    #[test]
371    fn project_warming_rejails_paths_and_skips_stale_records() {
372        let _data = crate::core::data_dir::isolated_data_dir();
373        let root = tempfile::tempdir().unwrap();
374        let outside = tempfile::tempdir().unwrap();
375        let good = root.path().join("good.rs");
376        let stale = root.path().join("stale.rs");
377        let secret = outside.path().join("secret.rs");
378        for path in [&good, &stale, &secret] {
379            std::fs::write(path, "fixture").unwrap();
380        }
381        let mut session = session_at(10);
382        session.project_root = Some(root.path().to_string_lossy().into_owned());
383        for path in [&good, &stale, &secret] {
384            session.touch_file(&path.to_string_lossy(), None, "full", 1);
385        }
386        session
387            .files_touched
388            .iter_mut()
389            .find(|file| file.path == stale.to_string_lossy().as_ref())
390            .expect("stale fixture")
391            .stale = true;
392
393        let warmed = collect_recent_files_in_project(
394            &[session],
395            root.path().to_str().expect("UTF-8 temporary root"),
396        );
397
398        assert_eq!(warmed.len(), 1);
399        assert_eq!(
400            warmed[0].path,
401            crate::core::pathutil::safe_canonicalize(&good)
402                .unwrap()
403                .to_string_lossy()
404                .into_owned()
405        );
406    }
407
408    #[cfg(feature = "no-jail")]
409    #[test]
410    fn project_warming_no_jail_skips_stale_and_accepts_outside_root() {
411        let root = tempfile::tempdir().unwrap();
412        let outside = tempfile::tempdir().unwrap();
413        let stale = root.path().join("stale.rs");
414        let outside_file = outside.path().join("outside.rs");
415        for path in [&stale, &outside_file] {
416            std::fs::write(path, "fixture").unwrap();
417        }
418
419        let mut session = session_at(10);
420        session.project_root = Some(root.path().to_string_lossy().into_owned());
421        for path in [&stale, &outside_file] {
422            session.touch_file(&path.to_string_lossy(), None, "full", 1);
423        }
424        session
425            .files_touched
426            .iter_mut()
427            .find(|file| file.path == stale.to_string_lossy().as_ref())
428            .expect("stale fixture")
429            .stale = true;
430
431        let warmed = collect_recent_files_in_project(
432            &[session],
433            root.path().to_str().expect("UTF-8 temporary root"),
434        );
435
436        assert_eq!(warmed.len(), 1);
437        assert_eq!(
438            warmed[0].path,
439            crate::core::pathutil::safe_canonicalize(&outside_file)
440                .unwrap()
441                .to_string_lossy()
442                .into_owned()
443        );
444    }
445}