use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::time::Duration;
use anyhow::Result;
use serde::{Deserialize, Serialize};
pub const MIN_AUTO_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionHistoryRecord {
pub agent: String,
pub duration_secs: u64,
#[serde(default)]
pub ts: String,
#[serde(default)]
pub exit_code: i32,
}
pub fn append_session_history(project_dir: &Path, record: &SessionHistoryRecord) -> Result<()> {
let vetto_dir = project_dir.join(".vetto");
let _ = fs::create_dir_all(&vetto_dir);
let history_file = vetto_dir.join("history");
let line = serde_json::to_string(record)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(history_file)?;
writeln!(file, "{line}")?;
Ok(())
}
pub fn load_agent_durations(project_dir: &Path, agent_name: &str) -> Vec<u64> {
let mut samples = Vec::new();
let history_file = project_dir.join(".vetto/history");
if let Ok(file) = fs::File::open(&history_file) {
let reader = BufReader::new(file);
for line in reader.lines().map_while(Result::ok) {
if let Ok(record) = serde_json::from_str::<SessionHistoryRecord>(&line) {
if record.agent == agent_name || record.agent.ends_with(agent_name) {
samples.push(record.duration_secs);
}
}
}
}
samples
}
pub fn compute_auto_timeout(project_dir: &Path, agent_name: &str) -> Option<Duration> {
let mut samples = load_agent_durations(project_dir, agent_name);
if samples.is_empty() {
return None;
}
samples.sort_unstable();
let p95_index = ((samples.len() as f64 - 1.0) * 0.95).round() as usize;
let p95 = samples[p95_index.min(samples.len() - 1)];
let computed_secs = (p95.saturating_mul(2)).max(MIN_AUTO_TIMEOUT_SECS);
Some(Duration::from_secs(computed_secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calculates_p95_timeout_with_floor() {
let temp = std::env::temp_dir().join(format!("vetto-hist-{}", std::process::id()));
let _ = fs::remove_dir_all(&temp);
fs::create_dir_all(&temp).unwrap();
assert_eq!(compute_auto_timeout(&temp, "codex"), None);
for d in [10, 20, 30, 40, 50] {
append_session_history(
&temp,
&SessionHistoryRecord {
agent: "codex".into(),
duration_secs: d,
ts: "".into(),
exit_code: 0,
},
)
.unwrap();
}
let timeout = compute_auto_timeout(&temp, "codex").unwrap();
assert_eq!(timeout, Duration::from_secs(300));
for d in 1..=100 {
append_session_history(
&temp,
&SessionHistoryRecord {
agent: "heavy-agent".into(),
duration_secs: d * 10, ts: "".into(),
exit_code: 0,
},
)
.unwrap();
}
let heavy_timeout = compute_auto_timeout(&temp, "heavy-agent").unwrap();
assert!(heavy_timeout.as_secs() >= 1800);
let _ = fs::remove_dir_all(&temp);
}
}