Skip to main content

mars_agents/models/probes/
opencode_cache.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use serde::{Deserialize, Serialize};
6
7use super::opencode::OpenCodeProbeResult;
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<OpenCodeProbeResult>,
21}
22
23#[derive(Debug, Clone)]
24pub enum CachedProbeOutcome {
25    Hit(OpenCodeProbeResult),
26    Stale(OpenCodeProbeResult),
27    Miss(OpenCodeProbeResult),
28    Unavailable,
29}
30
31impl CachedProbeOutcome {
32    pub fn result(&self) -> Option<&OpenCodeProbeResult> {
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 OpenCode 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<OpenCodeProbeResult> {
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 OpenCode 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<OpenCodeProbeResult> {
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("opencode-probe.json"))
78}
79
80fn lock_path() -> Result<PathBuf, MarsError> {
81    Ok(cache_dir()?.join(".opencode-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) -> CachedProbeOutcome {
199    if !super::should_probe_opencode(installed, mars_offline) {
200        return CachedProbeOutcome::Unavailable;
201    }
202
203    probe_cached_impl(
204        mars_offline,
205        probe_refresh,
206        &cache_path().ok(),
207        super::opencode::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) -> CachedProbeOutcome
219where
220    F: Fn() -> OpenCodeProbeResult,
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) => CachedProbeOutcome::Hit(result),
233        ProbeCacheBranch::Stale(result) => CachedProbeOutcome::Stale(result),
234        ProbeCacheBranch::Unavailable => CachedProbeOutcome::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) -> CachedProbeOutcome
256where
257    F: Fn() -> OpenCodeProbeResult,
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 CachedProbeOutcome::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 CachedProbeOutcome::Miss(probe_result);
273        } else {
274            write_failed_attempt(path, &entry, &probe_result);
275            return CachedProbeOutcome::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        CachedProbeOutcome::Miss(probe_result)
287    } else {
288        CachedProbeOutcome::Unavailable
289    }
290}
291
292fn write_probe_attempt(path: &Path, probe_result: OpenCodeProbeResult) {
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(
312    path: &Path,
313    existing: &ProbeCacheEntry,
314    failed_probe: &OpenCodeProbeResult,
315) {
316    let now = now_unix_secs();
317    let entry = ProbeCacheEntry {
318        schema_version: SCHEMA_VERSION,
319        fetched_at: existing.fetched_at,
320        last_attempt_at: now,
321        last_error: failed_probe.error.clone(),
322        result: existing.result.clone(),
323    };
324
325    if let Err(e) = write_cache_at(path, &entry) {
326        eprintln!("debug: probe cache write failed: {e}");
327    }
328}
329
330fn spawn_detached_refresh() -> std::io::Result<()> {
331    let mars_bin = std::env::current_exe()?;
332    let mut cmd = std::process::Command::new(mars_bin);
333    cmd.args(["models", "__refresh-probe", "--target", "opencode"]);
334    cmd.stdin(Stdio::null());
335    cmd.stdout(Stdio::null());
336    cmd.stderr(Stdio::null());
337
338    #[cfg(unix)]
339    {
340        use std::os::unix::process::CommandExt;
341        unsafe {
342            cmd.pre_exec(|| {
343                libc::setsid();
344                Ok(())
345            });
346        }
347    }
348
349    #[cfg(windows)]
350    {
351        use std::os::windows::process::CommandExt;
352        cmd.creation_flags(0x00000008);
353    }
354
355    cmd.spawn()?;
356    Ok(())
357}
358
359pub fn run_refresh_probe_command() -> Result<i32, MarsError> {
360    let Some(_lock) = blocking_lock() else {
361        return Ok(0);
362    };
363
364    if let Some(entry) = read_cache_tolerant()
365        && is_fresh(&entry)
366        && is_usable(&entry)
367    {
368        return Ok(0);
369    }
370
371    let probe_result = super::opencode::probe();
372    let now = now_unix_secs();
373    let existing = read_cache_tolerant();
374
375    let entry = if probe_result.model_probe_success {
376        ProbeCacheEntry {
377            schema_version: SCHEMA_VERSION,
378            fetched_at: now,
379            last_attempt_at: now,
380            last_error: None,
381            result: Some(probe_result),
382        }
383    } else {
384        ProbeCacheEntry {
385            schema_version: SCHEMA_VERSION,
386            fetched_at: existing.as_ref().map(|e| e.fetched_at).unwrap_or(0),
387            last_attempt_at: now,
388            last_error: probe_result.error.clone(),
389            result: existing.and_then(|e| e.result),
390        }
391    };
392    let _ = write_cache(&entry);
393
394    Ok(0)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use std::cell::Cell;
401    use tempfile::TempDir;
402
403    fn ok_result() -> OpenCodeProbeResult {
404        OpenCodeProbeResult {
405            model_slugs: vec!["openai/gpt-5.4".to_string()],
406            model_probe_success: true,
407            error: None,
408        }
409    }
410
411    fn fail_result() -> OpenCodeProbeResult {
412        OpenCodeProbeResult {
413            model_probe_success: false,
414            error: Some("boom".to_string()),
415            ..OpenCodeProbeResult::default()
416        }
417    }
418
419    fn entry(fetched_at: u64, result: Option<OpenCodeProbeResult>) -> ProbeCacheEntry {
420        ProbeCacheEntry {
421            schema_version: SCHEMA_VERSION,
422            fetched_at,
423            last_attempt_at: fetched_at,
424            last_error: None,
425            result,
426        }
427    }
428
429    fn cache_file(temp: &TempDir) -> PathBuf {
430        temp.path().join("availability").join("opencode-probe.json")
431    }
432
433    fn write_entry(path: &Path, entry: &ProbeCacheEntry) {
434        write_cache_at(path, entry).unwrap();
435    }
436
437    #[test]
438    fn fresh_hit_returns_cached_result() {
439        let temp = TempDir::new().unwrap();
440        let path = cache_file(&temp);
441        write_entry(&path, &entry(now_unix_secs(), Some(ok_result())));
442
443        let outcome = probe_cached_impl(
444            false,
445            crate::models::probes::ProbeRefreshMode::Background,
446            &Some(path),
447            fail_result,
448            || Ok(()),
449        );
450        assert!(matches!(outcome, CachedProbeOutcome::Hit(_)));
451        assert_eq!(outcome.result().unwrap().model_slugs[0], "openai/gpt-5.4");
452    }
453
454    #[test]
455    fn stale_entry_with_synchronous_refresh_runs_probe_without_spawn() {
456        let temp = TempDir::new().unwrap();
457        let path = cache_file(&temp);
458        write_entry(&path, &entry(1, Some(ok_result())));
459
460        let spawn_called = Cell::new(false);
461        let probe_called = Cell::new(false);
462        let outcome = probe_cached_impl(
463            false,
464            crate::models::probes::ProbeRefreshMode::Synchronous,
465            &Some(path.clone()),
466            || {
467                probe_called.set(true);
468                OpenCodeProbeResult {
469                    model_slugs: vec!["openai/gpt-5.5".to_string()],
470                    model_probe_success: true,
471                    error: None,
472                }
473            },
474            || {
475                spawn_called.set(true);
476                Ok(())
477            },
478        );
479
480        assert!(probe_called.get());
481        assert!(!spawn_called.get());
482        assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
483    }
484
485    #[test]
486    fn stale_entry_returns_stale_outcome() {
487        let temp = TempDir::new().unwrap();
488        let path = cache_file(&temp);
489        write_entry(&path, &entry(1, Some(ok_result())));
490
491        let outcome = probe_cached_impl(
492            false,
493            crate::models::probes::ProbeRefreshMode::Background,
494            &Some(path),
495            fail_result,
496            || Ok(()),
497        );
498        assert!(matches!(outcome, CachedProbeOutcome::Stale(_)));
499    }
500
501    #[test]
502    fn stale_cache_preserved_on_failed_probe() {
503        let temp = TempDir::new().unwrap();
504        let path = cache_file(&temp);
505        write_entry(&path, &entry(1, Some(ok_result())));
506
507        let outcome = probe_cached_impl(
508            false,
509            crate::models::probes::ProbeRefreshMode::Background,
510            &Some(path.clone()),
511            fail_result,
512            || Ok(()),
513        );
514
515        assert!(matches!(outcome, CachedProbeOutcome::Stale(_)));
516
517        let on_disk = read_cache_tolerant_at(&path).unwrap();
518        assert!(on_disk.result.as_ref().unwrap().model_probe_success);
519        assert_eq!(on_disk.fetched_at, 1);
520    }
521
522    #[test]
523    fn missing_cache_runs_synchronous_probe() {
524        let temp = TempDir::new().unwrap();
525        let path = cache_file(&temp);
526        let called = Cell::new(false);
527        let outcome = probe_cached_impl(
528            false,
529            crate::models::probes::ProbeRefreshMode::Background,
530            &Some(path.clone()),
531            || {
532                called.set(true);
533                ok_result()
534            },
535            || Ok(()),
536        );
537
538        assert!(called.get());
539        assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
540        assert!(read_cache_tolerant_at(&path).is_some());
541    }
542
543    #[test]
544    fn invalid_json_is_cache_miss() {
545        let temp = TempDir::new().unwrap();
546        let path = cache_file(&temp);
547        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
548        std::fs::write(&path, "not json").unwrap();
549
550        let outcome = probe_cached_impl(
551            false,
552            crate::models::probes::ProbeRefreshMode::Background,
553            &Some(path),
554            ok_result,
555            || Ok(()),
556        );
557        assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
558    }
559
560    #[test]
561    fn incompatible_schema_is_cache_miss() {
562        let temp = TempDir::new().unwrap();
563        let path = cache_file(&temp);
564        let mut old = entry(now_unix_secs(), Some(ok_result()));
565        old.schema_version = 999;
566        write_entry(&path, &old);
567
568        let outcome = probe_cached_impl(
569            false,
570            crate::models::probes::ProbeRefreshMode::Background,
571            &Some(path),
572            ok_result,
573            || Ok(()),
574        );
575        assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
576    }
577
578    #[test]
579    fn future_fetched_at_is_stale() {
580        let future = entry(now_unix_secs() + 3600, Some(ok_result()));
581        assert!(!is_fresh(&future));
582    }
583
584    #[test]
585    fn ttl_override_controls_freshness() {
586        let _guard = EnvGuard::set("MARS_PROBE_CACHE_TTL_SECS", "9999");
587        let recent = entry(now_unix_secs().saturating_sub(10), Some(ok_result()));
588        assert!(is_fresh(&recent));
589    }
590
591    #[test]
592    fn write_failure_degrades_gracefully() {
593        let temp = TempDir::new().unwrap();
594        let path = temp.path().join("availability");
595        std::fs::write(&path, "file blocks directory").unwrap();
596        let blocked = path.join("opencode-probe.json");
597
598        let outcome = probe_cached_impl(
599            false,
600            crate::models::probes::ProbeRefreshMode::Background,
601            &Some(blocked),
602            ok_result,
603            || Ok(()),
604        );
605        assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
606    }
607
608    struct EnvGuard {
609        key: &'static str,
610        prev: Option<std::ffi::OsString>,
611    }
612
613    impl EnvGuard {
614        fn set(key: &'static str, value: &str) -> Self {
615            let prev = std::env::var_os(key);
616            unsafe { std::env::set_var(key, value) };
617            Self { key, prev }
618        }
619    }
620
621    impl Drop for EnvGuard {
622        fn drop(&mut self) {
623            if let Some(prev) = &self.prev {
624                unsafe { std::env::set_var(self.key, prev) };
625            } else {
626                unsafe { std::env::remove_var(self.key) };
627            }
628        }
629    }
630}