1use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use chrono::Utc;
12use tokio::sync::{broadcast, mpsc, RwLock};
13use tokio_util::sync::CancellationToken;
14
15use bamboo_agent_core::storage::Storage;
16use bamboo_agent_core::tools::ToolExecutor;
17use bamboo_agent_core::{AgentEvent, Session};
18use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort};
19use bamboo_llm::ProviderModelRouter;
20
21use crate::runtime::Agent;
22
23use super::agent_spawn::SessionExecutionReservation;
24use super::child_completion::{ChildCompletion, ChildCompletionHandler};
25use super::runner_state::AgentRunner;
26
27#[derive(Debug, Clone)]
28pub struct SpawnJob {
29 pub parent_session_id: String,
30 pub child_session_id: String,
31 pub model: String,
32 pub disabled_tools: Option<Vec<String>>,
35}
36
37pub trait ChildRunLaunchHook: Send + Sync {
45 fn before_child_launch(&self, job: &SpawnJob, child_events: broadcast::Sender<AgentEvent>);
46}
47
48#[derive(Clone)]
54pub struct SessionInboxRuntimeBinding {
55 pub router: Arc<crate::SessionActivationRouter>,
56 pub inbox: Arc<dyn SessionInboxPort>,
57 pub storage: Arc<dyn Storage>,
58 pub persistence: Arc<dyn RuntimeSessionPersistence>,
59}
60
61#[async_trait::async_trait]
66pub trait ExternalChildRunner: Send + Sync {
67 async fn should_handle(&self, session: &Session) -> bool;
69
70 async fn execute_external_child(
72 &self,
73 session: &mut Session,
74 job: &SpawnJob,
75 event_tx: tokio::sync::mpsc::Sender<AgentEvent>,
76 cancel_token: CancellationToken,
77 ) -> crate::runtime::runner::Result<()>;
78
79 fn set_escalation_bridge(&self, _bridge: Option<bamboo_subagent::executor::HostBridge>) {}
86
87 fn set_session_inbox_runtime(&self, _binding: Option<SessionInboxRuntimeBinding>) {}
91}
92
93#[derive(Clone)]
94pub struct SpawnContext {
95 pub agent: Arc<Agent>,
96 pub tools: Arc<dyn ToolExecutor>,
97 pub sessions_cache: crate::SessionCache,
98 pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
99 pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
100 pub external_child_runner: Arc<dyn ExternalChildRunner>,
101 pub provider_router: Option<Arc<ProviderModelRouter>>,
102 pub app_data_dir: Option<std::path::PathBuf>,
103 pub completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
108 pub child_run_launch_hook: Option<Arc<dyn ChildRunLaunchHook>>,
111 pub account_feed_inbox: Option<super::event_forwarder::AccountFeedInbox>,
115}
116
117#[derive(Clone)]
118pub struct SpawnScheduler {
119 tx: mpsc::Sender<SpawnJob>,
120 ctx: SpawnContext,
121}
122
123impl SpawnScheduler {
124 pub fn new(ctx: SpawnContext) -> Self {
125 let (tx, mut rx) = mpsc::channel::<SpawnJob>(128);
126 let worker_ctx = ctx.clone();
127
128 tokio::spawn(async move {
136 while let Some(job) = rx.recv().await {
137 let job_ctx = worker_ctx.clone();
138 let job_for_panic = job.clone();
139 let handle = tokio::spawn(async move {
140 if let Err(err) = run_spawn_job(job_ctx, job).await {
141 tracing::warn!("spawn job failed: {}", err);
142 }
143 });
144 if let Err(join_error) = handle.await {
145 tracing::error!(
146 parent_session_id = %job_for_panic.parent_session_id,
147 child_session_id = %job_for_panic.child_session_id,
148 error = %join_error,
149 "spawn job panicked; publishing terminal error completion"
150 );
151 let parent_tx = super::session_events::get_or_create_event_sender(
152 &worker_ctx.session_event_senders,
153 &job_for_panic.parent_session_id,
154 )
155 .await;
156 publish_child_completion_parts(
157 &parent_tx,
158 worker_ctx.completion_handler.clone(),
159 job_for_panic.parent_session_id.clone(),
160 job_for_panic.child_session_id.clone(),
161 "error".to_string(),
162 Some(format!("child spawn panicked: {join_error}")),
163 )
164 .await;
165 }
166 }
167 });
168
169 Self { tx, ctx }
170 }
171
172 async fn prepare_child_launch(ctx: &SpawnContext, job: &SpawnJob) {
173 let child_tx = super::session_events::get_or_create_event_sender(
174 &ctx.session_event_senders,
175 &job.child_session_id,
176 )
177 .await;
178 invoke_child_run_launch_hook(ctx.child_run_launch_hook.as_ref(), job, child_tx);
179 }
180
181 pub async fn enqueue(&self, job: SpawnJob) -> Result<(), String> {
182 let ctx = self.ctx.clone();
183 let preparation_job = job.clone();
184 reserve_prepare_and_send(&self.tx, job, async move {
185 Self::prepare_child_launch(&ctx, &preparation_job).await;
186 })
187 .await
188 }
189
190 pub(crate) fn launch_reserved(
194 &self,
195 job: SpawnJob,
196 reservation: SessionExecutionReservation,
197 ) -> tokio::task::JoinHandle<()> {
198 let ctx = self.ctx.clone();
199 tokio::spawn(async move {
200 Self::prepare_child_launch(&ctx, &job).await;
201 let parent_tx = super::session_events::get_or_create_event_sender(
202 &ctx.session_event_senders,
203 &job.parent_session_id,
204 )
205 .await;
206 let _ = parent_tx.send(AgentEvent::SubAgentStarted {
207 parent_session_id: job.parent_session_id.clone(),
208 child_session_id: job.child_session_id.clone(),
209 title: None,
210 });
211 if let Err(error) =
212 crate::sdk::spawn::run_child_spawn_reserved(ctx, job.clone(), reservation).await
213 {
214 tracing::warn!(
215 parent_session_id = %job.parent_session_id,
216 child_session_id = %job.child_session_id,
217 %error,
218 "reserved child activation failed"
219 );
220 }
221 })
222 }
223}
224
225fn invoke_child_run_launch_hook(
226 hook: Option<&Arc<dyn ChildRunLaunchHook>>,
227 job: &SpawnJob,
228 child_tx: broadcast::Sender<AgentEvent>,
229) {
230 let Some(hook) = hook else {
231 return;
232 };
233 let invoked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
234 hook.before_child_launch(job, child_tx);
235 }));
236 if invoked.is_err() {
237 tracing::error!(
238 parent_session_id = %job.parent_session_id,
239 child_session_id = %job.child_session_id,
240 "child launch hook panicked; continuing with canonical execution"
241 );
242 }
243}
244
245async fn reserve_prepare_and_send(
248 tx: &mpsc::Sender<SpawnJob>,
249 job: SpawnJob,
250 preparation: impl std::future::Future<Output = ()>,
251) -> Result<(), String> {
252 let permit = tx
253 .reserve()
254 .await
255 .map_err(|_| "spawn scheduler is not running".to_string())?;
256 preparation.await;
257 permit.send(job);
258 Ok(())
259}
260
261#[derive(Debug, Clone, Copy)]
262pub(crate) struct ChildWatchdogPolicy {
263 check_interval_secs: i64,
264 pub(crate) max_total_secs: i64,
268 pub(crate) max_idle_secs: i64,
269}
270
271impl Default for ChildWatchdogPolicy {
272 fn default() -> Self {
273 Self {
274 check_interval_secs: 15,
275 max_total_secs: 60 * 60,
279 max_idle_secs: 15 * 60,
281 }
282 }
283}
284
285fn metadata_i64(session: &Session, key: &str) -> Option<i64> {
286 session
287 .metadata
288 .get(key)
289 .and_then(|value| value.trim().parse::<i64>().ok())
290 .filter(|value| *value > 0)
291}
292
293pub(crate) fn watchdog_policy_for_session(session: &Session) -> ChildWatchdogPolicy {
294 let mut policy = ChildWatchdogPolicy::default();
295 if let Some(value) = metadata_i64(session, "child_watchdog.max_total_secs") {
296 policy.max_total_secs = value;
297 }
298 if let Some(value) = metadata_i64(session, "child_watchdog.max_idle_secs") {
299 policy.max_idle_secs = value;
300 }
301 if let Some(value) = metadata_i64(session, "child_watchdog.check_interval_secs") {
302 policy.check_interval_secs = value;
303 }
304 policy
305}
306
307async fn publish_child_completion(
308 parent_tx: &broadcast::Sender<AgentEvent>,
309 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
310 completion: ChildCompletion,
311) {
312 let _ = parent_tx.send(AgentEvent::SubAgentCompleted {
313 parent_session_id: completion.parent_session_id.clone(),
314 child_session_id: completion.child_session_id.clone(),
315 status: completion.status.clone(),
316 error: completion.error.clone(),
317 });
318
319 if let Some(handler) = completion_handler {
320 use futures::FutureExt;
327 let parent_session_id = completion.parent_session_id.clone();
328 let child_session_id = completion.child_session_id.clone();
329 if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
330 .catch_unwind()
331 .await
332 .is_err()
333 {
334 tracing::error!(
335 %parent_session_id,
336 %child_session_id,
337 "child completion handler panicked; child-wait watchdog will backstop the parent wake"
338 );
339 }
340 }
341}
342
343pub(crate) async fn publish_child_completion_parts(
344 parent_tx: &broadcast::Sender<AgentEvent>,
345 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
346 parent_session_id: String,
347 child_session_id: String,
348 status: String,
349 error: Option<String>,
350) {
351 publish_child_completion(
352 parent_tx,
353 completion_handler,
354 ChildCompletion {
355 parent_session_id,
356 child_session_id,
357 status,
358 error,
359 completed_at: Utc::now(),
360 },
361 )
362 .await;
363}
364
365pub(crate) async fn watch_child_liveness(
366 parent_session_id: String,
367 child_session_id: String,
368 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
369 cancel_token: CancellationToken,
370 timeout_reason: Arc<RwLock<Option<String>>>,
371 done: CancellationToken,
372 policy: ChildWatchdogPolicy,
373) {
374 let mut ticker =
375 tokio::time::interval(Duration::from_secs(policy.check_interval_secs.max(1) as u64));
376 ticker.tick().await;
378
379 loop {
380 tokio::select! {
381 _ = done.cancelled() => return,
382 _ = ticker.tick() => {
383 if cancel_token.is_cancelled() {
384 return;
385 }
386
387 let snapshot = {
388 let guard = runners.read().await;
389 guard.get(&child_session_id).cloned()
390 };
391 let Some(runner) = snapshot else {
392 return;
393 };
394 if !matches!(runner.status, super::runner_state::AgentStatus::Running) {
395 return;
396 }
397
398 let now = Utc::now();
399 let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
400 if total_secs >= policy.max_total_secs {
401 let reason = format!(
402 "Child session timed out after {} seconds (max_total_secs={})",
403 total_secs, policy.max_total_secs
404 );
405 tracing::warn!(
406 parent_session_id = %parent_session_id,
407 child_session_id = %child_session_id,
408 reason = %reason,
409 "child session total timeout; cancelling child runner"
410 );
411 *timeout_reason.write().await = Some(reason);
412 cancel_token.cancel();
413 return;
414 }
415
416 let last_activity_at = runner.last_event_at.unwrap_or(runner.started_at);
417 let idle_secs = now.signed_duration_since(last_activity_at).num_seconds();
418 if idle_secs >= policy.max_idle_secs {
419 let reason = format!(
420 "Child session idle timeout after {} seconds without events (max_idle_secs={})",
421 idle_secs, policy.max_idle_secs
422 );
423 tracing::warn!(
424 parent_session_id = %parent_session_id,
425 child_session_id = %child_session_id,
426 reason = %reason,
427 last_tool_name = ?runner.last_tool_name,
428 last_tool_phase = ?runner.last_tool_phase,
429 round_count = runner.round_count,
430 "child session idle timeout; cancelling child runner"
431 );
432 *timeout_reason.write().await = Some(reason);
433 cancel_token.cancel();
434 return;
435 }
436 }
437 }
438 }
439}
440
441async fn run_spawn_job(ctx: SpawnContext, job: SpawnJob) -> Result<(), String> {
448 crate::sdk::spawn::run_child_spawn(ctx, job).await
449}
450
451#[cfg(test)]
452mod launch_hook_tests {
453 use super::*;
454 use std::sync::atomic::{AtomicUsize, Ordering};
455
456 fn job() -> SpawnJob {
457 SpawnJob {
458 parent_session_id: "parent".to_string(),
459 child_session_id: "child".to_string(),
460 model: "test".to_string(),
461 disabled_tools: None,
462 }
463 }
464
465 struct PanickingHook {
466 calls: AtomicUsize,
467 }
468
469 impl ChildRunLaunchHook for PanickingHook {
470 fn before_child_launch(
471 &self,
472 _job: &SpawnJob,
473 _child_events: broadcast::Sender<AgentEvent>,
474 ) {
475 self.calls.fetch_add(1, Ordering::SeqCst);
476 panic!("injected launch hook panic");
477 }
478 }
479
480 #[test]
481 fn launch_hook_panic_is_contained() {
482 let hook = Arc::new(PanickingHook {
483 calls: AtomicUsize::new(0),
484 });
485 let hook_port: Arc<dyn ChildRunLaunchHook> = hook.clone();
486 let (child_tx, _child_rx) = broadcast::channel(1);
487
488 invoke_child_run_launch_hook(Some(&hook_port), &job(), child_tx);
489
490 assert_eq!(hook.calls.load(Ordering::SeqCst), 1);
491 }
492
493 #[tokio::test]
494 async fn closed_scheduler_does_not_prepare_phantom_launch() {
495 let (tx, rx) = mpsc::channel(1);
496 drop(rx);
497 let preparations = Arc::new(AtomicUsize::new(0));
498 let preparations_for_future = preparations.clone();
499
500 let result = reserve_prepare_and_send(&tx, job(), async move {
501 preparations_for_future.fetch_add(1, Ordering::SeqCst);
502 })
503 .await;
504
505 assert_eq!(result.unwrap_err(), "spawn scheduler is not running");
506 assert_eq!(preparations.load(Ordering::SeqCst), 0);
507 }
508}