1use std::io::Write;
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Mode {
32 Hook,
33 Live,
34}
35
36impl Mode {
37 pub fn name(self) -> &'static str {
38 match self {
39 Mode::Hook => "hook",
40 Mode::Live => "live",
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Resolved {
49 pub mode: Mode,
50 pub source: &'static str,
51 pub warning: Option<String>,
52}
53
54pub const DEFAULT: Mode = Mode::Live;
57
58pub fn resolve(env: Option<&str>, configured: Option<&str>) -> Resolved {
61 let pick = |v: &str, source: &'static str| match v.trim().to_ascii_lowercase().as_str() {
62 "hook" => Resolved { mode: Mode::Hook, source, warning: None },
63 "live" => Resolved { mode: Mode::Live, source, warning: None },
64 _ => Resolved { mode: DEFAULT, source, warning: Some(format!("{source}: mode {v:?} is neither \"hook\" nor \"live\"; using {}", DEFAULT.name())) },
65 };
66 match (env.filter(|v| !v.trim().is_empty()), configured.filter(|v| !v.trim().is_empty())) {
67 (Some(v), _) => pick(v, "BORG_MODE"),
68 (None, Some(v)) => pick(v, "config"),
69 (None, None) => Resolved { mode: DEFAULT, source: "default", warning: None },
70 }
71}
72
73pub fn of(cfg: Option<&crate::collective::Config>, name: &str) -> Resolved {
75 let configured = cfg.and_then(|c| c.collectives.iter().find(|e| e.name == name)).and_then(|e| e.mode.as_deref());
76 resolve(std::env::var("BORG_MODE").ok().as_deref(), configured)
77}
78
79pub fn current() -> Mode {
82 resolve(std::env::var("BORG_MODE").ok().as_deref(), None).mode
83}
84
85pub fn unsupported() -> Option<&'static str> {
88 if std::env::var("BORG_MATCH").as_deref() == Ok("bm25") {
89 Some("BORG_MATCH=bm25")
90 } else if std::env::var_os("BORG_ONLY").is_some() {
91 Some("BORG_ONLY")
92 } else if std::env::var_os("BORG_CRITIC").is_some() {
93 Some("BORG_CRITIC")
94 } else {
95 None
96 }
97}
98
99pub const DEBOUNCE_S: i64 = 45;
103
104pub fn debounce_ms() -> i64 {
107 std::env::var("BORG_LIVE_DEBOUNCE_S").ok().and_then(|v| v.trim().parse::<i64>().ok()).unwrap_or(DEBOUNCE_S).max(0) * 1000
108}
109
110const STALE_SLACK_MS: i64 = 30_000;
113
114pub fn stale_ms() -> i64 {
117 i64::try_from(crate::predict::timeout().as_millis()).unwrap_or(i64::MAX).saturating_mul(2).saturating_add(STALE_SLACK_MS)
118}
119
120fn beside(ctx: &Path, ext: &str) -> PathBuf {
121 ctx.with_extension(ext)
122}
123
124pub fn counter_file(ctx: &Path) -> PathBuf {
125 beside(ctx, "live.n")
126}
127
128pub fn mark_file(ctx: &Path) -> PathBuf {
129 beside(ctx, "live.mark")
130}
131
132pub fn lock_file(ctx: &Path) -> PathBuf {
133 beside(ctx, "live.lock")
134}
135
136pub fn count(ctx: &Path) -> u64 {
138 std::fs::read_to_string(counter_file(ctx)).map_or(0, |t| t.lines().count() as u64)
139}
140
141pub fn bump(ctx: &Path, tool: &str) {
143 let _ = std::fs::OpenOptions::new().create(true).append(true).open(counter_file(ctx)).and_then(|mut f| writeln!(f, "{tool}"));
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub struct Mark {
149 pub covered: u64,
150 pub started_ms: i64,
151 pub turn: Option<u64>,
152}
153
154pub fn read_mark(ctx: &Path) -> Option<Mark> {
155 let t = std::fs::read_to_string(mark_file(ctx)).ok()?;
156 let mut it = t.split_whitespace();
157 Some(Mark { covered: it.next()?.parse().ok()?, started_ms: it.next()?.parse().ok()?, turn: it.next().and_then(|v| v.parse().ok()) })
158}
159
160pub fn write_mark(ctx: &Path, m: Mark) {
161 let turn = m.turn.map_or(String::new(), |t| format!(" {t}"));
162 let _ = std::fs::write(mark_file(ctx), format!("{} {}{turn}\n", m.covered, m.started_ms));
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum Lock {
167 Free,
168 Held,
169 Stale,
171}
172
173pub fn alive(pid: i32) -> bool {
175 pid > 0 && (unsafe { libc::kill(pid, 0) } == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM))
177}
178
179fn state_of(text: &str, file_ms: i64, now: i64, stale_after: i64, alive: impl Fn(i32) -> bool) -> Lock {
182 let mut it = text.split_whitespace();
183 match (it.next().and_then(|p| p.parse::<i32>().ok()), it.next().and_then(|m| m.parse::<i64>().ok())) {
184 (Some(pid), Some(ms)) if alive(pid) && now - ms <= stale_after => Lock::Held,
185 (Some(_), Some(_)) => Lock::Stale,
186 _ if now - file_ms > stale_after => Lock::Stale,
187 _ => Lock::Held,
188 }
189}
190
191fn modified_ms(path: &Path) -> i64 {
192 std::fs::metadata(path).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_millis() as i64)
193}
194
195pub fn lock_state(ctx: &Path, now: i64, stale_after: i64, alive: impl Fn(i32) -> bool) -> Lock {
197 let path = lock_file(ctx);
198 let Ok(t) = std::fs::read_to_string(&path) else { return Lock::Free };
199 state_of(&t, modified_ms(&path), now, stale_after, alive)
200}
201
202#[derive(Debug)]
204pub struct Guard {
205 path: PathBuf,
206 text: String,
207}
208
209impl Drop for Guard {
210 fn drop(&mut self) {
211 if std::fs::read_to_string(&self.path).is_ok_and(|t| t == self.text) {
212 let _ = std::fs::remove_file(&self.path);
213 }
214 }
215}
216
217pub fn acquire(ctx: &Path, now: i64) -> Option<Guard> {
223 let path = lock_file(ctx);
224 let pid = std::process::id();
225 let text = format!("{pid} {now}\n");
226 let tmp = path.with_extension(format!("lock.{pid}"));
227 std::fs::write(&tmp, &text).ok()?;
228 let mut got = None;
229 for _ in 0..2 {
230 if std::fs::hard_link(&tmp, &path).is_ok() {
231 got = Some(Guard { path: path.clone(), text: text.clone() });
232 break;
233 }
234 let Ok(seen) = std::fs::read_to_string(&path) else { continue };
235 if state_of(&seen, modified_ms(&path), now, stale_ms(), alive) != Lock::Stale {
236 break;
237 }
238 let aside = path.with_extension(format!("lock.stale.{pid}"));
239 if std::fs::rename(&path, &aside).is_err() {
240 continue;
241 }
242 let moved = std::fs::read_to_string(&aside).unwrap_or_default();
243 if moved != seen {
244 let _ = std::fs::hard_link(&aside, &path);
245 let _ = std::fs::remove_file(&aside);
246 break;
247 }
248 let _ = std::fs::remove_file(&aside);
249 }
250 let _ = std::fs::remove_file(&tmp);
251 got
252}
253
254pub fn locked<T>(file: &Path, f: impl FnOnce() -> T) -> T {
258 let held = lock_of(file).ok();
259 let out = f();
260 if let Some(l) = held {
261 let _ = l.unlock();
262 }
263 out
264}
265
266fn lock_of(file: &Path) -> std::io::Result<std::fs::File> {
268 let lock = std::fs::OpenOptions::new()
269 .create(true)
270 .truncate(false)
271 .write(true)
272 .open(file.with_extension(format!("{}.lock", file.extension().and_then(|e| e.to_str()).unwrap_or_default())))?;
273 lock.lock()?;
274 Ok(lock)
275}
276
277pub fn decide(mode: Mode, lock: Lock, count: u64, mark: Option<Mark>, now: i64, debounce: i64) -> Result<(), &'static str> {
280 if mode != Mode::Live {
281 return Err("hook mode");
282 }
283 if lock == Lock::Held {
284 return Err("running");
285 }
286 if count <= mark.map_or(0, |m| m.covered) {
287 return Err("nothing new");
288 }
289 if mark.is_some_and(|m| now - m.started_ms < debounce) {
290 return Err("debounce");
291 }
292 Ok(())
293}
294
295pub fn covered(lock: Lock, count: u64, mark: Option<Mark>, turn: u64) -> bool {
299 lock != Lock::Held && count > 0 && mark.is_some_and(|m| m.covered == count && m.turn == Some(turn))
300}
301
302const WAIT_POLL: Duration = Duration::from_millis(200);
304
305pub fn wait_free(ctx: &Path, max_ms: u64) -> Lock {
307 let until = std::time::Instant::now() + std::time::Duration::from_millis(max_ms);
308 loop {
309 let l = lock_state(ctx, crate::learn::now_ms(), stale_ms(), alive);
310 if l != Lock::Held || std::time::Instant::now() >= until {
311 return l;
312 }
313 std::thread::sleep(WAIT_POLL);
314 }
315}
316
317pub const WAIT_SHARE: u32 = 4;
320
321pub const STOP_WAIT: Duration = Duration::from_millis(crate::harness::DEFAULT_STOP_BUDGET.as_millis() as u64 / WAIT_SHARE as u64);
325
326pub const AFTER_CALL: Duration = Duration::from_secs(3);
329
330pub fn stop_wait(budget: Option<Duration>) -> Duration {
333 budget.map_or(STOP_WAIT, |b| STOP_WAIT.min(b / WAIT_SHARE))
334}
335
336pub fn call_limit(budget: Option<Duration>, spent: Duration) -> Duration {
340 let Some(b) = budget else { return crate::predict::timeout() };
341 crate::predict::timeout().min(b.saturating_sub(spent).saturating_sub(AFTER_CALL))
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn tmp(tag: &str) -> PathBuf {
349 let d = std::env::temp_dir().join(format!("borg-live-{tag}-{}", std::process::id()));
350 let _ = std::fs::remove_dir_all(&d);
351 std::fs::create_dir_all(&d).unwrap();
352 d.join("s1.ctx")
353 }
354
355 #[test]
356 fn env_beats_config_and_unknown_values_fall_back_to_the_default() {
357 assert_eq!(resolve(None, None), Resolved { mode: Mode::Live, source: "default", warning: None });
358 assert_eq!(resolve(None, Some("live")).mode, Mode::Live);
359 assert_eq!(resolve(Some("hook"), Some("live")), Resolved { mode: Mode::Hook, source: "BORG_MODE", warning: None });
360 assert_eq!(resolve(Some(" LIVE "), None).mode, Mode::Live);
361 assert_eq!(resolve(Some(""), Some("live")).source, "config", "an empty BORG_MODE is unset");
362 let r = resolve(None, Some("eager"));
363 assert_eq!(r.mode, Mode::Live);
364 assert!(r.warning.unwrap().contains("\"eager\""));
365 }
366
367 #[test]
368 fn a_check_starts_only_in_live_mode_with_new_changes_no_running_check_and_after_the_debounce() {
369 let m = |covered, started_ms| Some(Mark { covered, started_ms, turn: None });
370 assert_eq!(decide(Mode::Live, Lock::Free, 1, None, 0, 45_000), Ok(()), "the first change checks at once");
371 assert_eq!(decide(Mode::Hook, Lock::Free, 1, None, 0, 45_000), Err("hook mode"));
372 assert_eq!(decide(Mode::Live, Lock::Held, 3, m(1, 0), 100_000, 45_000), Err("running"));
373 assert_eq!(decide(Mode::Live, Lock::Stale, 3, m(1, 0), 100_000, 45_000), Ok(()), "a stale lock does not block");
374 assert_eq!(decide(Mode::Live, Lock::Free, 2, m(2, 0), 100_000, 45_000), Err("nothing new"));
375 assert_eq!(decide(Mode::Live, Lock::Free, 0, None, 0, 45_000), Err("nothing new"));
376 assert_eq!(decide(Mode::Live, Lock::Free, 3, m(2, 60_000), 100_000, 45_000), Err("debounce"));
377 assert_eq!(decide(Mode::Live, Lock::Free, 3, m(2, 55_000), 100_000, 45_000), Ok(()));
378 }
379
380 #[test]
381 fn the_lock_is_held_while_its_process_lives_and_stale_when_gone_or_too_old() {
382 let ctx = tmp("lock");
383 assert_eq!(lock_state(&ctx, 1000, 120_000, |_| true), Lock::Free);
384 let g = acquire(&ctx, 1000).expect("free lock taken");
385 assert!(acquire(&ctx, 1001).is_none(), "a second check does not run");
386 assert_eq!(lock_state(&ctx, 2000, 120_000, |_| true), Lock::Held);
387 assert_eq!(lock_state(&ctx, 2000, 120_000, |_| false), Lock::Stale, "its process is gone");
388 assert_eq!(lock_state(&ctx, 200_000, 120_000, |_| true), Lock::Stale, "older than twice the timeout");
389 drop(g);
390 assert_eq!(lock_state(&ctx, 2000, 120_000, |_| true), Lock::Free, "the guard removes it");
391 std::fs::write(lock_file(&ctx), format!("{} 1000\n", i32::MAX)).unwrap();
393 assert!(!alive(i32::MAX));
394 let g = acquire(&ctx, 2000).expect("stale lock taken over");
395 std::fs::write(lock_file(&ctx), "1 2000\n").unwrap();
397 drop(g);
398 assert!(lock_file(&ctx).exists(), "not this guard's lock any more");
399 }
400
401 #[test]
402 fn a_half_written_lock_is_held_until_its_file_is_old() {
403 assert_eq!(state_of("", 1000, 2000, 120_000, |_| false), Lock::Held, "being written: never taken over");
404 assert_eq!(state_of("12", 1000, 2000, 120_000, |_| false), Lock::Held);
405 assert_eq!(state_of("", 1000, 200_000, 120_000, |_| false), Lock::Stale, "left empty long ago");
406 assert_eq!(state_of("7 1000", 0, 2000, 120_000, |_| true), Lock::Held);
407 let ctx = tmp("half");
408 std::fs::write(lock_file(&ctx), "").unwrap();
409 assert!(acquire(&ctx, crate::learn::now_ms()).is_none(), "an empty, fresh lock is someone's");
410 }
411
412 #[test]
413 fn counter_and_mark_round_trip() {
414 let ctx = tmp("mark");
415 assert_eq!((count(&ctx), read_mark(&ctx)), (0, None));
416 bump(&ctx, "Edit");
417 bump(&ctx, "Bash");
418 assert_eq!(count(&ctx), 2);
419 write_mark(&ctx, Mark { covered: 2, started_ms: 77, turn: Some(9) });
420 assert_eq!(read_mark(&ctx), Some(Mark { covered: 2, started_ms: 77, turn: Some(9) }));
421 std::fs::write(mark_file(&ctx), "2 77\n").unwrap();
422 assert_eq!(read_mark(&ctx), Some(Mark { covered: 2, started_ms: 77, turn: None }), "a mark of before turns");
423 }
424
425 #[test]
426 fn the_stop_skips_only_when_a_check_of_this_turn_covered_every_change() {
427 let m = Some(Mark { covered: 3, started_ms: 0, turn: Some(40) });
428 assert!(covered(Lock::Free, 3, m, 40));
429 assert!(!covered(Lock::Held, 3, m, 40), "a running check");
430 assert!(!covered(Lock::Free, 4, m, 40), "a change after the check");
431 assert!(!covered(Lock::Free, 3, m, 41), "the check was an earlier turn's");
432 assert!(!covered(Lock::Free, 3, Some(Mark { covered: 3, started_ms: 0, turn: None }), 40), "a mark without its turn");
433 assert!(!covered(Lock::Free, 0, None, 40));
434 }
435
436 #[test]
437 fn the_stop_fits_its_wait_and_model_call_into_the_harness_budget() {
438 let s = Duration::from_secs;
439 assert_eq!(STOP_WAIT, s(15), "a quarter of the 60 s a stop gets where the harness names none");
440 assert_eq!((stop_wait(None), call_limit(None, s(30))), (s(15), s(45)), "Claude Code: unchanged");
441 assert_eq!((stop_wait(Some(s(90))), call_limit(Some(s(90)), s(15))), (s(15), s(45)), "opencode: room for both");
442 assert_eq!((stop_wait(Some(s(28))), call_limit(Some(s(28)), s(7))), (s(7), s(18)), "omp");
443 assert_eq!(call_limit(Some(s(28)), s(11)), s(14), "time spent before the call comes off it");
444 assert_eq!(call_limit(Some(s(28)), s(27)), Duration::ZERO);
445 assert_eq!(stop_wait(Some(s(8))), s(2));
446 }
447
448 #[test]
449 fn a_lock_that_cannot_be_taken_still_runs_f_unlocked() {
450 let d = tmp("try-locked");
452 let file = d.join("tasks.jsonl");
453 std::fs::create_dir_all(d.join("tasks.jsonl.lock")).unwrap();
454 assert!(locked(&file, || true));
455 let _ = std::fs::remove_dir_all(d);
456 }
457
458 #[test]
459 fn appends_under_the_queue_lock_never_land_in_a_taken_file() {
460 let ctx = tmp("queue");
461 let q = ctx.with_extension("queued");
462 let writers: Vec<_> = (0..8)
463 .map(|i| {
464 let q = q.clone();
465 std::thread::spawn(move || {
466 for j in 0..50 {
467 locked(&q, || std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| writeln!(f, "{i}-{j}")).unwrap());
468 }
469 })
470 })
471 .collect();
472 let mut got = 0;
473 let taken = q.with_extension("queued.t");
474 while writers.iter().any(|w| !w.is_finished()) {
475 if locked(&q, || std::fs::rename(&q, &taken).is_ok()) {
476 got += std::fs::read_to_string(&taken).unwrap().lines().count();
477 }
478 }
479 for w in writers {
480 w.join().unwrap();
481 }
482 if std::fs::rename(&q, &taken).is_ok() {
483 got += std::fs::read_to_string(&taken).unwrap().lines().count();
484 }
485 assert_eq!(got, 400, "every line taken once");
486 }
487}