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