Skip to main content

borg/
check_async.rs

1//! The turn-end check, detached (0.7.7): the stop never waits for the model.
2//!
3//! With `BORG_ASYNC` on (the default), a stop runs the free gate, writes the turn to a job file and
4//! starts `borg check --job <file>` in its own session, stdio null, and returns at once (hook.rs
5//! `stop_detached`): no block, no session lock, no model call in the hook. The job holds the turn as the
6//! stop saw it (`Job`): the state file's length then, the drone's message, where it worked. The check's
7//! decision is made on channel live, and its asks reach the drone with the next prompt or tool call
8//! (hook.rs `queued`, `ASK_NEXT`), as questions about the turn they judged. `BORG_ASYNC=off` keeps the
9//! stop's own check and its block.
10//!
11//! One check runs per session (`<s>.checking.lock`, an flock): a job that finds it held waits as
12//! `<s>.job.next`, replacing (superseding) an older one there, which never runs; the running check
13//! takes the waiting job when it is done. The model is called without any lock; only the write of the
14//! answer holds the session lock (live.rs), and a session that ended meanwhile gets nothing queued
15//! (hook.rs `run_job`, `settle`). What a check did is in `<s>.checked` (`Checked`), overwritten whole
16//! by each; `<s>.checks` counts the session's checks that called the model.
17//!
18//! Leftovers: the job a process claimed (`<s>.job.<check>.<pid>`) goes when it exits; a later stop
19//! removes the job files of a process that is gone or older than a call may take (`sweep`).
20
21use std::io::BufRead;
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use serde::{Deserialize, Serialize};
26
27use crate::{hook, learn, live};
28
29/// The stats mode of a detached check's events (stats.rs `Event::mode`).
30pub const MODE: &str = "async";
31
32/// Protocol: why a job never called the model (`not_run`, the `why` of stats.rs `CHECK_DROPPED`): a
33/// newer stop's job took its place, the session ended before the call, its check could not be started,
34/// or its job could not be placed or read where the running check takes it. borg-web's feed matches them.
35pub const WHY_SUPERSEDED: &str = "superseded";
36pub const WHY_GONE: &str = "gone";
37pub const WHY_UNSTARTED: &str = "unstarted";
38pub const WHY_UNPLACED: &str = "unplaced";
39pub const WHYS: [&str; 4] = [WHY_SUPERSEDED, WHY_GONE, WHY_UNSTARTED, WHY_UNPLACED];
40
41/// Protocol: the version of a job file's format (`Job::v`).
42pub const JOB_VERSION: u32 = 1;
43
44/// Whether stops check detached: `BORG_ASYNC` anything but `off` (the default is on). Evaluation switch:
45/// `off` keeps the stop's own check, its lock and its block, as before 0.7.7.
46#[must_use]
47pub fn on() -> bool {
48    !std::env::var("BORG_ASYNC").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
49}
50
51/// One turn to check, as its stop saw it (hook.rs `stop_detached`): `<state>/<s>.job.<check>`, written
52/// whole (a rename), read by the check it started. A file, never arguments: it holds the drone's words.
53#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
54pub struct Job {
55    pub v: u32,
56    pub session: String,
57    /// The check's id, from the stop's start and pid (select.rs `check_id`).
58    pub check: String,
59    pub stop_ms: i64,
60    pub harness: String,
61    pub cwd: String,
62    /// The state file's length at the stop: the check reads the turn up to here, never the next
63    /// prompt's lines.
64    pub offset: usize,
65    /// The hook's `last_assistant_message` (the work the gate and the pool read with the state file).
66    pub message: String,
67    /// The drone's whole last message (hook.rs `full_proposal`): the judge's message, and the turn the
68    /// asks name when they are delivered.
69    pub proposal: String,
70    /// BORG_STOP_GATE=edits: whether the turn changed anything.
71    pub changed: bool,
72    pub repo: String,
73    /// Where the session worked at the stop, and the message (hook.rs `where_now`): the pool's scopes.
74    #[serde(default)]
75    pub scope: String,
76    /// The .edits file's length at the stop: the deliveries the check judges and the work since them
77    /// are read up to here (hook.rs `pending`), never the next turn's. None from a job without it: all.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub edits: Option<usize>,
80}
81
82/// What a detached check did, `<state>/<s>.checked`, overwritten whole by each (`finish`). No prompt
83/// data: ids, times, whether the call failed.
84#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
85pub struct Checked {
86    pub check: String,
87    pub stop_ms: i64,
88    pub done_ms: i64,
89    /// The lessons it queued as asks, and as free hand-overs.
90    #[serde(default)]
91    pub asks: Vec<String>,
92    #[serde(default)]
93    pub free: Vec<String>,
94    /// The model gave no answer (fail open: nothing asked, the pending lessons stay pending).
95    #[serde(default)]
96    pub failed: bool,
97    /// The model call's duration.
98    #[serde(default)]
99    pub dur_ms: u64,
100    /// Waiting asks of earlier checks it took off the queue (hook.rs `supersede`).
101    #[serde(default)]
102    pub superseded: Vec<String>,
103}
104
105impl Checked {
106    /// A check of `job` that did nothing yet.
107    pub(crate) fn of(job: &Job) -> Checked {
108        Checked { check: job.check.clone(), stop_ms: job.stop_ms, ..Default::default() }
109    }
110}
111
112/// `<state>/<s>.<ext>`, beside the session's state file.
113fn file(session: &str, ext: &str) -> PathBuf {
114    hook::state_file(session).with_extension(ext)
115}
116
117/// The waiting job (`<s>.job.next`).
118fn next_file(session: &str) -> PathBuf {
119    file(session, "job.next")
120}
121
122/// The job file of `job`, written whole (a temporary file, then a rename); its path.
123pub(crate) fn write_job(job: &Job) -> std::io::Result<PathBuf> {
124    let path = file(&job.session, &format!("job.{}", job.check));
125    let tmp = file(&job.session, &format!("job.{}.tmp", job.check));
126    let text = serde_json::to_string(job).map_err(std::io::Error::other)?;
127    std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, &path)).inspect_err(|_| {
128        let _ = std::fs::remove_file(&tmp);
129    })?;
130    Ok(path)
131}
132
133/// `borg check --job <job>`, detached: its own session (setsid), stdio null (an adapter that waits for
134/// the hook's stdout to close is never held), not waited for. The environment goes with it: the routed
135/// store and state (`BORG_HOME`, `BORG_STATE`) and the harness (`BORG_HARNESS`, hook.rs `main`). A job
136/// that cannot start is removed; false then (the stop counts it, `not_run`).
137pub(crate) fn spawn(job: &Path) -> bool {
138    use std::os::unix::process::CommandExt;
139    let mut c = std::process::Command::new(crate::reexec::exe_path());
140    c.arg("check").arg("--job").arg(job).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
141    // SAFETY: setsid is async-signal-safe and touches no memory of the parent.
142    unsafe {
143        c.pre_exec(|| {
144            libc::setsid();
145            Ok(())
146        });
147    }
148    if c.spawn().is_err() {
149        let _ = std::fs::remove_file(job);
150        return false;
151    }
152    true
153}
154
155/// `borg check --job <file>`: started by a stop, never by hand.
156pub fn main(job: &str) -> i32 {
157    run(Path::new(job));
158    0
159}
160
161/// A job this process claimed: its file, removed when it is done, whatever way it ends.
162struct Claimed {
163    path: PathBuf,
164    job: Job,
165}
166
167impl Drop for Claimed {
168    fn drop(&mut self) {
169        let _ = std::fs::remove_file(&self.path);
170    }
171}
172
173fn read_job(path: &Path) -> Option<Job> {
174    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
175}
176
177/// `path` taken by this process (a rename to `<s>.job.<check>.<pid>`); None when another took it or it
178/// is gone.
179fn claim(path: &Path) -> Option<Claimed> {
180    let job = read_job(path)?;
181    let to = file(&job.session, &format!("job.{}.{}", job.check, std::process::id()));
182    std::fs::rename(path, &to).ok()?;
183    Some(Claimed { path: to, job })
184}
185
186/// What `claim_next` found waiting.
187enum Next {
188    /// The waiting job, claimed.
189    Job(Box<Claimed>),
190    /// None waits (none placed, or one that could not be claimed, removed and counted).
191    Empty,
192    /// One waits that can be neither claimed nor removed (a state directory gone read-only): the check
193    /// stops looking rather than spin on it; a later stop's `sweep` or job takes its place.
194    Stuck,
195}
196
197/// The waiting job, claimed. One that cannot be claimed (unreadable, a job format this binary does not
198/// read, a rename that fails) is removed and counted (`not_run`, `unplaced`): left there, every check of
199/// the session would find it waiting and never take it.
200fn claim_next(session: &str) -> Next {
201    let next = next_file(session);
202    live::locked(&file(session, "checking.next"), || {
203        if !next.exists() {
204            return Next::Empty;
205        }
206        if let Some(c) = claim(&next) {
207            return Next::Job(Box::new(c));
208        }
209        let job = read_job(&next).unwrap_or_else(|| Job { session: session.to_string(), ..Job::default() });
210        match std::fs::remove_file(&next) {
211            Ok(()) => {
212                not_run(&job, WHY_UNPLACED);
213                Next::Empty
214            }
215            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Next::Empty,
216            Err(_) => Next::Stuck,
217        }
218    })
219}
220
221/// `c` waits for the running check (`<s>.job.next`): of it and a job waiting already, the newer stop's
222/// waits and the older is superseded (counted, never run).
223fn wait_next(c: Claimed) {
224    let session = c.job.session.clone();
225    let next = next_file(&session);
226    live::locked(&file(&session, "checking.next"), || match read_job(&next) {
227        Some(waiting) if waiting.stop_ms > c.job.stop_ms => not_run(&c.job, WHY_SUPERSEDED),
228        waiting => match std::fs::rename(&c.path, &next) {
229            Ok(()) => {
230                if let Some(w) = waiting {
231                    not_run(&w, WHY_SUPERSEDED);
232                }
233            }
234            // Its file goes with `c`: counted, never lost unseen.
235            Err(_) => not_run(&c.job, WHY_UNPLACED),
236        },
237    });
238}
239
240/// The session's check lock (`<s>.checking.lock`) as `hold` finds it.
241enum Hold {
242    /// Taken, held until dropped.
243    Mine(std::fs::File),
244    /// Another check holds it.
245    Busy,
246    /// It cannot be opened or locked at all (a file system without flock, a full or read-only state
247    /// directory): the check runs without it, as the session lock's write does (hook.rs `run_job`),
248    /// rather than leave every job waiting for a check that never comes.
249    Broken,
250}
251
252fn hold(session: &str) -> Hold {
253    let Ok(f) = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file(session, "checking.lock")) else { return Hold::Broken };
254    match f.try_lock() {
255        Ok(()) => Hold::Mine(f),
256        Err(std::fs::TryLockError::WouldBlock) => Hold::Busy,
257        Err(std::fs::TryLockError::Error(_)) => Hold::Broken,
258    }
259}
260
261/// The job at `path`, and every job that waits after it, one at a time per session. A job that finds
262/// another check running waits for it (`wait_next`); the running check takes it when done. A check
263/// looks for a waiting job once more after it let the lock go, and one that placed its job looks for
264/// the lock once more, so a job never waits with no check left to take it. A job whose stop is older
265/// than one this process checked already (one left waiting by a check that died) is superseded, never
266/// run: a check never judges older work after newer.
267fn run(path: &Path) {
268    let Some(mine) = claim(path) else { return };
269    let session = mine.job.session.clone();
270    let mut mine = Some(mine);
271    let mut last = i64::MIN;
272    loop {
273        let lock = match hold(&session) {
274            Hold::Mine(f) => Some(f),
275            Hold::Broken => None,
276            Hold::Busy => match mine.take() {
277                Some(c) => {
278                    wait_next(c);
279                    continue;
280                }
281                None => return,
282            },
283        };
284        let mut job = match mine.take() {
285            Some(c) => Next::Job(Box::new(c)),
286            None => claim_next(&session),
287        };
288        while let Next::Job(c) = job {
289            if c.job.stop_ms < last {
290                not_run(&c.job, WHY_SUPERSEDED);
291            } else {
292                last = c.job.stop_ms;
293                hook::run_job(&c.job);
294            }
295            // The next one claimed before this one's file goes: a claimed job of this live process
296            // exists throughout, so a stop's `sweep` never takes the waiting job for one no check runs to
297            // take.
298            job = claim_next(&session);
299            drop(c);
300        }
301        drop(lock);
302        if matches!(job, Next::Stuck) || !next_file(&session).exists() {
303            return;
304        }
305    }
306}
307
308/// A job that never called the model, counted apart from the model calls (stats.rs `CHECK_DROPPED`) with
309/// why (`WHYS`).
310pub(crate) fn not_run(job: &Job, why: &str) {
311    crate::stats::count(&learn::home(), crate::stats::CHECK_DROPPED, &job.session, |e| {
312        e.why = why.into();
313        e.mode = MODE.into();
314        e.repo.clone_from(&job.repo);
315    });
316}
317
318/// The session's checks that called the model so far (`<s>.checks`): a check's index among them, read
319/// before its own is counted (`finish`).
320#[must_use]
321pub fn index(session: &str) -> u64 {
322    std::fs::read_to_string(file(session, "checks")).ok().and_then(|t| t.trim().parse().ok()).unwrap_or(0)
323}
324
325/// A detached check's end, under the session lock (hook.rs `run_job`): one more in `<s>.checks` when it
326/// called the model (`called`), then `<s>.checked` whole (a temporary file, then a rename).
327pub(crate) fn finish(session: &str, out: &mut Checked, called: bool) {
328    if called {
329        let _ = std::fs::write(file(session, "checks"), format!("{}\n", index(session) + 1));
330    }
331    out.done_ms = learn::now_ms();
332    let (path, tmp) = (file(session, "checked"), file(session, &format!("checked.{}", std::process::id())));
333    let Ok(text) = serde_json::to_string(out) else { return };
334    if std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, &path)).is_err() {
335        let _ = std::fs::remove_file(&tmp);
336    }
337}
338
339/// How long a detached check's model call may take: `BORG_PREDICT_TIMEOUT_S` when set, else the stop's
340/// limit (predict.rs `timeout`) or twice the 95th percentile of the call's durations, the larger: the
341/// latest `listen::GAPS_KEPT` answered checks of `model` in the store's outcomes (`dur_ms` of each
342/// `graded` record that did not fail). Derived as the session lock's staleness is (live.rs `stale_ms`):
343/// no hook waits for it, so it bounds a hung call, never a slow model's answer.
344#[must_use]
345pub fn call_limit(model: &str) -> Duration {
346    call_limit_in(&learn::home(), model)
347}
348
349/// `call_limit` from the outcomes of the store `home` (`borg status` shows each collective's).
350#[must_use]
351pub fn call_limit_in(home: &Path, model: &str) -> Duration {
352    let floor = crate::predict::timeout();
353    if std::env::var_os("BORG_PREDICT_TIMEOUT_S").is_some() {
354        return floor;
355    }
356    let p95 = p95(durations(&home.join("outcomes.jsonl"), model));
357    floor.max(Duration::from_millis(p95.saturating_mul(2)))
358}
359
360/// The `dur_ms` of the latest `listen::GAPS_KEPT` answered `graded` records of `model` in `log`, oldest
361/// first; read line by line.
362fn durations(log: &Path, model: &str) -> Vec<u64> {
363    let Ok(f) = std::fs::File::open(log) else { return Vec::new() };
364    let mut kept = std::collections::VecDeque::with_capacity(crate::listen::GAPS_KEPT);
365    for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
366        // Most lines are no check's: parsed only when they may be.
367        if !line.contains(crate::outcome::GRADED) {
368            continue;
369        }
370        let Ok(o) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
371        if o["kind"] != crate::outcome::GRADED || o["failed"] == true || o["model"].as_str().unwrap_or_default() != model {
372            continue;
373        }
374        let Some(ms) = o["dur_ms"].as_u64().filter(|ms| *ms > 0) else { continue };
375        if kept.len() == crate::listen::GAPS_KEPT {
376            kept.pop_front();
377        }
378        kept.push_back(ms);
379    }
380    kept.into()
381}
382
383/// The 95th percentile of `v` (nearest rank); 0 for none.
384fn p95(mut v: Vec<u64>) -> u64 {
385    v.sort_unstable();
386    let rank = (v.len() * 95).div_ceil(100);
387    v.get(rank.saturating_sub(1)).copied().unwrap_or(0)
388}
389
390/// This process's peak resident memory in KiB (`getrusage`), 0 when unknown: a detached check's, on
391/// its `check` event.
392#[must_use]
393pub fn rss_kb() -> u64 {
394    // SAFETY: getrusage only writes the struct it is given, zeroed here.
395    let mut u: libc::rusage = unsafe { std::mem::zeroed() };
396    if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut u) } != 0 {
397        return 0;
398    }
399    kib(u64::try_from(u.ru_maxrss).unwrap_or(0))
400}
401
402/// `ru_maxrss` in KiB: Linux counts it in KiB, macOS in bytes.
403const fn kib(maxrss: u64) -> u64 {
404    if cfg!(target_os = "macos") { maxrss / 1024 } else { maxrss }
405}
406
407/// Whether a check of `session` runs: a job claimed (`<s>.job.<check>.<pid>`) by a live process. A
408/// directory listing, no lock probe (one could turn a starting check away). A wake reserves no ask
409/// meanwhile (hook.rs `reserve_asks`): the running check may grade it again against newer work.
410#[must_use]
411pub fn running(session: &str) -> bool {
412    let ctx = hook::state_file(session);
413    let (Some(dir), Some(stem)) = (ctx.parent(), ctx.file_stem().and_then(|s| s.to_str())) else { return false };
414    let jobs = format!("{stem}.job.");
415    let Ok(rd) = std::fs::read_dir(dir) else { return false };
416    rd.flatten().any(|e| e.file_name().to_str().and_then(|n| claimed_pid(n, &jobs)).is_some_and(live::alive))
417}
418
419/// The pid of a claimed job's name (`<jobs><check>.<pid>`, `jobs` the session's `<s>.job.`); None for
420/// any other.
421fn claimed_pid(name: &str, jobs: &str) -> Option<i32> {
422    name.strip_prefix(jobs)?.split_once('.')?.1.parse().ok()
423}
424
425/// Removes `session`'s files a check left behind: a claimed job (`<s>.job.<check>.<pid>`) or a result
426/// being written (`<s>.checked.<pid>`) whose process is gone; a job no check claimed, and the waiting one
427/// (`<s>.job.next`) while no check runs to take it (no claimed job of a live process), once older than a
428/// check may take (`call_limit` plus the session lock's slack, live.rs `STALE_SLACK_MS`). A live
429/// process's files stay whatever their age. At a stop (hook.rs `stop_detached`) and for an idle session
430/// (learn.rs). Takes no lock: a probe of `<s>.checking.lock` could turn a starting check away.
431pub fn sweep(session: &str) {
432    let ctx = hook::state_file(session);
433    let (Some(dir), Some(stem)) = (ctx.parent(), ctx.file_stem().and_then(|s| s.to_str())) else { return };
434    let (jobs, results) = (format!("{stem}.job."), format!("{stem}.checked."));
435    let Ok(rd) = std::fs::read_dir(dir) else { return };
436    let mine: Vec<std::fs::DirEntry> =
437        rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with(&jobs) || n.starts_with(&results))).collect();
438    // The pid of a claimed job's or a result's name, None for any other.
439    let pid_of = |name: &str| -> Option<i32> { claimed_pid(name, &jobs).or_else(|| name.strip_prefix(&results)?.parse().ok()) };
440    let names: Vec<String> = mine.iter().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
441    let running = names.iter().filter(|n| n.starts_with(&jobs)).filter_map(|n| pid_of(n)).any(live::alive);
442    // Only a file older than the least a check may take needs the measured limit.
443    let least = ms(crate::predict::timeout()).saturating_add(live::STALE_SLACK_MS);
444    let mut limit: Option<i64> = None;
445    let now = learn::now_ms();
446    for (e, name) in mine.iter().zip(&names) {
447        let gone = match pid_of(name) {
448            Some(pid) => !live::alive(pid),
449            None if name.starts_with(&results) => false,
450            None => {
451                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));
452                let old = age > least
453                    && age
454                        > *limit.get_or_insert_with(|| {
455                            ms(call_limit(&std::env::var("BORG_PREDICT_MODEL").unwrap_or_default())).saturating_add(live::STALE_SLACK_MS)
456                        });
457                old && !(running && name.ends_with(".job.next"))
458            }
459        };
460        if gone {
461            let _ = std::fs::remove_file(e.path());
462        }
463    }
464}
465
466fn ms(d: Duration) -> i64 {
467    i64::try_from(d.as_millis()).unwrap_or(i64::MAX)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn the_95th_percentile_is_the_nearest_rank() {
476        assert_eq!(p95(vec![]), 0);
477        assert_eq!(p95(vec![7]), 7);
478        assert_eq!(p95((1..=100).rev().collect()), 95);
479        assert_eq!(p95(vec![1, 2, 3, 40]), 40, "4 durations: the 4th");
480    }
481
482    #[test]
483    fn the_limit_reads_the_latest_answered_checks_of_the_same_model() {
484        let d = std::env::temp_dir().join(format!("borg-check-async-limit-{}", std::process::id()));
485        let _ = std::fs::remove_dir_all(&d);
486        std::fs::create_dir_all(&d).unwrap();
487        let log = d.join("outcomes.jsonl");
488        let mut text = String::new();
489        for i in 0..100u64 {
490            text.push_str(&format!("{{\"kind\":\"graded\",\"session\":\"s\",\"ms\":{i},\"model\":\"m\",\"dur_ms\":{}}}\n", 1000 + i));
491        }
492        text.push_str("{\"kind\":\"graded\",\"session\":\"s\",\"ms\":200,\"model\":\"m\",\"dur_ms\":900000,\"failed\":true}\n");
493        text.push_str("{\"kind\":\"graded\",\"session\":\"s\",\"ms\":201,\"model\":\"other\",\"dur_ms\":800000}\n");
494        text.push_str("{\"kind\":\"asked\",\"session\":\"s\",\"ms\":202,\"model\":\"m\",\"dur_ms\":700000}\n");
495        std::fs::write(&log, text).unwrap();
496        let got = durations(&log, "m");
497        assert_eq!(got.len(), crate::listen::GAPS_KEPT, "the latest only");
498        assert_eq!((got[0], *got.last().unwrap()), (1036, 1099), "failed calls, other models and other kinds left out");
499        let _ = std::fs::remove_dir_all(d);
500    }
501}