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