Skip to main content

agentd/supervisor/
tree.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The supervision tree — the in-memory record of the subagent process tree.
3//!
4//! This module owns the *bookkeeping*: who spawned whom, each node's depth and
5//! `agent_path`, hierarchical token accounting to the root, the tree-wide
6//! `draining` flag, and the **spawn chokepoint** that enforces the fork-bomb
7//! caps. It is pure logic — no processes, pipes, or signals (those are
8//! `spawn.rs`/`reap.rs`/`kill.rs`). Depth is **minted here** from the parent's
9//! record, never trusted from a child's request: a child that could name its own
10//! depth could name `0` forever and defeat the recursion cap.
11
12use std::collections::HashMap;
13use std::time::Instant;
14
15/// A std-only token bucket for the tree-wide **spawn-rate** cap (8 burst,
16/// 2 tokens/s by default). Hand-rolled — a rate limiter is `Instant` +
17/// arithmetic, never a crate. Refill is **lazy**: every `try_take` first credits
18/// the tokens that have accrued since the last call, then spends one if it can.
19/// This catches a *fast churn loop* that stays under the absolute subagent count
20/// — a wedged child hammering `subagent.spawn` just keeps getting refusals.
21#[derive(Debug, Clone, Copy)]
22pub struct TokenBucket {
23    /// Burst ceiling — tokens never accrue past this.
24    capacity: f64,
25    /// Tokens currently available (fractional; whole tokens are spendable).
26    tokens: f64,
27    /// Steady-state refill rate, tokens per second.
28    refill_per_sec: f64,
29    /// When the bucket was last refilled (the lazy-refill anchor).
30    last: Instant,
31}
32
33/// Parse `"<burst>/<per>s"` — ONE rate spelling across the config surface
34/// (`a2a.principals[].quotas.rate`, webhook `rate`, step `rate`). Returns
35/// `(burst, window seconds)`.
36pub fn parse_rate(s: &str) -> Result<(u32, f64), String> {
37    let (b, p) = s
38        .split_once('/')
39        .ok_or_else(|| format!("want \"<burst>/<per>s\" (e.g. \"20/1s\"), got {s:?}"))?;
40    let burst: u32 = b
41        .trim()
42        .parse()
43        .map_err(|_| format!("burst must be a count, got {b:?}"))?;
44    let per = p.trim();
45    // Bare numbers and the `s`/`sec` suffix parse as (possibly fractional)
46    // seconds; any other duration unit (`ms`/`m`/`h`/`d`/`w`) goes through
47    // the one duration parser — a dunning cadence writes `1/1d`, not
48    // `1/86400s`.
49    let secs: f64 = match per
50        .strip_suffix("sec")
51        .or_else(|| per.strip_suffix('s').filter(|v| !v.ends_with('m')))
52        .unwrap_or(per)
53        .trim()
54        .parse()
55    {
56        Ok(v) => v,
57        Err(_) => crate::config::parse_duration(per)
58            .map_err(|_| format!("window must be a duration, got {p:?}"))?
59            .as_secs_f64(),
60    };
61    if burst == 0 || !secs.is_finite() || secs <= 0.0 {
62        return Err(format!("burst and window must be positive, got {s:?}"));
63    }
64    Ok((burst, secs))
65}
66
67impl TokenBucket {
68    /// A bucket that starts full (`burst` tokens) and refills `per_sec` tokens
69    /// each second up to `burst`.
70    pub fn new(burst: u32, per_sec: f64) -> TokenBucket {
71        let capacity = f64::from(burst);
72        TokenBucket {
73            capacity,
74            tokens: capacity,
75            refill_per_sec: per_sec,
76            last: Instant::now(),
77        }
78    }
79
80    /// Lazy-refill to `now`, then spend one token if one is available. Returns
81    /// whether a token was taken (i.e. whether the spawn may proceed). `now` is
82    /// explicit so the refill is deterministically unit-testable; `last` only
83    /// ever moves forward (a non-monotonic `now` simply adds no tokens).
84    pub fn try_take_at(&mut self, now: Instant) -> bool {
85        let elapsed = now.saturating_duration_since(self.last).as_secs_f64();
86        self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
87        self.last = now;
88        if self.tokens >= 1.0 {
89            self.tokens -= 1.0;
90            true
91        } else {
92            false
93        }
94    }
95
96    /// Spend one token against the wall clock (the production entry point).
97    pub fn try_take(&mut self) -> bool {
98        self.try_take_at(Instant::now())
99    }
100}
101
102/// Stable per-tree node id (the root is `0`).
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
104pub struct NodeId(pub u64);
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum NodeStatus {
108    /// Spawned, awaiting the child's `Ready` frame.
109    Spawning,
110    Running,
111    /// Reached a terminal status cleanly.
112    Done,
113    /// Crashed / killed / fatal-failed.
114    Failed,
115}
116
117#[derive(Debug, Clone)]
118pub struct Node {
119    pub id: NodeId,
120    pub parent: Option<NodeId>,
121    pub depth: u32,
122    /// Dotted tree path for log correlation (`0`, `0.2`, `0.2.1`).
123    pub agent_path: String,
124    pub status: NodeStatus,
125    /// Tokens charged to this node alone.
126    pub tokens: u64,
127    pub children: Vec<NodeId>,
128}
129
130/// Fork-bomb / runaway-recursion caps, enforced at the one spawn chokepoint.
131/// Conservative defaults; a spawn exceeding any of these is **refused as a tool
132/// result**, never a crash — the requesting agent gets to adapt, and one greedy
133/// branch cannot take the tree down.
134#[derive(Debug, Clone, Copy)]
135pub struct Caps {
136    pub max_depth: u32,
137    pub max_children: u32,
138    pub max_total: u32,
139    pub tree_token_ceiling: u64,
140    /// Spawn-rate token bucket burst.
141    pub spawn_rate_burst: u32,
142    /// Spawn-rate token bucket refill, tokens per second.
143    pub spawn_rate_per_sec: f64,
144}
145
146impl Default for Caps {
147    fn default() -> Self {
148        Caps {
149            max_depth: 4,
150            max_children: 8,
151            max_total: 64,
152            tree_token_ceiling: 2_000_000,
153            spawn_rate_burst: 8,
154            spawn_rate_per_sec: 2.0,
155        }
156    }
157}
158
159/// Why a spawn was refused. Surfaced to the requesting agent as a tool result
160/// so its model can adapt — not an error that crashes the tree.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum SpawnRefused {
163    Draining,
164    MaxDepth,
165    MaxChildren,
166    MaxTotal,
167    RateExceeded,
168    TreeBudget,
169    UnknownParent,
170}
171
172impl SpawnRefused {
173    pub fn as_str(self) -> &'static str {
174        match self {
175            SpawnRefused::Draining => "tree is draining; no new subagents",
176            SpawnRefused::MaxDepth => "max subagent depth reached",
177            SpawnRefused::MaxChildren => "parent has too many children",
178            SpawnRefused::MaxTotal => "max total subagents reached",
179            SpawnRefused::RateExceeded => "spawn rate exceeded",
180            SpawnRefused::TreeBudget => "tree token budget exhausted",
181            SpawnRefused::UnknownParent => "unknown parent handle",
182        }
183    }
184}
185
186pub struct Tree {
187    nodes: HashMap<NodeId, Node>,
188    next_id: u64,
189    root: Option<NodeId>,
190    draining: bool,
191    /// Tree-wide token total (source of truth for the ceiling).
192    total_tokens: u64,
193    /// Tree-wide spawn-rate limiter, enforced in `mint_child`.
194    spawn_bucket: TokenBucket,
195    caps: Caps,
196}
197
198impl Tree {
199    pub fn new(caps: Caps) -> Tree {
200        Tree {
201            nodes: HashMap::new(),
202            next_id: 0,
203            root: None,
204            draining: false,
205            total_tokens: 0,
206            spawn_bucket: TokenBucket::new(caps.spawn_rate_burst, caps.spawn_rate_per_sec),
207            caps,
208        }
209    }
210
211    pub fn caps(&self) -> Caps {
212        self.caps
213    }
214    pub fn is_draining(&self) -> bool {
215        self.draining
216    }
217    pub fn total_tokens(&self) -> u64 {
218        self.total_tokens
219    }
220    pub fn len(&self) -> usize {
221        self.nodes.len()
222    }
223    pub fn is_empty(&self) -> bool {
224        self.nodes.is_empty()
225    }
226    pub fn get(&self, id: NodeId) -> Option<&Node> {
227        self.nodes.get(&id)
228    }
229    pub fn root(&self) -> Option<NodeId> {
230        self.root
231    }
232
233    /// Mint the root node (depth 0, path `0`). The one-shot/loop root agent.
234    pub fn mint_root(&mut self) -> Result<NodeId, SpawnRefused> {
235        if self.draining {
236            return Err(SpawnRefused::Draining);
237        }
238        let id = self.alloc(None, 0, "0".to_string());
239        self.root = Some(id);
240        Ok(id)
241    }
242
243    /// Mint a child of `parent`, enforcing every cap. **Depth and path are
244    /// derived from the parent here** — this is the single chokepoint, so no
245    /// caller can bypass a cap by supplying its own depth. The caller then
246    /// attaches the OS handle to the returned node.
247    pub fn mint_child(&mut self, parent: NodeId) -> Result<NodeId, SpawnRefused> {
248        if self.draining {
249            return Err(SpawnRefused::Draining);
250        }
251        if self.total_tokens >= self.caps.tree_token_ceiling {
252            return Err(SpawnRefused::TreeBudget);
253        }
254        if self.nodes.len() as u32 >= self.caps.max_total {
255            return Err(SpawnRefused::MaxTotal);
256        }
257        let (depth, child_index, parent_path) = {
258            let p = self.nodes.get(&parent).ok_or(SpawnRefused::UnknownParent)?;
259            if p.depth + 1 > self.caps.max_depth {
260                return Err(SpawnRefused::MaxDepth);
261            }
262            if p.children.len() as u32 >= self.caps.max_children {
263                return Err(SpawnRefused::MaxChildren);
264            }
265            (p.depth + 1, p.children.len(), p.agent_path.clone())
266        };
267        // Spawn-rate cap: catches a fast churn loop that stays
268        // under the absolute depth/breadth/total counts. Last gate before the
269        // node is minted, so a refused spawn costs no token and no id.
270        if !self.spawn_bucket.try_take() {
271            return Err(SpawnRefused::RateExceeded);
272        }
273        let path = format!("{parent_path}.{child_index}");
274        let id = self.alloc(Some(parent), depth, path);
275        if let Some(p) = self.nodes.get_mut(&parent) {
276            p.children.push(id);
277        }
278        Ok(id)
279    }
280
281    fn alloc(&mut self, parent: Option<NodeId>, depth: u32, agent_path: String) -> NodeId {
282        let id = NodeId(self.next_id);
283        self.next_id += 1;
284        self.nodes.insert(
285            id,
286            Node {
287                id,
288                parent,
289                depth,
290                agent_path,
291                status: NodeStatus::Spawning,
292                tokens: 0,
293                children: Vec::new(),
294            },
295        );
296        id
297    }
298
299    pub fn set_status(&mut self, id: NodeId, status: NodeStatus) {
300        if let Some(n) = self.nodes.get_mut(&id) {
301            n.status = status;
302        }
303    }
304
305    /// Charge tokens to a node and the tree root. Returns true if the
306    /// tree-wide ceiling is now exceeded (caller drains the tree).
307    pub fn charge_tokens(&mut self, id: NodeId, tokens: u64) -> bool {
308        if let Some(n) = self.nodes.get_mut(&id) {
309            n.tokens = n.tokens.saturating_add(tokens);
310        }
311        self.total_tokens = self.total_tokens.saturating_add(tokens);
312        self.total_tokens >= self.caps.tree_token_ceiling
313    }
314
315    /// Flip the one-way draining flag (SIGTERM / tree-budget breach). After
316    /// this, `mint_*` refuses, so a parent cannot spawn replacements mid-teardown
317    /// and the kill ladder always terminates.
318    pub fn set_draining(&mut self) {
319        self.draining = true;
320    }
321
322    /// Node ids ordered **deepest-first** — the kill-ladder teardown order.
323    /// Children must die before parents, otherwise a killed parent's children are
324    /// reparented to pid 1 and escape the tree's accounting entirely.
325    pub fn deepest_first(&self) -> Vec<NodeId> {
326        let mut ids: Vec<NodeId> = self.nodes.keys().copied().collect();
327        ids.sort_by(|a, b| {
328            let da = self.nodes[a].depth;
329            let db = self.nodes[b].depth;
330            db.cmp(&da).then(b.cmp(a))
331        });
332        ids
333    }
334
335    pub fn remove(&mut self, id: NodeId) -> Option<Node> {
336        let node = self.nodes.remove(&id)?;
337        if let Some(p) = node.parent.and_then(|parent| self.nodes.get_mut(&parent)) {
338            p.children.retain(|c| *c != id);
339        }
340        Some(node)
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn root_then_child_depth_and_path() {
350        let mut t = Tree::new(Caps::default());
351        let root = t.mint_root().unwrap();
352        assert_eq!(t.get(root).unwrap().depth, 0);
353        assert_eq!(t.get(root).unwrap().agent_path, "0");
354        let c0 = t.mint_child(root).unwrap();
355        let c1 = t.mint_child(root).unwrap();
356        assert_eq!(t.get(c0).unwrap().depth, 1);
357        assert_eq!(t.get(c0).unwrap().agent_path, "0.0");
358        assert_eq!(t.get(c1).unwrap().agent_path, "0.1");
359        assert_eq!(t.get(root).unwrap().children.len(), 2);
360    }
361
362    #[test]
363    fn depth_cap_refuses() {
364        let caps = Caps {
365            max_depth: 2,
366            ..Caps::default()
367        };
368        let mut t = Tree::new(caps);
369        let root = t.mint_root().unwrap(); // depth 0
370        let a = t.mint_child(root).unwrap(); // depth 1
371        let b = t.mint_child(a).unwrap(); // depth 2
372        assert_eq!(t.mint_child(b).unwrap_err(), SpawnRefused::MaxDepth);
373    }
374
375    #[test]
376    fn children_cap_refuses() {
377        let caps = Caps {
378            max_children: 2,
379            ..Caps::default()
380        };
381        let mut t = Tree::new(caps);
382        let root = t.mint_root().unwrap();
383        t.mint_child(root).unwrap();
384        t.mint_child(root).unwrap();
385        assert_eq!(t.mint_child(root).unwrap_err(), SpawnRefused::MaxChildren);
386    }
387
388    #[test]
389    fn total_cap_refuses() {
390        let caps = Caps {
391            max_total: 2,
392            max_children: 10,
393            ..Caps::default()
394        };
395        let mut t = Tree::new(caps);
396        let root = t.mint_root().unwrap(); // count 1
397        t.mint_child(root).unwrap(); // count 2
398        assert_eq!(t.mint_child(root).unwrap_err(), SpawnRefused::MaxTotal);
399    }
400
401    #[test]
402    fn draining_refuses_new_spawns() {
403        let mut t = Tree::new(Caps::default());
404        let root = t.mint_root().unwrap();
405        t.set_draining();
406        assert_eq!(t.mint_child(root).unwrap_err(), SpawnRefused::Draining);
407    }
408
409    #[test]
410    fn token_accounting_rolls_to_root_and_trips_ceiling() {
411        let caps = Caps {
412            tree_token_ceiling: 100,
413            ..Caps::default()
414        };
415        let mut t = Tree::new(caps);
416        let root = t.mint_root().unwrap();
417        let c = t.mint_child(root).unwrap();
418        assert!(!t.charge_tokens(c, 60));
419        assert_eq!(t.total_tokens(), 60);
420        assert!(t.charge_tokens(c, 40)); // hits ceiling
421        assert_eq!(t.get(c).unwrap().tokens, 100);
422    }
423
424    #[test]
425    fn token_bucket_burst_then_refill() {
426        use std::time::Duration;
427        // 8 burst, 2/s refill (the default spawn-rate settings).
428        let mut b = TokenBucket::new(8, 2.0);
429        let t0 = Instant::now();
430        // The full burst of 8 is spendable without any time passing…
431        for i in 0..8 {
432            assert!(b.try_take_at(t0), "burst token {i} should be available");
433        }
434        // …and the 9th in the same instant is refused (empty bucket).
435        assert!(
436            !b.try_take_at(t0),
437            "9th take with no refill must be refused"
438        );
439        // After 0.4s only 0.8 tokens have refilled (< 1) → still refused.
440        assert!(!b.try_take_at(t0 + Duration::from_millis(400)));
441        // After 0.5s from t0, 1.0 tokens have accrued → one take succeeds, then
442        // the bucket is empty again.
443        let t1 = t0 + Duration::from_millis(500);
444        assert!(b.try_take_at(t1), "one token refills after the interval");
445        assert!(!b.try_take_at(t1), "and only one — the refill is metered");
446    }
447
448    #[test]
449    fn token_bucket_caps_at_burst() {
450        use std::time::Duration;
451        // Idle for a long time: accrued tokens never exceed the burst ceiling.
452        let mut b = TokenBucket::new(8, 2.0);
453        let t0 = Instant::now();
454        let far = t0 + Duration::from_secs(3600);
455        for _ in 0..8 {
456            assert!(b.try_take_at(far));
457        }
458        assert!(
459            !b.try_take_at(far),
460            "no more than `burst` tokens ever accrue"
461        );
462    }
463
464    #[test]
465    fn spawn_rate_cap_refuses_after_burst() {
466        // A wide breadth + a low burst isolates the rate cap as the binding one:
467        // the first 3 children mint, the 4th is refused for rate (not breadth),
468        // and once a token refills it is allowed again.
469        let caps = Caps {
470            max_children: 100,
471            max_total: 100,
472            spawn_rate_burst: 3,
473            spawn_rate_per_sec: 1000.0, // fast refill so the wait below is tiny
474            ..Caps::default()
475        };
476        let mut t = Tree::new(caps);
477        let root = t.mint_root().unwrap();
478        t.mint_child(root).unwrap();
479        t.mint_child(root).unwrap();
480        t.mint_child(root).unwrap();
481        assert_eq!(
482            t.mint_child(root).unwrap_err(),
483            SpawnRefused::RateExceeded,
484            "the 4th rapid spawn is rate-limited, not breadth-limited"
485        );
486        // A whole token refills in ~1ms at 1000/s; after a short wait it allows again.
487        std::thread::sleep(std::time::Duration::from_millis(5));
488        assert!(
489            t.mint_child(root).is_ok(),
490            "a refilled token re-admits a spawn"
491        );
492    }
493
494    #[test]
495    fn deepest_first_orders_children_before_parents() {
496        let mut t = Tree::new(Caps::default());
497        let root = t.mint_root().unwrap();
498        let a = t.mint_child(root).unwrap();
499        let b = t.mint_child(a).unwrap();
500        let order = t.deepest_first();
501        let pos = |id: NodeId| order.iter().position(|x| *x == id).unwrap();
502        assert!(pos(b) < pos(a));
503        assert!(pos(a) < pos(root));
504    }
505}