1use std::collections::HashMap;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use std::time::Duration;
11
12use chrono::Utc;
13use dashmap::DashMap;
14use futures::future::BoxFuture;
15use tokio::sync::{broadcast, mpsc, oneshot, Mutex, OwnedMutexGuard, RwLock};
16use tokio_util::sync::CancellationToken;
17
18use bamboo_agent_core::storage::Storage;
19use bamboo_agent_core::tools::ToolExecutor;
20use bamboo_agent_core::{AgentEvent, Session};
21use bamboo_domain::{AdmissionCommit, AdmissionGate, RuntimeSessionPersistence, SessionInboxPort};
22use bamboo_llm::ProviderModelRouter;
23
24use crate::runtime::Agent;
25
26use super::agent_spawn::SessionExecutionReservation;
27use super::child_completion::{ChildCompletion, ChildCompletionHandler};
28use super::runner_state::AgentRunner;
29
30#[derive(Debug, Clone)]
31pub struct SpawnJob {
32 pub parent_session_id: String,
33 pub child_session_id: String,
34 pub model: String,
35 pub disabled_tools: Option<Vec<String>>,
38}
39
40struct QueuedSpawnJob {
41 job: SpawnJob,
42 title: Option<String>,
43 launch_generation: Option<u64>,
44 announcement_ready: oneshot::Receiver<()>,
45 start_published: Arc<AtomicBool>,
46}
47
48#[derive(Clone, Default)]
51struct ChildLaunchLocks {
52 locks: Arc<DashMap<String, Arc<Mutex<()>>>>,
53}
54
55pub struct ChildLaunchGuard {
56 guard: Option<OwnedMutexGuard<()>>,
57 locks: ChildLaunchLocks,
58 child_id: String,
59}
60
61impl Drop for ChildLaunchGuard {
62 fn drop(&mut self) {
63 self.guard.take();
64 self.locks
65 .locks
66 .remove_if(&self.child_id, |_, lock| Arc::strong_count(lock) == 1);
67 }
68}
69
70impl ChildLaunchLocks {
71 async fn acquire(&self, child_id: &str) -> ChildLaunchGuard {
72 let lock = self
73 .locks
74 .entry(child_id.to_string())
75 .or_insert_with(|| Arc::new(Mutex::new(())))
76 .clone();
77 let mut guard = ChildLaunchGuard {
78 guard: None,
79 locks: self.clone(),
80 child_id: child_id.to_string(),
81 };
82 guard.guard = Some(lock.lock_owned().await);
83 guard
84 }
85}
86
87pub trait ChildRunLaunchHook: Send + Sync {
95 fn before_child_launch(&self, job: &SpawnJob, child_events: broadcast::Sender<AgentEvent>);
96
97 fn before_queued_dequeue(&self, _job: &SpawnJob) -> BoxFuture<'static, ()> {
101 Box::pin(async {})
102 }
103
104 fn before_queued_reservation(&self, _job: &SpawnJob) -> BoxFuture<'static, ()> {
108 Box::pin(async {})
109 }
110}
111
112#[derive(Clone)]
118pub struct SessionInboxRuntimeBinding {
119 pub router: Arc<crate::SessionActivationRouter>,
120 pub inbox: Arc<dyn SessionInboxPort>,
121 pub storage: Arc<dyn Storage>,
122 pub persistence: Arc<dyn RuntimeSessionPersistence>,
123 pub parent_question_lock: Option<Arc<bamboo_storage::LockedSessionStore>>,
126}
127
128#[async_trait::async_trait]
133pub trait ExternalChildRunner: Send + Sync {
134 fn set_local_child_skill_factory(
137 &self,
138 _factory: Option<Arc<dyn crate::external_agents::local_task::LocalChildSkillFactory>>,
139 ) {
140 }
141
142 fn set_local_task_runtime(
145 &self,
146 _runtime: Option<crate::external_agents::local_task::LocalTaskRuntime>,
147 ) {
148 }
149
150 async fn prepare_child_execution(
153 &self,
154 _session: &Session,
155 _run_id: &str,
156 _cancel: CancellationToken,
157 ) -> Result<Option<Arc<dyn RuntimeSessionPersistence>>, String> {
158 Ok(None)
159 }
160
161 async fn abandon_child_execution(
164 &self,
165 _session: &Session,
166 _run_id: &str,
167 ) -> Result<(), String> {
168 Ok(())
169 }
170
171 fn quarantine_child_execution(&self, _session: &Session, _run_id: &str) {}
173
174 fn track_child_task(
177 &self,
178 _session_id: &str,
179 _run_id: &str,
180 handle: tokio::task::JoinHandle<()>,
181 ) -> Option<tokio::task::JoinHandle<()>> {
182 Some(handle)
183 }
184
185 async fn finish_child_execution(
188 &self,
189 _session: &Session,
190 _run_id: &str,
191 _history_committed: bool,
192 ) -> Result<(), String> {
193 Ok(())
194 }
195
196 async fn shutdown(&self) -> Result<(), String> {
199 Ok(())
200 }
201
202 fn set_ticket_service(&self, _service: Option<Arc<bamboo_tickets::TicketService>>) {}
204 async fn validate_required_child_context_route(
207 &self,
208 _session: &Session,
209 ) -> Result<(), String> {
210 Err("required_child_context_unsupported: no supported registered worker route".into())
211 }
212 async fn should_handle(&self, session: &Session) -> bool;
214
215 async fn execute_external_child(
217 &self,
218 session: &mut Session,
219 job: &SpawnJob,
220 event_tx: tokio::sync::mpsc::Sender<AgentEvent>,
221 cancel_token: CancellationToken,
222 ) -> crate::runtime::runner::Result<()>;
223
224 async fn prepare_durable_child_delivery(
228 &self,
229 _session: &Session,
230 _activation_run_id: &str,
231 ) -> Result<bool, String> {
232 Ok(false)
233 }
234
235 async fn commit_durable_child_delivery(
242 &self,
243 _session: &Session,
244 _activation_run_id: &str,
245 ) -> Result<(), String> {
246 Ok(())
247 }
248
249 async fn confirm_durable_child_delivery(
254 &self,
255 _session: &Session,
256 _activation_run_id: &str,
257 _history_committed: bool,
258 ) -> Result<(), String> {
259 Ok(())
260 }
261
262 fn set_escalation_bridge(&self, _bridge: Option<bamboo_subagent::executor::HostBridge>) {}
269
270 fn set_session_inbox_runtime(&self, _binding: Option<SessionInboxRuntimeBinding>) {}
274
275 fn set_actor_directory_store(&self, _store: Option<Arc<bamboo_storage::SessionStoreV2>>) {}
278
279 fn set_canonical_subagent_tool(&self, _tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {}
282
283 fn set_actor_event_observer(
285 &self,
286 _observer: Option<Arc<dyn crate::external_agents::actor_event_stream::ActorEventObserver>>,
287 ) {
288 }
289}
290
291#[derive(Clone)]
292pub struct SpawnContext {
293 pub agent: Arc<Agent>,
294 pub tools: Arc<dyn ToolExecutor>,
295 pub sessions_cache: crate::SessionCache,
296 pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
297 pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
298 pub external_child_runner: Arc<dyn ExternalChildRunner>,
299 pub provider_router: Option<Arc<ProviderModelRouter>>,
300 pub app_data_dir: Option<std::path::PathBuf>,
301 pub completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
306 pub child_run_launch_hook: Option<Arc<dyn ChildRunLaunchHook>>,
309 pub account_feed_inbox: Option<super::event_forwarder::AccountFeedInbox>,
313}
314
315impl SpawnContext {
316 pub(crate) fn replayable_event_publisher(
317 &self,
318 ) -> super::session_events::ReplayableSessionEventPublisher {
319 super::session_events::ReplayableSessionEventPublisher::new(
320 self.agent_runners.clone(),
321 self.session_event_senders.clone(),
322 self.account_feed_inbox.clone(),
323 )
324 }
325}
326
327#[derive(Clone)]
328pub struct SpawnScheduler {
329 tx: mpsc::Sender<QueuedSpawnJob>,
330 ctx: SpawnContext,
331 launch_locks: ChildLaunchLocks,
332}
333
334impl SpawnScheduler {
335 pub fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
336 self.ctx.external_child_runner.set_ticket_service(service);
337 }
338
339 pub async fn validate_required_child_context_route(
340 &self,
341 metadata: &HashMap<String, String>,
342 role: &str,
343 ) -> Result<(), String> {
344 let mut candidate = Session::new("required-context-preflight", "");
345 candidate.kind = bamboo_domain::SessionKind::Child;
349 candidate.metadata = metadata.clone();
350 candidate
351 .metadata
352 .insert("subagent_type".into(), role.into());
353 self.ctx
354 .external_child_runner
355 .validate_required_child_context_route(&candidate)
356 .await
357 }
358
359 pub fn new(ctx: SpawnContext) -> Self {
360 let (tx, mut rx) = mpsc::channel::<QueuedSpawnJob>(128);
361 let worker_ctx = ctx.clone();
362 let launch_locks = ChildLaunchLocks::default();
363 let worker_launch_locks = launch_locks.clone();
364
365 tokio::spawn(async move {
373 while let Some(QueuedSpawnJob {
374 job,
375 title,
376 launch_generation,
377 announcement_ready,
378 start_published,
379 }) = rx.recv().await
380 {
381 let job_ctx = worker_ctx.clone();
382 let job_for_panic = job.clone();
383 let job_launch_locks = worker_launch_locks.clone();
384 let handle = tokio::spawn(async move {
385 let fallback_start =
389 needs_fallback_start(announcement_ready, &start_published).await;
390 if let Some(hook) = job_ctx.child_run_launch_hook.as_ref() {
391 hook.before_queued_dequeue(&job).await;
392 }
393 let launch_guard = if let Some(generation) = launch_generation {
394 let guard = job_launch_locks.acquire(&job.child_session_id).await;
395 if queued_launch_state(&job_ctx, &job, generation).await?
396 != QueuedLaunchState::Eligible
397 {
398 return Ok(());
399 }
400 Some(guard)
401 } else {
402 None
403 };
404 if let Some(hook) = job_ctx.child_run_launch_hook.as_ref() {
405 hook.before_queued_reservation(&job).await;
406 }
407 if fallback_start {
408 Self::prepare_child_launch(&job_ctx, &job).await;
409 job_ctx
410 .replayable_event_publisher()
411 .publish(
412 &job.parent_session_id,
413 AgentEvent::SubAgentStarted {
414 parent_session_id: job.parent_session_id.clone(),
415 child_session_id: job.child_session_id.clone(),
416 title,
417 },
418 )
419 .await;
420 }
421 if let Err(err) = run_spawn_job(job_ctx, job, launch_guard).await {
422 tracing::warn!("spawn job failed: {}", err);
423 }
424 Ok(())
425 });
426 let failure = match handle.await {
427 Ok(Ok(())) => continue,
428 Ok(Err(error)) => error,
429 Err(error) => format!("child spawn panicked: {error}"),
430 };
431 {
432 tracing::error!(
433 parent_session_id = %job_for_panic.parent_session_id,
434 child_session_id = %job_for_panic.child_session_id,
435 error = %failure,
436 "admitted spawn job failed; publishing terminal error completion"
437 );
438 let publisher = worker_ctx.replayable_event_publisher();
439 publish_child_completion_parts(
440 &publisher,
441 worker_ctx.completion_handler.clone(),
442 job_for_panic.parent_session_id.clone(),
443 job_for_panic.child_session_id.clone(),
444 "error".to_string(),
445 Some(failure),
446 )
447 .await;
448 }
449 }
450 });
451
452 Self {
453 tx,
454 ctx,
455 launch_locks,
456 }
457 }
458
459 async fn prepare_child_launch(ctx: &SpawnContext, job: &SpawnJob) {
460 let child_tx = super::session_events::get_or_create_event_sender(
461 &ctx.session_event_senders,
462 &job.child_session_id,
463 )
464 .await;
465 invoke_child_run_launch_hook(ctx.child_run_launch_hook.as_ref(), job, child_tx);
466 }
467
468 pub async fn enqueue(&self, job: SpawnJob) -> Result<(), String> {
469 self.enqueue_announced(job, None).await
470 }
471
472 pub async fn enqueue_announced(
476 &self,
477 job: SpawnJob,
478 title: Option<String>,
479 ) -> Result<(), String> {
480 match self.enqueue_announced_with_gate(job, title, None).await? {
481 AdmissionCommit::Committed(()) | AdmissionCommit::AlreadyCommitted => Ok(()),
482 AdmissionCommit::Cancelled => unreachable!("an ungated launch cannot be cancelled"),
483 }
484 }
485
486 pub async fn enqueue_announced_with_gate(
487 &self,
488 job: SpawnJob,
489 title: Option<String>,
490 gate: Option<&AdmissionGate>,
491 ) -> Result<AdmissionCommit<()>, String> {
492 self.enqueue_announced_for_generation(job, title, gate, None)
493 .await
494 }
495
496 pub async fn enqueue_announced_for_generation(
500 &self,
501 job: SpawnJob,
502 title: Option<String>,
503 gate: Option<&AdmissionGate>,
504 launch_generation: Option<u64>,
505 ) -> Result<AdmissionCommit<()>, String> {
506 let preparation_job = job.clone();
507 match reserve_and_send(&self.tx, job, title.clone(), gate, launch_generation).await? {
508 AdmissionCommit::Committed((announce_ready, start_published)) => {
509 let launch_guard = if launch_generation.is_some() {
510 Some(
511 self.launch_locks
512 .acquire(&preparation_job.child_session_id)
513 .await,
514 )
515 } else {
516 None
517 };
518 let state = match launch_generation {
519 Some(generation) => {
520 queued_launch_state(&self.ctx, &preparation_job, generation).await?
521 }
522 None => QueuedLaunchState::Eligible,
523 };
524 if state == QueuedLaunchState::Eligible {
525 Self::prepare_child_launch(&self.ctx, &preparation_job).await;
526 self.ctx
527 .replayable_event_publisher()
528 .publish(
529 &preparation_job.parent_session_id,
530 AgentEvent::SubAgentStarted {
531 parent_session_id: preparation_job.parent_session_id.clone(),
532 child_session_id: preparation_job.child_session_id.clone(),
533 title,
534 },
535 )
536 .await;
537 }
538 drop(launch_guard);
539 start_published.store(true, Ordering::SeqCst);
543 let _ = announce_ready.send(());
544 match state {
545 QueuedLaunchState::Eligible => Ok(AdmissionCommit::Committed(())),
546 QueuedLaunchState::AlreadyStarted => Ok(AdmissionCommit::AlreadyCommitted),
547 QueuedLaunchState::Stale => Ok(AdmissionCommit::Cancelled),
548 }
549 }
550 AdmissionCommit::AlreadyCommitted => Ok(AdmissionCommit::AlreadyCommitted),
551 AdmissionCommit::Cancelled => Ok(AdmissionCommit::Cancelled),
552 }
553 }
554
555 pub async fn publish_parent_replayable_event(
558 &self,
559 parent_session_id: &str,
560 event: AgentEvent,
561 ) {
562 self.ctx
563 .replayable_event_publisher()
564 .publish(parent_session_id, event)
565 .await;
566 }
567
568 pub async fn lock_child_launch(&self, child_id: &str) -> ChildLaunchGuard {
569 self.launch_locks.acquire(child_id).await
570 }
571
572 pub async fn publish_queued_child_cancellation(&self, parent_id: &str, child_id: &str) {
573 let publisher = self.ctx.replayable_event_publisher();
574 publish_child_completion_parts(
575 &publisher,
576 self.ctx.completion_handler.clone(),
577 parent_id.to_string(),
578 child_id.to_string(),
579 "cancelled".to_string(),
580 Some("Cancelled by parent before activation".to_string()),
581 )
582 .await;
583 }
584
585 pub(crate) fn launch_reserved(
589 &self,
590 job: SpawnJob,
591 reservation: SessionExecutionReservation,
592 ) -> tokio::task::JoinHandle<()> {
593 let ctx = self.ctx.clone();
594 tokio::spawn(async move {
595 Self::prepare_child_launch(&ctx, &job).await;
596 ctx.replayable_event_publisher()
597 .publish(
598 &job.parent_session_id,
599 AgentEvent::SubAgentStarted {
600 parent_session_id: job.parent_session_id.clone(),
601 child_session_id: job.child_session_id.clone(),
602 title: None,
603 },
604 )
605 .await;
606 if let Err(error) =
607 crate::sdk::spawn::run_child_spawn_reserved(ctx, job.clone(), reservation).await
608 {
609 tracing::warn!(
610 parent_session_id = %job.parent_session_id,
611 child_session_id = %job.child_session_id,
612 %error,
613 "reserved child activation failed"
614 );
615 }
616 })
617 }
618}
619
620async fn needs_fallback_start(
621 announcement_ready: oneshot::Receiver<()>,
622 start_published: &AtomicBool,
623) -> bool {
624 announcement_ready.await.is_err() && !start_published.load(Ordering::SeqCst)
625}
626
627fn invoke_child_run_launch_hook(
628 hook: Option<&Arc<dyn ChildRunLaunchHook>>,
629 job: &SpawnJob,
630 child_tx: broadcast::Sender<AgentEvent>,
631) {
632 let Some(hook) = hook else {
633 return;
634 };
635 let invoked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
636 hook.before_child_launch(job, child_tx);
637 }));
638 if invoked.is_err() {
639 tracing::error!(
640 parent_session_id = %job.parent_session_id,
641 child_session_id = %job.child_session_id,
642 "child launch hook panicked; continuing with canonical execution"
643 );
644 }
645}
646
647async fn reserve_and_send(
652 tx: &mpsc::Sender<QueuedSpawnJob>,
653 job: SpawnJob,
654 title: Option<String>,
655 gate: Option<&AdmissionGate>,
656 launch_generation: Option<u64>,
657) -> Result<AdmissionCommit<(oneshot::Sender<()>, Arc<AtomicBool>)>, String> {
658 if let Some(gate) = gate {
659 if gate.is_committed() {
660 return Ok(AdmissionCommit::AlreadyCommitted);
661 }
662 if gate.is_cancelled() {
663 return Ok(AdmissionCommit::Cancelled);
664 }
665 }
666 let permit = match gate {
667 Some(gate) => tokio::select! {
668 biased;
669 _ = gate.cancelled() => return Ok(AdmissionCommit::Cancelled),
670 permit = tx.reserve() => permit,
671 },
672 None => tx.reserve().await,
673 }
674 .map_err(|_| "spawn scheduler is not running".to_string())?;
675 let (announce_ready, announcement_ready) = oneshot::channel();
676 let start_published = Arc::new(AtomicBool::new(false));
677 let queued = QueuedSpawnJob {
678 job,
679 title,
680 launch_generation,
681 announcement_ready,
682 start_published: start_published.clone(),
683 };
684 match gate {
685 Some(gate) => gate.commit(|| {
686 permit.send(queued);
687 Ok((announce_ready, start_published))
688 }),
689 None => {
690 permit.send(queued);
691 Ok(AdmissionCommit::Committed((
692 announce_ready,
693 start_published,
694 )))
695 }
696 }
697}
698
699#[derive(Debug, Clone, Copy)]
700pub(crate) struct ChildWatchdogPolicy {
701 check_interval_secs: i64,
702 pub(crate) max_total_secs: i64,
706 pub(crate) max_idle_secs: i64,
707}
708
709impl Default for ChildWatchdogPolicy {
710 fn default() -> Self {
711 Self {
712 check_interval_secs: 15,
713 max_total_secs: 60 * 60,
717 max_idle_secs: 15 * 60,
719 }
720 }
721}
722
723fn metadata_i64(session: &Session, key: &str) -> Option<i64> {
724 session
725 .metadata
726 .get(key)
727 .and_then(|value| value.trim().parse::<i64>().ok())
728 .filter(|value| *value > 0)
729}
730
731pub(crate) fn watchdog_policy_for_session(session: &Session) -> ChildWatchdogPolicy {
732 let mut policy = ChildWatchdogPolicy::default();
733 if let Some(value) = metadata_i64(session, "child_watchdog.max_total_secs") {
734 policy.max_total_secs = value;
735 }
736 if let Some(value) = metadata_i64(session, "child_watchdog.max_idle_secs") {
737 policy.max_idle_secs = value;
738 }
739 if let Some(value) = metadata_i64(session, "child_watchdog.check_interval_secs") {
740 policy.check_interval_secs = value;
741 }
742 policy
743}
744
745pub(crate) async fn publish_child_completion(
746 publisher: &super::session_events::ReplayableSessionEventPublisher,
747 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
748 completion: ChildCompletion,
749) {
750 publisher
751 .publish(
752 &completion.parent_session_id,
753 AgentEvent::SubAgentCompleted {
754 parent_session_id: completion.parent_session_id.clone(),
755 child_session_id: completion.child_session_id.clone(),
756 status: completion.status.clone(),
757 error: completion.error.clone(),
758 },
759 )
760 .await;
761
762 if let Some(handler) = completion_handler {
763 use futures::FutureExt;
770 let parent_session_id = completion.parent_session_id.clone();
771 let child_session_id = completion.child_session_id.clone();
772 if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
773 .catch_unwind()
774 .await
775 .is_err()
776 {
777 tracing::error!(
778 %parent_session_id,
779 %child_session_id,
780 "child completion handler panicked; child-wait watchdog will backstop the parent wake"
781 );
782 }
783 }
784}
785
786pub(crate) async fn publish_child_completion_parts(
787 publisher: &super::session_events::ReplayableSessionEventPublisher,
788 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
789 parent_session_id: String,
790 child_session_id: String,
791 status: String,
792 error: Option<String>,
793) {
794 publish_child_completion(
795 publisher,
796 completion_handler,
797 ChildCompletion {
798 parent_session_id,
799 child_session_id,
800 status,
801 error,
802 completed_at: Utc::now(),
803 source: None,
804 },
805 )
806 .await;
807}
808
809pub(crate) async fn watch_child_liveness(
810 parent_session_id: String,
811 child_session_id: String,
812 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
813 cancel_token: CancellationToken,
814 timeout_reason: Arc<RwLock<Option<String>>>,
815 done: CancellationToken,
816 policy: ChildWatchdogPolicy,
817) {
818 let mut ticker =
819 tokio::time::interval(Duration::from_secs(policy.check_interval_secs.max(1) as u64));
820 ticker.tick().await;
822
823 loop {
824 tokio::select! {
825 _ = done.cancelled() => return,
826 _ = ticker.tick() => {
827 if cancel_token.is_cancelled() {
828 return;
829 }
830
831 let snapshot = {
832 let guard = runners.read().await;
833 guard.get(&child_session_id).cloned()
834 };
835 let Some(runner) = snapshot else {
836 return;
837 };
838 if !matches!(runner.status, super::runner_state::AgentStatus::Running) {
839 return;
840 }
841
842 let now = Utc::now();
843 let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
844 if total_secs >= policy.max_total_secs {
845 let reason = format!(
846 "Child session timed out after {} seconds (max_total_secs={})",
847 total_secs, policy.max_total_secs
848 );
849 tracing::warn!(
850 parent_session_id = %parent_session_id,
851 child_session_id = %child_session_id,
852 reason = %reason,
853 "child session total timeout; cancelling child runner"
854 );
855 *timeout_reason.write().await = Some(reason);
856 cancel_token.cancel();
857 return;
858 }
859
860 let last_activity_at = runner.last_activity_at().unwrap_or(runner.started_at);
861 let idle_secs = now.signed_duration_since(last_activity_at).num_seconds();
862 if idle_secs >= policy.max_idle_secs {
863 let reason = format!(
864 "Child session idle timeout after {} seconds without events (max_idle_secs={})",
865 idle_secs, policy.max_idle_secs
866 );
867 tracing::warn!(
868 parent_session_id = %parent_session_id,
869 child_session_id = %child_session_id,
870 reason = %reason,
871 last_tool_name = ?runner.last_tool_name,
872 last_tool_phase = ?runner.last_tool_phase,
873 round_count = runner.round_count,
874 "child session idle timeout; cancelling child runner"
875 );
876 *timeout_reason.write().await = Some(reason);
877 cancel_token.cancel();
878 return;
879 }
880 }
881 }
882 }
883}
884
885async fn run_spawn_job(
892 ctx: SpawnContext,
893 job: SpawnJob,
894 launch_guard: Option<ChildLaunchGuard>,
895) -> Result<(), String> {
896 match launch_guard {
897 Some(guard) => crate::sdk::spawn::run_child_spawn_fenced(ctx, job, guard).await,
898 None => crate::sdk::spawn::run_child_spawn(ctx, job).await,
899 }
900}
901
902#[derive(Clone, Copy, Debug, PartialEq, Eq)]
903enum QueuedLaunchState {
904 Eligible,
905 AlreadyStarted,
906 Stale,
907}
908
909async fn queued_launch_state(
910 ctx: &SpawnContext,
911 job: &SpawnJob,
912 generation: u64,
913) -> Result<QueuedLaunchState, String> {
914 let child = ctx
915 .agent
916 .storage()
917 .load_session(&job.child_session_id)
918 .await
919 .map_err(|error| format!("load queued child launch fence: {error}"))?;
920 let Some(child) = child else {
921 return Ok(QueuedLaunchState::Stale);
922 };
923 if child.kind != bamboo_domain::SessionKind::Child
924 || child.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
925 || child.child_launch_generation() != generation
926 || child.is_child_launch_cancelled(generation)
927 {
928 return Ok(QueuedLaunchState::Stale);
929 }
930 match child.last_run_status().as_deref() {
931 Some("pending") => Ok(QueuedLaunchState::Eligible),
932 Some("running" | "completed" | "error" | "timeout" | "skipped" | "suspended") => {
933 Ok(QueuedLaunchState::AlreadyStarted)
934 }
935 _ => Ok(QueuedLaunchState::Stale),
936 }
937}
938
939#[cfg(test)]
940mod launch_hook_tests {
941 use super::*;
942 use std::sync::atomic::{AtomicUsize, Ordering};
943
944 fn job() -> SpawnJob {
945 SpawnJob {
946 parent_session_id: "parent".to_string(),
947 child_session_id: "child".to_string(),
948 model: "test".to_string(),
949 disabled_tools: None,
950 }
951 }
952
953 struct PanickingHook {
954 calls: AtomicUsize,
955 }
956
957 impl ChildRunLaunchHook for PanickingHook {
958 fn before_child_launch(
959 &self,
960 _job: &SpawnJob,
961 _child_events: broadcast::Sender<AgentEvent>,
962 ) {
963 self.calls.fetch_add(1, Ordering::SeqCst);
964 panic!("injected launch hook panic");
965 }
966 }
967
968 #[test]
969 fn launch_hook_panic_is_contained() {
970 let hook = Arc::new(PanickingHook {
971 calls: AtomicUsize::new(0),
972 });
973 let hook_port: Arc<dyn ChildRunLaunchHook> = hook.clone();
974 let (child_tx, _child_rx) = broadcast::channel(1);
975
976 invoke_child_run_launch_hook(Some(&hook_port), &job(), child_tx);
977
978 assert_eq!(hook.calls.load(Ordering::SeqCst), 1);
979 }
980
981 #[tokio::test]
982 async fn closed_scheduler_does_not_prepare_phantom_launch() {
983 let (tx, rx) = mpsc::channel(1);
984 drop(rx);
985 let result = reserve_and_send(&tx, job(), None, None, None).await;
986
987 assert_eq!(result.unwrap_err(), "spawn scheduler is not running");
988 }
989
990 #[tokio::test]
991 async fn cancellation_while_waiting_for_queue_capacity_never_sends() {
992 let (tx, mut rx) = mpsc::channel(1);
993 let (_, announcement_ready) = oneshot::channel();
994 tx.send(QueuedSpawnJob {
995 job: job(),
996 title: None,
997 launch_generation: None,
998 announcement_ready,
999 start_published: Arc::new(AtomicBool::new(false)),
1000 })
1001 .await
1002 .unwrap();
1003 let gate = Arc::new(AdmissionGate::default());
1004 let waiting = {
1005 let tx = tx.clone();
1006 let gate = gate.clone();
1007 tokio::spawn(async move { reserve_and_send(&tx, job(), None, Some(&gate), None).await })
1008 };
1009 tokio::task::yield_now().await;
1010 assert!(!waiting.is_finished());
1011 gate.cancel_if_pending();
1012 assert!(matches!(
1013 tokio::time::timeout(Duration::from_millis(200), waiting)
1014 .await
1015 .expect("cancellation must wake without freeing queue capacity")
1016 .unwrap()
1017 .unwrap(),
1018 AdmissionCommit::Cancelled
1019 ));
1020 rx.recv().await.unwrap();
1021 assert!(matches!(
1022 rx.try_recv(),
1023 Err(mpsc::error::TryRecvError::Empty)
1024 ));
1025 }
1026
1027 #[tokio::test]
1028 async fn committed_queue_gate_does_not_enqueue_a_duplicate_job() {
1029 let (tx, mut rx) = mpsc::channel(1);
1030 let gate = AdmissionGate::default();
1031 assert!(matches!(
1032 reserve_and_send(&tx, job(), None, Some(&gate), None)
1033 .await
1034 .unwrap(),
1035 AdmissionCommit::Committed(_)
1036 ));
1037 assert!(matches!(
1038 reserve_and_send(&tx, job(), None, Some(&gate), None)
1039 .await
1040 .unwrap(),
1041 AdmissionCommit::AlreadyCommitted
1042 ));
1043 rx.recv().await.unwrap();
1044 assert!(matches!(
1045 rx.try_recv(),
1046 Err(mpsc::error::TryRecvError::Empty)
1047 ));
1048 }
1049
1050 #[tokio::test]
1051 async fn producer_drop_after_start_publication_does_not_require_fallback_start() {
1052 let (announce_ready, announcement_ready) = oneshot::channel::<()>();
1053 let start_published = Arc::new(AtomicBool::new(false));
1054 let runners = Arc::new(RwLock::new(HashMap::new()));
1055 let senders = Arc::new(RwLock::new(HashMap::new()));
1056 let (sender, mut receiver) = broadcast::channel(4);
1057 senders.write().await.insert("parent".to_string(), sender);
1058 let publisher = super::super::session_events::ReplayableSessionEventPublisher::new(
1059 runners, senders, None,
1060 );
1061 publisher
1065 .publish(
1066 "parent",
1067 AgentEvent::SubAgentStarted {
1068 parent_session_id: "parent".to_string(),
1069 child_session_id: "child".to_string(),
1070 title: None,
1071 },
1072 )
1073 .await;
1074 start_published.store(true, Ordering::SeqCst);
1075 drop(announce_ready);
1076 assert!(!needs_fallback_start(announcement_ready, &start_published).await);
1077 assert!(matches!(
1078 receiver.try_recv(),
1079 Ok(AgentEvent::SubAgentStarted { .. })
1080 ));
1081 assert!(matches!(
1082 receiver.try_recv(),
1083 Err(broadcast::error::TryRecvError::Empty)
1084 ));
1085
1086 let (announce_ready, announcement_ready) = oneshot::channel::<()>();
1087 let start_published = AtomicBool::new(false);
1088 drop(announce_ready);
1089 assert!(needs_fallback_start(announcement_ready, &start_published).await);
1090 }
1091}