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    /// Opt-in, host-owned scope authority. It is never copied into the Worker.
135    fn set_ticket_service(&self, _service: Option<Arc<bamboo_tickets::TicketService>>) {}
136    /// A narrow, pre-persistence compatibility check on the actual registered
137    /// runner. Unknown/custom routes fail closed for required one-shot packets.
138    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    /// Returns true if this runner should handle the given child session.
145    async fn should_handle(&self, session: &Session) -> bool;
146
147    /// Execute the child session using an external runtime.
148    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    /// Persist a Host-only broker receipt before the final Child checkpoint.
157    /// `Ok(true)` selects the append-safe final checkpoint. An unprepared
158    /// broker terminal must never be ACKed after a merely ordinary save.
159    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    /// Commit and read back any Host-only proof required for this terminal
168    /// delivery after the final Child Session save. Failure (including timeout)
169    /// prevents a committed completion source and successful runner finalization.
170    /// When the source is pending, publish its terminal status and seal with an
171    /// exact-snapshot canonical write before returning; the SDK verifies readback.
172    /// Legacy/default runners need no separate proof and retain save-only behavior.
173    async fn commit_durable_child_delivery(
174        &self,
175        _session: &Session,
176        _activation_run_id: &str,
177    ) -> Result<(), String> {
178        Ok(())
179    }
180
181    /// ACK transport delivery only after the final Child save and required
182    /// Host proof commit both succeed. The exact Child birth and activation run
183    /// identify the pending receipt; failed commitment leaves it unacknowledged.
184    /// ACK delivery failure does not invalidate an already-proven completion.
185    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    /// Bind this runner's per-run escalation host bridge (#68). A nested worker's
195    /// `run()` installs its OWN host bridge here so the runner can hand it to each
196    /// grandchild's `drive()` AT SPAWN time (captured into the drive task, not read
197    /// later), letting the grandchild re-proxy a non-bypass approval request UP to
198    /// its parent run for its whole lifetime — even when it outlives the run that
199    /// spawned it. Default no-op for runners that don't escalate (e.g. A2A).
200    fn set_escalation_bridge(&self, _bridge: Option<bamboo_subagent::executor::HostBridge>) {}
201
202    /// Bind the owning runtime's canonical SessionInbox resources. Actor
203    /// runners use this to bridge active local/remote/warm workers without a
204    /// process-global live-session registry.
205    fn set_session_inbox_runtime(&self, _binding: Option<SessionInboxRuntimeBinding>) {}
206
207    /// Optional actual host Store for the local zero-tool named-profile route.
208    /// This does not enable owned Inbox claims or grant authority to custom runners.
209    fn set_actor_directory_store(&self, _store: Option<Arc<bamboo_storage::SessionStoreV2>>) {}
210
211    /// Bind the application-owned logical SubAgent tool after its canonical
212    /// scheduler/adapter exist. Actor runners use it only for fenced child RPCs.
213    fn set_canonical_subagent_tool(&self, _tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {}
214
215    /// Optional host-owned, redacted canonical Actor event observer.
216    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    /// Optional application-layer completion hook. The engine still emits
234    /// `SubAgentCompleted` to the parent stream itself; this hook lets the
235    /// server persist parent wait state and resume the parent runner without
236    /// introducing an engine -> AppState dependency.
237    pub completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
238    /// Optional application observer setup shared by queued tool launches and
239    /// reserved idle SessionInbox activation.
240    pub child_run_launch_hook: Option<Arc<dyn ChildRunLaunchHook>>,
241    /// Optional inbox to the account-wide change feed. When present, durable
242    /// change events from child-session execution are mirrored onto the feed
243    /// for resumable multi-client sync.
244    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        // The worker loop is a single point of failure for ALL child spawning:
294        // if it unwinds, queued jobs are dropped with no completion published
295        // and every later enqueue fails with "spawn scheduler is not running"
296        // for the rest of the process lifetime. Run each job on its own task
297        // and await the JoinHandle — a panicking job is isolated, keeps the
298        // worker alive, and still publishes a terminal error completion so the
299        // waiting parent is woken instead of stranded.
300        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                    // The producer publishes Start before returning to the
314                    // tool. If it is aborted after queue admission, the worker
315                    // publishes Start before any child execution/completion.
316                    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    /// Reserve queue capacity, publish the observable child Start, and only
401    /// then release the job to the worker. This preserves S→C ordering even
402    /// when child loading/execution fails immediately.
403    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    /// A child action supplies the generation it durably prepared before
425    /// queue admission. Replayed or cancelled generations cannot borrow a
426    /// newer explicit retry's pending status.
427    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                // From publication through the acknowledgement there is no
468                // await. A dropped producer after this point must not make
469                // the worker publish a duplicate Start.
470                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    /// Publish replayable parent-session state through the scheduler's shared
484    /// runner/cache/account/broadcast boundary.
485    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    /// Launch through the canonical child core using a runner slot already
514    /// reserved by SessionInbox activation. This bypasses only queue mechanics;
515    /// placement and execution still flow through `run_child_spawn`.
516    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
575/// The queue send is the admission commit. The cancellation fence and send run
576/// in one synchronous critical section after capacity is reserved. Observer
577/// setup and `SubAgentStarted` run only for a committed queue entry, before
578/// the child execution starts.
579async 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): the child-wait watchdog (#546) reads these limits to decide
631    // when a Running runner entry whose task died (frozen last_event_at) is
632    // stale beyond what the per-child liveness watchdog could still act on.
633    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            // Parent waits may be longer, but child execution owns its own
642            // liveness. A one hour total cap avoids indefinitely orphaned
643            // sub-session runners.
644            max_total_secs: 60 * 60,
645            // No child event for 15 minutes is considered stalled.
646            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        // Contain a panicking handler: this call frequently runs on the caller's
692        // only liveness-critical task (the child's terminal block, or the spawn
693        // scheduler worker for early failures). Unwinding here would kill that
694        // task after the child already looks terminal everywhere — the classic
695        // stranded-parent signature. The child-wait watchdog backstops the wake
696        // that a panicked handler failed to deliver.
697        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    // Skip the immediate tick.
749    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
813/// Drive a single queued spawn job through the canonical child-spawn path.
814///
815/// ANTI-FORK: this is a 1-line delegator to [`crate::sdk::spawn::run_child_spawn`],
816/// which is the single implementation of the spawn/execute/finalize logic. The
817/// `SpawnScheduler` queue mechanics (above) remain here; the body lives in the SDK
818/// core so both the scheduler and the ergonomic `ChildRunner` funnel into it.
819async 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        // Reproduce the precise post-publish, pre-ack producer loss: the
990        // worker observes a closed announcement channel, but Start is already
991        // in the replayable event stream.
992        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}