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