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 until = Instant::now() + CATCH_UP;
281    while ahead.iter().any(|job| job.exists()) && Instant::now() < until {
282        std::thread::sleep(Duration::from_millis(20));
283    }
284}
285
286/// Whether jobs wait in the spool. A direct bookkeeper queues behind
287/// them, so the book sees calls in the order they came.
288#[must_use]
289pub fn has_pending() -> bool {
290    std::fs::read_dir(spool()).is_ok_and(|mut entries| {
291        entries.any(|e| e.is_ok_and(|e| e.path().extension().is_some_and(|x| x == "job")))
292    })
293}
294
295fn pending() -> Vec<PathBuf> {
296    let Ok(entries) = std::fs::read_dir(spool()) else {
297        return Vec::new();
298    };
299    let mut jobs: Vec<PathBuf> = entries
300        .filter_map(Result::ok)
301        .map(|e| e.path())
302        .filter(|p| p.extension().is_some_and(|x| x == "job"))
303        .collect();
304    jobs.sort();
305    jobs
306}
307
308fn read_job(path: &Path) -> Option<Job> {
309    serde_json::from_slice(&std::fs::read(path).ok()?).ok()
310}
311
312/// `ljos hook --drain`: apply every queued job. With `wait`, wait for a
313/// drainer that runs and return once the spool is empty; without, leave
314/// the work to that drainer.
315#[must_use]
316pub fn drain(wait: bool) -> i32 {
317    if mkdir_private(&spool()).is_err() {
318        return 1;
319    }
320    loop {
321        let Some(lock) = open_lock(&drain_lock_path()) else {
322            return 1;
323        };
324        if !flock(&lock, wait) {
325            return 0;
326        }
327        let drained = drain_held();
328        drop(lock);
329        if !drained {
330            return 1;
331        }
332        if pending().is_empty() {
333            return 0;
334        }
335    }
336}
337
338/// Apply jobs until the spool is empty (`true`), or stop early (`false`)
339/// when the ljos binary was replaced or a batch cannot start: the next
340/// hook's kick then starts a drainer from the binary that is installed,
341/// instead of this one dropping every job after [`TRIES`].
342fn drain_held() -> bool {
343    let stamp = binary_stamp();
344    let mut stuck: Option<(PathBuf, u32)> = None;
345    loop {
346        if binary_stamp() != stamp {
347            crate::bookkeep::log_line(
348                "book queue: the ljos binary changed; this drainer stops and the next job starts one",
349            );
350            return false;
351        }
352        let jobs = pending();
353        let Some(first) = jobs.first().cloned() else {
354            return true;
355        };
356        let Some(head) = read_job(&first) else {
357            crate::bookkeep::log_line(&format!(
358                "book queue: dropped unreadable job {}",
359                first.display()
360            ));
361            let _ = std::fs::remove_file(&first);
362            continue;
363        };
364        let mut batch = vec![first.clone()];
365        for path in jobs.iter().skip(1).take(BATCH - 1) {
366            match read_job(path) {
367                Some(job) if job.cwd == head.cwd && same_env(&job, &head) => {
368                    batch.push(path.clone());
369                }
370                _ => break,
371            }
372        }
373        if !run_batch(&head, &batch) {
374            return false;
375        }
376        if !first.exists() {
377            stuck = None;
378            continue;
379        }
380        let tries = match &stuck {
381            Some((path, n)) if *path == first => n + 1,
382            _ => 1,
383        };
384        let old = std::fs::metadata(&first)
385            .and_then(|m| m.modified())
386            .ok()
387            .and_then(|t| t.elapsed().ok())
388            .is_some_and(|age| age > STALE);
389        if tries >= TRIES || old {
390            crate::bookkeep::log_line(&format!(
391                "book queue: dropped {} {} after {tries} tries",
392                head.event,
393                first.display()
394            ));
395            let _ = std::fs::remove_file(&first);
396            stuck = None;
397            continue;
398        }
399        stuck = Some((first, tries));
400        // Nothing is held here but the drainer's own lock.
401        std::thread::sleep(Duration::from_millis(250));
402    }
403}
404
405fn same_env(a: &Job, b: &Job) -> bool {
406    let kept = |job: &Job| {
407        job.env
408            .iter()
409            .filter(|(k, _)| !PER_CALL.contains(&k.as_str()))
410            .cloned()
411            .collect::<Vec<_>>()
412    };
413    kept(a) == kept(b)
414}
415
416/// Set the variables of [`PER_CALL`] and the payload's session as the
417/// call that left `job` had them.
418fn wear_call(job: &Job) {
419    crate::with_env_mutation(|| {
420        for key in PER_CALL {
421            match job.env.iter().find(|(k, _)| k == key) {
422                Some((_, v)) => std::env::set_var(key, v),
423                None => std::env::remove_var(key),
424            }
425        }
426    });
427    crate::ballot_sig::export_hook_session(job.payload_session.as_deref());
428    crate::bind_call_conversation(&job.raw, &crate::hook_call_as(&job.raw, Some(&job.event)));
429}
430
431/// Apply `batch` in a child that has the first job's environment and
432/// directory, as the hook call had them. `false` when no child could
433/// start, which the book log names.
434fn run_batch(head: &Job, batch: &[PathBuf]) -> bool {
435    let Ok(exe) = std::env::current_exe() else {
436        crate::bookkeep::log_line("book queue: the running binary has no path; drainer stops");
437        return false;
438    };
439    let dir = if Path::new(&head.cwd).is_dir() {
440        PathBuf::from(&head.cwd)
441    } else {
442        PathBuf::from("/")
443    };
444    let mut cmd = std::process::Command::new(&exe);
445    cmd.arg("hook")
446        .arg("--apply-book")
447        .args(batch)
448        .env_clear()
449        .envs(head.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
450        .current_dir(dir)
451        .stdin(std::process::Stdio::null())
452        .stdout(std::process::Stdio::null())
453        .stderr(std::process::Stdio::null());
454    let mut child = match cmd.spawn() {
455        Ok(child) => child,
456        Err(e) => {
457            crate::bookkeep::log_line(&format!(
458                "book queue: cannot start {} to apply a batch: {e}; drainer stops",
459                exe.display()
460            ));
461            return false;
462        }
463    };
464    let started = Instant::now();
465    loop {
466        match child.try_wait() {
467            Ok(Some(_)) | Err(_) => return true,
468            Ok(None) if started.elapsed() > BATCH_DEADLINE => {
469                let _ = child.kill();
470                let _ = child.wait();
471                crate::bookkeep::log_line(
472                    "book queue: a batch ran past its deadline and was stopped",
473                );
474                return true;
475            }
476            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
477        }
478    }
479}
480
481/// `ljos hook --apply-book JOB...`: apply the jobs in order and commit the
482/// tracker once at the end. A job the book lock keeps out stays queued,
483/// and so do the ones after it.
484#[must_use]
485pub fn apply(files: &[PathBuf]) -> i32 {
486    crate::batch_tracker_commits();
487    for path in files {
488        let Some(job) = read_job(path) else {
489            let _ = std::fs::remove_file(path);
490            continue;
491        };
492        wear_call(&job);
493        crate::act_for_ancestry(Some(job.ancestry.clone()));
494        crate::bookkeep::act_for(Some((job.owner, job.head.clone())));
495        let call = crate::HookCall {
496            event: job.event.clone(),
497            cue: job.cue.clone(),
498            session: Some(job.session.clone()),
499            shape: crate::HookShape::default(),
500        };
501        let done = match job.ask.as_deref() {
502            Some(reason) => crate::bookkeep::note_ask_within(&job.raw, &call, reason),
503            None => crate::bookkeep::observe_queued(&job.raw, &call),
504        };
505        if !done {
506            break;
507        }
508        let _ = std::fs::remove_file(path);
509    }
510    crate::act_for_ancestry(None);
511    crate::bookkeep::act_for(None);
512    let said = crate::flush_tracker_commits();
513    for line in said.lines() {
514        if line.contains("could not") || line.contains("refused") {
515            crate::bookkeep::log_line(line.trim());
516        }
517    }
518    0
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    fn git(dir: &Path, args: &[&str]) {
526        let ok = std::process::Command::new("git")
527            .arg("-C")
528            .arg(dir)
529            .args(args)
530            .env("GIT_CONFIG_GLOBAL", "/dev/null")
531            .env("GIT_CONFIG_NOSYSTEM", "1")
532            .output()
533            .unwrap();
534        assert!(
535            ok.status.success(),
536            "{}",
537            String::from_utf8_lossy(&ok.stderr)
538        );
539    }
540
541    #[test]
542    fn head_is_read_from_the_git_files_as_git_reads_it() {
543        let dir = tempfile::tempdir().unwrap();
544        let repo = dir.path();
545        git(repo, &["init", "-q", "-b", "main"]);
546        assert_eq!(head_now(repo), None, "no commit yet");
547        std::fs::create_dir(repo.join("sub")).unwrap();
548        git(
549            repo,
550            &[
551                "-c",
552                "user.name=t",
553                "-c",
554                "user.email=t@t",
555                "commit",
556                "-q",
557                "--allow-empty",
558                "-m",
559                "one",
560            ],
561        );
562        let said = std::process::Command::new("git")
563            .arg("-C")
564            .arg(repo)
565            .args(["rev-parse", "HEAD"])
566            .output()
567            .unwrap();
568        let sha = String::from_utf8_lossy(&said.stdout).trim().to_string();
569        assert_eq!(head_now(&repo.join("sub")).as_deref(), Some(sha.as_str()));
570        git(repo, &["pack-refs", "--all"]);
571        assert_eq!(head_now(repo).as_deref(), Some(sha.as_str()), "packed ref");
572        let tree = tempfile::tempdir().unwrap();
573        let wt = tree.path().join("wt");
574        git(
575            repo,
576            &["worktree", "add", "-q", "--detach", wt.to_str().unwrap()],
577        );
578        assert_eq!(head_now(&wt).as_deref(), Some(sha.as_str()), "worktree");
579        assert_eq!(head_now(Path::new("/")), None);
580    }
581}