Skip to main content

agentd/supervisor/
tree.rs

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