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
48pub 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
59pub 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}