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