Skip to main content

ljos_cli/
book_queue.rs

1//! Bookkeeping off the hook's answer. Each tool call used to keep the
2//! book inside the call, under the one `book.lock` every seat on the host
3//! shares, and the first write of a session filed a claim and committed
4//! the tracker there. That cost seconds and hundreds of megabytes per call,
5//! with every other hook waiting on the lock. A call now leaves a job in
6//! the runtime directory and answers. One drainer per user applies the
7//! jobs in the order they came, one process at a time, and commits each
8//! tracker file once per batch.
9//!
10//! A job carries what the call knew and the drainer cannot learn later:
11//! the environment, the directory, the runner above the call and HEAD at
12//! the time of the call.
13
14use std::io::Write;
15use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
16use std::os::unix::io::AsRawFd;
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant};
19
20use serde::{Deserialize, Serialize};
21
22/// Jobs one apply process takes at most.
23const BATCH: usize = 64;
24/// How long one batch may run before the drainer stops it.
25const BATCH_DEADLINE: Duration = Duration::from_secs(120);
26/// A job the book lock kept out this many times, or this old, is dropped
27/// with a line in the book log.
28const TRIES: u32 = 20;
29const STALE: Duration = Duration::from_secs(600);
30/// How long a reader that acts on the book waits for the jobs queued
31/// before it.
32const CATCH_UP: Duration = Duration::from_secs(5);
33/// Variables the hook sets per call. Jobs that differ only in these share
34/// a batch, and the apply process sets them again for each job.
35const PER_CALL: &[&str] = &["LJOS_HARNESS_SESSION"];
36
37#[derive(Serialize, Deserialize)]
38struct Job {
39    raw: String,
40    event: String,
41    cue: String,
42    session: String,
43    #[serde(default)]
44    payload_session: Option<String>,
45    #[serde(default)]
46    ask: Option<String>,
47    owner: (u32, u64),
48    ancestry: Vec<(u32, String)>,
49    head: Option<String>,
50    cwd: String,
51    env: Vec<(String, String)>,
52}
53
54fn spool() -> PathBuf {
55    crate::runtime_dir().join("bookq")
56}
57
58fn drain_lock_path() -> PathBuf {
59    crate::runtime_dir().join("bookq.lock")
60}
61
62/// Queue the book for this hook call and return the recall a session
63/// start or a prompt hands the model, read from the book as it stands.
64#[must_use]
65pub fn book_later(raw: &str, call: &crate::HookCall) -> String {
66    enqueue(raw, call);
67    crate::bookkeep::recall_for(raw, call)
68}
69
70/// Queue the ask the guard put to this call.
71pub fn enqueue_ask(raw: &str, call: &crate::HookCall, reason: &str) {
72    write_job(raw, call, Some(reason));
73}
74
75/// Queue the book for this call, and start a drainer when none runs.
76pub fn enqueue(raw: &str, call: &crate::HookCall) {
77    if crate::bookkeep::books(&call.event) {
78        write_job(raw, call, None);
79    }
80}
81
82fn write_job(raw: &str, call: &crate::HookCall, ask: Option<&str>) {
83    let cwd = std::env::current_dir()
84        .map(|p| p.display().to_string())
85        .unwrap_or_default();
86    let value: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or_default();
87    let place = value["cwd"]
88        .as_str()
89        .or_else(|| value["workspacePaths"][0].as_str())
90        .filter(|s| !s.is_empty())
91        .map_or_else(|| PathBuf::from(&cwd), PathBuf::from);
92    let job = Job {
93        raw: raw.to_string(),
94        event: call.event.clone(),
95        cue: call.cue.clone(),
96        session: crate::bookkeep::session_key(call),
97        payload_session: call.session.clone(),
98        ask: ask.map(str::to_string),
99        owner: crate::bookkeep::hook_owner(),
100        ancestry: crate::ancestry(),
101        head: head_now(&place),
102        cwd,
103        env: std::env::vars_os()
104            .filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)))
105            .collect(),
106    };
107    let dir = spool();
108    if mkdir_private(&dir).is_err() {
109        crate::bookkeep::log_line("book queue: cannot make the spool; nothing booked");
110        return;
111    }
112    let Ok(body) = serde_json::to_vec(&job) else {
113        return;
114    };
115    let stamp = std::time::SystemTime::now()
116        .duration_since(std::time::UNIX_EPOCH)
117        .map_or(0, |d| d.as_nanos());
118    let name = format!("{stamp:024}-{:010}.job", std::process::id());
119    let tmp = dir.join(format!(".{name}.tmp"));
120    let written = std::fs::OpenOptions::new()
121        .create_new(true)
122        .write(true)
123        .mode(0o600)
124        .open(&tmp)
125        .and_then(|mut f| f.write_all(&body));
126    if written.is_err() || std::fs::rename(&tmp, dir.join(&name)).is_err() {
127        let _ = std::fs::remove_file(&tmp);
128        crate::bookkeep::log_line("book queue: a job could not be written; nothing booked");
129        return;
130    }
131    kick();
132}
133
134fn mkdir_private(dir: &Path) -> std::io::Result<()> {
135    use std::os::unix::fs::DirBuilderExt as _;
136    std::fs::DirBuilder::new()
137        .recursive(true)
138        .mode(0o700)
139        .create(dir)
140}
141
142/// HEAD of the checkout at `dir`, read from the git files without running
143/// git: a sha, or `None` when there is no checkout or HEAD has no commit.
144fn head_now(dir: &Path) -> Option<String> {
145    let mut at = dir;
146    let dot = loop {
147        let dot = at.join(".git");
148        if dot.exists() {
149            break dot;
150        }
151        at = at.parent()?;
152    };
153    let git_dir = if dot.is_file() {
154        let text = std::fs::read_to_string(&dot).ok()?;
155        let named = PathBuf::from(text.trim().strip_prefix("gitdir:")?.trim());
156        if named.is_absolute() {
157            named
158        } else {
159            at.join(named)
160        }
161    } else {
162        dot
163    };
164    let common = std::fs::read_to_string(git_dir.join("commondir"))
165        .ok()
166        .map(|c| git_dir.join(c.trim()))
167        .unwrap_or_else(|| git_dir.clone());
168    let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
169    let head = head.trim();
170    let sha = match head.strip_prefix("ref:") {
171        None => head.to_string(),
172        Some(name) => {
173            let name = name.trim();
174            match std::fs::read_to_string(common.join(name)) {
175                Ok(sha) => sha.trim().to_string(),
176                Err(_) => std::fs::read_to_string(common.join("packed-refs"))
177                    .ok()?
178                    .lines()
179                    .find_map(|line| {
180                        let (sha, refname) = line.split_once(' ')?;
181                        (refname.trim() == name).then(|| sha.to_string())
182                    })?,
183            }
184        }
185    };
186    (sha.len() >= 40 && sha.chars().all(|c| c.is_ascii_hexdigit())).then_some(sha)
187}
188
189fn open_lock(path: &Path) -> Option<std::fs::File> {
190    std::fs::OpenOptions::new()
191        .create(true)
192        .append(true)
193        .mode(0o600)
194        .open(path)
195        .ok()
196}
197
198fn flock(file: &std::fs::File, wait: bool) -> bool {
199    let how = if wait {
200        libc::LOCK_EX
201    } else {
202        libc::LOCK_EX | libc::LOCK_NB
203    };
204    // SAFETY: flock on a descriptor `file` owns; the lock goes with it.
205    unsafe { libc::flock(file.as_raw_fd(), how) == 0 }
206}
207
208/// Start `ljos hook --drain` unless one runs. A drainer that runs looks
209/// at the spool again before it exits, so a job queued meanwhile is not
210/// left behind.
211fn kick() {
212    use std::os::unix::process::CommandExt as _;
213    // A unit test's own binary is not ljos.
214    if cfg!(test) {
215        return;
216    }
217    let Some(lock) = open_lock(&drain_lock_path()) else {
218        return;
219    };
220    if !flock(&lock, false) {
221        return;
222    }
223    drop(lock);
224    let Some(exe) = ljos_exe() else {
225        return;
226    };
227    let mut cmd = std::process::Command::new(exe);
228    cmd.args(["hook", "--drain", "--no-wait"])
229        .stdin(std::process::Stdio::null())
230        .stdout(std::process::Stdio::null())
231        .stderr(std::process::Stdio::null());
232    // SAFETY: setsid is async-signal-safe and touches only the child.
233    unsafe {
234        cmd.pre_exec(|| {
235            libc::setsid();
236            Ok(())
237        });
238    }
239    let _ = cmd.spawn();
240}
241
242/// The `ljos` binary: this one, else the one installed beside it, as for
243/// `ljos-mcp`, else the one on PATH.
244fn ljos_exe() -> Option<PathBuf> {
245    let me = std::env::current_exe().ok()?;
246    if me.file_name().is_some_and(|name| name == "ljos") {
247        return Some(me);
248    }
249    let beside = me.with_file_name("ljos");
250    if beside.is_file() {
251        return Some(beside);
252    }
253    which::which("ljos").ok()
254}
255
256/// The inode, size and mtime of the binary this process runs, read
257/// through its path. An install replaces the file, so the stamp changes
258/// while the running process keeps the old one.
259fn binary_stamp() -> Option<(u64, u64, i64)> {
260    let exe = std::env::current_exe().ok()?;
261    let meta = std::fs::metadata(exe).ok()?;
262    Some((meta.ino(), meta.len(), meta.mtime()))
263}
264
265/// Let the jobs queued before this call apply first, for a reader that
266/// acts on the book or the claims: a finish, a start, a commit's book.
267/// Waits at most [`CATCH_UP`]; a job still queued then applies later.
268/// Jobs queued after the call began are not waited for, so a burst
269/// cannot hold the reader. Inside a hook or a drainer this returns at
270/// once, since the job it would wait for may be its own.
271pub fn catch_up() {
272    if std::env::var_os("LJOS_IN_HOOK").is_some() {
273        return;
274    }
275    let ahead = pending();
276    if ahead.is_empty() {
277        return;
278    }
279    kick();
280    let wait = crate::wall_left().map_or(CATCH_UP, |left| left.min(CATCH_UP));
281    let until = Instant::now() + wait;
282    while ahead.iter().any(|job| job.exists()) && Instant::now() < until {
283        std::thread::sleep(Duration::from_millis(20));
284    }
285}
286
287/// Whether jobs wait in the spool. A direct bookkeeper queues behind
288/// them, so the book sees calls in the order they came.
289#[must_use]
290pub fn has_pending() -> bool {
291    std::fs::read_dir(spool()).is_ok_and(|mut entries| {
292        entries.any(|e| e.is_ok_and(|e| e.path().extension().is_some_and(|x| x == "job")))
293    })
294}
295
296fn pending() -> Vec<PathBuf> {
297    let Ok(entries) = std::fs::read_dir(spool()) else {
298        return Vec::new();
299    };
300    let mut jobs: Vec<PathBuf> = entries
301        .filter_map(Result::ok)
302        .map(|e| e.path())
303        .filter(|p| p.extension().is_some_and(|x| x == "job"))
304        .collect();
305    jobs.sort();
306    jobs
307}
308
309fn read_job(path: &Path) -> Option<Job> {
310    serde_json::from_slice(&std::fs::read(path).ok()?).ok()
311}
312
313/// `ljos hook --drain`: apply every queued job. With `wait`, wait for a
314/// drainer that runs and return once the spool is empty; without, leave
315/// the work to that drainer.
316#[must_use]
317pub fn drain(wait: bool) -> i32 {
318    if mkdir_private(&spool()).is_err() {
319        return 1;
320    }
321    loop {
322        let Some(lock) = open_lock(&drain_lock_path()) else {
323            return 1;
324        };
325        if !flock(&lock, wait) {
326            return 0;
327        }
328        let drained = drain_held();
329        drop(lock);
330        if !drained {
331            return 1;
332        }
333        if pending().is_empty() {
334            return 0;
335        }
336    }
337}
338
339/// Apply jobs until the spool is empty (`true`), or stop early (`false`)
340/// when the ljos binary was replaced or a batch cannot start: the next
341/// hook's kick then starts a drainer from the binary that is installed,
342/// instead of this one dropping every job after [`TRIES`].
343fn drain_held() -> bool {
344    let stamp = binary_stamp();
345    let mut stuck: Option<(PathBuf, u32)> = None;
346    loop {
347        if binary_stamp() != stamp {
348            crate::bookkeep::log_line(
349                "book queue: the ljos binary changed; this drainer stops and the next job starts one",
350            );
351            return false;
352        }
353        let jobs = pending();
354        let Some(first) = jobs.first().cloned() else {
355            return true;
356        };
357        let Some(head) = read_job(&first) else {
358            crate::bookkeep::log_line(&format!(
359                "book queue: dropped unreadable job {}",
360                first.display()
361            ));
362            let _ = std::fs::remove_file(&first);
363            continue;
364        };
365        let mut batch = vec![first.clone()];
366        for path in jobs.iter().skip(1).take(BATCH - 1) {
367            match read_job(path) {
368                Some(job) if job.cwd == head.cwd && same_env(&job, &head) => {
369                    batch.push(path.clone());
370                }
371                _ => break,
372            }
373        }
374        if !run_batch(&head, &batch) {
375            return false;
376        }
377        if !first.exists() {
378            stuck = None;
379            continue;
380        }
381        let tries = match &stuck {
382            Some((path, n)) if *path == first => n + 1,
383            _ => 1,
384        };
385        let old = std::fs::metadata(&first)
386            .and_then(|m| m.modified())
387            .ok()
388            .and_then(|t| t.elapsed().ok())
389            .is_some_and(|age| age > STALE);
390        if tries >= TRIES || old {
391            crate::bookkeep::log_line(&format!(
392                "book queue: dropped {} {} after {tries} tries",
393                head.event,
394                first.display()
395            ));
396            let _ = std::fs::remove_file(&first);
397            stuck = None;
398            continue;
399        }
400        stuck = Some((first, tries));
401        // Nothing is held here but the drainer's own lock.
402        std::thread::sleep(Duration::from_millis(250));
403    }
404}
405
406fn same_env(a: &Job, b: &Job) -> bool {
407    let kept = |job: &Job| {
408        job.env
409            .iter()
410            .filter(|(k, _)| !PER_CALL.contains(&k.as_str()))
411            .cloned()
412            .collect::<Vec<_>>()
413    };
414    kept(a) == kept(b)
415}
416
417/// Set the variables of [`PER_CALL`] and the payload's session as the
418/// call that left `job` had them.
419fn wear_call(job: &Job) {
420    crate::with_env_mutation(|| {
421        for key in PER_CALL {
422            match job.env.iter().find(|(k, _)| k == key) {
423                Some((_, v)) => std::env::set_var(key, v),
424                None => std::env::remove_var(key),
425            }
426        }
427    });
428    crate::ballot_sig::export_hook_session(job.payload_session.as_deref());
429    crate::bind_call_conversation(&job.raw, &crate::hook_call_as(&job.raw, Some(&job.event)));
430}
431
432/// Apply `batch` in a child that has the first job's environment and
433/// directory, as the hook call had them. `false` when no child could
434/// start, which the book log names.
435fn run_batch(head: &Job, batch: &[PathBuf]) -> bool {
436    let Ok(exe) = std::env::current_exe() else {
437        crate::bookkeep::log_line("book queue: the running binary has no path; drainer stops");
438        return false;
439    };
440    let dir = if Path::new(&head.cwd).is_dir() {
441        PathBuf::from(&head.cwd)
442    } else {
443        PathBuf::from("/")
444    };
445    let mut cmd = std::process::Command::new(&exe);
446    cmd.arg("hook")
447        .arg("--apply-book")
448        .args(batch)
449        .env_clear()
450        .envs(head.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
451        .current_dir(dir)
452        .stdin(std::process::Stdio::null())
453        .stdout(std::process::Stdio::null())
454        .stderr(std::process::Stdio::null());
455    let mut child = match cmd.spawn() {
456        Ok(child) => child,
457        Err(e) => {
458            crate::bookkeep::log_line(&format!(
459                "book queue: cannot start {} to apply a batch: {e}; drainer stops",
460                exe.display()
461            ));
462            return false;
463        }
464    };
465    let started = Instant::now();
466    loop {
467        match child.try_wait() {
468            Ok(Some(_)) | Err(_) => return true,
469            Ok(None) if started.elapsed() > BATCH_DEADLINE => {
470                let _ = child.kill();
471                let _ = child.wait();
472                crate::bookkeep::log_line(
473                    "book queue: a batch ran past its deadline and was stopped",
474                );
475                return true;
476            }
477            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
478        }
479    }
480}
481
482/// `ljos hook --apply-book JOB...`: apply the jobs in order and commit the
483/// tracker once at the end. A job the book lock keeps out stays queued,
484/// and so do the ones after it.
485#[must_use]
486pub fn apply(files: &[PathBuf]) -> i32 {
487    crate::batch_tracker_commits();
488    for path in files {
489        let Some(job) = read_job(path) else {
490            let _ = std::fs::remove_file(path);
491            continue;
492        };
493        wear_call(&job);
494        crate::act_for_ancestry(Some(job.ancestry.clone()));
495        crate::bookkeep::act_for(Some((job.owner, job.head.clone())));
496        let call = crate::HookCall {
497            event: job.event.clone(),
498            cue: job.cue.clone(),
499            session: Some(job.session.clone()),
500            shape: crate::HookShape::default(),
501        };
502        let done = match job.ask.as_deref() {
503            Some(reason) => crate::bookkeep::note_ask_within(&job.raw, &call, reason),
504            None => crate::bookkeep::observe_queued(&job.raw, &call),
505        };
506        if !done {
507            break;
508        }
509        let _ = std::fs::remove_file(path);
510    }
511    crate::act_for_ancestry(None);
512    crate::bookkeep::act_for(None);
513    let said = crate::flush_tracker_commits();
514    for line in said.lines() {
515        if line.contains("could not") || line.contains("refused") {
516            crate::bookkeep::log_line(line.trim());
517        }
518    }
519    0
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn git(dir: &Path, args: &[&str]) {
527        let ok = crate::git_cmd()
528            .arg("-C")
529            .arg(dir)
530            .args(args)
531            .env("GIT_CONFIG_GLOBAL", "/dev/null")
532            .env("GIT_CONFIG_NOSYSTEM", "1")
533            .output()
534            .unwrap();
535        assert!(
536            ok.status.success(),
537            "{}",
538            String::from_utf8_lossy(&ok.stderr)
539        );
540    }
541
542    #[test]
543    fn head_is_read_from_the_git_files_as_git_reads_it() {
544        let dir = tempfile::tempdir().unwrap();
545        let repo = dir.path();
546        git(repo, &["init", "-q", "-b", "main"]);
547        assert_eq!(head_now(repo), None, "no commit yet");
548        std::fs::create_dir(repo.join("sub")).unwrap();
549        git(
550            repo,
551            &[
552                "-c",
553                "user.name=t",
554                "-c",
555                "user.email=t@t",
556                "commit",
557                "-q",
558                "--allow-empty",
559                "-m",
560                "one",
561            ],
562        );
563        let said = crate::git_cmd()
564            .arg("-C")
565            .arg(repo)
566            .args(["rev-parse", "HEAD"])
567            .output()
568            .unwrap();
569        let sha = String::from_utf8_lossy(&said.stdout).trim().to_string();
570        assert_eq!(head_now(&repo.join("sub")).as_deref(), Some(sha.as_str()));
571        git(repo, &["pack-refs", "--all"]);
572        assert_eq!(head_now(repo).as_deref(), Some(sha.as_str()), "packed ref");
573        let tree = tempfile::tempdir().unwrap();
574        let wt = tree.path().join("wt");
575        git(
576            repo,
577            &["worktree", "add", "-q", "--detach", wt.to_str().unwrap()],
578        );
579        assert_eq!(head_now(&wt).as_deref(), Some(sha.as_str()), "worktree");
580        assert_eq!(head_now(Path::new("/")), None);
581    }
582}