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
117#[derive(Clone)]
118pub struct SpawnScheduler {
119    tx: mpsc::Sender<SpawnJob>,
120    ctx: SpawnContext,
121}
122
123impl SpawnScheduler {
124    pub fn new(ctx: SpawnContext) -> Self {
125        let (tx, mut rx) = mpsc::channel::<SpawnJob>(128);
126        let worker_ctx = ctx.clone();
127
128        // The worker loop is a single point of failure for ALL child spawning:
129        // if it unwinds, queued jobs are dropped with no completion published
130        // and every later enqueue fails with "spawn scheduler is not running"
131        // for the rest of the process lifetime. Run each job on its own task
132        // and await the JoinHandle — a panicking job is isolated, keeps the
133        // worker alive, and still publishes a terminal error completion so the
134        // waiting parent is woken instead of stranded.
135        tokio::spawn(async move {
136            while let Some(job) = rx.recv().await {
137                let job_ctx = worker_ctx.clone();
138                let job_for_panic = job.clone();
139                let handle = tokio::spawn(async move {
140                    if let Err(err) = run_spawn_job(job_ctx, job).await {
141                        tracing::warn!("spawn job failed: {}", err);
142                    }
143                });
144                if let Err(join_error) = handle.await {
145                    tracing::error!(
146                        parent_session_id = %job_for_panic.parent_session_id,
147                        child_session_id = %job_for_panic.child_session_id,
148                        error = %join_error,
149                        "spawn job panicked; publishing terminal error completion"
150                    );
151                    let parent_tx = super::session_events::get_or_create_event_sender(
152                        &worker_ctx.session_event_senders,
153                        &job_for_panic.parent_session_id,
154                    )
155                    .await;
156                    publish_child_completion_parts(
157                        &parent_tx,
158                        worker_ctx.completion_handler.clone(),
159                        job_for_panic.parent_session_id.clone(),
160                        job_for_panic.child_session_id.clone(),
161                        "error".to_string(),
162                        Some(format!("child spawn panicked: {join_error}")),
163                    )
164                    .await;
165                }
166            }
167        });
168
169        Self { tx, ctx }
170    }
171
172    async fn prepare_child_launch(ctx: &SpawnContext, job: &SpawnJob) {
173        let child_tx = super::session_events::get_or_create_event_sender(
174            &ctx.session_event_senders,
175            &job.child_session_id,
176        )
177        .await;
178        invoke_child_run_launch_hook(ctx.child_run_launch_hook.as_ref(), job, child_tx);
179    }
180
181    pub async fn enqueue(&self, job: SpawnJob) -> Result<(), String> {
182        let ctx = self.ctx.clone();
183        let preparation_job = job.clone();
184        reserve_prepare_and_send(&self.tx, job, async move {
185            Self::prepare_child_launch(&ctx, &preparation_job).await;
186        })
187        .await
188    }
189
190    /// Launch through the canonical child core using a runner slot already
191    /// reserved by SessionInbox activation. This bypasses only queue mechanics;
192    /// placement and execution still flow through `run_child_spawn`.
193    pub(crate) fn launch_reserved(
194        &self,
195        job: SpawnJob,
196        reservation: SessionExecutionReservation,
197    ) -> tokio::task::JoinHandle<()> {
198        let ctx = self.ctx.clone();
199        tokio::spawn(async move {
200            Self::prepare_child_launch(&ctx, &job).await;
201            let parent_tx = super::session_events::get_or_create_event_sender(
202                &ctx.session_event_senders,
203                &job.parent_session_id,
204            )
205            .await;
206            let _ = parent_tx.send(AgentEvent::SubAgentStarted {
207                parent_session_id: job.parent_session_id.clone(),
208                child_session_id: job.child_session_id.clone(),
209                title: None,
210            });
211            if let Err(error) =
212                crate::sdk::spawn::run_child_spawn_reserved(ctx, job.clone(), reservation).await
213            {
214                tracing::warn!(
215                    parent_session_id = %job.parent_session_id,
216                    child_session_id = %job.child_session_id,
217                    %error,
218                    "reserved child activation failed"
219                );
220            }
221        })
222    }
223}
224
225fn invoke_child_run_launch_hook(
226    hook: Option<&Arc<dyn ChildRunLaunchHook>>,
227    job: &SpawnJob,
228    child_tx: broadcast::Sender<AgentEvent>,
229) {
230    let Some(hook) = hook else {
231        return;
232    };
233    let invoked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
234        hook.before_child_launch(job, child_tx);
235    }));
236    if invoked.is_err() {
237        tracing::error!(
238            parent_session_id = %job.parent_session_id,
239            child_session_id = %job.child_session_id,
240            "child launch hook panicked; continuing with canonical execution"
241        );
242    }
243}
244
245/// Reserve queue capacity before polling observer setup. A closed scheduler
246/// therefore cannot start a relay/observer for a child that will never launch.
247async fn reserve_prepare_and_send(
248    tx: &mpsc::Sender<SpawnJob>,
249    job: SpawnJob,
250    preparation: impl std::future::Future<Output = ()>,
251) -> Result<(), String> {
252    let permit = tx
253        .reserve()
254        .await
255        .map_err(|_| "spawn scheduler is not running".to_string())?;
256    preparation.await;
257    permit.send(job);
258    Ok(())
259}
260
261#[derive(Debug, Clone, Copy)]
262pub(crate) struct ChildWatchdogPolicy {
263    check_interval_secs: i64,
264    // pub(crate): the child-wait watchdog (#546) reads these limits to decide
265    // when a Running runner entry whose task died (frozen last_event_at) is
266    // stale beyond what the per-child liveness watchdog could still act on.
267    pub(crate) max_total_secs: i64,
268    pub(crate) max_idle_secs: i64,
269}
270
271impl Default for ChildWatchdogPolicy {
272    fn default() -> Self {
273        Self {
274            check_interval_secs: 15,
275            // Parent waits may be longer, but child execution owns its own
276            // liveness. A one hour total cap avoids indefinitely orphaned
277            // sub-session runners.
278            max_total_secs: 60 * 60,
279            // No child event for 15 minutes is considered stalled.
280            max_idle_secs: 15 * 60,
281        }
282    }
283}
284
285fn metadata_i64(session: &Session, key: &str) -> Option<i64> {
286    session
287        .metadata
288        .get(key)
289        .and_then(|value| value.trim().parse::<i64>().ok())
290        .filter(|value| *value > 0)
291}
292
293pub(crate) fn watchdog_policy_for_session(session: &Session) -> ChildWatchdogPolicy {
294    let mut policy = ChildWatchdogPolicy::default();
295    if let Some(value) = metadata_i64(session, "child_watchdog.max_total_secs") {
296        policy.max_total_secs = value;
297    }
298    if let Some(value) = metadata_i64(session, "child_watchdog.max_idle_secs") {
299        policy.max_idle_secs = value;
300    }
301    if let Some(value) = metadata_i64(session, "child_watchdog.check_interval_secs") {
302        policy.check_interval_secs = value;
303    }
304    policy
305}
306
307async fn publish_child_completion(
308    parent_tx: &broadcast::Sender<AgentEvent>,
309    completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
310    completion: ChildCompletion,
311) {
312    let _ = parent_tx.send(AgentEvent::SubAgentCompleted {
313        parent_session_id: completion.parent_session_id.clone(),
314        child_session_id: completion.child_session_id.clone(),
315        status: completion.status.clone(),
316        error: completion.error.clone(),
317    });
318
319    if let Some(handler) = completion_handler {
320        // Contain a panicking handler: this call frequently runs on the caller's
321        // only liveness-critical task (the child's terminal block, or the spawn
322        // scheduler worker for early failures). Unwinding here would kill that
323        // task after the child already looks terminal everywhere — the classic
324        // stranded-parent signature. The child-wait watchdog backstops the wake
325        // that a panicked handler failed to deliver.
326        use futures::FutureExt;
327        let parent_session_id = completion.parent_session_id.clone();
328        let child_session_id = completion.child_session_id.clone();
329        if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
330            .catch_unwind()
331            .await
332            .is_err()
333        {
334            tracing::error!(
335                %parent_session_id,
336                %child_session_id,
337                "child completion handler panicked; child-wait watchdog will backstop the parent wake"
338            );
339        }
340    }
341}
342
343pub(crate) async fn publish_child_completion_parts(
344    parent_tx: &broadcast::Sender<AgentEvent>,
345    completion_handler: Option<Arc<dyn ChildCompletionHandler>>,
346    parent_session_id: String,
347    child_session_id: String,
348    status: String,
349    error: Option<String>,
350) {
351    publish_child_completion(
352        parent_tx,
353        completion_handler,
354        ChildCompletion {
355            parent_session_id,
356            child_session_id,
357            status,
358            error,
359            completed_at: Utc::now(),
360        },
361    )
362    .await;
363}
364
365pub(crate) async fn watch_child_liveness(
366    parent_session_id: String,
367    child_session_id: String,
368    runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
369    cancel_token: CancellationToken,
370    timeout_reason: Arc<RwLock<Option<String>>>,
371    done: CancellationToken,
372    policy: ChildWatchdogPolicy,
373) {
374    let mut ticker =
375        tokio::time::interval(Duration::from_secs(policy.check_interval_secs.max(1) as u64));
376    // Skip the immediate tick.
377    ticker.tick().await;
378
379    loop {
380        tokio::select! {
381            _ = done.cancelled() => return,
382            _ = ticker.tick() => {
383                if cancel_token.is_cancelled() {
384                    return;
385                }
386
387                let snapshot = {
388                    let guard = runners.read().await;
389                    guard.get(&child_session_id).cloned()
390                };
391                let Some(runner) = snapshot else {
392                    return;
393                };
394                if !matches!(runner.status, super::runner_state::AgentStatus::Running) {
395                    return;
396                }
397
398                let now = Utc::now();
399                let total_secs = now.signed_duration_since(runner.started_at).num_seconds();
400                if total_secs >= policy.max_total_secs {
401                    let reason = format!(
402                        "Child session timed out after {} seconds (max_total_secs={})",
403                        total_secs, policy.max_total_secs
404                    );
405                    tracing::warn!(
406                        parent_session_id = %parent_session_id,
407                        child_session_id = %child_session_id,
408                        reason = %reason,
409                        "child session total timeout; cancelling child runner"
410                    );
411                    *timeout_reason.write().await = Some(reason);
412                    cancel_token.cancel();
413                    return;
414                }
415
416                let last_activity_at = runner.last_event_at.unwrap_or(runner.started_at);
417                let idle_secs = now.signed_duration_since(last_activity_at).num_seconds();
418                if idle_secs >= policy.max_idle_secs {
419                    let reason = format!(
420                        "Child session idle timeout after {} seconds without events (max_idle_secs={})",
421                        idle_secs, policy.max_idle_secs
422                    );
423                    tracing::warn!(
424                        parent_session_id = %parent_session_id,
425                        child_session_id = %child_session_id,
426                        reason = %reason,
427                        last_tool_name = ?runner.last_tool_name,
428                        last_tool_phase = ?runner.last_tool_phase,
429                        round_count = runner.round_count,
430                        "child session idle timeout; cancelling child runner"
431                    );
432                    *timeout_reason.write().await = Some(reason);
433                    cancel_token.cancel();
434                    return;
435                }
436            }
437        }
438    }
439}
440
441/// Drive a single queued spawn job through the canonical child-spawn path.
442///
443/// ANTI-FORK: this is a 1-line delegator to [`crate::sdk::spawn::run_child_spawn`],
444/// which is the single implementation of the spawn/execute/finalize logic. The
445/// `SpawnScheduler` queue mechanics (above) remain here; the body lives in the SDK
446/// core so both the scheduler and the ergonomic `ChildRunner` funnel into it.
447async fn run_spawn_job(ctx: SpawnContext, job: SpawnJob) -> Result<(), String> {
448    crate::sdk::spawn::run_child_spawn(ctx, job).await
449}
450
451#[cfg(test)]
452mod launch_hook_tests {
453    use super::*;
454    use std::sync::atomic::{AtomicUsize, Ordering};
455
456    fn job() -> SpawnJob {
457        SpawnJob {
458            parent_session_id: "parent".to_string(),
459            child_session_id: "child".to_string(),
460            model: "test".to_string(),
461            disabled_tools: None,
462        }
463    }
464
465    struct PanickingHook {
466        calls: AtomicUsize,
467    }
468
469    impl ChildRunLaunchHook for PanickingHook {
470        fn before_child_launch(
471            &self,
472            _job: &SpawnJob,
473            _child_events: broadcast::Sender<AgentEvent>,
474        ) {
475            self.calls.fetch_add(1, Ordering::SeqCst);
476            panic!("injected launch hook panic");
477        }
478    }
479
480    #[test]
481    fn launch_hook_panic_is_contained() {
482        let hook = Arc::new(PanickingHook {
483            calls: AtomicUsize::new(0),
484        });
485        let hook_port: Arc<dyn ChildRunLaunchHook> = hook.clone();
486        let (child_tx, _child_rx) = broadcast::channel(1);
487
488        invoke_child_run_launch_hook(Some(&hook_port), &job(), child_tx);
489
490        assert_eq!(hook.calls.load(Ordering::SeqCst), 1);
491    }
492
493    #[tokio::test]
494    async fn closed_scheduler_does_not_prepare_phantom_launch() {
495        let (tx, rx) = mpsc::channel(1);
496        drop(rx);
497        let preparations = Arc::new(AtomicUsize::new(0));
498        let preparations_for_future = preparations.clone();
499
500        let result = reserve_prepare_and_send(&tx, job(), async move {
501            preparations_for_future.fetch_add(1, Ordering::SeqCst);
502        })
503        .await;
504
505        assert_eq!(result.unwrap_err(), "spawn scheduler is not running");
506        assert_eq!(preparations.load(Ordering::SeqCst), 0);
507    }
508}