1use 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
28pub 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 pub(crate) subagent_model_resolver: crate::tools::OptionalSubagentModelResolver,
43 pub(crate) config: Arc<RwLock<Config>>,
45 pub(crate) project_store: Option<Arc<bamboo_projects::ProjectStore>>,
49 pub(crate) workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
50 pub(crate) parent_wait_slots: Arc<dashmap::DashMap<String, Arc<ParentWaitSlot>>>,
55}
56
57#[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
73fn 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 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 #[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 parent_wait_slots: Arc::new(dashmap::DashMap::new()),
158 }
159 }
160
161 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 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 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 slot.pending.lock().push((
205 child_session_id.to_string(),
206 tool_call_id.map(str::to_string),
207 ));
208
209 let _flush_guard = slot.flush_lock.lock().await;
211
212 let batch: Vec<(String, Option<String>)> = {
215 let mut pending = slot.pending.lock();
216 pending.drain(..).collect()
217 };
218 if batch.is_empty() {
219 return Ok(());
222 }
223
224 if let Err(error) = self
226 .flush_parent_waits(parent_session_id, &batch, ChildWaitPolicy::All)
227 .await
228 {
229 let mut pending = slot.pending.lock();
231 for item in batch {
232 pending.push(item);
233 }
234 return Err(error);
235 }
236
237 self.parent_wait_slots
251 .remove_if(parent_session_id, |_, slot| slot.pending.lock().is_empty());
252
253 Ok(())
254 }
255
256 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 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 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 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 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 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 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 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#[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 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 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 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 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 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 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}