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::Arc;
9use std::time::Duration;
10
11use chrono::Utc;
12use tokio::sync::{broadcast, mpsc, RwLock};
13use tokio_util::sync::CancellationToken;
14
15use bamboo_agent_core::storage::Storage;
16use bamboo_agent_core::tools::ToolExecutor;
17use bamboo_agent_core::{AgentEvent, Session};
18use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort};
19use bamboo_llm::ProviderModelRouter;
20
21use crate::runtime::Agent;
22
23use super::agent_spawn::SessionExecutionReservation;
24use super::child_completion::{ChildCompletion, ChildCompletionHandler};
25use super::runner_state::AgentRunner;
26
27#[derive(Debug, Clone)]
28pub struct SpawnJob {
29    pub parent_session_id: String,
30    pub child_session_id: String,
31    pub model: String,
32    /// Tool names to hide from the LLM schema for this child session.
33    /// Computed from the child's `subagent_type` profile policy.
34    pub disabled_tools: Option<Vec<String>>,
35}
36
37/// Optional application-layer preparation for a child run launched through
38/// the canonical scheduler.
39///
40/// The engine owns runner reservation and execution. Applications may use
41/// this synchronous, no-fail hook to attach observers to the already-created
42/// child event sender (for example, the server's always-on notification
43/// relay) without introducing an engine dependency on application services.
44pub trait ChildRunLaunchHook: Send + Sync {
45    fn before_child_launch(&self, job: &SpawnJob, child_events: broadcast::Sender<AgentEvent>);
46}
47
48/// Runtime-scoped durable inbox resources used by external actor drivers.
49///
50/// The host store remains canonical. A worker confirmation is only permission
51/// for the driver to checkpoint that canonical logical Session and then ack the
52/// exact claim; it never turns transport state into authority.
53#[derive(Clone)]
54pub struct SessionInboxRuntimeBinding {
55    pub router: Arc<crate::SessionActivationRouter>,
56    pub inbox: Arc<dyn SessionInboxPort>,
57    pub storage: Arc<dyn Storage>,
58    pub persistence: Arc<dyn RuntimeSessionPersistence>,
59}
60
61/// Trait for external child session runtimes (e.g. A2A, CLI adapters).
62///
63/// Implementors are responsible for emitting AgentEvents via `event_tx`
64/// and respecting the `cancel_token`.
65#[async_trait::async_trait]
66pub trait ExternalChildRunner: Send + Sync {
67    /// Returns true if this runner should handle the given child session.
68    async fn should_handle(&self, session: &Session) -> bool;
69
70    /// Execute the child session using an external runtime.
71    async fn execute_external_child(
72        &self,
73        session: &mut Session,
74        job: &SpawnJob,
75        event_tx: tokio::sync::mpsc::Sender<AgentEvent>,
76        cancel_token: CancellationToken,
77    ) -> crate::runtime::runner::Result<()>;
78
79    /// Bind this runner's per-run escalation host bridge (#68). A nested worker's
80    /// `run()` installs its OWN host bridge here so the runner can hand it to each
81    /// grandchild's `drive()` AT SPAWN time (captured into the drive task, not read
82    /// later), letting the grandchild re-proxy a non-bypass approval request UP to
83    /// its parent run for its whole lifetime — even when it outlives the run that
84    /// spawned it. Default no-op for runners that don't escalate (e.g. A2A).
85    fn set_escalation_bridge(&self, _bridge: Option<bamboo_subagent::executor::HostBridge>) {}
86
87    /// Bind the owning runtime's canonical SessionInbox resources. Actor
88    /// runners use this to bridge active local/remote/warm workers without a
89    /// process-global live-session registry.
90    fn set_session_inbox_runtime(&self, _binding: Option<SessionInboxRuntimeBinding>) {}
91}
92
93#[derive(Clone)]
94pub struct SpawnContext {
95    pub agent: Arc<Agent>,
96    pub tools: Arc<dyn ToolExecutor>,
97    pub sessions_cache: crate::SessionCache,
98    pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
99    pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
100    pub external_child_runner: Arc<dyn ExternalChildRunner>,
101    pub provider_router: Option<Arc<ProviderModelRouter>>,
102    pub app_data_dir: Option<std::path::PathBuf>,
103    /// Optional application-layer completion hook. The engine still emits
104    /// `SubAgentCompleted` to the parent stream itself; this hook lets the
105    /// server persist parent wait state and resume the parent runner without
106    /// introducing an engine -> AppState dependency.
107    pub completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
108    /// Optional application observer setup shared by queued tool launches and
109    /// reserved idle SessionInbox activation.
110    pub child_run_launch_hook: Option<Arc<dyn ChildRunLaunchHook>>,
111    /// Optional inbox to the account-wide change feed. When present, durable
112    /// change events from child-session execution are mirrored onto the feed
113    /// for resumable multi-client sync.
114    pub account_feed_inbox: Option<super::event_forwarder::AccountFeedInbox>,
115}
116
117impl SpawnContext {
118    pub(crate) fn replayable_event_publisher(
119        &self,
120    ) -> super::session_events::ReplayableSessionEventPublisher {
121        super::session_events::ReplayableSessionEventPublisher::new(
122            self.agent_runners.clone(),
123            self.session_event_senders.clone(),
124            self.account_feed_inbox.clone(),
125        )
126    }
127}
128
129#[derive(Clone)]
130pub struct SpawnScheduler {
131    tx: mpsc::Sender<SpawnJob>,
132    ctx: SpawnContext,
133}
134
135impl SpawnScheduler {
136    pub fn new(ctx: SpawnContext) -> Self {
137        let (tx, mut rx) = mpsc::channel::<SpawnJob>(128);
138        let worker_ctx = ctx.clone();
139
140        // The worker loop is a single point of failure for ALL child spawning:
141        // if it unwinds, queued jobs are dropped with no completion published
142        // and every later enqueue fails with "spawn scheduler is not running"
143        // for the rest of the process lifetime. Run each job on its own task
144        // and await the JoinHandle — a panicking job is isolated, keeps the
145        // worker alive, and still publishes a terminal error completion so the
146        // waiting parent is woken instead of stranded.
147        tokio::spawn(async move {
148            while let Some(job) = rx.recv().await {
149                let job_ctx = worker_ctx.clone();
150                let job_for_panic = job.clone();
151                let handle = tokio::spawn(async move {
152                    if let Err(err) = run_spawn_job(job_ctx, job).await {
153                        tracing::warn!("spawn job failed: {}", err);
154                    }
155                });
156                if let Err(join_error) = handle.await {
157                    tracing::error!(
158                        parent_session_id = %job_for_panic.parent_session_id,
159                        child_session_id = %job_for_panic.child_session_id,
160                        error = %join_error,
161                        "spawn job panicked; publishing terminal error completion"
162                    );
163                    let publisher = worker_ctx.replayable_event_publisher();
164                    publish_child_completion_parts(
165                        &publisher,
166                        worker_ctx.completion_handler.clone(),
167                        job_for_panic.parent_session_id.clone(),
168                        job_for_panic.child_session_id.clone(),
169                        "error".to_string(),
170                        Some(format!("child spawn panicked: {join_error}")),
171                    )
172                    .await;
173                }
174            }
175        });
176
177        Self { tx, ctx }
178    }
179
180    async fn prepare_child_launch(ctx: &SpawnContext, job: &SpawnJob) {
181        let child_tx = super::session_events::get_or_create_event_sender(
182            &ctx.session_event_senders,
183            &job.child_session_id,
184        )
185        .await;
186        invoke_child_run_launch_hook(ctx.child_run_launch_hook.as_ref(), job, child_tx);
187    }
188
189    pub async fn enqueue(&self, job: SpawnJob) -> Result<(), String> {
190        self.enqueue_announced(job, None).await
191    }
192
193    /// Reserve queue capacity, publish the observable child Start, and only
194    /// then release the job to the worker. This preserves S→C ordering even
195    /// when child loading/execution fails immediately.
196    pub async fn enqueue_announced(
197        &self,
198        job: SpawnJob,
199        title: Option<String>,
200    ) -> Result<(), String> {
201        let ctx = self.ctx.clone();
202        let preparation_job = job.clone();
203        reserve_prepare_and_send(&self.tx, job, async move {
204            Self::prepare_child_launch(&ctx, &preparation_job).await;
205            ctx.replayable_event_publisher()
206                .publish(
207                    &preparation_job.parent_session_id,
208                    AgentEvent::SubAgentStarted {
209                        parent_session_id: preparation_job.parent_session_id.clone(),
210                        child_session_id: preparation_job.child_session_id.clone(),
211                        title,
212                    },
213                )
214                .await;
215        })
216        .await
217    }
218
219    /// Publish replayable parent-session state through the scheduler's shared
220    /// runner/cache/account/broadcast boundary.
221    pub async fn publish_parent_replayable_event(
222        &self,
223        parent_session_id: &str,
224        event: AgentEvent,
225    ) {
226        self.ctx
227            .replayable_event_publisher()
228            .publish(parent_session_id, event)
229            .await;
230    }
231
232    /// Launch through the canonical child core using a runner slot already
233    /// reserved by SessionInbox activation. This bypasses only queue mechanics;
234    /// placement and execution still flow through `run_child_spawn`.
235    pub(crate) fn launch_reserved(
236        &self,
237        job: SpawnJob,
238        reservation: SessionExecutionReservation,
239    ) -> tokio::task::JoinHandle<()> {
240        let ctx = self.ctx.clone();
241        tokio::spawn(async move {
242            Self::prepare_child_launch(&ctx, &job).await;
243            ctx.replayable_event_publisher()
244                .publish(
245                    &job.parent_session_id,
246                    AgentEvent::SubAgentStarted {
247                        parent_session_id: job.parent_session_id.clone(),
248                        child_session_id: job.child_session_id.clone(),
249                        title: None,
250                    },
251                )
252                .await;
253            if let Err(error) =
254                crate::sdk::spawn::run_child_spawn_reserved(ctx, job.clone(), reservation).await
255            {
256                tracing::warn!(
257                    parent_session_id = %job.parent_session_id,
258                    child_session_id = %job.child_session_id,
259                    %error,
260                    "reserved child activation failed"
261                );
262            }
263        })
264    }
265}
266
267fn invoke_child_run_launch_hook(
268    hook: Option<&Arc<dyn ChildRunLaunchHook>>,
269    job: &SpawnJob,
270    child_tx: broadcast::Sender<AgentEvent>,
271) {
272    let Some(hook) = hook else {
273        return;
274    };
275    let invoked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
276        hook.before_child_launch(job, child_tx);
277    }));
278    if invoked.is_err() {
279        tracing::error!(
280            parent_session_id = %job.parent_session_id,
281            child_session_id = %job.child_session_id,
282            "child launch hook panicked; continuing with canonical execution"
283        );
284    }
285}
286
287/// Reserve queue capacity before polling observer setup. A closed scheduler
288/// therefore cannot start a relay/observer for a child that will never launch.
289async fn reserve_prepare_and_send(
290    tx: &mpsc::Sender<SpawnJob>,
291    job: SpawnJob,
292    preparation: impl std::future::Future<Output = ()>,
293) -> Result<(), String> {
294    let permit = tx
295        .reserve()
296        .await
297        .map_err(|_| "spawn scheduler is not running".to_string())?;
298    preparation.await;
299    permit.send(job);
300    Ok(())
301}
302
303#[derive(Debug, Clone, Copy)]
304pub(crate) struct ChildWatchdogPolicy {
305    check_interval_secs: i64,
306    // pub(crate): the child-wait watchdog (#546) reads these limits to decide
307    // when a Running runner entry whose task died (frozen last_event_at) is
308    // stale beyond what the per-child liveness watchdog could still act on.
309    pub(crate) max_total_secs: i64,
310    pub(crate) max_idle_secs: i64,
311}
312
313impl Default for ChildWatchdogPolicy {
314    fn default() -> Self {
315        Self {
316            check_interval_secs: 15,
317            // Parent waits may be longer, but child execution owns its own
318            // liveness. A one hour total cap avoids indefinitely orphaned
319            // sub-session runners.
320            max_total_secs: 60 * 60,
321            // No child event for 15 minutes is considered stalled.
322            max_idle_secs: 15 * 60,
323        }
324    }
325}
326
327fn metadata_i64(session: &Session, key: &str) -> Option<i64> {
328    session
329        .metadata
330        .get(key)
331        .and_then(|value| value.trim().parse::<i64>().ok())
332        .filter(|value| *value > 0)
333}
334
335pub(crate) fn watchdog_policy_for_session(session: &Session) -> ChildWatchdogPolicy {
336    let mut policy = ChildWatchdogPolicy::default();
337    if let Some(value) = metadata_i64(session, "child_watchdog.max_total_secs") {
338        policy.max_total_secs = value;
339    }
340    if let Some(value) = metadata_i64(session, "child_watchdog.max_idle_secs") {
341        policy.max_idle_secs = value;
342    }
343    if let Some(value) = metadata_i64(session, "child_watchdog.check_interval_secs") {
344        policy.check_interval_secs = value;
345    }
346    policy
347}
348
349async fn publish_child_completion(
350    publisher: &super::session_events::ReplayableSessionEventPublisher,
351    completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
352    completion: ChildCompletion,
353) {
354    publisher
355        .publish(
356            &completion.parent_session_id,
357            AgentEvent::SubAgentCompleted {
358                parent_session_id: completion.parent_session_id.clone(),
359                child_session_id: completion.child_session_id.clone(),
360                status: completion.status.clone(),
361                error: completion.error.clone(),
362            },
363        )
364        .await;
365
366    if let Some(handler) = completion_handler {
367        // Contain a panicking handler: this call frequently runs on the caller's
368        // only liveness-critical task (the child's terminal block, or the spawn
369        // scheduler worker for early failures). Unwinding here would kill that
370        // task after the child already looks terminal everywhere — the classic
371        // stranded-parent signature. The child-wait watchdog backstops the wake
372        // that a panicked handler failed to deliver.
373        use futures::FutureExt;
374        let parent_session_id = completion.parent_session_id.clone();
375        let child_session_id = completion.child_session_id.clone();
376        if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
377            .catch_unwind()
378            .await
379            .is_err()
380        {
381            tracing::error!(
382                %parent_session_id,
383                %child_session_id,
384                "child completion handler panicked; child-wait watchdog will backstop the parent wake"
385            );
386        }
387    }
388}
389
390pub(crate) async fn publish_child_completion_parts(
391    publisher: &super::session_events::ReplayableSessionEventPublisher,
392    completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
393    parent_session_id: String,
394    child_session_id: String,
395    status: String,
396    error: Option<String>,
397) {
398    publish_child_completion(
399        publisher,
400        completion_handler,
401        ChildCompletion {
402            parent_session_id,
403            child_session_id,
404            status,
405            error,
406            completed_at: Utc::now(),
407        },
408    )
409    .await;
410}
411
412pub(crate) async fn watch_child_liveness(
413    parent_session_id: String,
414    child_session_id: String,
415    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
416    cancel_token: CancellationToken,
417    timeout_reason: Arc<RwLock<Option<String>>>,
418    done: CancellationToken,
419    policy: ChildWatchdogPolicy,
420) {
421    let mut ticker =
422        tokio::time::interval(Duration::from_secs(policy.check_interval_secs.max(1) as u64));
423    // Skip the immediate tick.
424    ticker.tick().await;
425
426    loop {
427        tokio::select! {
428            _ = done.cancelled() => return,
429            _ = ticker.tick() => {
430                if cancel_token.is_cancelled() {
431                    return;
432                }
433
434                let snapshot = {
435                    let guard = runners.read().await;
436                    guard.get(&child_session_id).cloned()
437                };
438                let Some(runner) = snapshot else {
439                    return;
440                };
441                if !matches!(runner.status, super::runner_state::AgentStatus::Running) {
442                    return;
443                }
444
445                let now = Utc::now();
446                let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
447                if total_secs >= policy.max_total_secs {
448                    let reason = format!(
449                        "Child session timed out after {} seconds (max_total_secs={})",
450                        total_secs, policy.max_total_secs
451                    );
452                    tracing::warn!(
453                        parent_session_id = %parent_session_id,
454                        child_session_id = %child_session_id,
455                        reason = %reason,
456                        "child session total timeout; cancelling child runner"
457                    );
458                    *timeout_reason.write().await = Some(reason);
459                    cancel_token.cancel();
460                    return;
461                }
462
463                let last_activity_at = runner.last_event_at.unwrap_or(runner.started_at);
464                let idle_secs = now.signed_duration_since(last_activity_at).num_seconds();
465                if idle_secs >= policy.max_idle_secs {
466                    let reason = format!(
467                        "Child session idle timeout after {} seconds without events (max_idle_secs={})",
468                        idle_secs, policy.max_idle_secs
469                    );
470                    tracing::warn!(
471                        parent_session_id = %parent_session_id,
472                        child_session_id = %child_session_id,
473                        reason = %reason,
474                        last_tool_name = ?runner.last_tool_name,
475                        last_tool_phase = ?runner.last_tool_phase,
476                        round_count = runner.round_count,
477                        "child session idle timeout; cancelling child runner"
478                    );
479                    *timeout_reason.write().await = Some(reason);
480                    cancel_token.cancel();
481                    return;
482                }
483            }
484        }
485    }
486}
487
488/// Drive a single queued spawn job through the canonical child-spawn path.
489///
490/// ANTI-FORK: this is a 1-line delegator to [`crate::sdk::spawn::run_child_spawn`],
491/// which is the single implementation of the spawn/execute/finalize logic. The
492/// `SpawnScheduler` queue mechanics (above) remain here; the body lives in the SDK
493/// core so both the scheduler and the ergonomic `ChildRunner` funnel into it.
494async fn run_spawn_job(ctx: SpawnContext, job: SpawnJob) -> Result<(), String> {
495    crate::sdk::spawn::run_child_spawn(ctx, job).await
496}
497
498#[cfg(test)]
499mod launch_hook_tests {
500    use super::*;
501    use std::sync::atomic::{AtomicUsize, Ordering};
502
503    fn job() -> SpawnJob {
504        SpawnJob {
505            parent_session_id: "parent".to_string(),
506            child_session_id: "child".to_string(),
507            model: "test".to_string(),
508            disabled_tools: None,
509        }
510    }
511
512    struct PanickingHook {
513        calls: AtomicUsize,
514    }
515
516    impl ChildRunLaunchHook for PanickingHook {
517        fn before_child_launch(
518            &self,
519            _job: &SpawnJob,
520            _child_events: broadcast::Sender<AgentEvent>,
521        ) {
522            self.calls.fetch_add(1, Ordering::SeqCst);
523            panic!("injected launch hook panic");
524        }
525    }
526
527    #[test]
528    fn launch_hook_panic_is_contained() {
529        let hook = Arc::new(PanickingHook {
530            calls: AtomicUsize::new(0),
531        });
532        let hook_port: Arc<dyn ChildRunLaunchHook> = hook.clone();
533        let (child_tx, _child_rx) = broadcast::channel(1);
534
535        invoke_child_run_launch_hook(Some(&hook_port), &job(), child_tx);
536
537        assert_eq!(hook.calls.load(Ordering::SeqCst), 1);
538    }
539
540    #[tokio::test]
541    async fn closed_scheduler_does_not_prepare_phantom_launch() {
542        let (tx, rx) = mpsc::channel(1);
543        drop(rx);
544        let preparations = Arc::new(AtomicUsize::new(0));
545        let preparations_for_future = preparations.clone();
546
547        let result = reserve_prepare_and_send(&tx, job(), async move {
548            preparations_for_future.fetch_add(1, Ordering::SeqCst);
549        })
550        .await;
551
552        assert_eq!(result.unwrap_err(), "spawn scheduler is not running");
553        assert_eq!(preparations.load(Ordering::SeqCst), 0);
554    }
555}