use std::sync::atomic::{AtomicUsize, Ordering};
use crate::error::SubAgentError;
#[derive(Default)]
pub struct SessionSpawnBudget(AtomicUsize);
impl SessionSpawnBudget {
pub fn check(&self, max: usize) -> Result<(), SubAgentError> {
if max == 0 {
return Ok(());
}
let spawned = self.0.load(Ordering::Relaxed);
if spawned >= max {
return Err(SubAgentError::SessionSpawnLimit { spawned, max });
}
Ok(())
}
pub fn record_spawn(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
#[must_use]
pub fn spawned(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
}
impl std::fmt::Debug for SessionSpawnBudget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SessionSpawnBudget")
.field(&self.spawned())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_mints_independent_counters() {
let a = SessionSpawnBudget::default();
let b = SessionSpawnBudget::default();
a.record_spawn();
assert_eq!(a.spawned(), 1);
assert_eq!(
b.spawned(),
0,
"default() must not share state across instances"
);
}
#[test]
fn zero_is_unlimited_sentinel() {
let budget = SessionSpawnBudget::default();
for _ in 0..1000 {
budget.record_spawn();
}
assert!(budget.check(0).is_ok());
}
#[test]
fn check_does_not_consume() {
let budget = SessionSpawnBudget::default();
budget.check(5).unwrap();
budget.check(5).unwrap();
assert_eq!(budget.spawned(), 0, "check() must be read-only");
}
#[test]
fn cap_reached_returns_session_spawn_limit() {
let budget = SessionSpawnBudget::default();
budget.record_spawn();
let err = budget.check(1).unwrap_err();
assert!(matches!(
err,
SubAgentError::SessionSpawnLimit { spawned: 1, max: 1 }
));
}
#[test]
fn debug_prints_count() {
let budget = SessionSpawnBudget::default();
budget.record_spawn();
let debug = format!("{budget:?}");
assert!(
debug.contains('1'),
"Debug output must surface the count: {debug}"
);
}
}