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