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