Skip to main content

bamboo_server/tools/
child_session_adapter.rs

1//! Shared adapter implementing `ChildSessionPort` for server-side child session tools.
2//!
3//! The unified `SubAgentTool` delegates to this adapter instead of
4//! duplicating `ChildSessionPort` implementations.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use chrono::Utc;
11use tokio::sync::{broadcast, RwLock};
12use tokio::time::{sleep, Duration, Instant};
13
14use crate::app_state::session_events::get_or_create_event_sender;
15use crate::app_state::{AgentRunner, AgentStatus};
16use bamboo_agent_core::storage::Storage;
17use bamboo_agent_core::{AgentEvent, Session, SessionKind};
18use bamboo_domain::session::runtime_state::{
19    AgentRuntimeState, ChildWaitPolicy, WaitingForChildrenState,
20};
21use bamboo_engine::execution::spawn::{SpawnJob, SpawnScheduler};
22use bamboo_engine::session_app::child_session::{
23    ChildRunnerInfo, ChildSessionEntry, ChildSessionError, ChildSessionPort, DeleteChildResult,
24    SubagentResolutionPort,
25};
26use bamboo_llm::Config;
27use bamboo_storage::{LockedSessionStore, SessionIndexEntry, SessionStoreV2};
28
29/// Server-side adapter that bridges domain `ChildSessionPort` to infrastructure.
30///
31/// Holds all shared state needed by `SubAgentTool`.
32/// Implements the full `ChildSessionPort` trait with real methods (no stubs).
33pub struct ChildSessionAdapter {
34    pub(crate) session_store: Arc<SessionStoreV2>,
35    pub(crate) storage: Arc<dyn Storage>,
36    pub(crate) persistence: Arc<LockedSessionStore>,
37    pub(crate) scheduler: Arc<SpawnScheduler>,
38    pub(crate) sessions_cache: bamboo_engine::SessionCache,
39    pub(crate) agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
40    pub(crate) session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
41    /// Optional subagent model resolver: maps subagent_type → provider+model ref.
42    pub(crate) subagent_model_resolver: crate::tools::OptionalSubagentModelResolver,
43    /// Application config for resolving subagent routing and external agent profiles.
44    pub(crate) config: Arc<RwLock<Config>>,
45    /// Coalesces concurrent parent-wait registrations for the same parent that
46    /// arrive in one spawn round (the LLM emitting several `SubAgent.create`
47    /// calls at once → `join_all`) into a single parent persist. See
48    /// [`ChildSessionAdapter::register_parent_wait_for_child`].
49    pub(crate) parent_wait_slots: Arc<dashmap::DashMap<String, Arc<ParentWaitSlot>>>,
50    /// Deps to start the always-on notification relay for a NEWLY ENQUEUED
51    /// child session (see [`ChildSessionAdapter::enqueue_child_run`]).
52    ///
53    /// Without this, a child's own events (e.g. a `run_in_background` Bash
54    /// command finishing, or the child hitting critical context pressure)
55    /// only ever get classified if a client happens to be subscribed to that
56    /// specific child session's SSE/WS stream — the child's completion
57    /// (`SubAgentCompleted`) still reaches the owner via the PARENT's own
58    /// relay (already running for any in-process parent execution), but
59    /// events that occur only on the child's own stream would otherwise be
60    /// silently dropped for a headless/unwatched child. `None` for the
61    /// out-of-process worker binary (`ChildSessionAdapter::new`), which has
62    /// no local desktop/ntfy/bark config surface to deliver through.
63    pub(crate) notification_relay: Option<crate::app_state::session_events::NotificationRelayDeps>,
64}
65
66/// Per-parent coalescing slot for batched wait registration.
67///
68/// `flush_lock` is a barrier distinct from the persistence per-session lock
69/// (using the latter here would deadlock, since the flush itself takes it). The
70/// first registration to win the barrier drains `pending` and persists the whole
71/// batch once; concurrent registrations that find `pending` already drained were
72/// persisted by that holder before it released the barrier, so they return
73/// without an extra write.
74#[derive(Default)]
75pub(crate) struct ParentWaitSlot {
76    flush_lock: tokio::sync::Mutex<()>,
77    pending: parking_lot::Mutex<Vec<(String, Option<String>)>>,
78}
79
80const AGENT_RUNTIME_STATE_METADATA_KEY: &str = "agent.runtime.state";
81
82/// Terminal child run statuses, as mirrored into the session index. A child not
83/// in one of these states is considered active (still pending/running).
84fn is_terminal_child_status(status: &str) -> bool {
85    matches!(
86        status,
87        "completed" | "error" | "timeout" | "cancelled" | "skipped"
88    )
89}
90
91fn read_runtime_state(session: &Session) -> AgentRuntimeState {
92    session
93        .agent_runtime_state
94        .clone()
95        .or_else(|| {
96            session
97                .metadata
98                .get(AGENT_RUNTIME_STATE_METADATA_KEY)
99                .and_then(|raw| serde_json::from_str::<AgentRuntimeState>(raw).ok())
100        })
101        .unwrap_or_else(|| AgentRuntimeState::new(format!("{}-wait", session.id)))
102}
103
104fn write_runtime_state(session: &mut Session, runtime_state: &AgentRuntimeState) {
105    session.agent_runtime_state = Some(runtime_state.clone());
106    if let Ok(serialized) = serde_json::to_string(runtime_state) {
107        session
108            .metadata
109            .insert(AGENT_RUNTIME_STATE_METADATA_KEY.to_string(), serialized);
110    }
111}
112
113impl ChildSessionAdapter {
114    /// Shared tail of the two child-save methods: map the persist error and
115    /// refresh the in-memory cache. The two public methods differ ONLY in which
116    /// persistence call they make (adopting vs authoritative); everything after
117    /// is identical, so it lives here to stay in lockstep. #540.
118    fn finish_child_save(
119        &self,
120        child: &Session,
121        saved: std::io::Result<()>,
122    ) -> Result<(), ChildSessionError> {
123        saved.map_err(|error| {
124            ChildSessionError::Execution(format!("failed to save child session: {error}"))
125        })?;
126        self.sessions_cache.insert(
127            child.id.clone(),
128            Arc::new(parking_lot::RwLock::new(child.clone())),
129        );
130        Ok(())
131    }
132
133    /// Construct an adapter. Public so a self-orchestrating WORKER (Phase 6:
134    /// direct nested execution) can build its OWN child-session machinery
135    /// against its own store/scheduler — the struct fields are `pub(crate)`, so
136    /// out-of-crate callers (the worker binary) go through this constructor.
137    #[allow(clippy::too_many_arguments)]
138    pub fn new(
139        session_store: Arc<SessionStoreV2>,
140        storage: Arc<dyn Storage>,
141        persistence: Arc<LockedSessionStore>,
142        scheduler: Arc<SpawnScheduler>,
143        sessions_cache: bamboo_engine::SessionCache,
144        agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
145        session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
146        subagent_model_resolver: crate::tools::OptionalSubagentModelResolver,
147        config: Arc<RwLock<Config>>,
148    ) -> Self {
149        Self {
150            session_store,
151            storage,
152            persistence,
153            scheduler,
154            sessions_cache,
155            agent_runners,
156            session_event_senders,
157            subagent_model_resolver,
158            config,
159            // Fresh per-adapter wait-coalescing map (the type is private to this
160            // crate, so out-of-crate callers can't supply it).
161            parent_wait_slots: Arc::new(dashmap::DashMap::new()),
162            // The worker binary runs on a different machine than the desktop
163            // it would notify; it reports events back to the orchestrating
164            // server over the wire instead. See the field doc.
165            notification_relay: None,
166        }
167    }
168
169    /// Resolve the provider+model ref for a given subagent_type using the configured resolver.
170    pub async fn resolve_subagent_model(
171        &self,
172        subagent_type: &str,
173    ) -> Option<bamboo_domain::ProviderModelRef> {
174        match &self.subagent_model_resolver {
175            Some(resolver) => resolver(subagent_type.to_string()).await,
176            None => None,
177        }
178    }
179
180    /// Resolve runtime metadata (e.g. external agent routing) for a subagent_type.
181    pub async fn resolve_runtime_metadata(&self, subagent_type: &str) -> HashMap<String, String> {
182        let config = self.config.read().await;
183        bamboo_engine::external_agents::config::resolve_runtime_metadata(&config, subagent_type)
184    }
185
186    /// Register a durable parent wait for an enqueued child session.
187    ///
188    /// This is intentionally idempotent: repeated registrations for the same
189    /// child merge into the existing wait set. The child runner owns timeout
190    /// and liveness; the parent wait timeout is a long lease for observability.
191    ///
192    /// Registrations are **coalesced** per parent: when several children are
193    /// spawned in one round (the LLM issuing multiple `SubAgent.create` calls
194    /// that `join_all` runs concurrently), the first call to win the per-parent
195    /// barrier drains all currently-pending registrations and persists the parent
196    /// once, instead of each child triggering its own load+write. Callers whose
197    /// child was drained-and-persisted by that holder return without an extra
198    /// write — and only after the holder's write committed, so durability holds.
199    pub async fn register_parent_wait_for_child(
200        &self,
201        parent_session_id: &str,
202        child_session_id: &str,
203        tool_call_id: Option<&str>,
204    ) -> Result<(), ChildSessionError> {
205        let slot = self
206            .parent_wait_slots
207            .entry(parent_session_id.to_string())
208            .or_default()
209            .clone();
210
211        // 1. Enqueue this registration.
212        slot.pending.lock().push((
213            child_session_id.to_string(),
214            tool_call_id.map(str::to_string),
215        ));
216
217        // 2. Barrier: serialize flushers for this parent.
218        let _flush_guard = slot.flush_lock.lock().await;
219
220        // 3. Drain everything pending for this parent (siblings that enqueued
221        //    while we waited for the barrier are picked up here too).
222        let batch: Vec<(String, Option<String>)> = {
223            let mut pending = slot.pending.lock();
224            pending.drain(..).collect()
225        };
226        if batch.is_empty() {
227            // A prior barrier holder already persisted our child before releasing
228            // the barrier we just acquired — nothing left to write.
229            return Ok(());
230        }
231
232        // 4. Persist the whole batch in a single parent write.
233        if let Err(error) = self
234            .flush_parent_waits(parent_session_id, &batch, ChildWaitPolicy::All)
235            .await
236        {
237            // Re-queue so nothing is silently lost; a retry or sibling picks it up.
238            let mut pending = slot.pending.lock();
239            for item in batch {
240                pending.push(item);
241            }
242            return Err(error);
243        }
244
245        // 5. Self-clean: the slot exists only to coalesce a burst of sibling
246        //    registrations for THIS parent. Now that the batch is durably
247        //    persisted and nothing new is pending, drop the map entry so
248        //    `parent_wait_slots` does not retain one entry per parent-that-ever-
249        //    -spawned forever (issue #346). Still inside the flush barrier.
250        //
251        //    Race-freedom: `remove_if` re-checks `pending.is_empty()` under the
252        //    DashMap shard lock. A sibling that enqueued after our drain made
253        //    `pending` non-empty, so the predicate is false and we keep the slot;
254        //    that sibling (blocked on the barrier we still hold) will flush it and
255        //    run this same removal. A sibling that clones the slot Arc but has not
256        //    yet pushed keeps a live handle, so removing the map entry never loses
257        //    its child: whoever holds the barrier drains ALL pending on its Arc.
258        self.parent_wait_slots
259            .remove_if(parent_session_id, |_, slot| slot.pending.lock().is_empty());
260
261        Ok(())
262    }
263
264    /// Explicitly register a parent wait for an arbitrary set of children with a
265    /// chosen policy. Used by the `SubAgent.wait` action (wait on all active
266    /// children) and the end-of-turn safety net. A single parent write.
267    ///
268    /// Returns the number of children the wait now covers (0 means there was
269    /// nothing to wait on and no wait was registered).
270    pub async fn register_parent_wait_for_children(
271        &self,
272        parent_session_id: &str,
273        child_session_ids: &[String],
274        policy: ChildWaitPolicy,
275    ) -> Result<usize, ChildSessionError> {
276        if child_session_ids.is_empty() {
277            return Ok(0);
278        }
279        let batch: Vec<(String, Option<String>)> = child_session_ids
280            .iter()
281            .map(|id| (id.clone(), None))
282            .collect();
283        self.flush_parent_waits(parent_session_id, &batch, policy)
284            .await?;
285        Ok(batch.len())
286    }
287
288    /// The parent's currently-active (non-terminal) children, derived from the
289    /// session index (single source of truth).
290    pub async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String> {
291        self.storage
292            .list_child_run_statuses(parent_session_id)
293            .await
294            .unwrap_or_default()
295            .into_iter()
296            .filter(|(_, status)| !status.as_deref().is_some_and(is_terminal_child_status))
297            .map(|(id, _)| id)
298            .collect()
299    }
300
301    /// The subset of `candidates` the session index POSITIVELY reports as
302    /// terminal children of this parent, as `(child_id, status)` pairs
303    /// (issue #546). Unknown ids are not reported — an index-less backend or
304    /// a not-yet-indexed child must not be mistaken for a finished one.
305    pub async fn terminal_child_ids(
306        &self,
307        parent_session_id: &str,
308        candidates: &[String],
309    ) -> Vec<(String, String)> {
310        let statuses = self
311            .storage
312            .list_child_run_statuses(parent_session_id)
313            .await
314            .unwrap_or_default();
315        candidates
316            .iter()
317            .filter_map(|candidate| {
318                statuses.iter().find_map(|(id, status)| {
319                    let status = status.as_deref()?;
320                    (id == candidate && is_terminal_child_status(status))
321                        .then(|| (candidate.clone(), status.to_string()))
322                })
323            })
324            .collect()
325    }
326
327    /// Persist a batch of parent-wait registrations in one runtime-only save.
328    async fn flush_parent_waits(
329        &self,
330        parent_session_id: &str,
331        batch: &[(String, Option<String>)],
332        policy: ChildWaitPolicy,
333    ) -> Result<(), ChildSessionError> {
334        let Some(mut parent) =
335            self.storage
336                .load_session(parent_session_id)
337                .await
338                .map_err(|error| {
339                    ChildSessionError::Execution(format!(
340                        "failed to load parent session {parent_session_id}: {error}"
341                    ))
342                })?
343        else {
344            return Err(ChildSessionError::NotFound(parent_session_id.to_string()));
345        };
346
347        // The active/completed child sets are derived from the session index
348        // (single source of truth), so we no longer maintain a denormalized copy
349        // here. Only the durable wait state below is parent-owned.
350        let mut runtime_state = read_runtime_state(&parent);
351
352        let now = Utc::now();
353        let mut wait = runtime_state
354            .waiting_for_children
355            .take()
356            .unwrap_or_else(|| WaitingForChildrenState::for_children(Vec::new(), policy, now));
357        // An explicit wait re-asserts the policy on any pre-existing wait state.
358        wait.wait_for = policy;
359        for (child_session_id, tool_call_id) in batch {
360            if !wait
361                .child_session_ids
362                .iter()
363                .any(|id| id == child_session_id)
364            {
365                wait.child_session_ids.push(child_session_id.clone());
366            }
367            if wait.registered_by_tool_call_id.is_none() {
368                wait.registered_by_tool_call_id = tool_call_id.clone();
369            }
370        }
371        wait.child_session_ids.sort();
372        wait.child_session_ids.dedup();
373        runtime_state.waiting_for_children = Some(wait);
374
375        write_runtime_state(&mut parent, &runtime_state);
376        parent.metadata.insert(
377            "runtime.suspend_reason".to_string(),
378            "waiting_for_children".to_string(),
379        );
380        parent.updated_at = Utc::now();
381
382        // Runtime-only save: registering a parent's wait mutates the
383        // control-plane (runtime_state + suspend metadata) but NEVER the message
384        // history. Writing just the sidecar keeps spawn O(1) in conversation
385        // length instead of rewriting the parent's full session.json per child.
386        self.persistence
387            .save_runtime_only(&mut parent)
388            .await
389            .map_err(|error| {
390                ChildSessionError::Execution(format!("failed to save parent wait state: {error}"))
391            })?;
392        self.sessions_cache.insert(
393            parent.id.clone(),
394            Arc::new(parking_lot::RwLock::new(parent)),
395        );
396
397        Ok(())
398    }
399}
400
401fn map_index_entry_to_child_entry(entry: &SessionIndexEntry) -> ChildSessionEntry {
402    ChildSessionEntry {
403        child_session_id: entry.id.clone(),
404        title: entry.title.clone(),
405        pinned: entry.pinned,
406        message_count: entry.message_count,
407        updated_at: entry.updated_at.to_rfc3339(),
408        last_run_status: entry.last_run_status.clone(),
409        last_run_error: entry.last_run_error.clone(),
410    }
411}
412
413#[async_trait]
414impl SubagentResolutionPort for ChildSessionAdapter {
415    async fn resolve_subagent_model(
416        &self,
417        subagent_type: &str,
418    ) -> Option<bamboo_domain::ProviderModelRef> {
419        ChildSessionAdapter::resolve_subagent_model(self, subagent_type).await
420    }
421
422    async fn resolve_runtime_metadata(
423        &self,
424        subagent_type: &str,
425    ) -> std::collections::HashMap<String, String> {
426        ChildSessionAdapter::resolve_runtime_metadata(self, subagent_type).await
427    }
428}
429
430/// Lets a [`ChildSessionAdapter`] act as the engine's guardian-review spawner.
431///
432/// `Arc<ChildSessionAdapter>` therefore doubles as `Arc<dyn GuardianSpawner>`
433/// (wired onto `AppState`), so the terminal gate spawns the read-only reviewer
434/// through the same child-session machinery the `SubAgent` tool uses — no second
435/// spawn path. The reviewer is a real sub-agent: it fetches the diff and runs
436/// tests itself via its (read-only) toolset.
437#[async_trait]
438impl bamboo_engine::GuardianSpawner for ChildSessionAdapter {
439    async fn spawn_guardian_review(
440        &self,
441        parent_session: &Session,
442        review_prompt: String,
443        model: String,
444        disabled_tools: Option<std::collections::BTreeSet<String>>,
445    ) -> Result<String, String> {
446        let input = bamboo_engine::session_app::child_session::CreateChildInput {
447            parent_session: parent_session.clone(),
448            child_id: format!("guardian-{}", uuid::Uuid::new_v4()),
449            title: "Guardian review".to_string(),
450            responsibility: "Adversarially verify the parent agent's completed work.".to_string(),
451            assignment_prompt: review_prompt,
452            // The coordinator branches on this subagent_type to recognize a
453            // guardian completion and parse its verdict.
454            subagent_type: "guardian".to_string(),
455            workspace: parent_session.workspace_path_meta().unwrap_or_default(),
456            model_override: Some(model),
457            model_ref_override: None,
458            runtime_metadata: HashMap::new(),
459            auto_run: true,
460            reasoning_effort: None,
461            lifecycle: None,
462            resident_name: None,
463            resident_context: None,
464            disabled_tools,
465            context_fork: None,
466        };
467        bamboo_engine::session_app::child_session::create_child_action(self, input)
468            .await
469            .map(|result| result.child_session_id)
470            .map_err(|error| error.to_string())
471    }
472}
473
474#[async_trait]
475impl ChildSessionPort for ChildSessionAdapter {
476    async fn load_root_session(&self, root_session_id: &str) -> Result<Session, ChildSessionError> {
477        let Some(session) = self
478            .storage
479            .load_session(root_session_id)
480            .await
481            .map_err(|error| {
482                ChildSessionError::Execution(format!(
483                    "failed to load session {root_session_id}: {error}"
484                ))
485            })?
486        else {
487            return Err(ChildSessionError::NotFound(root_session_id.to_string()));
488        };
489
490        if session.kind != SessionKind::Root {
491            return Err(ChildSessionError::NotRootSession(
492                root_session_id.to_string(),
493            ));
494        }
495
496        Ok(session)
497    }
498
499    async fn load_child_for_parent(
500        &self,
501        parent_session_id: &str,
502        child_session_id: &str,
503    ) -> Result<Session, ChildSessionError> {
504        let Some(child) = self
505            .storage
506            .load_session(child_session_id)
507            .await
508            .map_err(|error| {
509                ChildSessionError::Execution(format!(
510                    "failed to load child session {child_session_id}: {error}"
511                ))
512            })?
513        else {
514            return Err(ChildSessionError::NotFound(child_session_id.to_string()));
515        };
516
517        if child.kind != SessionKind::Child {
518            return Err(ChildSessionError::NotChildSession(
519                child_session_id.to_string(),
520            ));
521        }
522
523        if child.parent_session_id.as_deref() != Some(parent_session_id) {
524            return Err(ChildSessionError::NotChildOfParent {
525                child_id: child_session_id.to_string(),
526                parent_id: parent_session_id.to_string(),
527            });
528        }
529
530        Ok(child)
531    }
532
533    async fn save_child_session(&self, child: &mut Session) -> Result<(), ChildSessionError> {
534        // Adopting save: most child actions (update/run/send_message/cancel)
535        // don't touch bypass_permissions, so a concurrent `PATCH` to a running
536        // child must still win over this control write. #540.
537        let saved = self.persistence.merge_save_runtime(child).await;
538        self.finish_child_save(child, saved)
539    }
540
541    async fn save_child_session_authoritative_flags(
542        &self,
543        child: &mut Session,
544    ) -> Result<(), ChildSessionError> {
545        // Non-adopting save: the caller just set the child's posture flags from
546        // the live parent (the #74 re-seed), so persist them as-is instead of
547        // reverting to the child's stale on-disk bypass. #540.
548        let saved = self
549            .persistence
550            .save_runtime_authoritative_flags(child)
551            .await;
552        self.finish_child_save(child, saved)
553    }
554
555    async fn is_child_running(&self, child_session_id: &str) -> bool {
556        let runners = self.agent_runners.read().await;
557        runners
558            .get(child_session_id)
559            .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
560    }
561
562    async fn list_children(&self, parent_session_id: &str) -> Vec<ChildSessionEntry> {
563        self.session_store
564            .list_index_entries()
565            .await
566            .into_iter()
567            .filter(|entry| {
568                entry.kind == SessionKind::Child
569                    && entry.parent_session_id.as_deref() == Some(parent_session_id)
570            })
571            .map(|entry| map_index_entry_to_child_entry(&entry))
572            .collect()
573    }
574
575    async fn find_resident_child(
576        &self,
577        root_session_id: &str,
578        resident_name: &str,
579    ) -> Option<String> {
580        let name = resident_name.trim();
581        if name.is_empty() {
582            return None;
583        }
584        // Scan the index for a child in this root tree tagged with the resident
585        // name. Prefer the most recently updated if (defensively) more than one
586        // exists. Index-backed: no session.json loads.
587        let mut best: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
588        for entry in self.session_store.list_index_entries().await {
589            if entry.kind == SessionKind::Child
590                && entry.root_session_id == root_session_id
591                && entry.resident_name.as_deref() == Some(name)
592            {
593                match &best {
594                    Some((_, ts)) if *ts >= entry.updated_at => {}
595                    _ => best = Some((entry.id.clone(), entry.updated_at)),
596                }
597            }
598        }
599        best.map(|(id, _)| id)
600    }
601
602    async fn enqueue_child_run(
603        &self,
604        parent: &Session,
605        child: &Session,
606    ) -> Result<(), ChildSessionError> {
607        let model = if child.model.trim().is_empty() {
608            parent.model.clone()
609        } else {
610            child.model.clone()
611        };
612        if model.trim().is_empty() {
613            return Err(ChildSessionError::Execution(
614                "child model is empty and parent model is unavailable".to_string(),
615            ));
616        }
617
618        // Per-child tool denylist: persisted onto the child session by
619        // `create_child_action` (JSON in metadata). Most sub-agents are full
620        // agents and carry none; a read-only Guardian reviewer carries a
621        // denylist here so the worker trims its toolset. `SpawnJob` wants a
622        // `Vec<String>`, so collect the set.
623        let disabled_tools = child
624            .metadata
625            .get("disabled_tools")
626            .and_then(|raw| serde_json::from_str::<std::collections::BTreeSet<String>>(raw).ok())
627            .filter(|set| !set.is_empty())
628            .map(|set| set.into_iter().collect::<Vec<String>>());
629
630        // Start the always-on notification relay for the CHILD's own session
631        // before enqueueing the job — mirrors the execute handler starting it
632        // at execution entry (`spawn_event_forwarder`) rather than waiting
633        // for a client to subscribe. Idempotent (`try_begin_relay`) and races
634        // harmlessly with the engine's own `get_or_create_event_sender` call
635        // for the same id in `run_child_spawn`, since both resolve to the
636        // same map entry. See the `notification_relay` field doc.
637        if let Some(relay) = &self.notification_relay {
638            let child_tx = get_or_create_event_sender(&self.session_event_senders, &child.id).await;
639            crate::app_state::session_events::ensure_notification_relay(relay, &child.id, child_tx);
640        }
641
642        // NOTE: enqueue only *runs* the child in the background. Registering the
643        // parent's wait (which suspends the parent) is now an explicit, separate
644        // step so the model can spawn several children without each one
645        // suspending it — see `register_parent_wait_for_child` /
646        // `register_parent_wait_for_children` and the `SubAgent.wait` action.
647        self.scheduler
648            .enqueue(SpawnJob {
649                parent_session_id: parent.id.clone(),
650                child_session_id: child.id.clone(),
651                model,
652                disabled_tools,
653            })
654            .await
655            .map_err(ChildSessionError::Execution)?;
656
657        let parent_tx = get_or_create_event_sender(&self.session_event_senders, &parent.id).await;
658        let _ = parent_tx.send(AgentEvent::SubAgentStarted {
659            parent_session_id: parent.id.clone(),
660            child_session_id: child.id.clone(),
661            title: Some(child.title.clone()),
662        });
663
664        Ok(())
665    }
666
667    async fn cancel_child_run_and_wait(
668        &self,
669        child_session_id: &str,
670    ) -> Result<(), ChildSessionError> {
671        let cancelled = {
672            let mut runners = self.agent_runners.write().await;
673            if let Some(runner) = runners.get_mut(child_session_id) {
674                if matches!(runner.status, AgentStatus::Running) {
675                    runner.cancel_token.cancel();
676                    true
677                } else {
678                    false
679                }
680            } else {
681                false
682            }
683        };
684
685        if !cancelled {
686            return Ok(());
687        }
688
689        let deadline = Instant::now() + Duration::from_secs(10);
690        loop {
691            let still_running = {
692                let runners = self.agent_runners.read().await;
693                runners
694                    .get(child_session_id)
695                    .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
696            };
697            if !still_running {
698                return Ok(());
699            }
700            if Instant::now() >= deadline {
701                return Err(ChildSessionError::Execution(format!(
702                    "timed out waiting for child session {child_session_id} to stop after cancellation"
703                )));
704            }
705            sleep(Duration::from_millis(50)).await;
706        }
707    }
708
709    async fn delete_child_session(
710        &self,
711        parent_session_id: &str,
712        child_id: &str,
713    ) -> Result<DeleteChildResult, ChildSessionError> {
714        let cancelled_running_child = {
715            let mut runners = self.agent_runners.write().await;
716            if let Some(runner) = runners.remove(child_id) {
717                runner.cancel_token.cancel();
718                true
719            } else {
720                false
721            }
722        };
723
724        let deleted = self
725            .storage
726            .delete_session(child_id)
727            .await
728            .map_err(|error| {
729                ChildSessionError::Execution(format!("failed to delete child session: {error}"))
730            })?;
731
732        self.sessions_cache.remove(child_id);
733        {
734            let mut senders = self.session_event_senders.write().await;
735            senders.remove(child_id);
736            if cancelled_running_child {
737                if let Some(parent_tx) = senders.get(parent_session_id) {
738                    let _ = parent_tx.send(AgentEvent::SubAgentCompleted {
739                        parent_session_id: parent_session_id.to_string(),
740                        child_session_id: child_id.to_string(),
741                        status: "cancelled".to_string(),
742                        error: Some("Child session deleted while running".to_string()),
743                    });
744                }
745            }
746        }
747
748        Ok(DeleteChildResult {
749            deleted,
750            cancelled_running_child,
751        })
752    }
753
754    async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo> {
755        let runners = self.agent_runners.read().await;
756        runners.get(child_id).map(|runner| ChildRunnerInfo {
757            started_at: Some(runner.started_at),
758            completed_at: runner.completed_at,
759            last_tool_name: runner.last_tool_name.clone(),
760            last_tool_phase: runner.last_tool_phase.clone(),
761            last_event_at: runner.last_event_at,
762            round_count: runner.round_count,
763        })
764    }
765
766    async fn register_parent_wait_for_child(
767        &self,
768        parent_session_id: &str,
769        child_session_id: &str,
770        tool_call_id: Option<&str>,
771    ) -> Result<(), ChildSessionError> {
772        ChildSessionAdapter::register_parent_wait_for_child(
773            self,
774            parent_session_id,
775            child_session_id,
776            tool_call_id,
777        )
778        .await
779    }
780
781    async fn register_parent_wait_for_children(
782        &self,
783        parent_session_id: &str,
784        child_session_ids: &[String],
785        policy: ChildWaitPolicy,
786    ) -> Result<usize, ChildSessionError> {
787        ChildSessionAdapter::register_parent_wait_for_children(
788            self,
789            parent_session_id,
790            child_session_ids,
791            policy,
792        )
793        .await
794    }
795
796    async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String> {
797        ChildSessionAdapter::active_child_ids(self, parent_session_id).await
798    }
799
800    async fn terminal_child_ids(
801        &self,
802        parent_session_id: &str,
803        candidates: &[String],
804    ) -> Vec<(String, String)> {
805        ChildSessionAdapter::terminal_child_ids(self, parent_session_id, candidates).await
806    }
807
808    async fn ensure_child_indexed(&self, child_session_id: &str) {
809        let _ = self.session_store.get_index_entry(child_session_id).await;
810    }
811}