Skip to main content

mars_agents/models/probes/
cursor_cache.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use serde::{Deserialize, Serialize};
6
7use super::cursor::CursorProbeResult;
8use super::probe_refresh::ProbeCacheBranch;
9use crate::error::MarsError;
10
11const SCHEMA_VERSION: u32 = 1;
12const DEFAULT_TTL_SECS: u64 = 60;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ProbeCacheEntry {
16    pub schema_version: u32,
17    pub fetched_at: u64,
18    pub last_attempt_at: u64,
19    pub last_error: Option<String>,
20    pub result: Option<CursorProbeResult>,
21}
22
23#[derive(Debug, Clone)]
24pub enum CachedCursorProbeOutcome {
25    Hit(CursorProbeResult),
26    Stale(CursorProbeResult),
27    Miss(CursorProbeResult),
28    Unavailable,
29}
30
31impl CachedCursorProbeOutcome {
32    pub fn result(&self) -> Option<&CursorProbeResult> {
33        match self {
34            Self::Hit(r) | Self::Stale(r) | Self::Miss(r) => Some(r),
35            Self::Unavailable => None,
36        }
37    }
38
39    pub fn cache_status(&self) -> &'static str {
40        match self {
41            Self::Hit(_) => "hit",
42            Self::Stale(_) => "stale",
43            Self::Miss(_) => "miss",
44            Self::Unavailable => "skipped",
45        }
46    }
47}
48
49/// Return the cached cursor probe result, if one exists, is usable, and is fresh.
50///
51/// This helper is read-only and never triggers a probe refresh.
52pub fn read_cached_probe_result() -> Option<CursorProbeResult> {
53    let entry = read_cache_tolerant()?;
54    if !is_fresh(&entry) || !is_usable(&entry) {
55        return None;
56    }
57    entry.result
58}
59
60/// Return the cached cursor probe result if usable, even when stale.
61///
62/// This helper is read-only and never triggers a probe refresh.
63pub fn read_cached_probe_result_usable() -> Option<CursorProbeResult> {
64    let entry = read_cache_tolerant()?;
65    if !is_usable(&entry) {
66        return None;
67    }
68    entry.result
69}
70
71fn cache_dir() -> Result<PathBuf, MarsError> {
72    let root = crate::platform::cache::global_cache_root()?;
73    Ok(root.join("availability"))
74}
75
76fn cache_path() -> Result<PathBuf, MarsError> {
77    Ok(cache_dir()?.join("cursor-probe.json"))
78}
79
80fn lock_path() -> Result<PathBuf, MarsError> {
81    Ok(cache_dir()?.join(".cursor-probe.lock"))
82}
83
84fn ttl_secs() -> u64 {
85    std::env::var("MARS_PROBE_CACHE_TTL_SECS")
86        .ok()
87        .and_then(|v| v.parse::<u64>().ok())
88        .unwrap_or(DEFAULT_TTL_SECS)
89}
90
91fn now_unix_secs() -> u64 {
92    std::time::SystemTime::now()
93        .duration_since(std::time::UNIX_EPOCH)
94        .unwrap_or_default()
95        .as_secs()
96}
97
98fn is_fresh(entry: &ProbeCacheEntry) -> bool {
99    let ttl = ttl_secs();
100    let now = now_unix_secs();
101    if entry.fetched_at > now {
102        return false;
103    }
104    (now - entry.fetched_at) < ttl
105}
106
107fn is_usable(entry: &ProbeCacheEntry) -> bool {
108    entry.result.as_ref().is_some_and(|r| r.model_probe_success)
109}
110
111fn read_cache_tolerant() -> Option<ProbeCacheEntry> {
112    read_cache_tolerant_at(&cache_path().ok()?)
113}
114
115fn read_cache_tolerant_at(path: &Path) -> Option<ProbeCacheEntry> {
116    let content = std::fs::read_to_string(path).ok()?;
117    let entry: ProbeCacheEntry = serde_json::from_str(&content).ok()?;
118    if entry.schema_version != SCHEMA_VERSION {
119        return None;
120    }
121    Some(entry)
122}
123
124fn write_cache(entry: &ProbeCacheEntry) -> Result<(), MarsError> {
125    write_cache_at(&cache_path()?, entry)
126}
127
128fn write_cache_at(path: &Path, entry: &ProbeCacheEntry) -> Result<(), MarsError> {
129    let json = serde_json::to_string_pretty(entry)
130        .map_err(|e| MarsError::Internal(format!("probe cache serialize: {e}")))?;
131    crate::fs::atomic_write(path, json.as_bytes())
132}
133
134struct FileLock {
135    _file: std::fs::File,
136}
137
138fn try_lock() -> Option<FileLock> {
139    lock_at(&lock_path().ok()?, true)
140}
141
142fn blocking_lock() -> Option<FileLock> {
143    lock_at(&lock_path().ok()?, false)
144}
145
146fn lock_at(path: &Path, nonblocking: bool) -> Option<FileLock> {
147    if let Some(parent) = path.parent() {
148        std::fs::create_dir_all(parent).ok()?;
149    }
150    let file = std::fs::OpenOptions::new()
151        .create(true)
152        .write(true)
153        .truncate(false)
154        .open(path)
155        .ok()?;
156
157    #[cfg(unix)]
158    {
159        use std::os::unix::io::AsRawFd;
160        let flags = if nonblocking {
161            libc::LOCK_EX | libc::LOCK_NB
162        } else {
163            libc::LOCK_EX
164        };
165        let ret = unsafe { libc::flock(file.as_raw_fd(), flags) };
166        if ret != 0 {
167            return None;
168        }
169    }
170
171    #[cfg(windows)]
172    {
173        use std::os::windows::io::AsRawHandle;
174        use windows_sys::Win32::Foundation::HANDLE;
175        use windows_sys::Win32::Storage::FileSystem::{
176            LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx,
177        };
178        let handle = file.as_raw_handle() as HANDLE;
179        let mut overlapped = unsafe { std::mem::zeroed() };
180        let flags = if nonblocking {
181            LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY
182        } else {
183            LOCKFILE_EXCLUSIVE_LOCK
184        };
185        let ret = unsafe { LockFileEx(handle, flags, 0, 1, 0, &mut overlapped) };
186        if ret == 0 {
187            return None;
188        }
189    }
190
191    Some(FileLock { _file: file })
192}
193
194pub fn probe_cached(
195    installed: &HashSet<String>,
196    mars_offline: bool,
197    probe_refresh: super::ProbeRefreshMode,
198) -> CachedCursorProbeOutcome {
199    if !super::should_probe_cursor(installed, mars_offline) {
200        return CachedCursorProbeOutcome::Unavailable;
201    }
202
203    probe_cached_impl(
204        mars_offline,
205        probe_refresh,
206        &cache_path().ok(),
207        super::cursor::probe,
208        || spawn_detached_refresh().map_err(|_| ()),
209    )
210}
211
212fn probe_cached_impl<F, S>(
213    mars_offline: bool,
214    probe_refresh: super::ProbeRefreshMode,
215    path: &Option<PathBuf>,
216    probe: F,
217    spawn_refresh: S,
218) -> CachedCursorProbeOutcome
219where
220    F: Fn() -> CursorProbeResult,
221    S: Fn() -> Result<(), ()>,
222{
223    let cached = path.as_deref().and_then(read_cache_tolerant_at);
224    match super::probe_refresh::resolve_probe_cache_branch(
225        cached,
226        mars_offline,
227        probe_refresh,
228        |entry| entry.result.as_ref().filter(|_| is_usable(entry)),
229        is_fresh,
230        || trigger_background_refresh_with(spawn_refresh),
231    ) {
232        ProbeCacheBranch::Hit(result) => CachedCursorProbeOutcome::Hit(result),
233        ProbeCacheBranch::Stale(result) => CachedCursorProbeOutcome::Stale(result),
234        ProbeCacheBranch::Unavailable => CachedCursorProbeOutcome::Unavailable,
235        ProbeCacheBranch::SynchronousProbe => synchronous_probe_with(path, probe),
236    }
237}
238
239fn trigger_background_refresh_with<S>(spawn_refresh: S)
240where
241    S: Fn() -> Result<(), ()>,
242{
243    let Some(lock) = try_lock() else { return };
244    if let Some(entry) = read_cache_tolerant()
245        && is_fresh(&entry)
246        && is_usable(&entry)
247    {
248        drop(lock);
249        return;
250    }
251    let _ = spawn_refresh();
252    drop(lock);
253}
254
255fn synchronous_probe_with<F>(path: &Option<PathBuf>, probe: F) -> CachedCursorProbeOutcome
256where
257    F: Fn() -> CursorProbeResult,
258{
259    let lock = blocking_lock();
260
261    if lock.is_some()
262        && let Some(path) = path
263        && let Some(entry) = read_cache_tolerant_at(path)
264        && is_usable(&entry)
265    {
266        if is_fresh(&entry) {
267            return CachedCursorProbeOutcome::Hit(entry.result.unwrap());
268        }
269        let probe_result = probe();
270        if probe_result.model_probe_success {
271            write_probe_attempt(path, probe_result.clone());
272            return CachedCursorProbeOutcome::Miss(probe_result);
273        } else {
274            write_failed_attempt(path, &entry, &probe_result);
275            return CachedCursorProbeOutcome::Stale(entry.result.unwrap());
276        }
277    }
278
279    let probe_result = probe();
280    if let Some(path) = path {
281        write_probe_attempt(path, probe_result.clone());
282    }
283    drop(lock);
284
285    if probe_result.model_probe_success {
286        CachedCursorProbeOutcome::Miss(probe_result)
287    } else {
288        CachedCursorProbeOutcome::Unavailable
289    }
290}
291
292fn write_probe_attempt(path: &Path, probe_result: CursorProbeResult) {
293    let now = now_unix_secs();
294    let entry = ProbeCacheEntry {
295        schema_version: SCHEMA_VERSION,
296        fetched_at: now,
297        last_attempt_at: now,
298        last_error: if probe_result.model_probe_success {
299            None
300        } else {
301            probe_result.error.clone()
302        },
303        result: Some(probe_result),
304    };
305
306    if let Err(e) = write_cache_at(path, &entry) {
307        eprintln!("debug: probe cache write failed: {e}");
308    }
309}
310
311fn write_failed_attempt(path: &Path, existing: &ProbeCacheEntry, failed_probe: &CursorProbeResult) {
312    let now = now_unix_secs();
313    let entry = ProbeCacheEntry {
314        schema_version: SCHEMA_VERSION,
315        fetched_at: existing.fetched_at,
316        last_attempt_at: now,
317        last_error: failed_probe.error.clone(),
318        result: existing.result.clone(),
319    };
320
321    if let Err(e) = write_cache_at(path, &entry) {
322        eprintln!("debug: probe cache write failed: {e}");
323    }
324}
325
326fn spawn_detached_refresh() -> std::io::Result<()> {
327    let mars_bin = std::env::current_exe()?;
328    let mut cmd = std::process::Command::new(mars_bin);
329    cmd.args(["models", "__refresh-probe", "--target", "cursor"]);
330    cmd.stdin(Stdio::null());
331    cmd.stdout(Stdio::null());
332    cmd.stderr(Stdio::null());
333
334    #[cfg(unix)]
335    {
336        use std::os::unix::process::CommandExt;
337        unsafe {
338            cmd.pre_exec(|| {
339                libc::setsid();
340                Ok(())
341            });
342        }
343    }
344
345    #[cfg(windows)]
346    {
347        use std::os::windows::process::CommandExt;
348        cmd.creation_flags(0x00000008);
349    }
350
351    cmd.spawn()?;
352    Ok(())
353}
354
355pub fn run_refresh_probe_command() -> Result<i32, MarsError> {
356    let Some(_lock) = blocking_lock() else {
357        return Ok(0);
358    };
359
360    if let Some(entry) = read_cache_tolerant()
361        && is_fresh(&entry)
362        && is_usable(&entry)
363    {
364        return Ok(0);
365    }
366
367    let probe_result = super::cursor::probe();
368    let now = now_unix_secs();
369    let existing = read_cache_tolerant();
370
371    let entry = if probe_result.model_probe_success {
372        ProbeCacheEntry {
373            schema_version: SCHEMA_VERSION,
374            fetched_at: now,
375            last_attempt_at: now,
376            last_error: None,
377            result: Some(probe_result),
378        }
379    } else {
380        ProbeCacheEntry {
381            schema_version: SCHEMA_VERSION,
382            fetched_at: existing.as_ref().map(|e| e.fetched_at).unwrap_or(0),
383            last_attempt_at: now,
384            last_error: probe_result.error.clone(),
385            result: existing.and_then(|e| e.result),
386        }
387    };
388    let _ = write_cache(&entry);
389
390    Ok(0)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::cell::Cell;
397    use tempfile::TempDir;
398
399    fn ok_result() -> CursorProbeResult {
400        CursorProbeResult {
401            slugs: vec!["gpt-5.5-high".to_string()],
402            model_probe_success: true,
403            error: None,
404        }
405    }
406
407    fn fail_result() -> CursorProbeResult {
408        CursorProbeResult {
409            model_probe_success: false,
410            error: Some("boom".to_string()),
411            ..CursorProbeResult::default()
412        }
413    }
414
415    fn entry(fetched_at: u64, result: Option<CursorProbeResult>) -> ProbeCacheEntry {
416        ProbeCacheEntry {
417            schema_version: SCHEMA_VERSION,
418            fetched_at,
419            last_attempt_at: fetched_at,
420            last_error: None,
421            result,
422        }
423    }
424
425    fn cache_file(temp: &TempDir) -> PathBuf {
426        temp.path().join("availability").join("cursor-probe.json")
427    }
428
429    fn write_entry(path: &Path, entry: &ProbeCacheEntry) {
430        write_cache_at(path, entry).unwrap();
431    }
432
433    #[test]
434    fn fresh_hit_returns_cached_result() {
435        let temp = TempDir::new().unwrap();
436        let path = cache_file(&temp);
437        write_entry(&path, &entry(now_unix_secs(), Some(ok_result())));
438
439        let outcome = probe_cached_impl(
440            false,
441            crate::models::probes::ProbeRefreshMode::Background,
442            &Some(path),
443            fail_result,
444            || Ok(()),
445        );
446        assert!(matches!(outcome, CachedCursorProbeOutcome::Hit(_)));
447        assert_eq!(outcome.result().unwrap().slugs[0], "gpt-5.5-high");
448    }
449
450    #[test]
451    fn stale_entry_returns_stale_outcome() {
452        let temp = TempDir::new().unwrap();
453        let path = cache_file(&temp);
454        write_entry(&path, &entry(1, Some(ok_result())));
455
456        let outcome = probe_cached_impl(
457            false,
458            crate::models::probes::ProbeRefreshMode::Background,
459            &Some(path),
460            fail_result,
461            || Ok(()),
462        );
463        assert!(matches!(outcome, CachedCursorProbeOutcome::Stale(_)));
464    }
465
466    #[test]
467    fn stale_cache_preserved_on_failed_probe() {
468        let temp = TempDir::new().unwrap();
469        let path = cache_file(&temp);
470        write_entry(&path, &entry(1, Some(ok_result())));
471
472        let outcome = probe_cached_impl(
473            false,
474            crate::models::probes::ProbeRefreshMode::Background,
475            &Some(path.clone()),
476            fail_result,
477            || Ok(()),
478        );
479
480        assert!(matches!(outcome, CachedCursorProbeOutcome::Stale(_)));
481
482        let on_disk = read_cache_tolerant_at(&path).unwrap();
483        assert!(on_disk.result.as_ref().unwrap().model_probe_success);
484        assert_eq!(on_disk.fetched_at, 1);
485    }
486
487    #[test]
488    fn missing_cache_runs_synchronous_probe() {
489        let temp = TempDir::new().unwrap();
490        let path = cache_file(&temp);
491        let called = Cell::new(false);
492        let outcome = probe_cached_impl(
493            false,
494            crate::models::probes::ProbeRefreshMode::Background,
495            &Some(path.clone()),
496            || {
497                called.set(true);
498                ok_result()
499            },
500            || Ok(()),
501        );
502
503        assert!(called.get());
504        assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
505        assert!(read_cache_tolerant_at(&path).is_some());
506    }
507
508    #[test]
509    fn invalid_json_is_cache_miss() {
510        let temp = TempDir::new().unwrap();
511        let path = cache_file(&temp);
512        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
513        std::fs::write(&path, "not json").unwrap();
514
515        let outcome = probe_cached_impl(
516            false,
517            crate::models::probes::ProbeRefreshMode::Background,
518            &Some(path),
519            ok_result,
520            || Ok(()),
521        );
522        assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
523    }
524
525    #[test]
526    fn incompatible_schema_is_cache_miss() {
527        let temp = TempDir::new().unwrap();
528        let path = cache_file(&temp);
529        let mut old = entry(now_unix_secs(), Some(ok_result()));
530        old.schema_version = 999;
531        write_entry(&path, &old);
532
533        let outcome = probe_cached_impl(
534            false,
535            crate::models::probes::ProbeRefreshMode::Background,
536            &Some(path),
537            ok_result,
538            || Ok(()),
539        );
540        assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
541    }
542
543    #[test]
544    fn future_fetched_at_is_stale() {
545        let future = entry(now_unix_secs() + 3600, Some(ok_result()));
546        assert!(!is_fresh(&future));
547    }
548
549    #[test]
550    fn ttl_override_controls_freshness() {
551        let _guard = EnvGuard::set("MARS_PROBE_CACHE_TTL_SECS", "9999");
552        let recent = entry(now_unix_secs().saturating_sub(10), Some(ok_result()));
553        assert!(is_fresh(&recent));
554    }
555
556    #[test]
557    fn write_failure_degrades_gracefully() {
558        let temp = TempDir::new().unwrap();
559        let path = temp.path().join("availability");
560        std::fs::write(&path, "file blocks directory").unwrap();
561        let blocked = path.join("cursor-probe.json");
562
563        let outcome = probe_cached_impl(
564            false,
565            crate::models::probes::ProbeRefreshMode::Background,
566            &Some(blocked),
567            ok_result,
568            || Ok(()),
569        );
570        assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
571    }
572
573    struct EnvGuard {
574        key: &'static str,
575        prev: Option<std::ffi::OsString>,
576    }
577
578    impl EnvGuard {
579        fn set(key: &'static str, value: &str) -> Self {
580            let prev = std::env::var_os(key);
581            unsafe { std::env::set_var(key, value) };
582            Self { key, prev }
583        }
584    }
585
586    impl Drop for EnvGuard {
587        fn drop(&mut self) {
588            if let Some(prev) = &self.prev {
589                unsafe { std::env::set_var(self.key, prev) };
590            } else {
591                unsafe { std::env::remove_var(self.key) };
592            }
593        }
594    }
595}