use std::path::Path;
use std::time::Duration;
use crate::cli::output::OutputConfig;
use crate::model_relay::readiness::{self, ReadinessFile, Verdict};
const READINESS_WAIT: Duration = Duration::from_secs(20);
const POLL: Duration = Duration::from_millis(500);
pub(crate) fn wait_for_verdicts(
ol_dir: &Path,
agents: &[&str],
wait: Duration,
poll: Duration,
) -> Option<ReadinessFile> {
let deadline = std::time::Instant::now() + wait;
loop {
let file = readiness::read(ol_dir);
let complete = file
.as_ref()
.is_some_and(|f| agents.iter().all(|a| f.agents.contains_key(*a)));
if complete || std::time::Instant::now() >= deadline {
return file;
}
std::thread::sleep(poll);
}
}
pub(crate) fn verdict_line(display: &str, v: &Verdict) -> String {
match v {
Verdict::Captured { .. } => format!("{display}: model calls captured"),
Verdict::Ready | Verdict::RestartNeeded { .. } => {
format!("{display}: ready — its next model call is captured")
}
Verdict::Failed { why, fix } => format!("{display}: not captured — {why}. {fix}"),
}
}
pub(crate) fn settle(
ol_dir: &Path,
agents: &[(&'static str, &'static str)],
output: &OutputConfig,
) {
if agents.is_empty() {
return;
}
let names: Vec<&str> = agents.iter().map(|(a, _)| *a).collect();
let Some(file) = wait_for_verdicts(ol_dir, &names, READINESS_WAIT, POLL) else {
output.print_substep(
"Model capture: no verdict yet — run `openlatch doctor` in a moment to confirm.",
);
return;
};
for (agent, display) in agents {
match file.agents.get(*agent) {
Some(entry) => output.print_substep(&verdict_line(display, &entry.verdict)),
None => output.print_substep(&format!(
"{display}: still being checked — run `openlatch doctor` in a moment."
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_relay::readiness::AgentReadiness;
use std::collections::BTreeMap;
#[test]
fn only_a_real_failure_reads_as_not_captured() {
let restart = Verdict::RestartNeeded {
app: "Cursor".into(),
how: "Quit and reopen Cursor.".into(),
by_openlatch: false,
};
let captured = Verdict::Captured {
at: chrono::Utc::now(),
};
for v in [Verdict::Ready, captured, restart] {
let line = verdict_line("Cursor", &v);
assert!(
!line.contains("not captured") && !line.contains("restart"),
"{line}"
);
}
assert!(verdict_line(
"Cursor",
&Verdict::Failed {
why: "x".into(),
fix: "y".into()
}
)
.contains("not captured"));
}
#[test]
fn the_wait_returns_once_every_agent_has_a_verdict() {
let dir = tempfile::tempdir().unwrap();
assert!(wait_for_verdicts(
dir.path(),
&["cursor"],
Duration::from_millis(50),
Duration::from_millis(10)
)
.is_none());
let mut agents = BTreeMap::new();
agents.insert(
"cursor".to_string(),
AgentReadiness {
verdict: Verdict::Ready,
wired_since: chrono::Utc::now(),
},
);
readiness::write_if_changed(dir.path(), agents);
let start = std::time::Instant::now();
let f = wait_for_verdicts(
dir.path(),
&["cursor"],
Duration::from_secs(5),
Duration::from_millis(10),
)
.expect("written");
assert!(
start.elapsed() < Duration::from_secs(1),
"no wait once complete"
);
assert_eq!(f.agents["cursor"].verdict, Verdict::Ready);
let partial = wait_for_verdicts(
dir.path(),
&["cursor", "cline"],
Duration::from_millis(50),
Duration::from_millis(10),
)
.expect("partial");
assert!(!partial.agents.contains_key("cline"));
}
}