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