atagen

roundabout

public
Star0
Watch0

Commit 56a853ebf254

tarball

atagen <boss@atagen.co>2026-09-27 03:59 UTC

3 files changed+111-19
daemon: hot reload config on save

Without this, edits only landed once the daemon idled out, which never happens while agents keep it busy. A bad config now falls back to defaults instead of exiting 2, since that exit code is a blocking PreToolUse verdict and one typo would have walled off every bash call in every session.
Modified file: README.md+1 additions−1 deletions
@@ -35,7 +35,7 @@ the plugin brings a skill and hooks. unwrapped builds get bounced back with inst
3535
3636 ## config
3737
38-`~/.config/roundabout/config.toml`, falling back to `/etc/roundabout/config.toml`, or `$ROUNDABOUT_CONFIG` over both. everything's optional, these are the defaults
38+`~/.config/roundabout/config.toml`, falling back to `/etc/roundabout/config.toml`, or `$ROUNDABOUT_CONFIG` over both. the daemon picks up edits on save, on linux anyway. a broken config gets complained about and ignored rather than taking every agent down with it. everything's optional, these are the defaults
3939
4040 ```toml
4141 [budget]
Modified file: src/daemon.rs+104 additions−11 deletions
@@ -70,24 +70,53 @@ fn lock(shared: &Shared) -> MutexGuard<'_, State> {
7070
7171 impl State {
7272 fn new(cfg: Config) -> Self {
73- let (total, live) = sys::memory();
74- let cap = Capacity {
75- ram_budget: cfg.budget.ram.resolve(total),
76- ram_live: live,
77- ram_reserve: cfg.budget.ram_reserve,
78- cores: cfg.budget.cores.resolve(sys::cores()),
79- disks: BTreeMap::new(),
80- pressure: false,
81- };
82- Self {
73+ let mut st = Self {
8374 cfg,
8475 next: 1,
8576 leases: BTreeMap::new(),
86- cap,
77+ cap: Capacity {
78+ ram_budget: Bytes::ZERO,
79+ ram_live: Bytes::ZERO,
80+ ram_reserve: Bytes::ZERO,
81+ cores: 1,
82+ disks: BTreeMap::new(),
83+ pressure: false,
84+ },
8785 baselines: BTreeMap::new(),
8886 conns: 0,
8987 idle_since: Instant::now(),
88+ };
89+ st.apply_budget();
90+ st
91+ }
92+
93+ fn apply_budget(&mut self) {
94+ let (total, live) = sys::memory();
95+ self.cap.ram_budget = self.cfg.budget.ram.resolve(total);
96+ self.cap.ram_live = live;
97+ self.cap.ram_reserve = self.cfg.budget.ram_reserve;
98+ self.cap.cores = self.cfg.budget.cores.resolve(sys::cores());
99+ }
100+
101+ /// running leases keep the heaviness they started with, so a reload can't
102+ /// retroactively put two heavies side by side
103+ fn reload(&mut self, cfg: Config) {
104+ self.cfg = cfg;
105+ self.apply_budget();
106+ let queued: Vec<(LeaseId, Need)> = self
107+ .leases
108+ .iter()
109+ .filter(|(_, l)| l.run.is_none())
110+ .map(|(id, l)| (*id, l.req.need))
111+ .collect();
112+ for (id, need) in queued {
113+ let heavy = self.heavy(&need);
114+ if let Some(l) = self.leases.get_mut(&id) {
115+ l.heavy = heavy;
116+ }
90117 }
118+ self.refresh();
119+ self.reschedule();
91120 }
92121
93122 fn heavy(&self, need: &Need) -> bool {
@@ -470,6 +499,65 @@ fn tick(shared: &Shared) {
470499 }
471500 }
472501
502+/// watches the directory, not the file. editors save by renaming over it,
503+/// which would orphan a watch on the old inode after the first edit
504+#[cfg(target_os = "linux")]
505+fn watch_config(shared: &Shared) {
506+ use std::os::unix::ffi::OsStrExt;
507+
508+ let path = paths::config_file();
509+ let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
510+ return;
511+ };
512+ let _ = fs::create_dir_all(dir);
513+ let Ok(cdir) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else {
514+ return;
515+ };
516+ let fd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
517+ if fd < 0 {
518+ return;
519+ }
520+ let mask = libc::IN_CLOSE_WRITE
521+ | libc::IN_MOVED_TO
522+ | libc::IN_MOVED_FROM
523+ | libc::IN_CREATE
524+ | libc::IN_DELETE;
525+ if unsafe { libc::inotify_add_watch(fd, cdir.as_ptr(), mask) } < 0 {
526+ return;
527+ }
528+
529+ let header = std::mem::size_of::<libc::inotify_event>();
530+ let mut buf = [0u8; 4096];
531+ loop {
532+ let n = unsafe { libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) };
533+ let Ok(n) = usize::try_from(n) else {
534+ return;
535+ };
536+ let mut hit = false;
537+ let mut off = 0;
538+ while off + header <= n {
539+ let ev: libc::inotify_event =
540+ unsafe { std::ptr::read_unaligned(buf.as_ptr().add(off).cast()) };
541+ let len = ev.len as usize;
542+ let raw = &buf[off + header..(off + header + len).min(n)];
543+ let ev_name = raw.split(|b| *b == 0).next().unwrap_or_default();
544+ hit |= ev_name == name.as_bytes();
545+ off += header + len;
546+ }
547+ if !hit {
548+ continue;
549+ }
550+ thread::sleep(Duration::from_millis(100));
551+ match Config::load() {
552+ Ok(cfg) => {
553+ lock(shared).reload(cfg);
554+ eprintln!("roundabout: config reloaded");
555+ }
556+ Err(e) => eprintln!("roundabout: kept the old config, {e}"),
557+ }
558+ }
559+}
560+
473561 fn try_spawn_lock() -> Option<client::Flock> {
474562 let file = fs::OpenOptions::new()
475563 .write(true)
@@ -506,6 +594,11 @@ pub fn main(cfg: Config) -> ! {
506594 let shared = shared.clone();
507595 thread::spawn(move || tick(&shared));
508596 }
597+ #[cfg(target_os = "linux")]
598+ {
599+ let shared = shared.clone();
600+ thread::spawn(move || watch_config(&shared));
601+ }
509602
510603 for stream in listener.incoming() {
511604 let Ok(stream) = stream else { continue };
Modified file: src/main.rs+6 additions−7 deletions
@@ -16,12 +16,11 @@ mod units;
1616 use pound::Parse;
1717
1818 fn main() {
19- let cfg = match config::Config::load() {
20- Ok(c) => c,
21- Err(e) => {
22- eprintln!("rabt: bad config, {e}");
23- std::process::exit(2);
24- }
25- };
19+ // a typo must not exit 2, that's a blocking pretooluse verdict and would
20+ // wall off every bash call in every session
21+ let cfg = config::Config::load().unwrap_or_else(|e| {
22+ eprintln!("rabt: bad config, using defaults. {e}");
23+ config::Config::default()
24+ });
2625 std::process::exit(cli::dispatch(&cfg, cli::Rabt::parse()));
2726 }