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            version: 0,
225            started_at: time,
226            updated_at: time,
227            project_root: None,
228            shell_cwd: None,
229            task: None,
230            findings: Vec::new(),
231            decisions: Vec::new(),
232            files_touched: Vec::new(),
233            test_results: None,
234            progress: Vec::new(),
235            next_steps: Vec::new(),
236            evidence: Vec::new(),
237            intents: Vec::new(),
238            active_structured_intent: None,
239            stats: crate::core::session::SessionStats::default(),
240            terse_mode: false,
241            compression_level: String::new(),
242            last_consolidate_ts: None,
243            last_aaak_hash: None,
244            extra_roots: Vec::new(),
245            playbook: crate::core::session::Playbook::default(),
246            last_semantic_query: None,
247            last_flush: None,
248            live_zone: Default::default(),
249            handoff_context: Default::default(),
250        }
251    }
252
253    #[test]
254    fn warming_skips_cached_missing_and_oversized_files() {
255        let mut cache = MemoryCache::default();
256        cache
257            .entries
258            .insert("cached.rs".to_string(), "cached".to_string());
259        let history = [
260            recent("cached.rs"),
261            recent("missing.rs"),
262            recent("large.rs"),
263            recent("good.rs"),
264        ];
265        let contents = HashMap::from([
266            ("large.rs", "x".repeat(MAX_WARM_FILE_BYTES + 1)),
267            ("good.rs", "fn good() {}".to_string()),
268        ]);
269        let mut loaded = Vec::new();
270
271        warm_cache_with(&mut cache, &history, 10_000, |path| {
272            loaded.push(path.to_string());
273            contents.get(path).cloned()
274        });
275
276        assert!(!loaded.contains(&"cached.rs".to_string()));
277        assert!(!cache.entries.contains_key("missing.rs"));
278        assert!(!cache.entries.contains_key("large.rs"));
279        assert_eq!(
280            cache.entries.get("good.rs").map(String::as_str),
281            Some("fn good() {}")
282        );
283    }
284
285    #[test]
286    fn warming_stops_at_eighty_percent_of_budget() {
287        let first_content = "first file content";
288        let first_tokens = count_tokens(first_content);
289        let budget = first_tokens.saturating_mul(5).div_ceil(4);
290        let history = [recent("first.rs"), recent("second.rs")];
291        let mut cache = MemoryCache::default();
292        let mut loaded = Vec::new();
293
294        warm_cache_with(&mut cache, &history, budget, |path| {
295            loaded.push(path.to_string());
296            Some(first_content.to_string())
297        });
298
299        assert!(cache.entries.contains_key("first.rs"));
300        assert!(!cache.entries.contains_key("second.rs"));
301        assert_eq!(loaded, vec!["first.rs"]);
302        assert!(cache.total_tokens() <= budget.saturating_mul(4) / 5);
303    }
304
305    #[test]
306    fn recent_files_are_deduplicated_and_ranked_by_expected_benefit() {
307        let mut older = session_at(10);
308        older.touch_file("frequent.rs", None, "full", 10);
309        older.touch_file("frequent.rs", None, "full", 10);
310        older.touch_file("recent.rs", None, "full", 10);
311        older.updated_at = Utc.timestamp_opt(10, 0).unwrap();
312
313        let mut newer = session_at(20);
314        newer.touch_file("frequent.rs", None, "full", 10);
315        newer.touch_file("recent.rs", None, "full", 10);
316        newer.touch_file("peer.rs", None, "full", 10);
317        newer.updated_at = Utc.timestamp_opt(20, 0).unwrap();
318
319        let result = collect_recent_files(&[older, newer]);
320
321        assert_eq!(
322            result
323                .iter()
324                .map(|file| file.path.as_str())
325                .collect::<Vec<_>>(),
326            vec!["frequent.rs", "recent.rs", "peer.rs"]
327        );
328        assert_eq!(result[0].read_count, 3);
329        assert_eq!(
330            result[0].last_access,
331            SystemTime::UNIX_EPOCH + Duration::from_secs(20)
332        );
333        assert_eq!(result[0].tokens, 10);
334    }
335
336    #[test]
337    fn recent_files_prioritize_read_count_times_tokens() {
338        let mut session = session_at(10);
339        session.touch_file("frequent.rs", None, "full", 10);
340        session.touch_file("frequent.rs", None, "full", 10);
341        session.touch_file("small.rs", None, "full", 1);
342        session.touch_file("large.rs", None, "full", 100);
343
344        let result = collect_recent_files(&[session]);
345
346        assert_eq!(
347            result
348                .iter()
349                .map(|file| file.path.as_str())
350                .collect::<Vec<_>>(),
351            vec!["large.rs", "frequent.rs", "small.rs"]
352        );
353    }
354
355    #[test]
356    fn recent_files_are_capped_at_fifty() {
357        let mut session = session_at(10);
358        for index in 0..60 {
359            session.touch_file(&format!("file-{index:02}.rs"), None, "full", 1);
360        }
361
362        let result = collect_recent_files(&[session]);
363
364        assert_eq!(result.len(), MAX_RECENT_FILES);
365    }
366
367    #[cfg(not(feature = "no-jail"))]
368    #[test]
369    fn project_warming_rejails_paths_and_skips_stale_records() {
370        let _data = crate::core::data_dir::isolated_data_dir();
371        let root = tempfile::tempdir().unwrap();
372        let outside = tempfile::tempdir().unwrap();
373        let good = root.path().join("good.rs");
374        let stale = root.path().join("stale.rs");
375        let secret = outside.path().join("secret.rs");
376        for path in [&good, &stale, &secret] {
377            std::fs::write(path, "fixture").unwrap();
378        }
379        let mut session = session_at(10);
380        session.project_root = Some(root.path().to_string_lossy().into_owned());
381        for path in [&good, &stale, &secret] {
382            session.touch_file(&path.to_string_lossy(), None, "full", 1);
383        }
384        session
385            .files_touched
386            .iter_mut()
387            .find(|file| file.path == stale.to_string_lossy().as_ref())
388            .expect("stale fixture")
389            .stale = true;
390
391        let warmed = collect_recent_files_in_project(
392            &[session],
393            root.path().to_str().expect("UTF-8 temporary root"),
394        );
395
396        assert_eq!(warmed.len(), 1);
397        assert_eq!(
398            warmed[0].path,
399            crate::core::pathutil::safe_canonicalize(&good)
400                .unwrap()
401                .to_string_lossy()
402                .into_owned()
403        );
404    }
405
406    #[cfg(feature = "no-jail")]
407    #[test]
408    fn project_warming_no_jail_skips_stale_and_accepts_outside_root() {
409        let root = tempfile::tempdir().unwrap();
410        let outside = tempfile::tempdir().unwrap();
411        let stale = root.path().join("stale.rs");
412        let outside_file = outside.path().join("outside.rs");
413        for path in [&stale, &outside_file] {
414            std::fs::write(path, "fixture").unwrap();
415        }
416
417        let mut session = session_at(10);
418        session.project_root = Some(root.path().to_string_lossy().into_owned());
419        for path in [&stale, &outside_file] {
420            session.touch_file(&path.to_string_lossy(), None, "full", 1);
421        }
422        session
423            .files_touched
424            .iter_mut()
425            .find(|file| file.path == stale.to_string_lossy().as_ref())
426            .expect("stale fixture")
427            .stale = true;
428
429        let warmed = collect_recent_files_in_project(
430            &[session],
431            root.path().to_str().expect("UTF-8 temporary root"),
432        );
433
434        assert_eq!(warmed.len(), 1);
435        assert_eq!(
436            warmed[0].path,
437            crate::core::pathutil::safe_canonicalize(&outside_file)
438                .unwrap()
439                .to_string_lossy()
440                .into_owned()
441        );
442    }
443}