use super::*;
fn make_ma_runtime_concurrent(
max_concurrency: usize,
client: Arc<dyn agent_base::llm_trait::LlmProvider>,
) -> Arc<MultiAgentRuntime> {
let config = MultiAgentConfig {
control: ControlConfig {
max_concurrency: Some(max_concurrency),
..Default::default()
},
..MultiAgentConfig::enabled()
};
Arc::new(MultiAgentRuntime::new(
config,
client,
vec![],
tokio_util::sync::CancellationToken::new(),
None,
agent_base::Language::En,
None,
None,
))
}
struct PanickingLlm;
#[async_trait::async_trait]
impl agent_base::llm_trait::LlmProvider for PanickingLlm {
async fn stream(
&self,
_request: agent_base::llm_trait::ChatRequest,
) -> Result<agent_base::llm_trait::ChatStream, agent_base::llm_trait::LlmError> {
panic!("boom: provider stream explodes")
}
async fn chat(
&self,
_request: agent_base::llm_trait::ChatRequest,
) -> Result<agent_base::llm_trait::ChatResponse, agent_base::llm_trait::LlmError> {
unreachable!("chat is not called by the child loop")
}
fn capabilities(&self) -> agent_base::llm_trait::Capabilities {
agent_base::llm_trait::Capabilities::default()
}
fn info(&self) -> agent_base::llm_trait::ProviderInfo {
agent_base::llm_trait::ProviderInfo {
name: "panicking".to_string(),
model: "panicking".to_string(),
version: None,
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn child_cleanup_normal_exit_releases_all_resources() {
let ma = make_ma_runtime_concurrent(4, Arc::new(StreamingStub));
let limiter = Arc::clone(&ma.limiter);
let registry = Arc::clone(&ma.registry);
let mailbox = Arc::clone(&ma.mailbox);
let path = AgentPath::root().join("w");
ma.spawn_child("w", "prompt".to_string(), 0, false, vec![])
.await
.unwrap();
assert_eq!(limiter.current(), 1, "slot held while child is live");
assert_eq!(registry.lock().unwrap().count(), 1);
ma.send_task("root/w", "work".to_string(), false).unwrap();
let r = ma.wait_for_result(Some("root/w"), 3000).await;
assert_eq!(r.status, "ok");
assert_eq!(limiter.current(), 1);
assert!(ma.close_agent("root/w").unwrap().closed);
assert!(
!ma.close_agent("root/w").unwrap().closed,
"second close on a cancelled token reports not found"
);
poll_until("normal exit", || {
limiter.current() == 0 && registry.lock().unwrap().count() == 0
})
.await;
let r = ma.wait_for_result(Some("root/w"), 500).await;
assert_eq!(r.status, "closed");
assert_eq!(r.agent_path.as_deref(), Some("root/w"));
assert!(!mailbox.contains(&path));
assert_eq!(limiter.current(), 0);
assert_eq!(registry.lock().unwrap().count(), 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn child_cleanup_panic_releases_all_resources() {
let ma = make_ma_runtime_concurrent(4, Arc::new(PanickingLlm));
let limiter = Arc::clone(&ma.limiter);
let registry = Arc::clone(&ma.registry);
ma.spawn_child("w", "prompt".to_string(), 0, false, vec![])
.await
.unwrap();
assert_eq!(limiter.current(), 1);
ma.send_task("root/w", "work".to_string(), false).unwrap();
poll_until("panic unwind", || {
limiter.current() == 0 && registry.lock().unwrap().count() == 0
})
.await;
let r = ma.wait_for_result(Some("root/w"), 500).await;
assert_eq!(r.status, "closed");
}
#[tokio::test(flavor = "multi_thread")]
async fn child_cleanup_parent_drop_releases_all_resources() {
let ma = make_ma_runtime_concurrent(2, Arc::new(HangingLlm));
let limiter = Arc::clone(&ma.limiter);
let registry = Arc::clone(&ma.registry);
let mailbox = Arc::clone(&ma.mailbox);
let path = AgentPath::root().join("w");
ma.spawn_child("w", "prompt".to_string(), 0, false, vec![])
.await
.unwrap();
ma.send_task("root/w", "work".to_string(), false).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(limiter.current(), 1);
drop(ma);
poll_until("parent-drop abort", || limiter.current() == 0).await;
assert_eq!(registry.lock().unwrap().count(), 0);
assert!(!mailbox.contains(&path));
}
#[tokio::test(flavor = "multi_thread")]
async fn concurrency_gate_failure_rolls_back_registry() {
let ma = make_ma_runtime_concurrent(1, Arc::new(StreamingStub));
ma.spawn_child("a", "p".to_string(), 0, false, vec![])
.await
.unwrap();
let err = ma
.spawn_child("b", "p".to_string(), 0, false, vec![])
.await
.unwrap_err();
assert!(
err.contains("max concurrency reached"),
"unexpected error: {err}"
);
assert_eq!(ma.limiter.current(), 1, "failed spawn holds no extra slot");
assert_eq!(ma.registry.lock().unwrap().count(), 1);
assert!(!ma.mailbox.contains(&AgentPath::root().join("b")));
}