Skip to main content

nexo_core/agent/
workspace_cache.rs

1//! In-memory cache for `WorkspaceBundle` with `notify`-driven invalidation.
2//!
3//! Hot path: `llm_behavior::run_turn` builds the system prompt once per
4//! turn. The legacy code re-read every workspace MD from disk on each
5//! turn — fine when the agent is idle but a measurable hit on a busy
6//! agent, and worse, the bundle's bytes vary across runs (read order,
7//! mtime metadata) which prevents Anthropic prompt-cache hits even
8//! though the *content* is stable.
9//!
10//! `WorkspaceCache` solves both:
11//!
12//! 1. **Stable bytes per workspace+scope+extras tuple** → the cache
13//!    returns the same `Arc<WorkspaceBundle>` until a watch event
14//!    invalidates it. Producers downstream (system prompt assembly)
15//!    can emit a stable cached block to Anthropic.
16//! 2. **Zero disk I/O on the steady state** → `get()` is a `DashMap`
17//!    lookup; cache misses run the full `WorkspaceLoader` once and
18//!    insert.
19//!
20//! Invalidation is per-workspace-root: a single `*.md` change drops
21//! every entry under that root (across all scopes / extras
22//! combinations). False-positive over-invalidation is fine — the next
23//! `get()` re-loads.
24//!
25//! Optional max_age: notify(7) drops events on NFS/FUSE; operators on
26//! exotic filesystems can set `WorkspaceCacheConfig::max_age_seconds`
27//! to force a refresh after N seconds even without a notify event.
28
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, Mutex};
33use std::time::{Duration, Instant};
34
35use dashmap::DashMap;
36use notify::{RecommendedWatcher, RecursiveMode, Watcher};
37use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileIdMap};
38
39use super::workspace::{SessionScope, WorkspaceBundle, WorkspaceLoader};
40
41/// Cache key. `extras` is sorted at insertion so callers can pass
42/// `&AgentConfig::extra_docs` in arbitrary order without splitting
43/// the cache.
44#[derive(Debug, Clone, Hash, PartialEq, Eq)]
45struct CacheKey {
46    root: PathBuf,
47    scope: SessionScope,
48    extras: Vec<String>,
49}
50
51#[derive(Debug)]
52struct Entry {
53    bundle: Arc<WorkspaceBundle>,
54    loaded_at: Instant,
55}
56
57#[derive(Debug, Default)]
58pub struct WorkspaceCacheMetrics {
59    /// Hits counted per workspace root (`PathBuf::display`-stringified).
60    pub hits: DashMap<String, AtomicU64>,
61    pub misses: DashMap<String, AtomicU64>,
62    pub invalidations: DashMap<String, AtomicU64>,
63}
64
65impl WorkspaceCacheMetrics {
66    fn bump(map: &DashMap<String, AtomicU64>, key: &str) {
67        if let Some(c) = map.get(key) {
68            c.value().fetch_add(1, Ordering::Relaxed);
69            return;
70        }
71        let entry = map
72            .entry(key.to_string())
73            .or_insert_with(|| AtomicU64::new(0));
74        entry.value().fetch_add(1, Ordering::Relaxed);
75    }
76    pub fn snapshot(&self) -> HashMap<String, (u64, u64, u64)> {
77        let mut out: HashMap<String, (u64, u64, u64)> = HashMap::new();
78        for kv in self.hits.iter() {
79            out.entry(kv.key().clone()).or_default().0 = kv.value().load(Ordering::Relaxed);
80        }
81        for kv in self.misses.iter() {
82            out.entry(kv.key().clone()).or_default().1 = kv.value().load(Ordering::Relaxed);
83        }
84        for kv in self.invalidations.iter() {
85            out.entry(kv.key().clone()).or_default().2 = kv.value().load(Ordering::Relaxed);
86        }
87        out
88    }
89}
90
91pub struct WorkspaceCache {
92    bundles: DashMap<CacheKey, Arc<Entry>>,
93    metrics: Arc<WorkspaceCacheMetrics>,
94    /// Roots being watched. The watcher itself is held to keep
95    /// notifications flowing for the lifetime of the cache.
96    roots: Vec<PathBuf>,
97    max_age: Option<Duration>,
98    _watcher: Mutex<Option<Debouncer<RecommendedWatcher, FileIdMap>>>,
99}
100
101impl WorkspaceCache {
102    /// Build a cache for the given workspace roots. Each root is
103    /// watched recursively; any `*.md` change invalidates every entry
104    /// keyed under that root.
105    ///
106    /// `debounce_ms`: coalesces bursts (saves from editors that write +
107    /// rename + chmod). 500ms is a reasonable default.
108    ///
109    /// `max_age_seconds`: force a refresh after this many seconds even
110    /// without a watch event. 0 = disabled.
111    pub fn new(
112        roots: &[PathBuf],
113        debounce_ms: u32,
114        max_age_seconds: u32,
115    ) -> anyhow::Result<Arc<Self>> {
116        let metrics = Arc::new(WorkspaceCacheMetrics::default());
117        let bundles: DashMap<CacheKey, Arc<Entry>> = DashMap::new();
118        let max_age = if max_age_seconds == 0 {
119            None
120        } else {
121            Some(Duration::from_secs(max_age_seconds as u64))
122        };
123        let cache = Arc::new(Self {
124            bundles,
125            metrics,
126            roots: roots.iter().map(|p| p.to_path_buf()).collect(),
127            max_age,
128            _watcher: Mutex::new(None),
129        });
130        cache.start_watcher(debounce_ms)?;
131        Ok(cache)
132    }
133
134    fn start_watcher(self: &Arc<Self>, debounce_ms: u32) -> anyhow::Result<()> {
135        if self.roots.is_empty() {
136            return Ok(());
137        }
138        let weak = Arc::downgrade(self);
139        let timeout = Duration::from_millis(debounce_ms.max(50) as u64);
140        let mut debouncer = new_debouncer(timeout, None, move |res: DebounceEventResult| {
141            let Some(this) = weak.upgrade() else {
142                return;
143            };
144            match res {
145                Ok(events) => {
146                    let mut roots_to_drop: Vec<PathBuf> = Vec::new();
147                    for ev in events {
148                        for path in &ev.event.paths {
149                            // Only react to .md changes — extras are MDs too,
150                            // so a single test covers the lot. Anything else
151                            // (transient editor swap files, .DS_Store, …) is
152                            // a no-op.
153                            if path.extension().and_then(|e| e.to_str()) != Some("md") {
154                                continue;
155                            }
156                            // Find which watched root this path lives under.
157                            for root in &this.roots {
158                                if path.starts_with(root) && !roots_to_drop.contains(root) {
159                                    roots_to_drop.push(root.clone());
160                                }
161                            }
162                        }
163                    }
164                    for root in roots_to_drop {
165                        this.invalidate_root(&root);
166                    }
167                }
168                Err(errs) => {
169                    for e in errs {
170                        tracing::warn!(error = ?e, "workspace_cache: watcher error");
171                    }
172                }
173            }
174        })?;
175        for root in &self.roots {
176            // RecursiveMode::Recursive — we want subdirectory changes
177            // (memory/YYYY-MM-DD.md, doc subfolders).
178            if let Err(e) = debouncer.watcher().watch(root, RecursiveMode::Recursive) {
179                tracing::warn!(
180                    root = %root.display(),
181                    error = %e,
182                    "workspace_cache: failed to start watcher (cache will still serve hits, just no auto-invalidation)"
183                );
184            }
185        }
186        if let Ok(mut slot) = self._watcher.lock() {
187            *slot = Some(debouncer);
188        }
189        Ok(())
190    }
191
192    /// Invalidate every entry under the given workspace root. Idempotent.
193    pub fn invalidate_root(&self, root: &Path) {
194        let key = root.display().to_string();
195        let before = self.bundles.len();
196        self.bundles.retain(|k, _| k.root != root);
197        let dropped = before - self.bundles.len();
198        if dropped > 0 {
199            WorkspaceCacheMetrics::bump(&self.metrics.invalidations, &key);
200            tracing::info!(
201                root = %root.display(),
202                dropped,
203                "workspace_cache: invalidated entries"
204            );
205        }
206    }
207
208    /// Clear the entire cache. Test-only / admin tool.
209    pub fn clear(&self) {
210        self.bundles.clear();
211    }
212
213    /// Return the metrics handle (counters per workspace root).
214    pub fn metrics(&self) -> Arc<WorkspaceCacheMetrics> {
215        Arc::clone(&self.metrics)
216    }
217
218    /// Get (or build) the bundle for `(root, scope, extras)`. Falls
219    /// back to a fresh `WorkspaceLoader` on miss; subsequent calls hit
220    /// the cache until invalidated.
221    pub async fn get(
222        &self,
223        root: &Path,
224        scope: SessionScope,
225        extras: &[String],
226    ) -> anyhow::Result<Arc<WorkspaceBundle>> {
227        let mut key_extras: Vec<String> = extras
228            .iter()
229            .map(|s| s.trim().to_string())
230            .filter(|s| !s.is_empty())
231            .collect();
232        key_extras.sort();
233        let key = CacheKey {
234            root: root.to_path_buf(),
235            scope,
236            extras: key_extras,
237        };
238        let root_label = root.display().to_string();
239        if let Some(entry) = self.bundles.get(&key) {
240            // max_age guard: stale entries are dropped lazily on access
241            // so an idle cache doesn't grow unbounded with expired
242            // entries.
243            if let Some(max_age) = self.max_age {
244                if entry.value().loaded_at.elapsed() > max_age {
245                    drop(entry);
246                    self.bundles.remove(&key);
247                    WorkspaceCacheMetrics::bump(&self.metrics.invalidations, &root_label);
248                } else {
249                    WorkspaceCacheMetrics::bump(&self.metrics.hits, &root_label);
250                    return Ok(Arc::clone(&entry.value().bundle));
251                }
252            } else {
253                WorkspaceCacheMetrics::bump(&self.metrics.hits, &root_label);
254                return Ok(Arc::clone(&entry.value().bundle));
255            }
256        }
257        // Miss → build via the same loader the legacy path uses.
258        WorkspaceCacheMetrics::bump(&self.metrics.misses, &root_label);
259        let bundle = WorkspaceLoader::new(root)
260            .load_with_extras(scope, extras)
261            .await?;
262        let entry = Arc::new(Entry {
263            bundle: Arc::new(bundle),
264            loaded_at: Instant::now(),
265        });
266        // Race-safe insert: if another task beat us to it, return
267        // theirs to keep cache identity stable for prompt-cache hashing.
268        let stored = self
269            .bundles
270            .entry(key)
271            .or_insert_with(|| Arc::clone(&entry))
272            .value()
273            .clone();
274        Ok(Arc::clone(&stored.bundle))
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use tempfile::tempdir;
282
283    fn write(p: &Path, name: &str, text: &str) {
284        std::fs::write(p.join(name), text).unwrap();
285    }
286
287    #[tokio::test]
288    async fn cold_then_warm() {
289        let dir = tempdir().unwrap();
290        write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
291        let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
292        let a = cache
293            .get(dir.path(), SessionScope::Main, &[])
294            .await
295            .unwrap();
296        let b = cache
297            .get(dir.path(), SessionScope::Main, &[])
298            .await
299            .unwrap();
300        // Same Arc → same bytes → cache hit on the prompt-cache side.
301        assert!(Arc::ptr_eq(&a, &b));
302        let snap = cache.metrics().snapshot();
303        let (hits, misses, _) = snap
304            .get(&dir.path().display().to_string())
305            .copied()
306            .unwrap_or_default();
307        assert_eq!(misses, 1);
308        assert_eq!(hits, 1);
309    }
310
311    #[tokio::test]
312    async fn different_scope_separate_entry() {
313        let dir = tempdir().unwrap();
314        write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
315        let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
316        let main = cache
317            .get(dir.path(), SessionScope::Main, &[])
318            .await
319            .unwrap();
320        let shared = cache
321            .get(dir.path(), SessionScope::Shared, &[])
322            .await
323            .unwrap();
324        assert!(!Arc::ptr_eq(&main, &shared));
325    }
326
327    #[tokio::test]
328    async fn extras_order_does_not_split_cache() {
329        let dir = tempdir().unwrap();
330        write(dir.path(), "A.md", "a");
331        write(dir.path(), "B.md", "b");
332        let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
333        let one = cache
334            .get(
335                dir.path(),
336                SessionScope::Main,
337                &["A.md".to_string(), "B.md".to_string()],
338            )
339            .await
340            .unwrap();
341        let two = cache
342            .get(
343                dir.path(),
344                SessionScope::Main,
345                &["B.md".to_string(), "A.md".to_string()],
346            )
347            .await
348            .unwrap();
349        assert!(Arc::ptr_eq(&one, &two));
350    }
351
352    #[tokio::test]
353    async fn manual_invalidate_drops_entries() {
354        let dir = tempdir().unwrap();
355        write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
356        let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
357        let _ = cache
358            .get(dir.path(), SessionScope::Main, &[])
359            .await
360            .unwrap();
361        cache.invalidate_root(dir.path());
362        let snap = cache.metrics().snapshot();
363        let (_, _, inv) = snap
364            .get(&dir.path().display().to_string())
365            .copied()
366            .unwrap_or_default();
367        assert_eq!(inv, 1);
368    }
369
370    #[tokio::test]
371    async fn watcher_invalidates_after_md_write() {
372        let dir = tempdir().unwrap();
373        write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
374        let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 80, 0).unwrap();
375        let a = cache
376            .get(dir.path(), SessionScope::Main, &[])
377            .await
378            .unwrap();
379        // Modify the watched file.
380        std::fs::write(
381            dir.path().join("IDENTITY.md"),
382            "- **Name:** Ana\n- **Vibe:** updated",
383        )
384        .unwrap();
385        // Wait for debounce + a margin.
386        tokio::time::sleep(Duration::from_millis(500)).await;
387        let b = cache
388            .get(dir.path(), SessionScope::Main, &[])
389            .await
390            .unwrap();
391        assert!(
392            !Arc::ptr_eq(&a, &b),
393            "expected cache miss after MD modification"
394        );
395        let snap = cache.metrics().snapshot();
396        let (_, _, inv) = snap
397            .get(&dir.path().display().to_string())
398            .copied()
399            .unwrap_or_default();
400        assert!(inv >= 1, "expected at least one invalidation, got {inv}");
401    }
402
403    #[tokio::test]
404    async fn max_age_forces_refresh() {
405        let dir = tempdir().unwrap();
406        write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
407        // max_age 1s
408        let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 1).unwrap();
409        let a = cache
410            .get(dir.path(), SessionScope::Main, &[])
411            .await
412            .unwrap();
413        tokio::time::sleep(Duration::from_millis(1100)).await;
414        let b = cache
415            .get(dir.path(), SessionScope::Main, &[])
416            .await
417            .unwrap();
418        assert!(!Arc::ptr_eq(&a, &b));
419    }
420
421    #[tokio::test]
422    async fn non_md_file_ignored_by_watcher() {
423        let dir = tempdir().unwrap();
424        write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
425        let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 80, 0).unwrap();
426        let a = cache
427            .get(dir.path(), SessionScope::Main, &[])
428            .await
429            .unwrap();
430        std::fs::write(dir.path().join("note.txt"), "ignored").unwrap();
431        tokio::time::sleep(Duration::from_millis(400)).await;
432        let b = cache
433            .get(dir.path(), SessionScope::Main, &[])
434            .await
435            .unwrap();
436        // Same Arc — non-md change must not invalidate.
437        assert!(Arc::ptr_eq(&a, &b));
438    }
439}