use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{hook, learn, live};
pub const MODE: &str = "async";
pub const WHY_SUPERSEDED: &str = "superseded";
pub const WHY_GONE: &str = "gone";
pub const WHY_UNSTARTED: &str = "unstarted";
pub const WHY_UNPLACED: &str = "unplaced";
pub const WHYS: [&str; 4] = [WHY_SUPERSEDED, WHY_GONE, WHY_UNSTARTED, WHY_UNPLACED];
pub const JOB_VERSION: u32 = 1;
#[must_use]
pub fn on() -> bool {
!std::env::var("BORG_ASYNC").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Job {
pub v: u32,
pub session: String,
pub check: String,
pub stop_ms: i64,
pub harness: String,
pub cwd: String,
pub offset: usize,
pub message: String,
pub proposal: String,
pub changed: bool,
pub repo: String,
#[serde(default)]
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edits: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Checked {
pub check: String,
pub stop_ms: i64,
pub done_ms: i64,
#[serde(default)]
pub asks: Vec<String>,
#[serde(default)]
pub free: Vec<String>,
#[serde(default)]
pub failed: bool,
#[serde(default)]
pub dur_ms: u64,
#[serde(default)]
pub superseded: Vec<String>,
}
impl Checked {
pub(crate) fn of(job: &Job) -> Checked {
Checked { check: job.check.clone(), stop_ms: job.stop_ms, ..Default::default() }
}
}
fn file(session: &str, ext: &str) -> PathBuf {
hook::state_file(session).with_extension(ext)
}
fn next_file(session: &str) -> PathBuf {
file(session, "job.next")
}
pub(crate) fn write_job(job: &Job) -> std::io::Result<PathBuf> {
let path = file(&job.session, &format!("job.{}", job.check));
let tmp = file(&job.session, &format!("job.{}.tmp", job.check));
let text = serde_json::to_string(job).map_err(std::io::Error::other)?;
std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, &path)).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})?;
Ok(path)
}
pub(crate) fn spawn(job: &Path) -> bool {
use std::os::unix::process::CommandExt;
let mut c = std::process::Command::new(crate::reexec::exe_path());
c.arg("check").arg("--job").arg(job).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
unsafe {
c.pre_exec(|| {
libc::setsid();
Ok(())
});
}
if c.spawn().is_err() {
let _ = std::fs::remove_file(job);
return false;
}
true
}
pub fn main(job: &str) -> i32 {
run(Path::new(job));
0
}
struct Claimed {
path: PathBuf,
job: Job,
}
impl Drop for Claimed {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn read_job(path: &Path) -> Option<Job> {
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
fn claim(path: &Path) -> Option<Claimed> {
let job = read_job(path)?;
let to = file(&job.session, &format!("job.{}.{}", job.check, std::process::id()));
std::fs::rename(path, &to).ok()?;
Some(Claimed { path: to, job })
}
enum Next {
Job(Box<Claimed>),
Empty,
Stuck,
}
fn claim_next(session: &str) -> Next {
let next = next_file(session);
live::locked(&file(session, "checking.next"), || {
if !next.exists() {
return Next::Empty;
}
if let Some(c) = claim(&next) {
return Next::Job(Box::new(c));
}
let job = read_job(&next).unwrap_or_else(|| Job { session: session.to_string(), ..Job::default() });
match std::fs::remove_file(&next) {
Ok(()) => {
not_run(&job, WHY_UNPLACED);
Next::Empty
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Next::Empty,
Err(_) => Next::Stuck,
}
})
}
fn wait_next(c: Claimed) {
let session = c.job.session.clone();
let next = next_file(&session);
live::locked(&file(&session, "checking.next"), || match read_job(&next) {
Some(waiting) if waiting.stop_ms > c.job.stop_ms => not_run(&c.job, WHY_SUPERSEDED),
waiting => match std::fs::rename(&c.path, &next) {
Ok(()) => {
if let Some(w) = waiting {
not_run(&w, WHY_SUPERSEDED);
}
}
Err(_) => not_run(&c.job, WHY_UNPLACED),
},
});
}
enum Hold {
Mine(std::fs::File),
Busy,
Broken,
}
fn hold(session: &str) -> Hold {
let Ok(f) = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file(session, "checking.lock")) else { return Hold::Broken };
match f.try_lock() {
Ok(()) => Hold::Mine(f),
Err(std::fs::TryLockError::WouldBlock) => Hold::Busy,
Err(std::fs::TryLockError::Error(_)) => Hold::Broken,
}
}
fn run(path: &Path) {
let Some(mine) = claim(path) else { return };
let session = mine.job.session.clone();
let mut mine = Some(mine);
let mut last = i64::MIN;
loop {
let lock = match hold(&session) {
Hold::Mine(f) => Some(f),
Hold::Broken => None,
Hold::Busy => match mine.take() {
Some(c) => {
wait_next(c);
continue;
}
None => return,
},
};
let mut job = match mine.take() {
Some(c) => Next::Job(Box::new(c)),
None => claim_next(&session),
};
while let Next::Job(c) = job {
if c.job.stop_ms < last {
not_run(&c.job, WHY_SUPERSEDED);
} else {
last = c.job.stop_ms;
hook::run_job(&c.job);
}
job = claim_next(&session);
drop(c);
}
drop(lock);
if matches!(job, Next::Stuck) || !next_file(&session).exists() {
return;
}
}
}
pub(crate) fn not_run(job: &Job, why: &str) {
crate::stats::count(&learn::home(), crate::stats::CHECK_DROPPED, &job.session, |e| {
e.why = why.into();
e.mode = MODE.into();
e.repo.clone_from(&job.repo);
});
}
#[must_use]
pub fn index(session: &str) -> u64 {
std::fs::read_to_string(file(session, "checks")).ok().and_then(|t| t.trim().parse().ok()).unwrap_or(0)
}
pub(crate) fn finish(session: &str, out: &mut Checked, called: bool) {
if called {
let _ = std::fs::write(file(session, "checks"), format!("{}\n", index(session) + 1));
}
out.done_ms = learn::now_ms();
let (path, tmp) = (file(session, "checked"), file(session, &format!("checked.{}", std::process::id())));
let Ok(text) = serde_json::to_string(out) else { return };
if std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, &path)).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
#[must_use]
pub fn call_limit(model: &str) -> Duration {
call_limit_in(&learn::home(), model)
}
#[must_use]
pub fn call_limit_in(home: &Path, model: &str) -> Duration {
let floor = crate::predict::timeout();
if std::env::var_os("BORG_PREDICT_TIMEOUT_S").is_some() {
return floor;
}
let p95 = p95(durations(&home.join("outcomes.jsonl"), model));
floor.max(Duration::from_millis(p95.saturating_mul(2)))
}
fn durations(log: &Path, model: &str) -> Vec<u64> {
let Ok(f) = std::fs::File::open(log) else { return Vec::new() };
let mut kept = std::collections::VecDeque::with_capacity(crate::listen::GAPS_KEPT);
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
if !line.contains(crate::outcome::GRADED) {
continue;
}
let Ok(o) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
if o["kind"] != crate::outcome::GRADED || o["failed"] == true || o["model"].as_str().unwrap_or_default() != model {
continue;
}
let Some(ms) = o["dur_ms"].as_u64().filter(|ms| *ms > 0) else { continue };
if kept.len() == crate::listen::GAPS_KEPT {
kept.pop_front();
}
kept.push_back(ms);
}
kept.into()
}
fn p95(mut v: Vec<u64>) -> u64 {
v.sort_unstable();
let rank = (v.len() * 95).div_ceil(100);
v.get(rank.saturating_sub(1)).copied().unwrap_or(0)
}
#[must_use]
pub fn rss_kb() -> u64 {
let mut u: libc::rusage = unsafe { std::mem::zeroed() };
if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut u) } != 0 {
return 0;
}
kib(u64::try_from(u.ru_maxrss).unwrap_or(0))
}
const fn kib(maxrss: u64) -> u64 {
if cfg!(target_os = "macos") { maxrss / 1024 } else { maxrss }
}
#[must_use]
pub fn running(session: &str) -> bool {
let ctx = hook::state_file(session);
let (Some(dir), Some(stem)) = (ctx.parent(), ctx.file_stem().and_then(|s| s.to_str())) else { return false };
let jobs = format!("{stem}.job.");
let Ok(rd) = std::fs::read_dir(dir) else { return false };
rd.flatten().any(|e| e.file_name().to_str().and_then(|n| claimed_pid(n, &jobs)).is_some_and(live::alive))
}
fn claimed_pid(name: &str, jobs: &str) -> Option<i32> {
name.strip_prefix(jobs)?.split_once('.')?.1.parse().ok()
}
pub fn sweep(session: &str) {
let ctx = hook::state_file(session);
let (Some(dir), Some(stem)) = (ctx.parent(), ctx.file_stem().and_then(|s| s.to_str())) else { return };
let (jobs, results) = (format!("{stem}.job."), format!("{stem}.checked."));
let Ok(rd) = std::fs::read_dir(dir) else { return };
let mine: Vec<std::fs::DirEntry> =
rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with(&jobs) || n.starts_with(&results))).collect();
let pid_of = |name: &str| -> Option<i32> { claimed_pid(name, &jobs).or_else(|| name.strip_prefix(&results)?.parse().ok()) };
let names: Vec<String> = mine.iter().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
let running = names.iter().filter(|n| n.starts_with(&jobs)).filter_map(|n| pid_of(n)).any(live::alive);
let least = ms(crate::predict::timeout()).saturating_add(live::STALE_SLACK_MS);
let mut limit: Option<i64> = None;
let now = learn::now_ms();
for (e, name) in mine.iter().zip(&names) {
let gone = match pid_of(name) {
Some(pid) => !live::alive(pid),
None if name.starts_with(&results) => false,
None => {
let age = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| now - ms(d));
let old = age > least
&& age
> *limit.get_or_insert_with(|| {
ms(call_limit(&std::env::var("BORG_PREDICT_MODEL").unwrap_or_default())).saturating_add(live::STALE_SLACK_MS)
});
old && !(running && name.ends_with(".job.next"))
}
};
if gone {
let _ = std::fs::remove_file(e.path());
}
}
}
fn ms(d: Duration) -> i64 {
i64::try_from(d.as_millis()).unwrap_or(i64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_95th_percentile_is_the_nearest_rank() {
assert_eq!(p95(vec![]), 0);
assert_eq!(p95(vec![7]), 7);
assert_eq!(p95((1..=100).rev().collect()), 95);
assert_eq!(p95(vec![1, 2, 3, 40]), 40, "4 durations: the 4th");
}
#[test]
fn the_limit_reads_the_latest_answered_checks_of_the_same_model() {
let d = std::env::temp_dir().join(format!("borg-check-async-limit-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
let log = d.join("outcomes.jsonl");
let mut text = String::new();
for i in 0..100u64 {
text.push_str(&format!("{{\"kind\":\"graded\",\"session\":\"s\",\"ms\":{i},\"model\":\"m\",\"dur_ms\":{}}}\n", 1000 + i));
}
text.push_str("{\"kind\":\"graded\",\"session\":\"s\",\"ms\":200,\"model\":\"m\",\"dur_ms\":900000,\"failed\":true}\n");
text.push_str("{\"kind\":\"graded\",\"session\":\"s\",\"ms\":201,\"model\":\"other\",\"dur_ms\":800000}\n");
text.push_str("{\"kind\":\"asked\",\"session\":\"s\",\"ms\":202,\"model\":\"m\",\"dur_ms\":700000}\n");
std::fs::write(&log, text).unwrap();
let got = durations(&log, "m");
assert_eq!(got.len(), crate::listen::GAPS_KEPT, "the latest only");
assert_eq!((got[0], *got.last().unwrap()), (1036, 1099), "failed calls, other models and other kinds left out");
let _ = std::fs::remove_dir_all(d);
}
}