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