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) session_messenger: Option<Arc<bamboo_engine::SessionMessenger>>,
38    pub(crate) scheduler: Arc<SpawnScheduler>,
39    pub(crate) sessions_cache: bamboo_engine::SessionCache,
40    pub(crate) agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
41    pub(crate) session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
42    /// Optional subagent model resolver: maps subagent_type → provider+model ref.
43    pub(crate) subagent_model_resolver: crate::tools::OptionalSubagentModelResolver,
44    /// Application config for resolving subagent routing and external agent profiles.
45    pub(crate) config: Arc<RwLock<Config>>,
46    /// Authoritative Project registry for child workspace ownership checks.
47    /// Out-of-process worker embeddings may omit it and retain confinement-only
48    /// validation; the server always supplies it.
49    pub(crate) project_store: Option<Arc<bamboo_projects::ProjectStore>>,
50    pub(crate) workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
51    /// Coalesces concurrent parent-wait registrations for the same parent that
52    /// arrive in one spawn round (the LLM emitting several `SubAgent.create`
53    /// calls at once → `join_all`) into a single parent persist. See
54    /// [`ChildSessionAdapter::register_parent_wait_for_child`].
55    pub(crate) parent_wait_slots: Arc<dashmap::DashMap<String, Arc<ParentWaitSlot>>>,
56}
57
58/// Per-parent coalescing slot for batched wait registration.
59///
60/// `flush_lock` is a barrier distinct from the persistence per-session lock
61/// (using the latter here would deadlock, since the flush itself takes it). The
62/// first registration to win the barrier drains `pending` and persists the whole
63/// batch once; concurrent registrations that find `pending` already drained were
64/// persisted by that holder before it released the barrier, so they return
65/// without an extra write.
66#[derive(Default)]
67pub(crate) struct ParentWaitSlot {
68    flush_lock: tokio::sync::Mutex<()>,
69    pending: parking_lot::Mutex<Vec<(String, Option<String>)>>,
70}
71
72const AGENT_RUNTIME_STATE_METADATA_KEY: &str = "agent.runtime.state";
73
74/// Terminal child run statuses, as mirrored into the session index. A child not
75/// in one of these states is considered active (still pending/running).
76fn is_terminal_child_status(status: &str) -> bool {
77    matches!(
78        status,
79        "completed" | "error" | "timeout" | "cancelled" | "skipped"
80    )
81}
82
83fn read_runtime_state(session: &Session) -> AgentRuntimeState {
84    session
85        .agent_runtime_state
86        .clone()
87        .or_else(|| {
88            session
89                .metadata
90                .get(AGENT_RUNTIME_STATE_METADATA_KEY)
91                .and_then(|raw| serde_json::from_str::<AgentRuntimeState>(raw).ok())
92        })
93        .unwrap_or_else(|| AgentRuntimeState::new(format!("{}-wait", session.id)))
94}
95
96fn write_runtime_state(session: &mut Session, runtime_state: &AgentRuntimeState) {
97    session.agent_runtime_state = Some(runtime_state.clone());
98    if let Ok(serialized) = serde_json::to_string(runtime_state) {
99        session
100            .metadata
101            .insert(AGENT_RUNTIME_STATE_METADATA_KEY.to_string(), serialized);
102    }
103}
104
105impl ChildSessionAdapter {
106    /// Shared tail of the two child-save methods: map the persist error and
107    /// refresh the in-memory cache. The two public methods differ ONLY in which
108    /// persistence call they make (adopting vs authoritative); everything after
109    /// is identical, so it lives here to stay in lockstep. #540.
110    fn finish_child_save(
111        &self,
112        child: &Session,
113        saved: std::io::Result<()>,
114    ) -> Result<(), ChildSessionError> {
115        saved.map_err(|error| {
116            ChildSessionError::Execution(format!("failed to save child session: {error}"))
117        })?;
118        self.sessions_cache.insert(
119            child.id.clone(),
120            Arc::new(parking_lot::RwLock::new(child.clone())),
121        );
122        Ok(())
123    }
124
125    /// Construct an adapter. Public so a self-orchestrating WORKER (Phase 6:
126    /// direct nested execution) can build its OWN child-session machinery
127    /// against its own store/scheduler — the struct fields are `pub(crate)`, so
128    /// out-of-crate callers (the worker binary) go through this constructor.
129    #[allow(clippy::too_many_arguments)]
130    pub fn new(
131        session_store: Arc<SessionStoreV2>,
132        storage: Arc<dyn Storage>,
133        persistence: Arc<LockedSessionStore>,
134        scheduler: Arc<SpawnScheduler>,
135        sessions_cache: bamboo_engine::SessionCache,
136        agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
137        session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
138        session_messenger: Option<Arc<bamboo_engine::SessionMessenger>>,
139        subagent_model_resolver: crate::tools::OptionalSubagentModelResolver,
140        config: Arc<RwLock<Config>>,
141    ) -> Self {
142        Self {
143            session_store,
144            storage,
145            persistence,
146            session_messenger,
147            scheduler,
148            sessions_cache,
149            agent_runners,
150            session_event_senders,
151            subagent_model_resolver,
152            config,
153            project_store: None,
154            workspace_resolver:
155                bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
156            // Fresh per-adapter wait-coalescing map (the type is private to this
157            // crate, so out-of-crate callers can't supply it).
158            parent_wait_slots: Arc::new(dashmap::DashMap::new()),
159        }
160    }
161
162    /// Resolve the provider+model ref for a given subagent_type using the configured resolver.
163    pub async fn resolve_subagent_model(
164        &self,
165        subagent_type: &str,
166    ) -> Option<bamboo_domain::ProviderModelRef> {
167        match &self.subagent_model_resolver {
168            Some(resolver) => resolver(subagent_type.to_string()).await,
169            None => None,
170        }
171    }
172
173    /// Resolve runtime metadata (e.g. external agent routing) for a subagent_type.
174    pub async fn resolve_runtime_metadata(&self, subagent_type: &str) -> HashMap<String, String> {
175        let config = self.config.read().await;
176        bamboo_engine::external_agents::config::resolve_runtime_metadata(&config, subagent_type)
177    }
178
179    /// Register a durable parent wait for an enqueued child session.
180    ///
181    /// This is intentionally idempotent: repeated registrations for the same
182    /// child merge into the existing wait set. The child runner owns timeout
183    /// and liveness; the parent wait timeout is a long lease for observability.
184    ///
185    /// Registrations are **coalesced** per parent: when several children are
186    /// spawned in one round (the LLM issuing multiple `SubAgent.create` calls
187    /// that `join_all` runs concurrently), the first call to win the per-parent
188    /// barrier drains all currently-pending registrations and persists the parent
189    /// once, instead of each child triggering its own load+write. Callers whose
190    /// child was drained-and-persisted by that holder return without an extra
191    /// write — and only after the holder's write committed, so durability holds.
192    pub async fn register_parent_wait_for_child(
193        &self,
194        parent_session_id: &str,
195        child_session_id: &str,
196        tool_call_id: Option<&str>,
197    ) -> Result<(), ChildSessionError> {
198        let slot = self
199            .parent_wait_slots
200            .entry(parent_session_id.to_string())
201            .or_default()
202            .clone();
203
204        // 1. Enqueue this registration.
205        slot.pending.lock().push((
206            child_session_id.to_string(),
207            tool_call_id.map(str::to_string),
208        ));
209
210        // 2. Barrier: serialize flushers for this parent.
211        let _flush_guard = slot.flush_lock.lock().await;
212
213        // 3. Drain everything pending for this parent (siblings that enqueued
214        //    while we waited for the barrier are picked up here too).
215        let batch: Vec<(String, Option<String>)> = {
216            let mut pending = slot.pending.lock();
217            pending.drain(..).collect()
218        };
219        if batch.is_empty() {
220            // A prior barrier holder already persisted our child before releasing
221            // the barrier we just acquired — nothing left to write.
222            return Ok(());
223        }
224
225        // 4. Persist the whole batch in a single parent write.
226        if let Err(error) = self
227            .flush_parent_waits(parent_session_id, &batch, ChildWaitPolicy::All)
228            .await
229        {
230            // Re-queue so nothing is silently lost; a retry or sibling picks it up.
231            let mut pending = slot.pending.lock();
232            for item in batch {
233                pending.push(item);
234            }
235            return Err(error);
236        }
237
238        // 5. Self-clean: the slot exists only to coalesce a burst of sibling
239        //    registrations for THIS parent. Now that the batch is durably
240        //    persisted and nothing new is pending, drop the map entry so
241        //    `parent_wait_slots` does not retain one entry per parent-that-ever-
242        //    -spawned forever (issue #346). Still inside the flush barrier.
243        //
244        //    Race-freedom: `remove_if` re-checks `pending.is_empty()` under the
245        //    DashMap shard lock. A sibling that enqueued after our drain made
246        //    `pending` non-empty, so the predicate is false and we keep the slot;
247        //    that sibling (blocked on the barrier we still hold) will flush it and
248        //    run this same removal. A sibling that clones the slot Arc but has not
249        //    yet pushed keeps a live handle, so removing the map entry never loses
250        //    its child: whoever holds the barrier drains ALL pending on its Arc.
251        self.parent_wait_slots
252            .remove_if(parent_session_id, |_, slot| slot.pending.lock().is_empty());
253
254        Ok(())
255    }
256
257    /// Explicitly register a parent wait for an arbitrary set of children with a
258    /// chosen policy. Used by the `SubAgent.wait` action (wait on all active
259    /// children) and the end-of-turn safety net. A single parent write.
260    ///
261    /// Returns the number of children the wait now covers (0 means there was
262    /// nothing to wait on and no wait was registered).
263    pub async fn register_parent_wait_for_children(
264        &self,
265        parent_session_id: &str,
266        child_session_ids: &[String],
267        policy: ChildWaitPolicy,
268    ) -> Result<usize, ChildSessionError> {
269        if child_session_ids.is_empty() {
270            return Ok(0);
271        }
272        let batch: Vec<(String, Option<String>)> = child_session_ids
273            .iter()
274            .map(|id| (id.clone(), None))
275            .collect();
276        self.flush_parent_waits(parent_session_id, &batch, policy)
277            .await?;
278        Ok(batch.len())
279    }
280
281    /// The parent's currently-active (non-terminal) children, derived from the
282    /// session index (single source of truth).
283    pub async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String> {
284        self.storage
285            .list_child_run_statuses(parent_session_id)
286            .await
287            .unwrap_or_default()
288            .into_iter()
289            .filter(|(_, status)| !status.as_deref().is_some_and(is_terminal_child_status))
290            .map(|(id, _)| id)
291            .collect()
292    }
293
294    /// The subset of `candidates` the session index POSITIVELY reports as
295    /// terminal children of this parent, as `(child_id, status)` pairs
296    /// (issue #546). Unknown ids are not reported — an index-less backend or
297    /// a not-yet-indexed child must not be mistaken for a finished one.
298    pub async fn terminal_child_ids(
299        &self,
300        parent_session_id: &str,
301        candidates: &[String],
302    ) -> Vec<(String, String)> {
303        let statuses = self
304            .storage
305            .list_child_run_statuses(parent_session_id)
306            .await
307            .unwrap_or_default();
308        candidates
309            .iter()
310            .filter_map(|candidate| {
311                statuses.iter().find_map(|(id, status)| {
312                    let status = status.as_deref()?;
313                    (id == candidate && is_terminal_child_status(status))
314                        .then(|| (candidate.clone(), status.to_string()))
315                })
316            })
317            .collect()
318    }
319
320    /// Persist a batch of parent-wait registrations in one runtime-only save.
321    async fn flush_parent_waits(
322        &self,
323        parent_session_id: &str,
324        batch: &[(String, Option<String>)],
325        policy: ChildWaitPolicy,
326    ) -> Result<(), ChildSessionError> {
327        let Some(mut parent) =
328            self.storage
329                .load_session(parent_session_id)
330                .await
331                .map_err(|error| {
332                    ChildSessionError::Execution(format!(
333                        "failed to load parent session {parent_session_id}: {error}"
334                    ))
335                })?
336        else {
337            return Err(ChildSessionError::NotFound(parent_session_id.to_string()));
338        };
339
340        // The active/completed child sets are derived from the session index
341        // (single source of truth), so we no longer maintain a denormalized copy
342        // here. Only the durable wait state below is parent-owned.
343        let mut runtime_state = read_runtime_state(&parent);
344
345        let now = Utc::now();
346        let mut wait = runtime_state
347            .waiting_for_children
348            .take()
349            .unwrap_or_else(|| WaitingForChildrenState::for_children(Vec::new(), policy, now));
350        // An explicit wait re-asserts the policy on any pre-existing wait state.
351        wait.wait_for = policy;
352        for (child_session_id, tool_call_id) in batch {
353            if !wait
354                .child_session_ids
355                .iter()
356                .any(|id| id == child_session_id)
357            {
358                wait.child_session_ids.push(child_session_id.clone());
359            }
360            if wait.registered_by_tool_call_id.is_none() {
361                wait.registered_by_tool_call_id = tool_call_id.clone();
362            }
363        }
364        wait.child_session_ids.sort();
365        wait.child_session_ids.dedup();
366        runtime_state.waiting_for_children = Some(wait);
367
368        write_runtime_state(&mut parent, &runtime_state);
369        parent.metadata.insert(
370            "runtime.suspend_reason".to_string(),
371            "waiting_for_children".to_string(),
372        );
373        parent.updated_at = Utc::now();
374
375        // Runtime-only save: registering a parent's wait mutates the
376        // control-plane (runtime_state + suspend metadata) but NEVER the message
377        // history. Writing just the sidecar keeps spawn O(1) in conversation
378        // length instead of rewriting the parent's full session.json per child.
379        self.persistence
380            .save_runtime_only(&mut parent)
381            .await
382            .map_err(|error| {
383                ChildSessionError::Execution(format!("failed to save parent wait state: {error}"))
384            })?;
385        self.sessions_cache.insert(
386            parent.id.clone(),
387            Arc::new(parking_lot::RwLock::new(parent)),
388        );
389
390        Ok(())
391    }
392}
393
394fn map_index_entry_to_child_entry(entry: &SessionIndexEntry) -> ChildSessionEntry {
395    ChildSessionEntry {
396        child_session_id: entry.id.clone(),
397        title: entry.title.clone(),
398        pinned: entry.pinned,
399        message_count: entry.message_count,
400        updated_at: entry.updated_at.to_rfc3339(),
401        last_run_status: entry.last_run_status.clone(),
402        last_run_error: entry.last_run_error.clone(),
403    }
404}
405
406#[async_trait]
407impl SubagentResolutionPort for ChildSessionAdapter {
408    async fn resolve_subagent_model(
409        &self,
410        subagent_type: &str,
411    ) -> Option<bamboo_domain::ProviderModelRef> {
412        ChildSessionAdapter::resolve_subagent_model(self, subagent_type).await
413    }
414
415    async fn resolve_runtime_metadata(
416        &self,
417        subagent_type: &str,
418    ) -> std::collections::HashMap<String, String> {
419        ChildSessionAdapter::resolve_runtime_metadata(self, subagent_type).await
420    }
421}
422
423/// Lets a [`ChildSessionAdapter`] act as the engine's guardian-review spawner.
424///
425/// `Arc<ChildSessionAdapter>` therefore doubles as `Arc<dyn GuardianSpawner>`
426/// (wired onto `AppState`), so the terminal gate spawns the read-only reviewer
427/// through the same child-session machinery the `SubAgent` tool uses — no second
428/// spawn path. The reviewer is a real sub-agent: it fetches the diff and runs
429/// tests itself via its (read-only) toolset.
430#[async_trait]
431impl bamboo_engine::GuardianSpawner for ChildSessionAdapter {
432    async fn spawn_guardian_review(
433        &self,
434        parent_session: &Session,
435        review_prompt: String,
436        model: String,
437        disabled_tools: Option<std::collections::BTreeSet<String>>,
438    ) -> Result<String, String> {
439        let persisted_parent_workspace = parent_session.workspace_path_meta();
440        let parent_workspace_is_project_default = parent_session
441            .metadata
442            .get(bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY)
443            .map(String::as_str)
444            == Some(bamboo_engine::project_context::WorkspaceSource::ProjectDefault.as_str());
445        let workspace_source = if parent_workspace_is_project_default
446            || (persisted_parent_workspace.is_none()
447                && matches!(
448                    bamboo_engine::project_context::ProjectContextResolver::session_project_identity(
449                        parent_session
450                    ),
451                    bamboo_engine::project_context::SessionProjectIdentity::Assigned(_)
452                ))
453        {
454            bamboo_engine::project_context::WorkspaceSource::ProjectDefault
455        } else {
456            match parent_session
457                .metadata
458                .get(bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY)
459                .map(String::as_str)
460            {
461                Some("project_default") => {
462                    bamboo_engine::project_context::WorkspaceSource::ProjectDefault
463                }
464                _ => bamboo_engine::project_context::WorkspaceSource::Session,
465            }
466        };
467        let input = bamboo_engine::session_app::child_session::CreateChildInput {
468            parent_session: parent_session.clone(),
469            child_id: format!("guardian-{}", uuid::Uuid::new_v4()),
470            title: "Guardian review".to_string(),
471            responsibility: "Adversarially verify the parent agent's completed work.".to_string(),
472            assignment_prompt: review_prompt,
473            // The coordinator branches on this subagent_type to recognize a
474            // guardian completion and parse its verdict.
475            subagent_type: "guardian".to_string(),
476            workspace: if parent_workspace_is_project_default {
477                String::new()
478            } else {
479                persisted_parent_workspace.unwrap_or_default()
480            },
481            workspace_source,
482            model_override: Some(model),
483            model_ref_override: None,
484            runtime_metadata: HashMap::new(),
485            auto_run: true,
486            reasoning_effort: None,
487            lifecycle: None,
488            resident_name: None,
489            resident_context: None,
490            disabled_tools,
491            context_fork: None,
492        };
493        bamboo_engine::session_app::child_session::create_child_action(self, input)
494            .await
495            .map(|result| result.child_session_id)
496            .map_err(|error| error.to_string())
497    }
498}
499
500#[async_trait]
501impl ChildSessionPort for ChildSessionAdapter {
502    fn publish_child_workspace(
503        &self,
504        session_id: &str,
505        workspace: std::path::PathBuf,
506        source: &str,
507    ) -> std::path::PathBuf {
508        self.workspace_resolver
509            .publish_resolved_workspace(session_id, workspace, source)
510    }
511
512    async fn validate_child_workspace(
513        &self,
514        project_id: Option<&bamboo_domain::ProjectId>,
515        requested_workspace: &str,
516    ) -> Result<String, ChildSessionError> {
517        let Some(store) = self.project_store.as_deref() else {
518            if requested_workspace.trim().is_empty() {
519                return Err(ChildSessionError::InvalidArguments(
520                    "child workspace must be a non-empty path".to_string(),
521                ));
522            }
523            let requested = std::path::PathBuf::from(requested_workspace);
524            if requested.exists() && !requested.is_dir() {
525                return Err(ChildSessionError::InvalidArguments(format!(
526                    "child workspace is not a directory: {requested_workspace}"
527                )));
528            }
529            let canonical = requested.canonicalize().unwrap_or(requested);
530            let final_workspace =
531                bamboo_agent_core::workspace_state::resolve_workspace_path(canonical);
532            return Ok(bamboo_config::paths::path_to_display_string(
533                &final_workspace,
534            ));
535        };
536        let final_workspace = crate::project_context::validate_workspace_assignment_with_resolver(
537            store,
538            project_id,
539            Some(requested_workspace),
540            &self.workspace_resolver,
541        )
542        .map_err(|error| ChildSessionError::InvalidArguments(error.to_string()))?
543        .ok_or_else(|| {
544            ChildSessionError::InvalidArguments(
545                "child workspace must be a non-empty path".to_string(),
546            )
547        })?;
548        Ok(bamboo_config::paths::path_to_display_string(
549            &final_workspace,
550        ))
551    }
552
553    async fn load_root_session(&self, root_session_id: &str) -> Result<Session, ChildSessionError> {
554        let Some(session) = self
555            .storage
556            .load_session(root_session_id)
557            .await
558            .map_err(|error| {
559                ChildSessionError::Execution(format!(
560                    "failed to load session {root_session_id}: {error}"
561                ))
562            })?
563        else {
564            return Err(ChildSessionError::NotFound(root_session_id.to_string()));
565        };
566
567        if session.kind != SessionKind::Root {
568            return Err(ChildSessionError::NotRootSession(
569                root_session_id.to_string(),
570            ));
571        }
572
573        Ok(session)
574    }
575
576    async fn load_child_for_parent(
577        &self,
578        parent_session_id: &str,
579        child_session_id: &str,
580    ) -> Result<Session, ChildSessionError> {
581        let Some(child) = self
582            .storage
583            .load_session(child_session_id)
584            .await
585            .map_err(|error| {
586                ChildSessionError::Execution(format!(
587                    "failed to load child session {child_session_id}: {error}"
588                ))
589            })?
590        else {
591            return Err(ChildSessionError::NotFound(child_session_id.to_string()));
592        };
593
594        if child.kind != SessionKind::Child {
595            return Err(ChildSessionError::NotChildSession(
596                child_session_id.to_string(),
597            ));
598        }
599
600        if child.parent_session_id.as_deref() != Some(parent_session_id) {
601            return Err(ChildSessionError::NotChildOfParent {
602                child_id: child_session_id.to_string(),
603                parent_id: parent_session_id.to_string(),
604            });
605        }
606
607        Ok(child)
608    }
609
610    async fn save_child_session(&self, child: &mut Session) -> Result<(), ChildSessionError> {
611        // Adopting save: most child actions (update/run/send_message/cancel)
612        // don't touch bypass_permissions, so a concurrent `PATCH` to a running
613        // child must still win over this control write. #540.
614        let saved = self.persistence.merge_save_runtime(child).await;
615        self.finish_child_save(child, saved)
616    }
617
618    async fn save_child_session_authoritative_flags(
619        &self,
620        child: &mut Session,
621    ) -> Result<(), ChildSessionError> {
622        // Non-adopting save: the caller just set the child's posture flags from
623        // the live parent (the #74 re-seed), so persist them as-is instead of
624        // reverting to the child's stale on-disk bypass. #540.
625        let saved = self
626            .persistence
627            .save_runtime_authoritative_flags(child)
628            .await;
629        self.finish_child_save(child, saved)
630    }
631
632    async fn save_resident_reuse_state(
633        &self,
634        child: &mut Session,
635        workspace: &str,
636        workspace_source: bamboo_engine::project_context::WorkspaceSource,
637        permission_audit: bamboo_domain::PermissionAuditSeed,
638        no_human_approver: bool,
639    ) -> Result<(), ChildSessionError> {
640        let child_id = child.id.clone();
641        let workspace_value = workspace.to_string();
642        let source_value = workspace_source.as_str().to_string();
643        let saved = self
644            .persistence
645            .update_authoritative_permission_posture_and_publish(
646                &child_id,
647                &permission_audit,
648                |latest| {
649                    latest.workspace = Some(workspace_value.clone());
650                    latest.set_workspace_path_meta(&workspace_value);
651                    latest.metadata.insert(
652                        bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
653                        source_value,
654                    );
655                    latest
656                        .agent_runtime_state
657                        .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
658                        .no_human_approver = no_human_approver;
659                },
660                |latest| {
661                    self.sessions_cache.insert(
662                        latest.id.clone(),
663                        Arc::new(parking_lot::RwLock::new(latest.clone())),
664                    );
665                },
666            )
667            .await
668            .map_err(|error| {
669                ChildSessionError::Execution(format!(
670                    "failed to atomically re-seed resident child: {error}"
671                ))
672            })?
673            .ok_or_else(|| ChildSessionError::NotFound(child_id.clone()))?;
674        *child = saved;
675        self.publish_child_workspace(
676            &child.id,
677            std::path::PathBuf::from(workspace),
678            workspace_source.as_str(),
679        );
680        Ok(())
681    }
682
683    async fn send_session_message(
684        &self,
685        source_session_id: &str,
686        target_session_id: &str,
687        message: &str,
688        idempotency_key: Option<&str>,
689    ) -> Result<
690        bamboo_engine::session_app::child_session::ChildSessionMessageDelivery,
691        ChildSessionError,
692    > {
693        let messenger = self.session_messenger.as_ref().ok_or_else(|| {
694            ChildSessionError::Execution(
695                "logical SessionMessenger is not configured for this runtime".to_string(),
696            )
697        })?;
698        let id = idempotency_key.map_or_else(bamboo_domain::SessionMessageId::new, |key| {
699            bamboo_domain::SessionMessageId::stable(
700                "subagent_send_message",
701                &serde_json::json!({
702                    "source_session_id": source_session_id,
703                    "target_session_id": target_session_id,
704                    "tool_call_id": key,
705                }),
706            )
707        });
708        let envelope = bamboo_domain::SessionMessageEnvelope {
709            id,
710            source: bamboo_domain::SessionMessageSource::Session {
711                session_id: source_session_id.to_string(),
712            },
713            target_session_id: target_session_id.to_string(),
714            kind: bamboo_domain::SessionMessageKind::PeerMessage,
715            body: bamboo_domain::SessionMessageBody::Content(
716                bamboo_domain::SessionMessageContent::text(message),
717            ),
718            created_at: chrono::Utc::now(),
719            thread_id: None,
720            in_reply_to: None,
721            attempt: None,
722            correlation_id: None,
723        };
724        match messenger.send(envelope).await {
725            Ok(receipt) => Ok(
726                bamboo_engine::session_app::child_session::ChildSessionMessageDelivery::Activated(
727                    receipt,
728                ),
729            ),
730            Err(bamboo_engine::SessionMessengerError::Activation {
731                receipt, source, ..
732            }) => Ok(
733                bamboo_engine::session_app::child_session::ChildSessionMessageDelivery::ActivationPending {
734                    delivery: receipt,
735                    error: source.to_string(),
736                },
737            ),
738            Err(error) => Err(ChildSessionError::Execution(error.to_string())),
739        }
740    }
741
742    async fn is_child_running(&self, child_session_id: &str) -> bool {
743        let runners = self.agent_runners.read().await;
744        runners
745            .get(child_session_id)
746            .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
747    }
748
749    async fn list_children(&self, parent_session_id: &str) -> Vec<ChildSessionEntry> {
750        self.session_store
751            .list_index_entries()
752            .await
753            .into_iter()
754            .filter(|entry| {
755                entry.kind == SessionKind::Child
756                    && entry.parent_session_id.as_deref() == Some(parent_session_id)
757            })
758            .map(|entry| map_index_entry_to_child_entry(&entry))
759            .collect()
760    }
761
762    async fn find_resident_child(
763        &self,
764        root_session_id: &str,
765        resident_name: &str,
766    ) -> Option<String> {
767        let name = resident_name.trim();
768        if name.is_empty() {
769            return None;
770        }
771        // Scan the index for a child in this root tree tagged with the resident
772        // name. Prefer the most recently updated if (defensively) more than one
773        // exists. Index-backed: no session.json loads.
774        let mut best: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
775        for entry in self.session_store.list_index_entries().await {
776            if entry.kind == SessionKind::Child
777                && entry.root_session_id == root_session_id
778                && entry.resident_name.as_deref() == Some(name)
779            {
780                match &best {
781                    Some((_, ts)) if *ts >= entry.updated_at => {}
782                    _ => best = Some((entry.id.clone(), entry.updated_at)),
783                }
784            }
785        }
786        best.map(|(id, _)| id)
787    }
788
789    async fn enqueue_child_run(
790        &self,
791        parent: &Session,
792        child: &Session,
793    ) -> Result<(), ChildSessionError> {
794        let model = if child.model.trim().is_empty() {
795            parent.model.clone()
796        } else {
797            child.model.clone()
798        };
799        if model.trim().is_empty() {
800            return Err(ChildSessionError::Execution(
801                "child model is empty and parent model is unavailable".to_string(),
802            ));
803        }
804
805        // Per-child tool denylist: persisted onto the child session by
806        // `create_child_action` (JSON in metadata). Most sub-agents are full
807        // agents and carry none; a read-only Guardian reviewer carries a
808        // denylist here so the worker trims its toolset. `SpawnJob` wants a
809        // `Vec<String>`, so collect the set.
810        let disabled_tools = child
811            .metadata
812            .get("disabled_tools")
813            .and_then(|raw| serde_json::from_str::<std::collections::BTreeSet<String>>(raw).ok())
814            .filter(|set| !set.is_empty())
815            .map(|set| set.into_iter().collect::<Vec<String>>());
816
817        // NOTE: enqueue only *runs* the child in the background. Registering the
818        // parent's wait (which suspends the parent) is now an explicit, separate
819        // step so the model can spawn several children without each one
820        // suspending it — see `register_parent_wait_for_child` /
821        // `register_parent_wait_for_children` and the `SubAgent.wait` action.
822        self.scheduler
823            .enqueue(SpawnJob {
824                parent_session_id: parent.id.clone(),
825                child_session_id: child.id.clone(),
826                model,
827                disabled_tools,
828            })
829            .await
830            .map_err(ChildSessionError::Execution)?;
831
832        let parent_tx = get_or_create_event_sender(&self.session_event_senders, &parent.id).await;
833        let _ = parent_tx.send(AgentEvent::SubAgentStarted {
834            parent_session_id: parent.id.clone(),
835            child_session_id: child.id.clone(),
836            title: Some(child.title.clone()),
837        });
838
839        Ok(())
840    }
841
842    async fn cancel_child_run_and_wait(
843        &self,
844        child_session_id: &str,
845    ) -> Result<(), ChildSessionError> {
846        let cancelled = {
847            let mut runners = self.agent_runners.write().await;
848            if let Some(runner) = runners.get_mut(child_session_id) {
849                if matches!(runner.status, AgentStatus::Running) {
850                    runner.cancel_token.cancel();
851                    true
852                } else {
853                    false
854                }
855            } else {
856                false
857            }
858        };
859
860        if !cancelled {
861            return Ok(());
862        }
863
864        let deadline = Instant::now() + Duration::from_secs(10);
865        loop {
866            let still_running = {
867                let runners = self.agent_runners.read().await;
868                runners
869                    .get(child_session_id)
870                    .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
871            };
872            if !still_running {
873                return Ok(());
874            }
875            if Instant::now() >= deadline {
876                return Err(ChildSessionError::Execution(format!(
877                    "timed out waiting for child session {child_session_id} to stop after cancellation"
878                )));
879            }
880            sleep(Duration::from_millis(50)).await;
881        }
882    }
883
884    async fn delete_child_session(
885        &self,
886        parent_session_id: &str,
887        child_id: &str,
888    ) -> Result<DeleteChildResult, ChildSessionError> {
889        let cancelled_running_child = {
890            let mut runners = self.agent_runners.write().await;
891            if let Some(runner) = runners.remove(child_id) {
892                runner.cancel_token.cancel();
893                true
894            } else {
895                false
896            }
897        };
898
899        let deleted = self
900            .storage
901            .delete_session(child_id)
902            .await
903            .map_err(|error| {
904                ChildSessionError::Execution(format!("failed to delete child session: {error}"))
905            })?;
906
907        self.sessions_cache.remove(child_id);
908        {
909            let mut senders = self.session_event_senders.write().await;
910            senders.remove(child_id);
911            if cancelled_running_child {
912                if let Some(parent_tx) = senders.get(parent_session_id) {
913                    let _ = parent_tx.send(AgentEvent::SubAgentCompleted {
914                        parent_session_id: parent_session_id.to_string(),
915                        child_session_id: child_id.to_string(),
916                        status: "cancelled".to_string(),
917                        error: Some("Child session deleted while running".to_string()),
918                    });
919                }
920            }
921        }
922
923        Ok(DeleteChildResult {
924            deleted,
925            cancelled_running_child,
926        })
927    }
928
929    async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo> {
930        let runners = self.agent_runners.read().await;
931        runners.get(child_id).map(|runner| ChildRunnerInfo {
932            started_at: Some(runner.started_at),
933            completed_at: runner.completed_at,
934            last_tool_name: runner.last_tool_name.clone(),
935            last_tool_phase: runner.last_tool_phase.clone(),
936            last_event_at: runner.last_event_at,
937            round_count: runner.round_count,
938        })
939    }
940
941    async fn register_parent_wait_for_child(
942        &self,
943        parent_session_id: &str,
944        child_session_id: &str,
945        tool_call_id: Option<&str>,
946    ) -> Result<(), ChildSessionError> {
947        ChildSessionAdapter::register_parent_wait_for_child(
948            self,
949            parent_session_id,
950            child_session_id,
951            tool_call_id,
952        )
953        .await
954    }
955
956    async fn register_parent_wait_for_children(
957        &self,
958        parent_session_id: &str,
959        child_session_ids: &[String],
960        policy: ChildWaitPolicy,
961    ) -> Result<usize, ChildSessionError> {
962        ChildSessionAdapter::register_parent_wait_for_children(
963            self,
964            parent_session_id,
965            child_session_ids,
966            policy,
967        )
968        .await
969    }
970
971    async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String> {
972        ChildSessionAdapter::active_child_ids(self, parent_session_id).await
973    }
974
975    async fn terminal_child_ids(
976        &self,
977        parent_session_id: &str,
978        candidates: &[String],
979    ) -> Vec<(String, String)> {
980        ChildSessionAdapter::terminal_child_ids(self, parent_session_id, candidates).await
981    }
982
983    async fn ensure_child_indexed(&self, child_session_id: &str) {
984        let _ = self.session_store.get_index_entry(child_session_id).await;
985    }
986}