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_ticket_service(&self, _service: Option<Arc<bamboo_tickets::TicketService>>) {}
136 async fn validate_required_child_context_route(
139 &self,
140 _session: &Session,
141 ) -> Result<(), String> {
142 Err("required_child_context_unsupported: no supported registered worker route".into())
143 }
144 async fn should_handle(&self, session: &Session) -> bool;
146
147 async fn execute_external_child(
149 &self,
150 session: &mut Session,
151 job: &SpawnJob,
152 event_tx: tokio::sync::mpsc::Sender<AgentEvent>,
153 cancel_token: CancellationToken,
154 ) -> crate::runtime::runner::Result<()>;
155
156 async fn prepare_durable_child_delivery(
160 &self,
161 _session: &Session,
162 _activation_run_id: &str,
163 ) -> Result<bool, String> {
164 Ok(false)
165 }
166
167 async fn commit_durable_child_delivery(
174 &self,
175 _session: &Session,
176 _activation_run_id: &str,
177 ) -> Result<(), String> {
178 Ok(())
179 }
180
181 async fn confirm_durable_child_delivery(
186 &self,
187 _session: &Session,
188 _activation_run_id: &str,
189 _history_committed: bool,
190 ) -> Result<(), String> {
191 Ok(())
192 }
193
194 fn set_escalation_bridge(&self, _bridge: Option<bamboo_subagent::executor::HostBridge>) {}
201
202 fn set_session_inbox_runtime(&self, _binding: Option<SessionInboxRuntimeBinding>) {}
206
207 fn set_actor_directory_store(&self, _store: Option<Arc<bamboo_storage::SessionStoreV2>>) {}
210
211 fn set_canonical_subagent_tool(&self, _tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {}
214
215 fn set_actor_event_observer(
217 &self,
218 _observer: Option<Arc<dyn crate::external_agents::actor_event_stream::ActorEventObserver>>,
219 ) {
220 }
221}
222
223#[derive(Clone)]
224pub struct SpawnContext {
225 pub agent: Arc<Agent>,
226 pub tools: Arc<dyn ToolExecutor>,
227 pub sessions_cache: crate::SessionCache,
228 pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
229 pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
230 pub external_child_runner: Arc<dyn ExternalChildRunner>,
231 pub provider_router: Option<Arc<ProviderModelRouter>>,
232 pub app_data_dir: Option<std::path::PathBuf>,
233 pub completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
238 pub child_run_launch_hook: Option<Arc<dyn ChildRunLaunchHook>>,
241 pub account_feed_inbox: Option<super::event_forwarder::AccountFeedInbox>,
245}
246
247impl SpawnContext {
248 pub(crate) fn replayable_event_publisher(
249 &self,
250 ) -> super::session_events::ReplayableSessionEventPublisher {
251 super::session_events::ReplayableSessionEventPublisher::new(
252 self.agent_runners.clone(),
253 self.session_event_senders.clone(),
254 self.account_feed_inbox.clone(),
255 )
256 }
257}
258
259#[derive(Clone)]
260pub struct SpawnScheduler {
261 tx: mpsc::Sender<QueuedSpawnJob>,
262 ctx: SpawnContext,
263 launch_locks: ChildLaunchLocks,
264}
265
266impl SpawnScheduler {
267 pub fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
268 self.ctx.external_child_runner.set_ticket_service(service);
269 }
270
271 pub async fn validate_required_child_context_route(
272 &self,
273 metadata: &HashMap<String, String>,
274 role: &str,
275 ) -> Result<(), String> {
276 let mut candidate = Session::new("required-context-preflight", "");
277 candidate.metadata = metadata.clone();
278 candidate
279 .metadata
280 .insert("subagent_type".into(), role.into());
281 self.ctx
282 .external_child_runner
283 .validate_required_child_context_route(&candidate)
284 .await
285 }
286
287 pub fn new(ctx: SpawnContext) -> Self {
288 let (tx, mut rx) = mpsc::channel::<QueuedSpawnJob>(128);
289 let worker_ctx = ctx.clone();
290 let launch_locks = ChildLaunchLocks::default();
291 let worker_launch_locks = launch_locks.clone();
292
293 tokio::spawn(async move {
301 while let Some(QueuedSpawnJob {
302 job,
303 title,
304 launch_generation,
305 announcement_ready,
306 start_published,
307 }) = rx.recv().await
308 {
309 let job_ctx = worker_ctx.clone();
310 let job_for_panic = job.clone();
311 let job_launch_locks = worker_launch_locks.clone();
312 let handle = tokio::spawn(async move {
313 let fallback_start =
317 needs_fallback_start(announcement_ready, &start_published).await;
318 if let Some(hook) = job_ctx.child_run_launch_hook.as_ref() {
319 hook.before_queued_dequeue(&job).await;
320 }
321 let launch_guard = if let Some(generation) = launch_generation {
322 let guard = job_launch_locks.acquire(&job.child_session_id).await;
323 if queued_launch_state(&job_ctx, &job, generation).await?
324 != QueuedLaunchState::Eligible
325 {
326 return Ok(());
327 }
328 Some(guard)
329 } else {
330 None
331 };
332 if let Some(hook) = job_ctx.child_run_launch_hook.as_ref() {
333 hook.before_queued_reservation(&job).await;
334 }
335 if fallback_start {
336 Self::prepare_child_launch(&job_ctx, &job).await;
337 job_ctx
338 .replayable_event_publisher()
339 .publish(
340 &job.parent_session_id,
341 AgentEvent::SubAgentStarted {
342 parent_session_id: job.parent_session_id.clone(),
343 child_session_id: job.child_session_id.clone(),
344 title,
345 },
346 )
347 .await;
348 }
349 if let Err(err) = run_spawn_job(job_ctx, job, launch_guard).await {
350 tracing::warn!("spawn job failed: {}", err);
351 }
352 Ok(())
353 });
354 let failure = match handle.await {
355 Ok(Ok(())) => continue,
356 Ok(Err(error)) => error,
357 Err(error) => format!("child spawn panicked: {error}"),
358 };
359 {
360 tracing::error!(
361 parent_session_id = %job_for_panic.parent_session_id,
362 child_session_id = %job_for_panic.child_session_id,
363 error = %failure,
364 "admitted spawn job failed; publishing terminal error completion"
365 );
366 let publisher = worker_ctx.replayable_event_publisher();
367 publish_child_completion_parts(
368 &publisher,
369 worker_ctx.completion_handler.clone(),
370 job_for_panic.parent_session_id.clone(),
371 job_for_panic.child_session_id.clone(),
372 "error".to_string(),
373 Some(failure),
374 )
375 .await;
376 }
377 }
378 });
379
380 Self {
381 tx,
382 ctx,
383 launch_locks,
384 }
385 }
386
387 async fn prepare_child_launch(ctx: &SpawnContext, job: &SpawnJob) {
388 let child_tx = super::session_events::get_or_create_event_sender(
389 &ctx.session_event_senders,
390 &job.child_session_id,
391 )
392 .await;
393 invoke_child_run_launch_hook(ctx.child_run_launch_hook.as_ref(), job, child_tx);
394 }
395
396 pub async fn enqueue(&self, job: SpawnJob) -> Result<(), String> {
397 self.enqueue_announced(job, None).await
398 }
399
400 pub async fn enqueue_announced(
404 &self,
405 job: SpawnJob,
406 title: Option<String>,
407 ) -> Result<(), String> {
408 match self.enqueue_announced_with_gate(job, title, None).await? {
409 AdmissionCommit::Committed(()) | AdmissionCommit::AlreadyCommitted => Ok(()),
410 AdmissionCommit::Cancelled => unreachable!("an ungated launch cannot be cancelled"),
411 }
412 }
413
414 pub async fn enqueue_announced_with_gate(
415 &self,
416 job: SpawnJob,
417 title: Option<String>,
418 gate: Option<&AdmissionGate>,
419 ) -> Result<AdmissionCommit<()>, String> {
420 self.enqueue_announced_for_generation(job, title, gate, None)
421 .await
422 }
423
424 pub async fn enqueue_announced_for_generation(
428 &self,
429 job: SpawnJob,
430 title: Option<String>,
431 gate: Option<&AdmissionGate>,
432 launch_generation: Option<u64>,
433 ) -> Result<AdmissionCommit<()>, String> {
434 let preparation_job = job.clone();
435 match reserve_and_send(&self.tx, job, title.clone(), gate, launch_generation).await? {
436 AdmissionCommit::Committed((announce_ready, start_published)) => {
437 let launch_guard = if launch_generation.is_some() {
438 Some(
439 self.launch_locks
440 .acquire(&preparation_job.child_session_id)
441 .await,
442 )
443 } else {
444 None
445 };
446 let state = match launch_generation {
447 Some(generation) => {
448 queued_launch_state(&self.ctx, &preparation_job, generation).await?
449 }
450 None => QueuedLaunchState::Eligible,
451 };
452 if state == QueuedLaunchState::Eligible {
453 Self::prepare_child_launch(&self.ctx, &preparation_job).await;
454 self.ctx
455 .replayable_event_publisher()
456 .publish(
457 &preparation_job.parent_session_id,
458 AgentEvent::SubAgentStarted {
459 parent_session_id: preparation_job.parent_session_id.clone(),
460 child_session_id: preparation_job.child_session_id.clone(),
461 title,
462 },
463 )
464 .await;
465 }
466 drop(launch_guard);
467 start_published.store(true, Ordering::SeqCst);
471 let _ = announce_ready.send(());
472 match state {
473 QueuedLaunchState::Eligible => Ok(AdmissionCommit::Committed(())),
474 QueuedLaunchState::AlreadyStarted => Ok(AdmissionCommit::AlreadyCommitted),
475 QueuedLaunchState::Stale => Ok(AdmissionCommit::Cancelled),
476 }
477 }
478 AdmissionCommit::AlreadyCommitted => Ok(AdmissionCommit::AlreadyCommitted),
479 AdmissionCommit::Cancelled => Ok(AdmissionCommit::Cancelled),
480 }
481 }
482
483 pub async fn publish_parent_replayable_event(
486 &self,
487 parent_session_id: &str,
488 event: AgentEvent,
489 ) {
490 self.ctx
491 .replayable_event_publisher()
492 .publish(parent_session_id, event)
493 .await;
494 }
495
496 pub async fn lock_child_launch(&self, child_id: &str) -> ChildLaunchGuard {
497 self.launch_locks.acquire(child_id).await
498 }
499
500 pub async fn publish_queued_child_cancellation(&self, parent_id: &str, child_id: &str) {
501 let publisher = self.ctx.replayable_event_publisher();
502 publish_child_completion_parts(
503 &publisher,
504 self.ctx.completion_handler.clone(),
505 parent_id.to_string(),
506 child_id.to_string(),
507 "cancelled".to_string(),
508 Some("Cancelled by parent before activation".to_string()),
509 )
510 .await;
511 }
512
513 pub(crate) fn launch_reserved(
517 &self,
518 job: SpawnJob,
519 reservation: SessionExecutionReservation,
520 ) -> tokio::task::JoinHandle<()> {
521 let ctx = self.ctx.clone();
522 tokio::spawn(async move {
523 Self::prepare_child_launch(&ctx, &job).await;
524 ctx.replayable_event_publisher()
525 .publish(
526 &job.parent_session_id,
527 AgentEvent::SubAgentStarted {
528 parent_session_id: job.parent_session_id.clone(),
529 child_session_id: job.child_session_id.clone(),
530 title: None,
531 },
532 )
533 .await;
534 if let Err(error) =
535 crate::sdk::spawn::run_child_spawn_reserved(ctx, job.clone(), reservation).await
536 {
537 tracing::warn!(
538 parent_session_id = %job.parent_session_id,
539 child_session_id = %job.child_session_id,
540 %error,
541 "reserved child activation failed"
542 );
543 }
544 })
545 }
546}
547
548async fn needs_fallback_start(
549 announcement_ready: oneshot::Receiver<()>,
550 start_published: &AtomicBool,
551) -> bool {
552 announcement_ready.await.is_err() && !start_published.load(Ordering::SeqCst)
553}
554
555fn invoke_child_run_launch_hook(
556 hook: Option<&Arc<dyn ChildRunLaunchHook>>,
557 job: &SpawnJob,
558 child_tx: broadcast::Sender<AgentEvent>,
559) {
560 let Some(hook) = hook else {
561 return;
562 };
563 let invoked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
564 hook.before_child_launch(job, child_tx);
565 }));
566 if invoked.is_err() {
567 tracing::error!(
568 parent_session_id = %job.parent_session_id,
569 child_session_id = %job.child_session_id,
570 "child launch hook panicked; continuing with canonical execution"
571 );
572 }
573}
574
575async fn reserve_and_send(
580 tx: &mpsc::Sender<QueuedSpawnJob>,
581 job: SpawnJob,
582 title: Option<String>,
583 gate: Option<&AdmissionGate>,
584 launch_generation: Option<u64>,
585) -> Result<AdmissionCommit<(oneshot::Sender<()>, Arc<AtomicBool>)>, String> {
586 if let Some(gate) = gate {
587 if gate.is_committed() {
588 return Ok(AdmissionCommit::AlreadyCommitted);
589 }
590 if gate.is_cancelled() {
591 return Ok(AdmissionCommit::Cancelled);
592 }
593 }
594 let permit = match gate {
595 Some(gate) => tokio::select! {
596 biased;
597 _ = gate.cancelled() => return Ok(AdmissionCommit::Cancelled),
598 permit = tx.reserve() => permit,
599 },
600 None => tx.reserve().await,
601 }
602 .map_err(|_| "spawn scheduler is not running".to_string())?;
603 let (announce_ready, announcement_ready) = oneshot::channel();
604 let start_published = Arc::new(AtomicBool::new(false));
605 let queued = QueuedSpawnJob {
606 job,
607 title,
608 launch_generation,
609 announcement_ready,
610 start_published: start_published.clone(),
611 };
612 match gate {
613 Some(gate) => gate.commit(|| {
614 permit.send(queued);
615 Ok((announce_ready, start_published))
616 }),
617 None => {
618 permit.send(queued);
619 Ok(AdmissionCommit::Committed((
620 announce_ready,
621 start_published,
622 )))
623 }
624 }
625}
626
627#[derive(Debug, Clone, Copy)]
628pub(crate) struct ChildWatchdogPolicy {
629 check_interval_secs: i64,
630 pub(crate) max_total_secs: i64,
634 pub(crate) max_idle_secs: i64,
635}
636
637impl Default for ChildWatchdogPolicy {
638 fn default() -> Self {
639 Self {
640 check_interval_secs: 15,
641 max_total_secs: 60 * 60,
645 max_idle_secs: 15 * 60,
647 }
648 }
649}
650
651fn metadata_i64(session: &Session, key: &str) -> Option<i64> {
652 session
653 .metadata
654 .get(key)
655 .and_then(|value| value.trim().parse::<i64>().ok())
656 .filter(|value| *value > 0)
657}
658
659pub(crate) fn watchdog_policy_for_session(session: &Session) -> ChildWatchdogPolicy {
660 let mut policy = ChildWatchdogPolicy::default();
661 if let Some(value) = metadata_i64(session, "child_watchdog.max_total_secs") {
662 policy.max_total_secs = value;
663 }
664 if let Some(value) = metadata_i64(session, "child_watchdog.max_idle_secs") {
665 policy.max_idle_secs = value;
666 }
667 if let Some(value) = metadata_i64(session, "child_watchdog.check_interval_secs") {
668 policy.check_interval_secs = value;
669 }
670 policy
671}
672
673pub(crate) async fn publish_child_completion(
674 publisher: &super::session_events::ReplayableSessionEventPublisher,
675 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
676 completion: ChildCompletion,
677) {
678 publisher
679 .publish(
680 &completion.parent_session_id,
681 AgentEvent::SubAgentCompleted {
682 parent_session_id: completion.parent_session_id.clone(),
683 child_session_id: completion.child_session_id.clone(),
684 status: completion.status.clone(),
685 error: completion.error.clone(),
686 },
687 )
688 .await;
689
690 if let Some(handler) = completion_handler {
691 use futures::FutureExt;
698 let parent_session_id = completion.parent_session_id.clone();
699 let child_session_id = completion.child_session_id.clone();
700 if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
701 .catch_unwind()
702 .await
703 .is_err()
704 {
705 tracing::error!(
706 %parent_session_id,
707 %child_session_id,
708 "child completion handler panicked; child-wait watchdog will backstop the parent wake"
709 );
710 }
711 }
712}
713
714pub(crate) async fn publish_child_completion_parts(
715 publisher: &super::session_events::ReplayableSessionEventPublisher,
716 completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
717 parent_session_id: String,
718 child_session_id: String,
719 status: String,
720 error: Option<String>,
721) {
722 publish_child_completion(
723 publisher,
724 completion_handler,
725 ChildCompletion {
726 parent_session_id,
727 child_session_id,
728 status,
729 error,
730 completed_at: Utc::now(),
731 source: None,
732 },
733 )
734 .await;
735}
736
737pub(crate) async fn watch_child_liveness(
738 parent_session_id: String,
739 child_session_id: String,
740 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
741 cancel_token: CancellationToken,
742 timeout_reason: Arc<RwLock<Option<String>>>,
743 done: CancellationToken,
744 policy: ChildWatchdogPolicy,
745) {
746 let mut ticker =
747 tokio::time::interval(Duration::from_secs(policy.check_interval_secs.max(1) as u64));
748 ticker.tick().await;
750
751 loop {
752 tokio::select! {
753 _ = done.cancelled() => return,
754 _ = ticker.tick() => {
755 if cancel_token.is_cancelled() {
756 return;
757 }
758
759 let snapshot = {
760 let guard = runners.read().await;
761 guard.get(&child_session_id).cloned()
762 };
763 let Some(runner) = snapshot else {
764 return;
765 };
766 if !matches!(runner.status, super::runner_state::AgentStatus::Running) {
767 return;
768 }
769
770 let now = Utc::now();
771 let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
772 if total_secs >= policy.max_total_secs {
773 let reason = format!(
774 "Child session timed out after {} seconds (max_total_secs={})",
775 total_secs, policy.max_total_secs
776 );
777 tracing::warn!(
778 parent_session_id = %parent_session_id,
779 child_session_id = %child_session_id,
780 reason = %reason,
781 "child session total timeout; cancelling child runner"
782 );
783 *timeout_reason.write().await = Some(reason);
784 cancel_token.cancel();
785 return;
786 }
787
788 let last_activity_at = runner.last_activity_at().unwrap_or(runner.started_at);
789 let idle_secs = now.signed_duration_since(last_activity_at).num_seconds();
790 if idle_secs >= policy.max_idle_secs {
791 let reason = format!(
792 "Child session idle timeout after {} seconds without events (max_idle_secs={})",
793 idle_secs, policy.max_idle_secs
794 );
795 tracing::warn!(
796 parent_session_id = %parent_session_id,
797 child_session_id = %child_session_id,
798 reason = %reason,
799 last_tool_name = ?runner.last_tool_name,
800 last_tool_phase = ?runner.last_tool_phase,
801 round_count = runner.round_count,
802 "child session idle timeout; cancelling child runner"
803 );
804 *timeout_reason.write().await = Some(reason);
805 cancel_token.cancel();
806 return;
807 }
808 }
809 }
810 }
811}
812
813async fn run_spawn_job(
820 ctx: SpawnContext,
821 job: SpawnJob,
822 launch_guard: Option<ChildLaunchGuard>,
823) -> Result<(), String> {
824 match launch_guard {
825 Some(guard) => crate::sdk::spawn::run_child_spawn_fenced(ctx, job, guard).await,
826 None => crate::sdk::spawn::run_child_spawn(ctx, job).await,
827 }
828}
829
830#[derive(Clone, Copy, Debug, PartialEq, Eq)]
831enum QueuedLaunchState {
832 Eligible,
833 AlreadyStarted,
834 Stale,
835}
836
837async fn queued_launch_state(
838 ctx: &SpawnContext,
839 job: &SpawnJob,
840 generation: u64,
841) -> Result<QueuedLaunchState, String> {
842 let child = ctx
843 .agent
844 .storage()
845 .load_session(&job.child_session_id)
846 .await
847 .map_err(|error| format!("load queued child launch fence: {error}"))?;
848 let Some(child) = child else {
849 return Ok(QueuedLaunchState::Stale);
850 };
851 if child.kind != bamboo_domain::SessionKind::Child
852 || child.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
853 || child.child_launch_generation() != generation
854 || child.is_child_launch_cancelled(generation)
855 {
856 return Ok(QueuedLaunchState::Stale);
857 }
858 match child.last_run_status().as_deref() {
859 Some("pending") => Ok(QueuedLaunchState::Eligible),
860 Some("running" | "completed" | "error" | "timeout" | "skipped" | "suspended") => {
861 Ok(QueuedLaunchState::AlreadyStarted)
862 }
863 _ => Ok(QueuedLaunchState::Stale),
864 }
865}
866
867#[cfg(test)]
868mod launch_hook_tests {
869 use super::*;
870 use std::sync::atomic::{AtomicUsize, Ordering};
871
872 fn job() -> SpawnJob {
873 SpawnJob {
874 parent_session_id: "parent".to_string(),
875 child_session_id: "child".to_string(),
876 model: "test".to_string(),
877 disabled_tools: None,
878 }
879 }
880
881 struct PanickingHook {
882 calls: AtomicUsize,
883 }
884
885 impl ChildRunLaunchHook for PanickingHook {
886 fn before_child_launch(
887 &self,
888 _job: &SpawnJob,
889 _child_events: broadcast::Sender<AgentEvent>,
890 ) {
891 self.calls.fetch_add(1, Ordering::SeqCst);
892 panic!("injected launch hook panic");
893 }
894 }
895
896 #[test]
897 fn launch_hook_panic_is_contained() {
898 let hook = Arc::new(PanickingHook {
899 calls: AtomicUsize::new(0),
900 });
901 let hook_port: Arc<dyn ChildRunLaunchHook> = hook.clone();
902 let (child_tx, _child_rx) = broadcast::channel(1);
903
904 invoke_child_run_launch_hook(Some(&hook_port), &job(), child_tx);
905
906 assert_eq!(hook.calls.load(Ordering::SeqCst), 1);
907 }
908
909 #[tokio::test]
910 async fn closed_scheduler_does_not_prepare_phantom_launch() {
911 let (tx, rx) = mpsc::channel(1);
912 drop(rx);
913 let result = reserve_and_send(&tx, job(), None, None, None).await;
914
915 assert_eq!(result.unwrap_err(), "spawn scheduler is not running");
916 }
917
918 #[tokio::test]
919 async fn cancellation_while_waiting_for_queue_capacity_never_sends() {
920 let (tx, mut rx) = mpsc::channel(1);
921 let (_, announcement_ready) = oneshot::channel();
922 tx.send(QueuedSpawnJob {
923 job: job(),
924 title: None,
925 launch_generation: None,
926 announcement_ready,
927 start_published: Arc::new(AtomicBool::new(false)),
928 })
929 .await
930 .unwrap();
931 let gate = Arc::new(AdmissionGate::default());
932 let waiting = {
933 let tx = tx.clone();
934 let gate = gate.clone();
935 tokio::spawn(async move { reserve_and_send(&tx, job(), None, Some(&gate), None).await })
936 };
937 tokio::task::yield_now().await;
938 assert!(!waiting.is_finished());
939 gate.cancel_if_pending();
940 assert!(matches!(
941 tokio::time::timeout(Duration::from_millis(200), waiting)
942 .await
943 .expect("cancellation must wake without freeing queue capacity")
944 .unwrap()
945 .unwrap(),
946 AdmissionCommit::Cancelled
947 ));
948 rx.recv().await.unwrap();
949 assert!(matches!(
950 rx.try_recv(),
951 Err(mpsc::error::TryRecvError::Empty)
952 ));
953 }
954
955 #[tokio::test]
956 async fn committed_queue_gate_does_not_enqueue_a_duplicate_job() {
957 let (tx, mut rx) = mpsc::channel(1);
958 let gate = AdmissionGate::default();
959 assert!(matches!(
960 reserve_and_send(&tx, job(), None, Some(&gate), None)
961 .await
962 .unwrap(),
963 AdmissionCommit::Committed(_)
964 ));
965 assert!(matches!(
966 reserve_and_send(&tx, job(), None, Some(&gate), None)
967 .await
968 .unwrap(),
969 AdmissionCommit::AlreadyCommitted
970 ));
971 rx.recv().await.unwrap();
972 assert!(matches!(
973 rx.try_recv(),
974 Err(mpsc::error::TryRecvError::Empty)
975 ));
976 }
977
978 #[tokio::test]
979 async fn producer_drop_after_start_publication_does_not_require_fallback_start() {
980 let (announce_ready, announcement_ready) = oneshot::channel::<()>();
981 let start_published = Arc::new(AtomicBool::new(false));
982 let runners = Arc::new(RwLock::new(HashMap::new()));
983 let senders = Arc::new(RwLock::new(HashMap::new()));
984 let (sender, mut receiver) = broadcast::channel(4);
985 senders.write().await.insert("parent".to_string(), sender);
986 let publisher = super::super::session_events::ReplayableSessionEventPublisher::new(
987 runners, senders, None,
988 );
989 publisher
993 .publish(
994 "parent",
995 AgentEvent::SubAgentStarted {
996 parent_session_id: "parent".to_string(),
997 child_session_id: "child".to_string(),
998 title: None,
999 },
1000 )
1001 .await;
1002 start_published.store(true, Ordering::SeqCst);
1003 drop(announce_ready);
1004 assert!(!needs_fallback_start(announcement_ready, &start_published).await);
1005 assert!(matches!(
1006 receiver.try_recv(),
1007 Ok(AgentEvent::SubAgentStarted { .. })
1008 ));
1009 assert!(matches!(
1010 receiver.try_recv(),
1011 Err(broadcast::error::TryRecvError::Empty)
1012 ));
1013
1014 let (announce_ready, announcement_ready) = oneshot::channel::<()>();
1015 let start_published = AtomicBool::new(false);
1016 drop(announce_ready);
1017 assert!(needs_fallback_start(announcement_ready, &start_published).await);
1018 }
1019}