Skip to main content

bamboo_engine/runtime/execution/
spawn.rs

1//! Sub-session spawn scheduler.
2//!
3//! Provides a background queue for spawning child sessions. Spawn is async
4//! (tool returns immediately), but the UI can observe child progress via
5//! events forwarded to the parent session stream.
6
7use 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    /// Tool names to hide from the LLM schema for this child session.
36    /// Computed from the child's `subagent_type` profile policy.
37    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/// Serializes a queued launch's durable eligibility check and runner
49/// reservation against parent cancellation of that same logical child.
50#[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
87/// Optional application-layer preparation for a child run launched through
88/// the canonical scheduler.
89///
90/// The engine owns runner reservation and execution. Applications may use
91/// this synchronous, no-fail hook to attach observers to the already-created
92/// child event sender (for example, the server's always-on notification
93/// relay) without introducing an engine dependency on application services.
94pub trait ChildRunLaunchHook: Send + Sync {
95    fn before_child_launch(&self, job: &SpawnJob, child_events: broadcast::Sender<AgentEvent>);
96
97    /// Optional queue boundary observer. The production notification hook is
98    /// a no-op; deterministic scheduler tests can hold dequeue before the
99    /// cancellation fence is acquired.
100    fn before_queued_dequeue(&self, _job: &SpawnJob) -> BoxFuture<'static, ()> {
101        Box::pin(async {})
102    }
103
104    /// Optional observer inside the cancellation fence, immediately before
105    /// runner reservation. It must never make a model decision or perform
106    /// unbounded work while this guard is held.
107    fn before_queued_reservation(&self, _job: &SpawnJob) -> BoxFuture<'static, ()> {
108        Box::pin(async {})
109    }
110}
111
112/// Runtime-scoped durable inbox resources used by external actor drivers.
113///
114/// The host store remains canonical. A worker confirmation is only permission
115/// for the driver to checkpoint that canonical logical Session and then ack the
116/// exact claim; it never turns transport state into authority.
117#[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    /// The production Host's exact SessionRepository write coordinator.
124    /// Canonical Worker checkpoints fail closed when this is absent.
125    pub parent_question_lock: Option<Arc<bamboo_storage::LockedSessionStore>>,
126}
127
128/// Trait for external child session runtimes (e.g. A2A, CLI adapters).
129///
130/// Implementors are responsible for emitting AgentEvents via `event_tx`
131/// and respecting the `cancel_token`.
132#[async_trait::async_trait]
133pub trait ExternalChildRunner: Send + Sync {
134    /// App catalog/read services for the owned local backend only.
135    /// Process and remote adapters deliberately retain the default no-op.
136    fn set_local_child_skill_factory(
137        &self,
138        _factory: Option<Arc<dyn crate::external_agents::local_task::LocalChildSkillFactory>>,
139    ) {
140    }
141
142    /// Bind the Host's in-process execution resources. Process/remote adapters
143    /// deliberately ignore this capability and retain their existing mode.
144    fn set_local_task_runtime(
145        &self,
146        _runtime: Option<crate::external_agents::local_task::LocalTaskRuntime>,
147    ) {
148    }
149
150    /// Acquire this backend's exact durable writer before the canonical
151    /// running checkpoint. A default writer is never substituted after claim.
152    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    /// Roll back a prepared execution whose initial canonical checkpoint failed.
162    /// Local implementations must close the launch gate and prove resource drain.
163    async fn abandon_child_execution(
164        &self,
165        _session: &Session,
166        _run_id: &str,
167    ) -> Result<(), String> {
168        Ok(())
169    }
170
171    /// Record a setup panic without inventing a successful resource stop.
172    fn quarantine_child_execution(&self, _session: &Session, _run_id: &str) {}
173
174    /// Keep a local coordinator handle owned until it really exits. Returning
175    /// the handle leaves supervision to an outer composite or legacy caller.
176    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    /// End the exact backend writer after terminal persistence and the history
186    /// barrier, before publishing runner replacement eligibility.
187    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    /// Stop admission, request cancellation, and await owned task/resource
197    /// completion. This is called by the Host's graceful shutdown path.
198    async fn shutdown(&self) -> Result<(), String> {
199        Ok(())
200    }
201
202    /// Opt-in, host-owned scope authority. It is never copied into the Worker.
203    fn set_ticket_service(&self, _service: Option<Arc<bamboo_tickets::TicketService>>) {}
204    /// A narrow, pre-persistence compatibility check on the actual registered
205    /// runner. Unknown/custom routes fail closed for required one-shot packets.
206    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    /// Returns true if this runner should handle the given child session.
213    async fn should_handle(&self, session: &Session) -> bool;
214
215    /// Execute the child session using an external runtime.
216    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    /// Persist a Host-only broker receipt before the final Child checkpoint.
225    /// `Ok(true)` selects the append-safe final checkpoint. An unprepared
226    /// broker terminal must never be ACKed after a merely ordinary save.
227    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    /// Commit and read back any Host-only proof required for this terminal
236    /// delivery after the final Child Session save. Failure (including timeout)
237    /// prevents a committed completion source and successful runner finalization.
238    /// When the source is pending, publish its terminal status and seal with an
239    /// exact-snapshot canonical write before returning; the SDK verifies readback.
240    /// Legacy/default runners need no separate proof and retain save-only behavior.
241    async fn commit_durable_child_delivery(
242        &self,
243        _session: &Session,
244        _activation_run_id: &str,
245    ) -> Result<(), String> {
246        Ok(())
247    }
248
249    /// ACK transport delivery only after the final Child save and required
250    /// Host proof commit both succeed. The exact Child birth and activation run
251    /// identify the pending receipt; failed commitment leaves it unacknowledged.
252    /// ACK delivery failure does not invalidate an already-proven completion.
253    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    /// Bind this runner's per-run escalation host bridge (#68). A nested worker's
263    /// `run()` installs its OWN host bridge here so the runner can hand it to each
264    /// grandchild's `drive()` AT SPAWN time (captured into the drive task, not read
265    /// later), letting the grandchild re-proxy a non-bypass approval request UP to
266    /// its parent run for its whole lifetime — even when it outlives the run that
267    /// spawned it. Default no-op for runners that don't escalate (e.g. A2A).
268    fn set_escalation_bridge(&self, _bridge: Option<bamboo_subagent::executor::HostBridge>) {}
269
270    /// Bind the owning runtime's canonical SessionInbox resources. Actor
271    /// runners use this to bridge active local/remote/warm workers without a
272    /// process-global live-session registry.
273    fn set_session_inbox_runtime(&self, _binding: Option<SessionInboxRuntimeBinding>) {}
274
275    /// Optional actual host Store for the local zero-tool named-profile route.
276    /// This does not enable owned Inbox claims or grant authority to custom runners.
277    fn set_actor_directory_store(&self, _store: Option<Arc<bamboo_storage::SessionStoreV2>>) {}
278
279    /// Bind the application-owned logical SubAgent tool after its canonical
280    /// scheduler/adapter exist. Actor runners use it only for fenced child RPCs.
281    fn set_canonical_subagent_tool(&self, _tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {}
282
283    /// Optional host-owned, redacted canonical Actor event observer.
284    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    /// Optional application-layer completion hook. The engine still emits
302    /// `SubAgentCompleted` to the parent stream itself; this hook lets the
303    /// server persist parent wait state and resume the parent runner without
304    /// introducing an engine -> AppState dependency.
305    pub completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
306    /// Optional application observer setup shared by queued tool launches and
307    /// reserved idle SessionInbox activation.
308    pub child_run_launch_hook: Option<Arc<dyn ChildRunLaunchHook>>,
309    /// Optional inbox to the account-wide change feed. When present, durable
310    /// change events from child-session execution are mirrored onto the feed
311    /// for resumable multi-client sync.
312    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        // This is a route-only probe for the Child we have not created yet.
346        // Keep it out of execution/persistence, but select the same backend as
347        // that Child; a Root-shaped probe cannot match the native Child runner.
348        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        // The worker loop is a single point of failure for ALL child spawning:
366        // if it unwinds, queued jobs are dropped with no completion published
367        // and every later enqueue fails with "spawn scheduler is not running"
368        // for the rest of the process lifetime. Run each job on its own task
369        // and await the JoinHandle — a panicking job is isolated, keeps the
370        // worker alive, and still publishes a terminal error completion so the
371        // waiting parent is woken instead of stranded.
372        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                    // The producer publishes Start before returning to the
386                    // tool. If it is aborted after queue admission, the worker
387                    // publishes Start before any child execution/completion.
388                    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    /// Reserve queue capacity, publish the observable child Start, and only
473    /// then release the job to the worker. This preserves S→C ordering even
474    /// when child loading/execution fails immediately.
475    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    /// A child action supplies the generation it durably prepared before
497    /// queue admission. Replayed or cancelled generations cannot borrow a
498    /// newer explicit retry's pending status.
499    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                // From publication through the acknowledgement there is no
540                // await. A dropped producer after this point must not make
541                // the worker publish a duplicate Start.
542                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    /// Publish replayable parent-session state through the scheduler's shared
556    /// runner/cache/account/broadcast boundary.
557    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    /// Launch through the canonical child core using a runner slot already
586    /// reserved by SessionInbox activation. This bypasses only queue mechanics;
587    /// placement and execution still flow through `run_child_spawn`.
588    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
647/// The queue send is the admission commit. The cancellation fence and send run
648/// in one synchronous critical section after capacity is reserved. Observer
649/// setup and `SubAgentStarted` run only for a committed queue entry, before
650/// the child execution starts.
651async 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): the child-wait watchdog (#546) reads these limits to decide
703    // when a Running runner entry whose task died (frozen last_event_at) is
704    // stale beyond what the per-child liveness watchdog could still act on.
705    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            // Parent waits may be longer, but child execution owns its own
714            // liveness. A one hour total cap avoids indefinitely orphaned
715            // sub-session runners.
716            max_total_secs: 60 * 60,
717            // No child event for 15 minutes is considered stalled.
718            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        // Contain a panicking handler: this call frequently runs on the caller's
764        // only liveness-critical task (the child's terminal block, or the spawn
765        // scheduler worker for early failures). Unwinding here would kill that
766        // task after the child already looks terminal everywhere — the classic
767        // stranded-parent signature. The child-wait watchdog backstops the wake
768        // that a panicked handler failed to deliver.
769        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    // Skip the immediate tick.
821    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
885/// Drive a single queued spawn job through the canonical child-spawn path.
886///
887/// ANTI-FORK: this is a 1-line delegator to [`crate::sdk::spawn::run_child_spawn`],
888/// which is the single implementation of the spawn/execute/finalize logic. The
889/// `SpawnScheduler` queue mechanics (above) remain here; the body lives in the SDK
890/// core so both the scheduler and the ergonomic `ChildRunner` funnel into it.
891async 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        // Reproduce the precise post-publish, pre-ack producer loss: the
1062        // worker observes a closed announcement channel, but Start is already
1063        // in the replayable event stream.
1064        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}