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
117impl SpawnContext {
118 pub(crate) fn replayable_event_publisher(
119 &self,
120 ) -> super::session_events::ReplayableSessionEventPublisher {
121 super::session_events::ReplayableSessionEventPublisher::new(
122 self.agent_runners.clone(),
123 self.session_event_senders.clone(),
124 self.account_feed_inbox.clone(),
125 )
126 }
127}
128
129#[derive(Clone)]
130pub struct SpawnScheduler {
131 tx: mpsc::Sender<SpawnJob>,
132 ctx: SpawnContext,
133}
134
135impl SpawnScheduler {
136 pub fn new(ctx: SpawnContext) -> Self {
137 let (tx, mut rx) = mpsc::channel::<SpawnJob>(128);
138 let worker_ctx = ctx.clone();
139
140 tokio::spawn(async move {
148 while let Some(job) = rx.recv().await {
149 let job_ctx = worker_ctx.clone();
150 let job_for_panic = job.clone();
151 let handle = tokio::spawn(async move {
152 if let Err(err) = run_spawn_job(job_ctx, job).await {
153 tracing::warn!("spawn job failed: {}", err);
154 }
155 });
156 if let Err(join_error) = handle.await {
157 tracing::error!(
158 parent_session_id = %job_for_panic.parent_session_id,
159 child_session_id = %job_for_panic.child_session_id,
160 error = %join_error,
161 "spawn job panicked; publishing terminal error completion"
162 );
163 let publisher = worker_ctx.replayable_event_publisher();
164 publish_child_completion_parts(
165 &publisher,
166 worker_ctx.completion_handler.clone(),
167 job_for_panic.parent_session_id.clone(),
168 job_for_panic.child_session_id.clone(),
169 "error".to_string(),
170 Some(format!("child spawn panicked: {join_error}")),
171 )
172 .await;
173 }
174 }
175 });
176
177 Self { tx, ctx }
178 }
179
180 async fn prepare_child_launch(ctx: &SpawnContext, job: &SpawnJob) {
181 let child_tx = super::session_events::get_or_create_event_sender(
182 &ctx.session_event_senders,
183 &job.child_session_id,
184 )
185 .await;
186 invoke_child_run_launch_hook(ctx.child_run_launch_hook.as_ref(), job, child_tx);
187 }
188
189 pub async fn enqueue(&self, job: SpawnJob) -> Result<(), String> {
190 self.enqueue_announced(job, None).await
191 }
192
193 pub async fn enqueue_announced(
197 &self,
198 job: SpawnJob,
199 title: Option<String>,
200 ) -> Result<(), String> {
201 let ctx = self.ctx.clone();
202 let preparation_job = job.clone();
203 reserve_prepare_and_send(&self.tx, job, async move {
204 Self::prepare_child_launch(&ctx, &preparation_job).await;
205 ctx.replayable_event_publisher()
206 .publish(
207 &preparation_job.parent_session_id,
208 AgentEvent::SubAgentStarted {
209 parent_session_id: preparation_job.parent_session_id.clone(),
210 child_session_id: preparation_job.child_session_id.clone(),
211 title,
212 },
213 )
214 .await;
215 })
216 .await
217 }
218
219 pub async fn publish_parent_replayable_event(
222 &self,
223 parent_session_id: &str,
224 event: AgentEvent,
225 ) {
226 self.ctx
227 .replayable_event_publisher()
228 .publish(parent_session_id, event)
229 .await;
230 }
231
232 pub(crate) fn launch_reserved(
236 &self,
237 job: SpawnJob,
238 reservation: SessionExecutionReservation,
239 ) -> tokio::task::JoinHandle<()> {
240 let ctx = self.ctx.clone();
241 tokio::spawn(async move {
242 Self::prepare_child_launch(&ctx, &job).await;
243 ctx.replayable_event_publisher()
244 .publish(
245 &job.parent_session_id,
246 AgentEvent::SubAgentStarted {
247 parent_session_id: job.parent_session_id.clone(),
248 child_session_id: job.child_session_id.clone(),
249 title: None,
250 },
251 )
252 .await;
253 if let Err(error) =
254 crate::sdk::spawn::run_child_spawn_reserved(ctx, job.clone(), reservation).await
255 {
256 tracing::warn!(
257 parent_session_id = %job.parent_session_id,
258 child_session_id = %job.child_session_id,
259 %error,
260 "reserved child activation failed"
261 );
262 }
263 })
264 }
265}
266
267fn invoke_child_run_launch_hook(
268 hook: Option<&Arc<dyn ChildRunLaunchHook>>,
269 job: &SpawnJob,
270 child_tx: broadcast::Sender<AgentEvent>,
271) {
272 let Some(hook) = hook else {
273 return;
274 };
275 let invoked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
276 hook.before_child_launch(job, child_tx);
277 }));
278 if invoked.is_err() {
279 tracing::error!(
280 parent_session_id = %job.parent_session_id,
281 child_session_id = %job.child_session_id,
282 "child launch hook panicked; continuing with canonical execution"
283 );
284 }
285}
286
287async fn reserve_prepare_and_send(
290 tx: &mpsc::Sender<SpawnJob>,
291 job: SpawnJob,
292 preparation: impl std::future::Future<Output = ()>,
293) -> Result<(), String> {
294 let permit = tx
295 .reserve()
296 .await
297 .map_err(|_| "spawn scheduler is not running".to_string())?;
298 preparation.await;
299 permit.send(job);
300 Ok(())
301}
302
303#[derive(Debug, Clone, Copy)]
304pub(crate) struct ChildWatchdogPolicy {
305 check_interval_secs: i64,
306 pub(crate) max_total_secs: i64,
310 pub(crate) max_idle_secs: i64,
311}
312
313impl Default for ChildWatchdogPolicy {
314 fn default() -> Self {
315 Self {
316 check_interval_secs: 15,
317 max_total_secs: 60 * 60,
321 max_idle_secs: 15 * 60,
323 }
324 }
325}
326
327fn metadata_i64(session: &Session, key: &str) -> Option<i64> {
328 session
329 .metadata
330 .get(key)
331 .and_then(|value| value.trim().parse::<i64>().ok())
332 .filter(|value| *value > 0)
333}
334
335pub(crate) fn watchdog_policy_for_session(session: &Session) -> ChildWatchdogPolicy {
336 let mut policy = ChildWatchdogPolicy::default();
337 if let Some(value) = metadata_i64(session, "child_watchdog.max_total_secs") {
338 policy.max_total_secs = value;
339 }
340 if let Some(value) = metadata_i64(session, "child_watchdog.max_idle_secs") {
341 policy.max_idle_secs = value;
342 }
343 if let Some(value) = metadata_i64(session, "child_watchdog.check_interval_secs") {
344 policy.check_interval_secs = value;
345 }
346 policy
347}
348
349async fn publish_child_completion(
350 publisher: &super::session_events::ReplayableSessionEventPublisher,
351 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
352 completion: ChildCompletion,
353) {
354 publisher
355 .publish(
356 &completion.parent_session_id,
357 AgentEvent::SubAgentCompleted {
358 parent_session_id: completion.parent_session_id.clone(),
359 child_session_id: completion.child_session_id.clone(),
360 status: completion.status.clone(),
361 error: completion.error.clone(),
362 },
363 )
364 .await;
365
366 if let Some(handler) = completion_handler {
367 use futures::FutureExt;
374 let parent_session_id = completion.parent_session_id.clone();
375 let child_session_id = completion.child_session_id.clone();
376 if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
377 .catch_unwind()
378 .await
379 .is_err()
380 {
381 tracing::error!(
382 %parent_session_id,
383 %child_session_id,
384 "child completion handler panicked; child-wait watchdog will backstop the parent wake"
385 );
386 }
387 }
388}
389
390pub(crate) async fn publish_child_completion_parts(
391 publisher: &super::session_events::ReplayableSessionEventPublisher,
392 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
393 parent_session_id: String,
394 child_session_id: String,
395 status: String,
396 error: Option<String>,
397) {
398 publish_child_completion(
399 publisher,
400 completion_handler,
401 ChildCompletion {
402 parent_session_id,
403 child_session_id,
404 status,
405 error,
406 completed_at: Utc::now(),
407 },
408 )
409 .await;
410}
411
412pub(crate) async fn watch_child_liveness(
413 parent_session_id: String,
414 child_session_id: String,
415 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
416 cancel_token: CancellationToken,
417 timeout_reason: Arc<RwLock<Option<String>>>,
418 done: CancellationToken,
419 policy: ChildWatchdogPolicy,
420) {
421 let mut ticker =
422 tokio::time::interval(Duration::from_secs(policy.check_interval_secs.max(1) as u64));
423 ticker.tick().await;
425
426 loop {
427 tokio::select! {
428 _ = done.cancelled() => return,
429 _ = ticker.tick() => {
430 if cancel_token.is_cancelled() {
431 return;
432 }
433
434 let snapshot = {
435 let guard = runners.read().await;
436 guard.get(&child_session_id).cloned()
437 };
438 let Some(runner) = snapshot else {
439 return;
440 };
441 if !matches!(runner.status, super::runner_state::AgentStatus::Running) {
442 return;
443 }
444
445 let now = Utc::now();
446 let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
447 if total_secs >= policy.max_total_secs {
448 let reason = format!(
449 "Child session timed out after {} seconds (max_total_secs={})",
450 total_secs, policy.max_total_secs
451 );
452 tracing::warn!(
453 parent_session_id = %parent_session_id,
454 child_session_id = %child_session_id,
455 reason = %reason,
456 "child session total timeout; cancelling child runner"
457 );
458 *timeout_reason.write().await = Some(reason);
459 cancel_token.cancel();
460 return;
461 }
462
463 let last_activity_at = runner.last_event_at.unwrap_or(runner.started_at);
464 let idle_secs = now.signed_duration_since(last_activity_at).num_seconds();
465 if idle_secs >= policy.max_idle_secs {
466 let reason = format!(
467 "Child session idle timeout after {} seconds without events (max_idle_secs={})",
468 idle_secs, policy.max_idle_secs
469 );
470 tracing::warn!(
471 parent_session_id = %parent_session_id,
472 child_session_id = %child_session_id,
473 reason = %reason,
474 last_tool_name = ?runner.last_tool_name,
475 last_tool_phase = ?runner.last_tool_phase,
476 round_count = runner.round_count,
477 "child session idle timeout; cancelling child runner"
478 );
479 *timeout_reason.write().await = Some(reason);
480 cancel_token.cancel();
481 return;
482 }
483 }
484 }
485 }
486}
487
488async fn run_spawn_job(ctx: SpawnContext, job: SpawnJob) -> Result<(), String> {
495 crate::sdk::spawn::run_child_spawn(ctx, job).await
496}
497
498#[cfg(test)]
499mod launch_hook_tests {
500 use super::*;
501 use std::sync::atomic::{AtomicUsize, Ordering};
502
503 fn job() -> SpawnJob {
504 SpawnJob {
505 parent_session_id: "parent".to_string(),
506 child_session_id: "child".to_string(),
507 model: "test".to_string(),
508 disabled_tools: None,
509 }
510 }
511
512 struct PanickingHook {
513 calls: AtomicUsize,
514 }
515
516 impl ChildRunLaunchHook for PanickingHook {
517 fn before_child_launch(
518 &self,
519 _job: &SpawnJob,
520 _child_events: broadcast::Sender<AgentEvent>,
521 ) {
522 self.calls.fetch_add(1, Ordering::SeqCst);
523 panic!("injected launch hook panic");
524 }
525 }
526
527 #[test]
528 fn launch_hook_panic_is_contained() {
529 let hook = Arc::new(PanickingHook {
530 calls: AtomicUsize::new(0),
531 });
532 let hook_port: Arc<dyn ChildRunLaunchHook> = hook.clone();
533 let (child_tx, _child_rx) = broadcast::channel(1);
534
535 invoke_child_run_launch_hook(Some(&hook_port), &job(), child_tx);
536
537 assert_eq!(hook.calls.load(Ordering::SeqCst), 1);
538 }
539
540 #[tokio::test]
541 async fn closed_scheduler_does_not_prepare_phantom_launch() {
542 let (tx, rx) = mpsc::channel(1);
543 drop(rx);
544 let preparations = Arc::new(AtomicUsize::new(0));
545 let preparations_for_future = preparations.clone();
546
547 let result = reserve_prepare_and_send(&tx, job(), async move {
548 preparations_for_future.fetch_add(1, Ordering::SeqCst);
549 })
550 .await;
551
552 assert_eq!(result.unwrap_err(), "spawn scheduler is not running");
553 assert_eq!(preparations.load(Ordering::SeqCst), 0);
554 }
555}