fix(agents): bound how many subagents one session runs at once

Port of upstream grok-build 1.0.1 (landed upstream 2026-08-06). The
coordinator fire-and-forgot every spawn, so a wide fan-out (graph
waves, swarm bursts, model-driven Task storms) could exhaust file
descriptors.

- kigi-tools task/admission: SubagentLimits — 32 concurrent per session
  by default (KIGI_MAX_CONCURRENT_SUBAGENTS), queue|fail behavior
  (KIGI_SUBAGENT_LIMIT_BEHAVIOR), zero clamped to 1, read once at the
  composition root; parse_positive_env helper.
- Each parent session gets one spawn semaphore (map entries live for
  the process so a resumed session shares its predecessor's limit);
  handle_subagent_request holds an owned permit for the whole child
  run — queue awaits FIFO with a unified-log notice at saturation,
  fail routes through send_pre_spawn_failure. After a queued wait, a
  closed parent command channel (teardown) fails the spawn instead of
  running against a dead parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
This commit is contained in:
ZacharyZhang-NYandClaude Fable 5 committed 2026-08-27 11:09:24 -04:00
1 parent 26d26e15c9
commit bf93091416
9 files changed
+240

No files matched your search

@@ -0,0 +1,117 @@
//! Session-scoped subagent spawn limits, enforced by the shell's spawn path.
use crate::util::env::parse_positive_env;
pub const DEFAULT_MAX_CONCURRENT: usize = 32;
/// What happens to a spawn that arrives at the concurrent limit.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LimitBehavior {
#[default]
Queue,
Fail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubagentLimits {
/// Subagents one session may run at once. Zero does not disable the
/// limit: [`Self::effective_max_concurrent`] clamps it to 1.
pub max_concurrent: usize,
pub behavior: LimitBehavior,
}
impl Default for SubagentLimits {
fn default() -> Self {
Self {
max_concurrent: DEFAULT_MAX_CONCURRENT,
behavior: LimitBehavior::Queue,
}
}
}
impl SubagentLimits {
/// Read once at the composition root; inject everywhere else.
pub fn from_env() -> Self {
Self::from_lookup(|var| std::env::var(var).ok())
}
/// A limit can be adjusted but never disabled.
fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
let default = Self::default();
let max_concurrent = parse_positive_env(
"KIGI_MAX_CONCURRENT_SUBAGENTS",
lookup("KIGI_MAX_CONCURRENT_SUBAGENTS"),
)
.unwrap_or(default.max_concurrent);
let behavior = match lookup("KIGI_SUBAGENT_LIMIT_BEHAVIOR") {
None => LimitBehavior::Queue,
Some(value) if value.eq_ignore_ascii_case("fail") => LimitBehavior::Fail,
Some(value) if value.eq_ignore_ascii_case("queue") => LimitBehavior::Queue,
Some(value) => {
tracing::warn!(
%value,
"KIGI_SUBAGENT_LIMIT_BEHAVIOR is neither `queue` nor `fail`; keeping `queue`"
);
LimitBehavior::Queue
}
};
Self {
max_concurrent,
behavior,
}
}
/// The semaphore size: a zero limit must still admit one child, or
/// queued spawns starve.
pub fn effective_max_concurrent(&self) -> usize {
self.max_concurrent.max(1)
}
}
#[cfg(test)]
mod tests {
use super::{LimitBehavior, SubagentLimits};
#[test]
fn limits_accept_plain_positive_digits_and_ignore_everything_else() {
let from = |concurrent: Option<&str>, behavior: Option<&str>| {
SubagentLimits::from_lookup(|var| {
match var {
"KIGI_MAX_CONCURRENT_SUBAGENTS" => concurrent,
"KIGI_SUBAGENT_LIMIT_BEHAVIOR" => behavior,
other => panic!("unexpected lookup: {other}"),
}
.map(str::to_owned)
})
};
assert_eq!(from(None, None), SubagentLimits::default());
assert_eq!(
from(Some("5"), Some("FAIL")),
SubagentLimits {
max_concurrent: 5,
behavior: LimitBehavior::Fail,
}
);
// Adjustable but never disabled: zero, negatives, non-digits,
// scientific notation, digit separators, and out-of-range values all
// keep defaults.
for ignored in ["0", "-1", "abc", "1e3", "20_000", "18446744073709551616"] {
assert_eq!(
from(Some(ignored), Some("nonsense")),
SubagentLimits::default(),
"value {ignored:?} should fall back to the defaults"
);
}
}
#[test]
fn a_zero_limit_is_clamped_to_one_not_disabled() {
let limits = SubagentLimits {
max_concurrent: 0,
behavior: LimitBehavior::Queue,
};
assert_eq!(limits.effective_max_concurrent(), 1);
assert_eq!(SubagentLimits::default().effective_max_concurrent(), 32);
}
}
@@ -12,6 +12,7 @@
//! - `SessionIdResource` — current session ID for parent scoping (optional)
//! - `TaskModelValidator` — validates explicit model slugs before spawn
pub mod admission;
pub mod backend;
pub mod types;
+21
View File
@@ -6,6 +6,27 @@
pub use kigi_tty_utils::{detach_from_tty, pager_env};
/// Parse a positive whole number in plain digits (no sign, no separators,
/// no scientific notation). `None` for zero or anything non-canonical.
pub fn parse_positive(value: &str) -> Option<u64> {
value.parse::<u64>().ok().filter(|&parsed| parsed > 0)
}
/// Parse a positive whole-number env value into a count. A set-but-invalid
/// value warns and reads as unset, so the caller's default applies.
pub fn parse_positive_env(var: &str, value: Option<String>) -> Option<usize> {
let value = value?;
let parsed = parse_positive(&value).and_then(|parsed| usize::try_from(parsed).ok());
if parsed.is_none() {
tracing::warn!(
var,
%value,
"env value is not a positive whole number in plain digits; using the default"
);
}
parsed
}
/// Env var set on agent-spawned terminal processes so host tools (e.g. `x ban`)
/// can distinguish agent invocations from human interactive shells.
/// Note: the CLI also uses `KIGI_AGENT` as an