Synced from monorepo

Synced from monorepo

Changes:
- Workspace task snapshots only list incomplete backgrounded tasks
- Quiet auth, LSP, and config warnings in the shell
- Fix observability attributes for warm store errors, restore setup, remote tools, and preview denials
- Fail closed when soak metrics are missing
- Run plan-mode exit last in mixed tool batches
- Allow /loop to store prompts that can terminate the loop
- Make subagent maximum nesting depth configurable
- Security: apply sandbox profile to the leader process that executes tools

Source-Revision: 1adcd1f477870e4a97bacbd6be78c8a3bfbac46d
This commit is contained in:
grokkybara[bot] 2026-07-27 17:54:34 +00:00
commit 02d9359435
96 changed files with 2346 additions and 351 deletions

View file

@ -85,6 +85,7 @@ chrono = { workspace = true }
futures-util = { workspace = true }
xai-grok-sandbox = { path = "../xai-grok-sandbox", default-features = false }
xai-file-utils = { path = "../xai-file-utils" }
dhat = { workspace = true, optional = true }
[target.'cfg(unix)'.dependencies]
command-fds = { version = "0.3", features = ["tokio"] }
@ -101,9 +102,11 @@ windows = { workspace = true, features = ["Win32_Storage_FileSystem"] }
[dev-dependencies]
dirs = { workspace = true }
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
tracing-subscriber = { workspace = true, features = ["fmt"] }
wiremock = { workspace = true }
xai-grok-test-support = { workspace = true }
xai-test-utils = { workspace = true }
[build-dependencies]
@ -118,3 +121,4 @@ workspace = true
default-bazel = ["serde"]
default = ["serde"]
serde = []
dhat-heap = ["dep:dhat"]

View file

@ -835,6 +835,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: owner.map(|s| s.to_string()),
description: None,
is_backgrounded: false,
}
}

View file

@ -444,6 +444,7 @@ impl ProcessState {
kind: self.kind,
owner_session_id: self.owner_session_id.clone(),
description: self.description.clone(),
is_backgrounded: self.bg_status.is_backgrounded(),
}
}
}
@ -1617,6 +1618,7 @@ impl LocalTerminalActor {
explicitly_killed: p.explicitly_killed,
owner_session_id: p.owner_session_id.clone(),
description: p.description.clone(),
is_backgrounded: true,
};
self.completed_task_snapshots.insert(id.clone(), snapshot);
}

View file

@ -219,6 +219,9 @@ pub struct TaskSnapshot {
/// Model-supplied label for task UI / snapshots.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// True after explicit/user/auto backgrounding; false for pure foreground runs.
#[serde(default)]
pub is_backgrounded: bool,
}
impl TaskSnapshot {
@ -238,6 +241,11 @@ impl TaskSnapshot {
pub fn is_outstanding(&self) -> bool {
!self.completed
}
/// Incomplete and backgrounded — tray/`tasks_snapshot` predicate (not FG in-flight).
pub fn is_outstanding_background(&self) -> bool {
!self.completed && self.is_backgrounded
}
}
/// Result of killing a terminal task.

View file

@ -50,7 +50,8 @@ pub use lsp::LspTool;
pub use monitor::tool::MonitorTool;
pub use read_file::ReadFileTool;
pub use scheduler::create::{
SCHEDULER_CREATE_TOOL_NAME, SchedulerCreateTool, loop_schedule_instruction, loop_usage_message,
LoopFireMode, SCHEDULER_CREATE_TOOL_NAME, SchedulerCreateTool, loop_schedule_instruction,
loop_usage_message,
};
pub use scheduler::delete::{SCHEDULER_DELETE_TOOL_NAME, SchedulerDeleteTool};
pub use scheduler::list::SchedulerListTool;

View file

@ -110,6 +110,53 @@ impl MonitorInput {
}
}
// Mid-turn monitor event buffer
/// A monitor event notification to be surfaced as a `<system-reminder>` mid-turn.
#[derive(Debug, Clone)]
pub struct MonitorEventNotification {
pub task_id: String,
pub event_text: String,
/// Session that owns the monitor which produced this event.
///
/// In leader mode every session shares one [`MonitorEventBuffer`], so the
/// drain sites filter on this to avoid surfacing one session's monitor
/// events inside another session's turn. `None` for legacy / non-grok-build
/// backends, which any session drains for backwards compatibility.
pub owner_session_id: Option<String>,
}
impl MonitorEventNotification {
/// Whether this buffered event should surface in the session whose owner id
/// is `my_owner`. Mirrors `task_owned_by_session`: an event surfaces only
/// when it has no recorded owner (legacy) or its owner matches the draining
/// session. Foreign events stay buffered for their own session to drain.
pub fn owned_by_session(&self, my_owner: Option<&str>) -> bool {
match (my_owner, self.owner_session_id.as_deref()) {
(Some(me), Some(owner)) => me == owner,
_ => true,
}
}
}
/// Shared buffer for mid-turn monitor event notifications: an [`EventQueue`]
/// of [`MonitorEventNotification`]. Producers `push_capped`; the turn loop
/// drains its session's events via [`drain_owned`].
///
/// [`EventQueue`]: xai_interjection_core::EventQueue
pub type MonitorEventBuffer = xai_interjection_core::EventQueue<MonitorEventNotification>;
crate::register_resource!("grok_build", "MonitorEventBuffer", MonitorEventBuffer);
/// Drain only `my_owner`'s events (the buffer is shared across sessions in
/// leader mode); owner-less legacy events drain anywhere.
pub fn drain_owned(
buffer: &MonitorEventBuffer,
my_owner: Option<&str>,
) -> Vec<MonitorEventNotification> {
buffer.drain_matching(|e| e.owned_by_session(my_owner))
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -8,7 +8,7 @@ use super::types::{ScheduledTask, SchedulerCommand, SchedulerHandle, scheduler_t
// Canonical /loop wording lives in the light API crate so other consumers can
// link it without the tools implementation crate; re-exported to keep paths stable.
pub use xai_grok_tools_api::slash_commands::{
SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
LoopFireMode, SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
@ -475,7 +475,7 @@ mod tests {
#[test]
fn loop_schedule_instruction_holds_invariants() {
let args = "every 30 minutes do x";
let instr = loop_schedule_instruction(args);
let instr = loop_schedule_instruction(args, LoopFireMode::Detached);
assert!(
!instr.contains("10m"),
"instruction must not default: {instr}"

View file

@ -14,7 +14,6 @@ use super::types::{
};
/// Cap on retained completed-subagent entries before the oldest are evicted.
/// Public so the subagent soak test can assert the coordinator stays bounded.
pub const MAX_COMPLETED_ENTRIES: usize = 1024;
pub(super) const OUTPUT_UNAVAILABLE_PLACEHOLDER: &str = "[subagent output no longer available]";

View file

@ -9,6 +9,7 @@
//!
//! - `SubagentBackendResource` — backend for spawn/query/cancel (required)
//! - `SubagentDepthCounter` — current nesting depth (optional, defaults to 0)
//! - `MaxSubagentDepth` — max nesting (optional, defaults to [`MAX_SUBAGENT_DEPTH`])
//! - `SessionIdResource` — current session ID for parent scoping (optional)
//! - `SubagentForegroundWait` — host wait-window guard factory (optional)
//! - `TaskModelValidator` — validates explicit model slugs before spawn
@ -30,10 +31,16 @@ use crate::types::resources::SharedResources;
use crate::types::tool::{ToolKind, ToolNamespace};
use xai_tool_types::{SubagentCompletedOutput, SubagentIsolationMode, TaskToolInput};
/// Maximum nesting depth for subagents. A top-level session is depth 0;
/// the first subagent is depth 1. Subagents cannot spawn further subagents.
/// Default max nesting depth when [`MaxSubagentDepth`] is not injected.
pub const MAX_SUBAGENT_DEPTH: u32 = 1;
pub fn effective_max_subagent_depth(resources: &crate::types::resources::Resources) -> u32 {
resources
.get::<MaxSubagentDepth>()
.map(|d| d.0)
.unwrap_or(MAX_SUBAGENT_DEPTH)
}
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
@ -125,10 +132,19 @@ impl xai_tool_runtime::Tool for TaskTool {
.map(|cancellation| cancellation.0.clone());
// 1. Depth check
let (depth, backend, model_validator, parent_session_id, parent_prompt_id, foreground_wait) = {
let (
depth,
max_depth,
backend,
model_validator,
parent_session_id,
parent_prompt_id,
foreground_wait,
) = {
let res = resources.lock().await;
let depth = res.get::<SubagentDepthCounter>().map(|d| d.0).unwrap_or(0);
let max_depth = effective_max_subagent_depth(&res);
let backend = res
.get::<SubagentBackendResource>()
@ -155,6 +171,7 @@ impl xai_tool_runtime::Tool for TaskTool {
(
depth,
max_depth,
backend,
model_validator,
parent_session_id,
@ -163,9 +180,9 @@ impl xai_tool_runtime::Tool for TaskTool {
)
};
if depth >= MAX_SUBAGENT_DEPTH {
if depth >= max_depth {
return Err(xai_tool_runtime::ToolError::invalid_arguments(format!(
"Subagent depth limit exceeded (current depth: {depth}, max: {MAX_SUBAGENT_DEPTH}). \
"Subagent depth limit exceeded (current depth: {depth}, max: {max_depth}). \
Cannot spawn further nested subagents."
)));
}
@ -558,6 +575,41 @@ mod tests {
assert!(err.contains("depth limit exceeded"), "error: {err}");
}
#[tokio::test]
async fn raised_max_depth_allows_nested_spawn() {
let (backend, mut rx) = make_backend();
let mut resources = Resources::new();
resources.insert(backend);
resources.insert(SubagentDepthCounter(1));
resources.insert(MaxSubagentDepth(2));
resources.insert(SessionIdResource("child-session".to_string()));
resources.insert(CurrentPromptIdResource("prompt-nested".to_string()));
let result = xai_tool_runtime::Tool::run(
&TaskTool,
test_ctx(resources.into_shared()),
TaskToolInput {
description: "nested ok".into(),
prompt: "should be allowed at max_depth=2".into(),
subagent_type: "explore".into(),
run_in_background: true,
capability_mode: None,
isolation: None,
resume_from: None,
cwd: None,
model: None,
task_id: None,
},
)
.await;
assert!(
result.is_ok(),
"expected Ok at depth 1 with max 2: {result:?}"
);
let _ = rx.try_recv();
}
#[tokio::test]
async fn subagent_cannot_spawn_nested_subagent() {
let (backend, _rx) = make_backend();

View file

@ -10,7 +10,8 @@
//!
//! - `SubagentBackendResource` — wraps an `Arc<dyn SubagentBackend>` that
//! abstracts spawn/query/cancel (see [`super::backend`])
//! - `SubagentDepthCounter` — tracks nesting depth (max 1, no recursive spawning)
//! - `SubagentDepthCounter` — current nesting depth
//! - `MaxSubagentDepth` — configured max nesting depth
//! - `SessionIdResource` — carries the current session ID for parent scoping
//! - `TaskModelValidator` — validates explicit model slugs before background spawn
//!
@ -877,51 +878,6 @@ pub struct SubagentEventSender(#[educe(Debug(ignore))] pub mpsc::UnboundedSender
register_resource!("grok_build", "SubagentEventSender", SubagentEventSender);
// Mid-turn monitor event buffer
/// A monitor event notification to be surfaced as a `<system-reminder>` mid-turn.
#[derive(Debug, Clone)]
pub struct MonitorEventNotification {
pub task_id: String,
pub event_text: String,
/// Session that owns the monitor which produced this event.
///
/// In leader mode every session shares one [`MonitorEventBuffer`], so the
/// drain sites filter on this to avoid surfacing one session's monitor
/// events inside another session's turn. `None` for legacy / non-grok-build
/// backends, which any session drains for backwards compatibility.
pub owner_session_id: Option<String>,
}
impl MonitorEventNotification {
/// Whether this buffered event should surface in the session whose owner id
/// is `my_owner`. Mirrors `task_owned_by_session`: an event surfaces only
/// when it has no recorded owner (legacy) or its owner matches the draining
/// session. Foreign events stay buffered for their own session to drain.
pub fn owned_by_session(&self, my_owner: Option<&str>) -> bool {
match (my_owner, self.owner_session_id.as_deref()) {
(Some(me), Some(owner)) => me == owner,
_ => true,
}
}
}
/// Shared buffer for mid-turn monitor event notifications: an [`EventQueue`]
/// of [`MonitorEventNotification`]. Producers `push_capped`; the turn loop
/// drains its session's events via [`drain_owned`].
pub type MonitorEventBuffer = xai_interjection_core::EventQueue<MonitorEventNotification>;
register_resource!("grok_build", "MonitorEventBuffer", MonitorEventBuffer);
/// Drain only `my_owner`'s events (the buffer is shared across sessions in
/// leader mode); owner-less legacy events drain anywhere.
pub fn drain_owned(
buffer: &MonitorEventBuffer,
my_owner: Option<&str>,
) -> Vec<MonitorEventNotification> {
buffer.drain_matching(|e| e.owned_by_session(my_owner))
}
// Active subagent listing (compaction)
/// Lightweight summary of a running subagent.
@ -952,15 +908,18 @@ pub struct SubagentListActiveRequest {
pub respond_to: oneshot::Sender<Vec<ActiveSubagentSummary>>,
}
/// Tracks nesting depth. Injected into child's Resources with depth+1.
///
/// Top-level sessions start at depth 0. Each child increments by 1.
/// `TaskTool` rejects spawns when `depth >= MAX_SUBAGENT_DEPTH`.
/// Current nesting depth (top-level = 0; child = parent + 1).
#[derive(Debug, Clone)]
pub struct SubagentDepthCounter(pub u32);
register_resource!("grok_build", "SubagentDepthCounter", SubagentDepthCounter);
/// Host-injected max nesting depth; absent → [`super::MAX_SUBAGENT_DEPTH`].
#[derive(Debug, Clone, Copy)]
pub struct MaxSubagentDepth(pub u32);
register_resource!("grok_build", "MaxSubagentDepth", MaxSubagentDepth);
/// Session-scoped validator for model-facing `Task.model` arguments.
///
/// Returns an error message for an invalid slug and `None` for a valid slug.

View file

@ -917,6 +917,7 @@ pub(crate) mod test_helpers {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
}
}

View file

@ -1,7 +1,6 @@
use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::tool::{ToolKind, ToolNamespace};
use super::task::MAX_SUBAGENT_DEPTH;
use super::task::types::SubagentDepthCounter;
pub use xai_grok_tools_api::slash_commands::WORKFLOW_TOOL_NAME;
@ -237,7 +236,8 @@ impl xai_tool_runtime::Tool for WorkflowTool {
(depth, sender)
};
if depth >= MAX_SUBAGENT_DEPTH {
// Workflows stay top-level-only regardless of configurable subagent depth.
if depth > 0 {
return Err(xai_tool_runtime::ToolError::custom(
"workflow_depth_exceeded",
"Workflows can only be launched from a top-level session (subagents and \

View file

@ -108,6 +108,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
}
}

View file

@ -255,7 +255,7 @@ fn split_wrapped_monitor_event(event_text: &str) -> Option<(&str, &str)> {
/// Buffered `event_text` arrives pre-wrapped (`wrap_monitor_event`); it is
/// unwrapped via [`split_wrapped_monitor_event`] with verbatim fallback.
pub fn format_monitor_events(
events: &[crate::implementations::grok_build::task::types::MonitorEventNotification],
events: &[crate::implementations::grok_build::monitor::types::MonitorEventNotification],
task_output_name: Option<&str>,
) -> Option<String> {
use std::fmt::Write as _;
@ -278,7 +278,8 @@ pub fn format_monitor_events(
))
}
_ => {
type Event = crate::implementations::grok_build::task::types::MonitorEventNotification;
type Event =
crate::implementations::grok_build::monitor::types::MonitorEventNotification;
let mut groups: Vec<(&str, Vec<&Event>)> = Vec::new();
for event in events {
match groups.iter_mut().find(|(id, _)| *id == event.task_id) {
@ -802,6 +803,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("abc-123"));
@ -829,6 +831,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_monitor_completion(&task, Some("get_command_or_subagent_output"));
assert!(
@ -862,6 +865,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_monitor_completion(&task, None);
assert!(
@ -890,6 +894,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("cargo test"));
@ -915,6 +920,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("exit code: unknown"));
@ -943,6 +949,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(
@ -982,6 +989,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(
@ -1020,6 +1028,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("exit code: 0"));
@ -1181,6 +1190,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
}
}
fn make_running(id: &str) -> TaskSnapshot {
@ -1202,6 +1212,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
}
}
fn make_bg_started(id: &str) -> crate::types::output::BackgroundTaskStarted {
@ -1811,7 +1822,7 @@ mod tests {
/// reintroduced.
#[tokio::test]
async fn reminder_pipeline_ignores_monitor_event_buffer() {
use crate::implementations::grok_build::task::types::{
use crate::implementations::grok_build::monitor::types::{
MonitorEventBuffer, MonitorEventNotification,
};
use crate::types::resources::Resources;
@ -1845,7 +1856,7 @@ mod tests {
/// own + owner-less legacy events; foreign events stay buffered.
#[test]
fn drain_owned_partitions_by_session_owner() {
use crate::implementations::grok_build::task::types::{
use crate::implementations::grok_build::monitor::types::{
MonitorEventBuffer, MonitorEventNotification, drain_owned,
};
let shared_buffer = MonitorEventBuffer::default();
@ -1882,7 +1893,7 @@ mod tests {
/// empty => `None`.
#[test]
fn format_monitor_events_single_vs_batched() {
use crate::implementations::grok_build::task::types::MonitorEventNotification;
use crate::implementations::grok_build::monitor::types::MonitorEventNotification;
let event = |task: &str, desc: &str, text: &str| MonitorEventNotification {
task_id: task.to_string(),
event_text: format!(
@ -1900,7 +1911,7 @@ mod tests {
single, "<monitor-event task_id=\"task-0\">\n[alpha] line 0\n</monitor-event>",
"single event must use the lean monitor-event form"
);
let bare = crate::implementations::grok_build::task::types::MonitorEventNotification {
let bare = crate::implementations::grok_build::monitor::types::MonitorEventNotification {
task_id: "task-9".into(),
event_text: "bare text, no wrapper".into(),
owner_session_id: None,
@ -1988,7 +1999,7 @@ mod tests {
/// End-to-end multibyte safety through the formatter (single + batch).
#[test]
fn format_monitor_events_handles_multibyte_content() {
use crate::implementations::grok_build::task::types::MonitorEventNotification;
use crate::implementations::grok_build::monitor::types::MonitorEventNotification;
let event = |task: &str, desc: &str, text: &str| MonitorEventNotification {
task_id: task.to_string(),
event_text: format!(

View file

@ -0,0 +1,928 @@
//! Subagent lifecycle soak: churn spawn/run/completion/eviction and assert
//! threads, fds, and heap/RSS reach steady state. A stub `ChildRunner` drives
//! the real coordinator/transport.
//!
//! SUBAGENT_SOAK_CYCLES=20000 cargo test -p xai-grok-tools \
//! [--features dhat-heap] --test test_subagent_soak -- --ignored --nocapture
#![cfg(unix)]
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static DHAT_ALLOC: dhat::Alloc = dhat::Alloc;
use std::sync::Arc;
use std::time::Duration;
use serde::ser::SerializeMap;
use serde::{Serialize, Serializer};
use strum::{EnumCount, IntoEnumIterator};
use tokio_util::sync::CancellationToken;
use xai_grok_test_support::env::env_parse;
use xai_grok_test_support::resources::{ResourceGrowth, ResourceSnapshot};
use xai_grok_tools::implementations::grok_build::task::backend::{ChannelBackend, SubagentBackend};
use xai_grok_tools::implementations::grok_build::task::coordinator::{
ChildCompletion, ChildControl, ChildRunOutput, ChildRunRequest, ChildRunner, CoordinatorConfig,
LocalBoxFuture, MAX_COMPLETED_ENTRIES, StartedChild, SubagentCoordinator, SubagentProgress,
};
use xai_grok_tools::implementations::grok_build::task::types::{
SubagentDescribeOutcome, SubagentOwner, SubagentRegistryCounts, SubagentRequest,
SubagentResult, SubagentValidateTypeOutcome,
};
const PARENT_SESSION_ID: &str = "subagent-soak-parent";
#[derive(Clone, Copy, strum::EnumCount, strum::EnumIter)]
enum Metric {
Rss,
Threads,
Fds,
}
impl Metric {
fn label(self) -> &'static str {
match self {
Metric::Rss => "rss",
Metric::Threads => "threads",
Metric::Fds => "fds",
}
}
/// RSS reports raw bytes, so its key names the unit.
fn summary_key(self) -> &'static str {
match self {
Metric::Rss => "rss_bytes",
Metric::Threads => "threads",
Metric::Fds => "fds",
}
}
fn unit(self) -> Option<&'static str> {
match self {
Metric::Rss => Some("MiB"),
Metric::Threads | Metric::Fds => None,
}
}
fn budget(self, bounds: &Bounds) -> f64 {
match self {
Metric::Rss => bounds.max_rss_growth_mib as f64,
Metric::Threads => bounds.max_thread_growth as f64,
Metric::Fds => bounds.max_fd_growth as f64,
}
}
/// RSS growth samples are bytes; convert to MiB for the budget comparison.
fn growth_in_budget_unit(self, raw: usize) -> f64 {
match self {
Metric::Rss => bytes_to_mib(raw),
Metric::Threads | Metric::Fds => raw as f64,
}
}
/// RSS is sampled on every unix; thread and fd counts are Linux-only.
fn expected_on_this_platform(self) -> bool {
match self {
Metric::Rss => true,
Metric::Threads | Metric::Fds => cfg!(target_os = "linux"),
}
}
}
/// Reads a metric's field from a snapshot or a growth delta so serialization and
/// the gates share one projection instead of repeating it.
trait MetricValue {
fn value_of(&self, metric: Metric) -> Option<usize>;
}
impl MetricValue for ResourceSnapshot {
fn value_of(&self, metric: Metric) -> Option<usize> {
// Destructure so a new resource field is a compile error here, not a
// silently dropped metric.
let ResourceSnapshot { rss, threads, fds } = *self;
match metric {
Metric::Rss => rss,
Metric::Threads => threads,
Metric::Fds => fds,
}
}
}
impl MetricValue for ResourceGrowth {
fn value_of(&self, metric: Metric) -> Option<usize> {
let ResourceGrowth { rss, threads, fds } = *self;
match metric {
Metric::Rss => rss,
Metric::Threads => threads,
Metric::Fds => fds,
}
}
}
fn serialize_metrics<T: MetricValue, S: Serializer>(
value: &T,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(Metric::COUNT))?;
for metric in Metric::iter() {
map.serialize_entry(metric.summary_key(), &value.value_of(metric))?;
}
map.end()
}
fn bytes_to_mib(bytes: usize) -> f64 {
bytes as f64 / (1024.0 * 1024.0)
}
#[cfg_attr(not(feature = "dhat-heap"), allow(dead_code))]
#[derive(Clone, Copy, Serialize)]
struct HeapSample {
blocks: i64,
bytes: i64,
}
#[derive(Clone, Copy, Serialize)]
struct HeapMetrics {
before: HeapSample,
after: HeapSample,
blocks_per_cycle: f64,
bytes_per_cycle: f64,
}
impl HeapMetrics {
fn new(before: HeapSample, after: HeapSample, cycles: u64) -> Self {
// `SUBAGENT_SOAK_CYCLES=0` would otherwise divide by zero and feed
// NaN/inf into the leak gates.
let cycles = cycles.max(1) as f64;
Self {
before,
after,
blocks_per_cycle: (after.blocks - before.blocks) as f64 / cycles,
bytes_per_cycle: (after.bytes - before.bytes) as f64 / cycles,
}
}
}
#[derive(Serialize)]
struct Bounds {
#[serde(rename = "warmup_cycles")]
warmup: u64,
#[serde(rename = "measured_cycles")]
measure: u64,
concurrency: u64,
max_thread_growth: u64,
max_fd_growth: u64,
max_rss_growth_mib: u64,
max_blocks_per_cycle: f64,
max_bytes_per_cycle: f64,
}
impl Bounds {
fn from_env() -> Self {
Self {
// Default warmup to the completed-entry cap so the ring is saturated
// and the measured window observes steady-state eviction rather than
// one-time cache fill.
warmup: env_parse("SUBAGENT_SOAK_WARMUP", MAX_COMPLETED_ENTRIES as u64),
measure: env_parse("SUBAGENT_SOAK_CYCLES", 512u64),
concurrency: env_parse("SUBAGENT_SOAK_CONCURRENCY", 16u64),
// RSS is looser than threads and fds to absorb allocator noise.
max_thread_growth: env_parse("SUBAGENT_SOAK_MAX_THREAD_GROWTH", 8u64),
max_fd_growth: env_parse("SUBAGENT_SOAK_MAX_FD_GROWTH", 16u64),
max_rss_growth_mib: env_parse("SUBAGENT_SOAK_MAX_RSS_GROWTH_MIB", 256u64),
max_blocks_per_cycle: env_parse("SUBAGENT_SOAK_MAX_BLOCKS_PER_CYCLE", 2.0f64),
max_bytes_per_cycle: env_parse("SUBAGENT_SOAK_MAX_BYTES_PER_CYCLE", 4096.0f64),
}
}
}
#[derive(Serialize)]
struct Measurement {
#[serde(serialize_with = "serialize_metrics")]
before: ResourceSnapshot,
#[serde(serialize_with = "serialize_metrics")]
after: ResourceSnapshot,
#[serde(serialize_with = "serialize_metrics")]
growth: ResourceGrowth,
#[serde(serialize_with = "serialize_counts")]
counts: SubagentRegistryCounts,
heap: Option<HeapMetrics>,
quiesced: bool,
}
fn serialize_counts<S: Serializer>(
counts: &SubagentRegistryCounts,
serializer: S,
) -> Result<S::Ok, S::Error> {
// Exhaustive destructure so a new count field is a compile error here, not a
// silently dropped summary key.
let SubagentRegistryCounts {
pending,
active,
completed,
} = counts;
let entries = [
("pending", pending),
("active", active),
("completed", completed),
];
let mut map = serializer.serialize_map(Some(entries.len()))?;
for (key, value) in entries {
map.serialize_entry(key, value)?;
}
map.end()
}
#[derive(Serialize)]
struct Summary<'a> {
#[serde(flatten)]
bounds: &'a Bounds,
#[serde(flatten)]
measurement: &'a Measurement,
}
fn heap_capture() -> Option<HeapSample> {
#[cfg(feature = "dhat-heap")]
{
let stats = dhat::HeapStats::get();
Some(HeapSample {
blocks: stats.curr_blocks as i64,
bytes: stats.curr_bytes as i64,
})
}
#[cfg(not(feature = "dhat-heap"))]
{
None
}
}
async fn quiesce(backend: &ChannelBackend) -> bool {
const MAX_POLLS: usize = 200;
const SLEEP: Duration = Duration::from_millis(5);
for _ in 0..MAX_POLLS {
let counts = backend.registry_counts().await;
if counts.pending == 0 && counts.active == 0 {
return true;
}
tokio::time::sleep(SLEEP).await;
}
let counts = backend.registry_counts().await;
eprintln!(
"[soak] quiesce budget expired with pending={} active={}; snapshot may be noisy",
counts.pending, counts.active
);
false
}
#[derive(Clone)]
struct SoakControl {
cancellation: CancellationToken,
}
impl ChildControl for SoakControl {
type ProgressFuture = std::future::Ready<SubagentProgress>;
fn progress(&self) -> Self::ProgressFuture {
std::future::ready(SubagentProgress::default())
}
fn cancel(&self) {
self.cancellation.cancel();
}
}
struct SoakRunner {
gate: Arc<tokio::sync::Semaphore>,
}
impl ChildRunner for SoakRunner {
type Control = SoakControl;
type CompletionData = ();
type RunFuture = LocalBoxFuture<ChildRunOutput<()>>;
type ValidateFuture = LocalBoxFuture<SubagentValidateTypeOutcome>;
type DescribeFuture = LocalBoxFuture<SubagentDescribeOutcome>;
fn run(&self, run: ChildRunRequest<Self::Control>) -> Self::RunFuture {
let gate = self.gate.clone();
Box::pin(async move {
let ChildRunRequest {
request,
cancellation,
reporter,
} = run;
let promoted = reporter
.started(StartedChild {
child_session_id: request.id.clone(),
persona: None,
resumed_from: request.resume_from.clone(),
child_cwd: request.cwd.clone().unwrap_or_default(),
worktree_path: None,
effective_model_id: "soak-model".to_owned(),
definition_background: false,
control: SoakControl {
cancellation: cancellation.clone(),
},
})
.await;
if !promoted || cancellation.is_cancelled() {
return ChildRunOutput {
result: SubagentResult {
success: false,
cancelled: true,
error: Some("cancelled before start".to_owned()),
subagent_id: request.id.clone(),
child_session_id: request.id,
..Default::default()
},
completion_data: (),
snapshot_ref: None,
};
}
if request.id.starts_with("conc-") {
// Hold in `active` until the concurrent phase releases the gate.
let _ = gate.acquire().await;
}
ChildRunOutput {
result: SubagentResult {
success: true,
output: Arc::from("soak child output"),
subagent_id: request.id.clone(),
child_session_id: request.id,
tool_calls: 1,
turns: 1,
..Default::default()
},
completion_data: (),
snapshot_ref: None,
}
})
}
fn validate_type(&self, _subagent_type: String, _parent: String) -> Self::ValidateFuture {
Box::pin(std::future::ready(SubagentValidateTypeOutcome::Ok))
}
fn describe_type(
&self,
_subagent_type: String,
_harness_agent_type: Option<String>,
_parent: String,
) -> Self::DescribeFuture {
Box::pin(std::future::ready(SubagentDescribeOutcome::Unavailable))
}
fn on_completed(&self, _completion: ChildCompletion<Self::CompletionData>) {}
}
fn soak_request(id: String, background: bool) -> SubagentRequest {
SubagentRequest {
id,
prompt: "soak work".to_owned(),
description: "soak child".to_owned(),
subagent_type: "explore".to_owned(),
parent_session_id: PARENT_SESSION_ID.to_owned(),
parent_prompt_id: Some("soak-prompt".to_owned()),
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: background,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: SubagentOwner::Task,
cancel_token: CancellationToken::new(),
}
}
async fn run_cycle(backend: &ChannelBackend, i: u64) {
let fg = backend
.spawn(soak_request(format!("fg-{i}"), false))
.await
.expect("foreground spawn round-trips through the coordinator");
assert!(fg.success, "cycle {i}: foreground child must complete");
let bg_id = format!("bg-{i}");
let bg = backend
.spawn(soak_request(bg_id.clone(), true))
.await
.expect("background spawn round-trips through the coordinator");
assert!(bg.success, "cycle {i}: background child must complete");
let blocking = true;
let timeout_ms = Some(5_000);
let snapshot = backend.query(&bg_id, blocking, timeout_ms).await;
assert!(
snapshot.is_some(),
"cycle {i}: completed subagent must be queryable"
);
}
async fn await_concurrency(backend: &ChannelBackend, n: u64) -> bool {
const MAX_POLLS: usize = 400;
const SLEEP: Duration = Duration::from_millis(5);
for _ in 0..MAX_POLLS {
if backend.registry_counts().await.active as u64 >= n {
return true;
}
tokio::time::sleep(SLEEP).await;
}
false
}
async fn concurrent_phase(backend: &ChannelBackend, gate: &tokio::sync::Semaphore, n: u64) {
let handles: Vec<_> = (0..n)
.map(|k| {
let backend = backend.clone();
tokio::task::spawn_local(async move {
backend.spawn(soak_request(format!("conc-{k}"), true)).await
})
})
.collect();
let reached = await_concurrency(backend, n).await;
// Release then join before asserting, so no child is left blocked on failure.
gate.add_permits(n as usize);
for h in handles {
let result = h.await.expect("concurrent spawn task");
assert!(
result.expect("concurrent spawn round-trips").success,
"concurrent child must complete"
);
}
assert!(reached, "expected {n} concurrently active children");
}
async fn warmup(backend: &ChannelBackend, cycles: u64) -> bool {
for i in 0..cycles {
run_cycle(backend, i).await;
}
quiesce(backend).await
}
async fn measure(
backend: &ChannelBackend,
bounds: &Bounds,
baseline_quiesced: bool,
) -> Measurement {
let heap_before = heap_capture();
let before = ResourceSnapshot::capture();
// Continue ids past the warmup window so measured cycles use fresh entries
// and keep exercising eviction instead of colliding with warmup ids.
for i in bounds.warmup..(bounds.warmup + bounds.measure) {
run_cycle(backend, i).await;
}
// A baseline that never drained already poisons `before`, so skip the
// measured-window drain and report the window as not quiesced.
let quiesced = baseline_quiesced && quiesce(backend).await;
let heap_after = heap_capture();
let after = ResourceSnapshot::capture();
let counts = backend.registry_counts().await;
Measurement {
before,
after,
growth: after.growth_from(&before),
counts,
heap: heap_before
.zip(heap_after)
.map(|(before, after)| HeapMetrics::new(before, after, bounds.measure)),
quiesced,
}
}
/// Takes `expected` as a parameter so the skip arm is testable on any platform.
fn metric_failure(
metric: Metric,
value: Option<usize>,
expected: bool,
bounds: &Bounds,
) -> Option<String> {
let Some(raw) = value else {
return expected.then(|| {
format!(
"{}: growth sample unavailable; the soak cannot bound it",
metric.label()
)
});
};
let growth = metric.growth_in_budget_unit(raw);
let budget = metric.budget(bounds);
(growth > budget).then(|| {
let unit = metric.unit().map(|u| format!(" {u}")).unwrap_or_default();
format!(
"{}: grew {growth:.1}{unit} over the soak (bound {budget:.1}{unit})",
metric.label()
)
})
}
fn check_bounds(bounds: &Bounds, m: &Measurement) -> Vec<String> {
// Drain first: a non-quiesced window has nonzero counts and noisy growth, so
// report the quiesce failure alone; the gates below only mean anything once
// drained.
if !m.quiesced {
return vec![
"quiesce budget expired before the measured window drained; soak result is unreliable"
.to_owned(),
];
}
let mut failures = Vec::new();
if m.counts.pending != 0 {
failures.push(format!(
"no subagent may remain pending, saw {}",
m.counts.pending
));
}
if m.counts.active != 0 {
failures.push(format!(
"no subagent may remain active, saw {}",
m.counts.active
));
}
if m.counts.completed > MAX_COMPLETED_ENTRIES {
failures.push(format!(
"completed retention must stay bounded by its cap, saw {}",
m.counts.completed
));
}
for metric in Metric::iter() {
let expected = metric.expected_on_this_platform();
if let Some(f) = metric_failure(metric, m.growth.value_of(metric), expected, bounds) {
failures.push(f);
}
}
if let Some(h) = m.heap {
let measure = bounds.measure;
if h.blocks_per_cycle > bounds.max_blocks_per_cycle {
failures.push(format!(
"block-count leak: {:.3} blocks/cycle retained ({} over {measure} cycles) \
exceeds the {} gate",
h.blocks_per_cycle,
h.after.blocks - h.before.blocks,
bounds.max_blocks_per_cycle
));
}
if h.bytes_per_cycle > bounds.max_bytes_per_cycle {
failures.push(format!(
"byte leak: {:.1} bytes/cycle retained ({} over {measure} cycles) \
exceeds the {} gate",
h.bytes_per_cycle,
h.after.bytes - h.before.bytes,
bounds.max_bytes_per_cycle
));
}
}
failures
}
fn assert_bounds(bounds: &Bounds, m: &Measurement) {
let failures = check_bounds(bounds, m);
assert!(
failures.is_empty(),
"subagent soak bounds violated:\n - {}",
failures.join("\n - ")
);
}
/// Keep this the only test in the binary that creates a `dhat::Profiler`.
#[tokio::test(flavor = "current_thread")]
#[ignore = "subagent soak; run with --ignored (SUBAGENT_SOAK_CYCLES bounds the measured window)"]
async fn subagent_lifecycle_soak_bounds_threads_fds_and_heap() {
#[cfg(feature = "dhat-heap")]
let _profiler = dhat::Profiler::builder().testing().build();
let bounds = Bounds::from_env();
let local = tokio::task::LocalSet::new();
local
.run_until(async move {
let (command_tx, command_rx) = tokio::sync::mpsc::unbounded_channel();
let config = CoordinatorConfig {
foreground_budget: Duration::from_secs(600),
..CoordinatorConfig::default()
};
let gate = Arc::new(tokio::sync::Semaphore::new(0));
tokio::task::spawn_local(
SubagentCoordinator::new(command_rx, SoakRunner { gate: gate.clone() }, config)
.run(),
);
let backend = ChannelBackend::new(command_tx);
let warmup_quiesced = warmup(&backend, bounds.warmup).await;
// Drain the concurrent phase into the baseline; a failed drain marks
// the window unreliable.
concurrent_phase(&backend, &gate, bounds.concurrency).await;
let baseline_quiesced = warmup_quiesced && quiesce(&backend).await;
let measurement = measure(&backend, &bounds, baseline_quiesced).await;
let summary = Summary {
bounds: &bounds,
measurement: &measurement,
};
eprintln!(
"SUBAGENT_SOAK_SUMMARY {}",
serde_json::to_string(&summary).expect("summary serializes")
);
assert_bounds(&bounds, &measurement);
})
.await;
}
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn value_of_reads_the_matching_slot_of_snapshot_and_growth() {
let snapshot = ResourceSnapshot {
rss: Some(11),
threads: Some(22),
fds: Some(33),
};
assert_eq!(snapshot.value_of(Metric::Rss), Some(11));
assert_eq!(snapshot.value_of(Metric::Threads), Some(22));
assert_eq!(snapshot.value_of(Metric::Fds), Some(33));
let growth = ResourceGrowth {
rss: Some(1),
threads: None,
fds: Some(3),
};
assert_eq!(growth.value_of(Metric::Rss), Some(1));
assert_eq!(growth.value_of(Metric::Threads), None);
assert_eq!(growth.value_of(Metric::Fds), Some(3));
}
#[test]
fn serialize_metrics_keys_match_summary_keys_in_order() {
#[derive(Serialize)]
struct Wrap(#[serde(serialize_with = "serialize_metrics")] ResourceSnapshot);
let snapshot = ResourceSnapshot {
rss: Some(1),
threads: None,
fds: Some(3),
};
let json = serde_json::to_string(&Wrap(snapshot)).expect("snapshot serializes");
assert_eq!(json, r#"{"rss_bytes":1,"threads":null,"fds":3}"#);
}
#[test]
fn bytes_to_mib_divides_by_1024_squared() {
assert_eq!(bytes_to_mib(0), 0.0);
assert_eq!(bytes_to_mib(1024 * 1024), 1.0);
assert_eq!(bytes_to_mib(3 * 1024 * 1024), 3.0);
}
#[test]
fn growth_in_budget_unit_scales_only_rss() {
assert_eq!(Metric::Rss.growth_in_budget_unit(2 * 1024 * 1024), 2.0);
assert_eq!(Metric::Threads.growth_in_budget_unit(7), 7.0);
assert_eq!(Metric::Fds.growth_in_budget_unit(7), 7.0);
}
#[test]
fn budget_reads_per_metric_bound() {
let bounds = Bounds {
warmup: 0,
measure: 0,
concurrency: 0,
max_thread_growth: 3,
max_fd_growth: 5,
max_rss_growth_mib: 7,
max_blocks_per_cycle: 1.0,
max_bytes_per_cycle: 2.0,
};
assert_eq!(Metric::Rss.budget(&bounds), 7.0);
assert_eq!(Metric::Threads.budget(&bounds), 3.0);
assert_eq!(Metric::Fds.budget(&bounds), 5.0);
}
#[test]
fn heap_metrics_clamps_zero_cycles() {
let before = HeapSample {
blocks: 10,
bytes: 100,
};
let after = HeapSample {
blocks: 20,
bytes: 400,
};
let heap = HeapMetrics::new(before, after, 0);
assert!(heap.blocks_per_cycle.is_finite());
assert!(heap.bytes_per_cycle.is_finite());
assert_eq!(heap.blocks_per_cycle, 10.0);
assert_eq!(heap.bytes_per_cycle, 300.0);
}
fn generous_bounds() -> Bounds {
Bounds {
warmup: 0,
measure: 4,
concurrency: 4,
max_thread_growth: 100,
max_fd_growth: 100,
max_rss_growth_mib: 100,
max_blocks_per_cycle: 10.0,
max_bytes_per_cycle: 10_000.0,
}
}
/// Zero growth that reads as measured, unlike `ResourceGrowth::default()`.
fn zero_growth() -> ResourceGrowth {
ResourceGrowth {
rss: Some(0),
threads: Some(0),
fds: Some(0),
}
}
fn drained(growth: ResourceGrowth, heap: Option<HeapMetrics>) -> Measurement {
Measurement {
before: ResourceSnapshot::default(),
after: ResourceSnapshot::default(),
growth,
counts: SubagentRegistryCounts {
pending: 0,
active: 0,
completed: 0,
},
heap,
quiesced: true,
}
}
#[test]
fn check_bounds_passes_a_clean_drained_window() {
let m = drained(zero_growth(), None);
assert!(check_bounds(&generous_bounds(), &m).is_empty());
}
#[test]
fn check_bounds_fails_when_an_expected_metric_is_unavailable() {
let growth = ResourceGrowth {
rss: None,
threads: Some(0),
fds: Some(0),
};
let failures = check_bounds(&generous_bounds(), &drained(growth, None));
assert!(
failures
.iter()
.any(|f| f.starts_with("rss:") && f.contains("unavailable")),
"{failures:?}"
);
}
#[test]
fn metric_failure_covers_expected_missing_unexpected_missing_and_budget() {
let b = generous_bounds();
assert!(metric_failure(Metric::Threads, None, false, &b).is_none());
assert!(
metric_failure(Metric::Rss, None, true, &b)
.unwrap()
.contains("unavailable")
);
assert!(metric_failure(Metric::Fds, Some(0), true, &b).is_none());
assert!(
metric_failure(Metric::Rss, Some(500 * 1024 * 1024), true, &b)
.unwrap()
.starts_with("rss:")
);
}
#[test]
fn check_bounds_reports_non_quiesce_first_and_alone() {
let mut m = drained(zero_growth(), None);
m.quiesced = false;
m.counts.pending = 3;
let failures = check_bounds(&generous_bounds(), &m);
assert_eq!(failures.len(), 1);
assert!(failures[0].contains("quiesce"));
}
#[test]
fn check_bounds_flags_over_budget_growth() {
let growth = ResourceGrowth {
rss: Some(200 * 1024 * 1024),
threads: Some(0),
fds: Some(0),
};
let failures = check_bounds(&generous_bounds(), &drained(growth, None));
assert!(
failures.iter().any(|f| f.starts_with("rss:")),
"{failures:?}"
);
}
#[test]
fn check_bounds_treats_the_budget_as_an_inclusive_max() {
let growth = ResourceGrowth {
rss: Some(100 * 1024 * 1024),
threads: Some(100),
fds: Some(100),
};
assert!(check_bounds(&generous_bounds(), &drained(growth, None)).is_empty());
}
#[test]
fn check_bounds_flags_nonzero_counts_and_heap_leak() {
let mut m = drained(
zero_growth(),
Some(HeapMetrics {
before: HeapSample {
blocks: 0,
bytes: 0,
},
after: HeapSample {
blocks: 0,
bytes: 0,
},
blocks_per_cycle: 0.0,
bytes_per_cycle: 1_000_000.0,
}),
);
m.counts.active = 2;
let failures = check_bounds(&generous_bounds(), &m);
assert!(
failures.iter().any(|f| f.contains("active")),
"{failures:?}"
);
assert!(
failures.iter().any(|f| f.contains("byte leak")),
"{failures:?}"
);
}
#[test]
fn check_bounds_flags_pending_while_quiesced() {
let mut m = drained(zero_growth(), None);
m.counts.pending = 3;
let failures = check_bounds(&generous_bounds(), &m);
assert!(
failures.iter().any(|f| f.contains("pending")),
"{failures:?}"
);
}
#[test]
fn check_bounds_flags_completed_over_cap() {
let mut m = drained(zero_growth(), None);
m.counts.completed = MAX_COMPLETED_ENTRIES + 1;
let failures = check_bounds(&generous_bounds(), &m);
assert!(
failures.iter().any(|f| f.contains("completed retention")),
"{failures:?}"
);
}
#[test]
fn check_bounds_flags_thread_and_fd_over_budget() {
let growth = ResourceGrowth {
rss: Some(0),
threads: Some(200),
fds: Some(200),
};
let failures = check_bounds(&generous_bounds(), &drained(growth, None));
assert!(
failures.iter().any(|f| f.starts_with("threads:")),
"{failures:?}"
);
assert!(
failures.iter().any(|f| f.starts_with("fds:")),
"{failures:?}"
);
}
#[test]
fn check_bounds_flags_block_count_leak() {
let m = drained(
zero_growth(),
Some(HeapMetrics {
before: HeapSample {
blocks: 0,
bytes: 0,
},
after: HeapSample {
blocks: 0,
bytes: 0,
},
blocks_per_cycle: 50.0,
bytes_per_cycle: 0.0,
}),
);
let failures = check_bounds(&generous_bounds(), &m);
assert!(
failures.iter().any(|f| f.contains("block-count leak")),
"{failures:?}"
);
}
}