mars_agents/models/probes/
opencode_cache.rs1use 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}