1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17
18use async_trait::async_trait;
19use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
20use bamboo_domain::poison::PoisonRecover;
21use tokio::sync::mpsc;
22use tokio_util::sync::CancellationToken;
23
24use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
25use bamboo_subagent::proto::{
26 AgentRecord, ChildFrame, ParentFrame, PermissionPolicyContext, RunSpec, TerminalStatus,
27};
28use bamboo_subagent::provision::{
29 ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
30};
31use bamboo_subagent::transport::{client_config_trusting_cert, ChildClient};
32
33use crate::runtime::execution::{ExternalChildRunner, SpawnJob};
34
35pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 8;
37
38pub const MAX_SPAWN_DEPTH: u32 = 4;
44
45const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
47
48const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
51
52const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
59
60pub struct IssuedCodexRunToken {
63 pub token_id: String,
64 pub token: String,
65}
66
67pub trait CodexRunTokenAuthority: Send + Sync + 'static {
70 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
71 fn revoke(&self, token_id: &str);
72}
73
74struct CodexRunTokenGuard {
75 authority: Arc<dyn CodexRunTokenAuthority>,
76 token_id: String,
77}
78
79impl Drop for CodexRunTokenGuard {
80 fn drop(&mut self) {
81 self.authority.revoke(&self.token_id);
82 }
83}
84
85fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
86 matches!(
87 executor,
88 ExecutorSpec::Codex {
89 auth_mode: Some(mode),
90 ..
91 } if mode == "bamboo"
92 ) || matches!(
93 executor,
94 ExecutorSpec::Codex {
95 auth_mode: None,
96 inherit_user_config,
97 ..
98 } if !inherit_user_config.unwrap_or(false)
99 )
100}
101
102fn workspace_is_bamboo_owned(raw: &str) -> bool {
103 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
104 let configured_root = bamboo_config::paths::resolve_workspace_root();
105 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
106 if workspace.starts_with(&configured_root) {
107 return true;
108 }
109
110 workspace.ancestors().any(|candidate| {
115 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
116 return false;
117 };
118 let Some(worktree_root) = candidate.parent() else {
119 return false;
120 };
121 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
122 || worktree_root.parent().and_then(Path::file_name)
123 != Some(std::ffi::OsStr::new(".bamboo"))
124 {
125 return false;
126 }
127 let marker = worktree_root.join(".bamboo-owned").join(name);
128 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
129 })
130}
131
132fn build_codex_run_secrets(
133 executor: &ExecutorSpec,
134 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
135 child_session_id: &str,
136) -> Result<
137 (
138 bamboo_subagent::proto::RunSecrets,
139 Option<CodexRunTokenGuard>,
140 ),
141 AgentError,
142> {
143 if !executor_uses_bamboo_codex(executor) {
144 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
145 }
146
147 let authority = authority.ok_or_else(|| {
148 AgentError::LLM(
149 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
150 )
151 })?;
152 let issued = authority
153 .issue(child_session_id)
154 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
155 let guard = CodexRunTokenGuard {
156 authority,
157 token_id: issued.token_id,
158 };
159 Ok((
160 bamboo_subagent::proto::RunSecrets {
161 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
162 },
163 Some(guard),
164 ))
165}
166
167struct PooledWorker {
173 worker: SpawnedChild,
174 mailbox_id: String,
176}
177
178#[derive(Debug, Clone)]
184pub struct ResolvedRemotePlacement {
185 pub endpoint: String,
186 pub token: Option<String>,
187 pub ca_cert_file: Option<PathBuf>,
188 pub host_label: Option<String>,
192}
193
194#[derive(Debug, Clone)]
201pub struct ResolvedSchedulablePlacement {
202 pub pool: String,
203 pub host_label: Option<String>,
207}
208
209enum PlacementKind {
215 Local,
216 Remote,
217 Schedulable,
218}
219
220pub struct ActorChildRunner {
222 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
223 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
224 agent_id: String,
225 worker_bin: PathBuf,
226 worker_args: Vec<String>,
227 fabric_dir: PathBuf,
228 executor: ExecutorSpec,
229 credentials: Vec<ScopedCredential>,
232 default_provider: String,
234 bus: Option<bamboo_subagent::BusEndpoint>,
237 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
241 pool: Arc<tokio::sync::Mutex<HashMap<String, Vec<PooledWorker>>>>,
247 max_idle_per_key: usize,
248 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
252 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
256 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
267 remote_placements: HashMap<String, ResolvedRemotePlacement>,
273 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
281 schedule_cursor: Arc<std::sync::Mutex<HashMap<String, usize>>>,
286 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
288}
289
290#[async_trait]
300pub trait ChildApprovalDecider: Send + Sync {
301 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
304}
305
306async fn decide_child_approval(
309 decider: Option<&Arc<dyn ChildApprovalDecider>>,
310 child_session_id: &str,
311 request: &serde_json::Value,
312) -> bool {
313 match decider {
314 Some(decider) => decider.decide(child_session_id, request).await,
315 None => false,
316 }
317}
318
319const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
323
324#[async_trait]
333pub trait ChildApprovalReviewer: Send + Sync {
334 async fn review(
337 &self,
338 parent_session_id: &str,
339 child_session_id: &str,
340 request: &serde_json::Value,
341 ) -> bool;
342}
343
344fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
345 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
346 &SLOT
347}
348
349pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
351 let _ = child_approval_reviewer_slot().set(reviewer);
352}
353
354pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
356 child_approval_reviewer_slot().get().cloned()
357}
358
359impl ActorChildRunner {
360 #[allow(clippy::too_many_arguments)]
361 pub fn new(
362 agent_id: String,
363 worker_bin: PathBuf,
364 worker_args: Vec<String>,
365 fabric_dir: PathBuf,
366 executor: ExecutorSpec,
367 credentials: Vec<ScopedCredential>,
368 default_provider: String,
369 max_concurrent: usize,
370 ) -> Self {
371 Self {
372 approval_registry: None,
373 permission_config: None,
374 agent_id,
375 worker_bin,
376 worker_args,
377 fabric_dir,
378 executor,
379 credentials,
380 default_provider,
381 bus: None,
382 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
383 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
384 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
385 approval_decider: None,
386 approval_reviewer: None,
387 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
388 remote_placements: HashMap::new(),
389 schedulable_placements: HashMap::new(),
390 schedule_cursor: Arc::new(std::sync::Mutex::new(HashMap::new())),
391 codex_run_tokens: None,
392 }
393 }
394
395 pub fn with_approval_registry(
396 mut self,
397 registry: super::approval_registry::SharedApprovalRegistry,
398 ) -> Self {
399 self.approval_registry = Some(registry);
400 self
401 }
402
403 pub fn with_permission_config(
404 mut self,
405 config: Arc<bamboo_tools::permission::PermissionConfig>,
406 ) -> Self {
407 self.permission_config = Some(config);
408 self
409 }
410
411 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
416 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
417 self
418 }
419
420 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
423 self.approval_decider = Some(decider);
424 self
425 }
426
427 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
428 self.approval_reviewer = Some(reviewer);
429 self
430 }
431
432 pub fn with_codex_run_tokens(
433 mut self,
434 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
435 ) -> Self {
436 self.codex_run_tokens = authority;
437 self
438 }
439
440 pub fn with_remote_placements(
446 mut self,
447 placements: HashMap<String, ResolvedRemotePlacement>,
448 ) -> Self {
449 self.remote_placements = placements;
450 self
451 }
452
453 pub fn with_schedulable_placements(
459 mut self,
460 placements: HashMap<String, ResolvedSchedulablePlacement>,
461 ) -> Self {
462 self.schedulable_placements = placements;
463 self
464 }
465
466 fn fingerprint(spec: &ProvisionSpec) -> String {
483 let role = spec.identity.role.as_str();
484 let (provider, model) = spec
485 .model
486 .as_ref()
487 .map(|m| (m.provider.as_str(), m.model.as_str()))
488 .unwrap_or(("", ""));
489 let workspace = spec.workspace.as_deref().unwrap_or("");
490 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
491 tools.sort();
492 let caps = &spec.capabilities;
493 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
496 format!(
497 "{role}\u{1}{provider}\u{1}{model}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}by={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}gro={}\u{1}executor={executor}",
498 tools.join(","),
499 spec.identity.depth,
500 caps.nested_spawn,
501 caps.bypass,
502 caps.enforce_permissions,
503 caps.max_spawn_depth.unwrap_or(0),
504 caps.no_human_approver,
511 caps.guardian_read_only,
515 )
516 }
517
518 async fn acquire_bus_worker(
524 &self,
525 key: &str,
526 spec: &ProvisionSpec,
527 ) -> crate::runtime::runner::Result<PooledWorker> {
528 loop {
531 let candidate = {
532 let mut pool = self.pool.lock().await;
533 pool.get_mut(key).and_then(|bucket| bucket.pop())
534 };
535 let Some(mut candidate) = candidate else {
536 break;
537 };
538 if candidate.worker.is_alive() {
539 return Ok(candidate);
540 }
541 candidate.worker.kill().await;
542 }
543
544 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
545 .await
546 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
547 let mailbox_id = spawned.record.agent_id.clone();
548 Ok(PooledWorker {
549 worker: spawned,
550 mailbox_id,
551 })
552 }
553
554 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
558 if !worker.worker.is_alive() {
559 worker.worker.kill().await;
560 return;
561 }
562 let mut pool = self.pool.lock().await;
563 let bucket = pool.entry(key.to_string()).or_default();
564 if bucket.len() >= self.max_idle_per_key {
565 drop(pool);
566 worker.worker.kill().await;
567 return;
568 }
569 bucket.push(worker);
570 }
571
572 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
574 let mut spec = ProvisionSpec::new(
575 ChildIdentity {
576 child_id: job.child_session_id.clone(),
577 parent_id: Some(job.parent_session_id.clone()),
578 project_key: None,
579 role: session
580 .metadata
581 .get("subagent_type")
582 .cloned()
583 .unwrap_or_else(|| "worker".to_string()),
584 depth: session.spawn_depth,
589 },
590 self.executor.clone(),
591 self.fabric_dir.to_string_lossy().into_owned(),
592 );
593 spec.workspace = session.workspace.clone();
594 if let ExecutorSpec::Codex {
595 workspace_owned, ..
596 } = &mut spec.executor
597 {
598 *workspace_owned = Some(
599 spec.workspace
600 .as_deref()
601 .is_some_and(workspace_is_bamboo_owned),
602 );
603 }
604 spec.bus = self.bus.clone();
607 spec.model = session
610 .model_ref
611 .as_ref()
612 .map(|r| ModelRefSpec {
613 provider: r.provider.clone(),
614 model: r.model.clone(),
615 })
616 .or_else(|| {
617 let m = job.model.trim();
618 (!m.is_empty()).then(|| ModelRefSpec {
619 provider: self.default_provider.clone(),
620 model: m.to_string(),
621 })
622 });
623 spec.disabled_tools = job.disabled_tools.clone();
624 match &spec.executor {
625 ExecutorSpec::Codex {
631 auth_mode,
632 provider_key_ref,
633 ..
634 } => {
635 if auth_mode.as_deref() == Some("custom") {
636 if let Some(reference) = provider_key_ref {
637 if let Some(credential) = self.credentials.iter().find(|credential| {
638 credential.credential_ref.as_deref() == Some(reference)
639 }) {
640 spec.secrets.provider_credentials.push(credential.clone());
641 } else {
642 tracing::warn!(
643 "actor child {}: custom Codex credential reference '{}' did not resolve",
644 job.child_session_id,
645 reference
646 );
647 }
648 } else {
649 tracing::warn!(
650 "actor child {}: custom Codex executor has no credential reference",
651 job.child_session_id
652 );
653 }
654 }
655 }
656 _ => {
659 let provider = spec
660 .model
661 .as_ref()
662 .map(|model| model.provider.as_str())
663 .filter(|provider| !provider.trim().is_empty())
664 .unwrap_or(&self.default_provider);
665 if let Some(credential) = self
666 .credentials
667 .iter()
668 .find(|credential| credential.provider == provider)
669 {
670 spec.secrets.provider_credentials.push(credential.clone());
671 } else {
672 tracing::warn!(
673 "actor child {}: no credential found for provider '{}'",
674 job.child_session_id,
675 provider
676 );
677 }
678 }
679 }
680 spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
687 spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
688 spec.capabilities.enforce_permissions = true;
694 spec.capabilities.bypass = session
699 .agent_runtime_state
700 .as_ref()
701 .is_some_and(|s| s.bypass_permissions);
702 spec.capabilities.no_human_approver = session
707 .agent_runtime_state
708 .as_ref()
709 .is_some_and(|s| s.no_human_approver);
710 spec.capabilities.guardian_read_only =
720 session.metadata.get("subagent_type").map(String::as_str) == Some("guardian");
721 if spec.capabilities.guardian_read_only {
722 if let ExecutorSpec::Codex {
723 permission_profile, ..
724 } = &mut spec.executor
725 {
726 *permission_profile = Some("read-only".to_string());
727 }
728 }
729 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
736 spec.placement = Placement::Remote {
737 endpoint: placement.endpoint.clone(),
738 };
739 spec.secrets.worker_auth_token = placement.token.clone();
740 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
741 {
742 spec.placement = Placement::Schedulable {
750 pool: placement.pool.clone(),
751 };
752 }
753 spec
754 }
755
756 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
762 let host_label = match &spec.placement {
763 Placement::Remote { .. } => self
764 .remote_placements
765 .get(spec.identity.role.as_str())
766 .and_then(|p| p.host_label.as_deref()),
767 Placement::Schedulable { .. } => self
768 .schedulable_placements
769 .get(spec.identity.role.as_str())
770 .and_then(|p| p.host_label.as_deref()),
771 Placement::Local => None,
772 };
773 placement_metadata(&spec.placement, host_label)
774 }
775
776 async fn resolve_schedulable_worker(
783 &self,
784 role: &str,
785 ) -> std::result::Result<String, AgentError> {
786 let pool = self
787 .schedulable_placements
788 .get(role)
789 .ok_or_else(|| {
790 AgentError::LLM(format!(
791 "schedulable placement for role '{role}' vanished before scheduling"
792 ))
793 })?
794 .pool
795 .clone();
796 let bus = self.bus.as_ref().ok_or_else(|| {
797 AgentError::LLM(format!(
798 "schedulable role '{role}': no mailbox bus configured (subagents.broker)"
799 ))
800 })?;
801
802 let mut q = bamboo_broker::BrokerClient::connect(
805 &bus.endpoint,
806 bamboo_subagent::AgentRef {
807 session_id: format!("sched-q-{role}"),
808 role: None,
809 },
810 &bus.token,
811 )
812 .await
813 .map_err(|e| {
814 AgentError::LLM(format!(
815 "schedulable role '{role}': bus connect failed: {e}"
816 ))
817 })?;
818 let candidates = q.list_connected(&pool).await.map_err(|e| {
819 AgentError::LLM(format!(
820 "schedulable role '{role}': bus presence query failed: {e}"
821 ))
822 })?;
823
824 if candidates.is_empty() {
825 return Err(AgentError::LLM(format!(
826 "schedulable role '{role}': no live worker in pool '{pool}' on the bus \
827 (NOT spawning a local subprocess — a schedulable role has no local fallback)"
828 )));
829 }
830
831 let idx = {
836 let mut cursors = self.schedule_cursor.lock().recover_poison();
837 let cursor = cursors.entry(pool.clone()).or_insert(0);
838 let i = *cursor % candidates.len();
839 *cursor = cursor.wrapping_add(1);
840 i
841 };
842 Ok(candidates[idx].clone())
843 }
844}
845
846#[async_trait]
847impl ExternalChildRunner for ActorChildRunner {
848 async fn should_handle(&self, session: &Session) -> bool {
849 session.metadata.get("runtime.kind") == Some(&"external".to_string())
850 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
851 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
852 }
853
854 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
855 *self.escalation_bridge.lock().recover_poison() = bridge;
856 }
857
858 async fn execute_external_child(
859 &self,
860 session: &mut Session,
861 job: &SpawnJob,
862 event_tx: mpsc::Sender<AgentEvent>,
863 cancel_token: CancellationToken,
864 ) -> crate::runtime::runner::Result<()> {
865 let escalation = self.escalation_bridge.lock().recover_poison().clone();
874 let assignment = extract_assignment(session);
875 let mut spec = self.build_spec(session, job);
876 spec.reusable = true;
879 if spec.limits.idle_timeout_secs.is_none() {
880 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
881 }
882 let pool_key = Self::fingerprint(&spec);
883
884 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
889 {
890 return Err(AgentError::LLM(
891 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
892 .to_string(),
893 ));
894 }
895 let messages: Vec<serde_json::Value> = session
901 .messages
902 .iter()
903 .filter_map(|m| serde_json::to_value(m).ok())
904 .collect();
905 let project_id = project_id_for_actor_run(session)?;
906 let permission_policy = self.permission_config.as_ref().and_then(|config| {
911 serde_json::to_value(config.to_serializable())
912 .ok()
913 .map(|policy| PermissionPolicyContext {
914 revision: config.policy_revision(),
915 bypass_permissions: session
916 .agent_runtime_state
917 .as_ref()
918 .is_some_and(|state| state.bypass_permissions),
919 session_id: session.id.clone(),
920 workspace_path: session.workspace.clone(),
921 inherit_session_grants: false,
922 policy,
923 })
924 });
925
926 let _slot = self
931 .concurrency
932 .acquire()
933 .await
934 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".to_string()))?;
935
936 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
943 &spec.executor,
944 self.codex_run_tokens.clone(),
945 &job.child_session_id,
946 )?;
947
948 let kind = match spec.placement {
956 Placement::Remote { .. } => PlacementKind::Remote,
957 Placement::Schedulable { .. } => PlacementKind::Schedulable,
958 Placement::Local => PlacementKind::Local,
959 };
960 let remote = !matches!(kind, PlacementKind::Local);
961
962 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
968 session
969 .metadata
970 .insert("placement".to_string(), placement_meta);
971 }
972
973 let mut attempt = 0u8;
980 let (result, actor) = loop {
981 let (actor, mut client) = match kind {
982 PlacementKind::Remote => {
983 let placement = self
987 .remote_placements
988 .get(spec.identity.role.as_str())
989 .ok_or_else(|| {
990 AgentError::LLM(format!(
991 "remote placement for role '{}' vanished before connect",
992 spec.identity.role
993 ))
994 })?;
995 let endpoint = placement.endpoint.clone();
996 let trust_cfg = match placement.ca_cert_file.as_deref() {
999 Some(path) => Some(client_config_trusting_cert(path).map_err(|e| {
1000 AgentError::LLM(format!(
1001 "remote worker CA cert '{}': {e}",
1002 path.display()
1003 ))
1004 })?),
1005 None => None,
1006 };
1007 let client = ChildClient::connect_with_auth_tls(
1008 &endpoint,
1009 placement.token.as_deref(),
1010 trust_cfg,
1011 )
1012 .await
1013 .map_err(|e| {
1014 AgentError::LLM(format!("remote actor connect to '{endpoint}' failed: {e}"))
1015 })?;
1016 let record = AgentRecord {
1019 agent_id: job.child_session_id.clone(),
1020 role: spec.identity.role.clone(),
1021 labels: Vec::new(),
1022 endpoint: endpoint.clone(),
1023 pid: 0,
1024 version: String::new(),
1025 started_at: chrono::Utc::now(),
1026 lease_expires_at: chrono::Utc::now(),
1027 };
1028 let _ = endpoint;
1029 let actor = PooledWorker {
1030 worker: SpawnedChild::remote(record),
1031 mailbox_id: job.child_session_id.clone(),
1032 };
1033 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(client);
1034 (actor, client)
1035 }
1036 PlacementKind::Schedulable => {
1037 let bus = self.bus.as_ref().ok_or_else(|| {
1044 AgentError::LLM(
1045 "schedulable sub-agents require a mailbox bus (subagents.broker)"
1046 .to_string(),
1047 )
1048 })?;
1049 let mailbox_id = self
1050 .resolve_schedulable_worker(spec.identity.role.as_str())
1051 .await?;
1052 let parent = bamboo_subagent::AgentRef {
1053 session_id: format!("p-{}", job.child_session_id),
1054 role: None,
1055 };
1056 let link = bamboo_broker::BrokerChildLink::connect(
1057 &bus.endpoint,
1058 parent,
1059 &bus.token,
1060 mailbox_id.clone(),
1061 )
1062 .await
1063 .map_err(|e| {
1064 AgentError::LLM(format!(
1065 "schedulable link connect to '{mailbox_id}' failed: {e}"
1066 ))
1067 })?;
1068 let actor = PooledWorker {
1071 worker: SpawnedChild::remote(AgentRecord {
1072 agent_id: mailbox_id.clone(),
1073 role: spec.identity.role.clone(),
1074 labels: Vec::new(),
1075 endpoint: bus.endpoint.clone(),
1076 pid: 0,
1077 version: String::new(),
1078 started_at: chrono::Utc::now(),
1079 lease_expires_at: chrono::Utc::now(),
1080 }),
1081 mailbox_id,
1082 };
1083 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1084 (actor, client)
1085 }
1086 PlacementKind::Local => {
1087 let bus = self.bus.as_ref().ok_or_else(|| {
1094 AgentError::LLM(
1095 "local sub-agents require a mailbox bus (subagents.broker); none is \
1096 configured and the bus could not be embedded"
1097 .to_string(),
1098 )
1099 })?;
1100 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
1101 let parent = bamboo_subagent::AgentRef {
1102 session_id: format!("p-{}", job.child_session_id),
1103 role: None,
1104 };
1105 let link = bamboo_broker::BrokerChildLink::connect(
1106 &bus.endpoint,
1107 parent,
1108 &bus.token,
1109 actor.mailbox_id.clone(),
1110 )
1111 .await
1112 .map_err(|e| {
1113 AgentError::LLM(format!("broker child link connect failed: {e}"))
1114 })?;
1115 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1116 (actor, client)
1117 }
1118 };
1119
1120 if let Err(e) = client
1121 .send(ParentFrame::Run(RunSpec {
1122 assignment: assignment.clone(),
1124 project_id: project_id.clone(),
1125 reasoning_effort: None,
1126 permission_policy: permission_policy.clone(),
1127 messages: messages.clone(),
1128 secrets: run_secrets.clone(),
1129 }))
1130 .await
1131 {
1132 if !remote {
1133 actor.worker.kill().await;
1134 }
1135 return Err(AgentError::LLM(format!("actor run dispatch failed: {e}")));
1136 }
1137
1138 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
1142 let live_guard = super::live::register(
1143 &job.child_session_id,
1144 live_tx,
1145 attempt as u32,
1146 self.approval_registry.clone(),
1147 );
1148
1149 let result = drive(
1150 &mut *client,
1151 &job.parent_session_id,
1152 &job.child_session_id,
1153 attempt as u32,
1154 self.approval_registry.as_ref(),
1155 self.approval_decider.as_ref(),
1156 self.approval_reviewer.as_ref(),
1157 escalation.clone(),
1158 &event_tx,
1159 &cancel_token,
1160 &mut live_rx,
1161 Some(WORKER_FIRST_FRAME_TIMEOUT),
1168 )
1169 .await;
1170 drop(live_guard);
1176 drop(client);
1179
1180 if attempt == 0 && matches!(result, Err(AgentError::WorkerUnresponsive(_))) {
1187 match kind {
1188 PlacementKind::Local => {
1189 tracing::warn!(
1190 "actor child {} got no first frame; reaping the worker and respawning once",
1191 job.child_session_id
1192 );
1193 actor.worker.kill().await;
1194 attempt += 1;
1195 continue;
1196 }
1197 PlacementKind::Schedulable => {
1198 tracing::warn!(
1199 "scheduled actor child {} got no first frame; re-selecting a pool worker",
1200 job.child_session_id
1201 );
1202 drop(actor);
1203 attempt += 1;
1204 continue;
1205 }
1206 PlacementKind::Remote => {}
1207 }
1208 }
1209 break (result, actor);
1210 };
1211
1212 if remote {
1216 drop(actor);
1217 } else {
1218 match &result {
1219 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
1220 Err(_) => actor.worker.kill().await,
1221 }
1222 }
1223
1224 match result {
1228 Ok(Some(text)) => {
1229 if !text.is_empty() {
1230 session.add_message(bamboo_agent_core::Message::assistant(text, None));
1231 }
1232 Ok(())
1233 }
1234 Ok(None) => Ok(()),
1235 Err(e) => Err(e),
1236 }
1237 }
1238}
1239
1240fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
1246 let value = match placement {
1249 Placement::Local => return None,
1250 Placement::Remote { endpoint } => serde_json::json!({
1251 "kind": "remote",
1252 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
1253 }),
1254 Placement::Schedulable { pool } => serde_json::json!({
1255 "kind": "remote",
1256 "host": host_label.unwrap_or(pool),
1257 }),
1258 };
1259 serde_json::to_string(&value).ok()
1260}
1261
1262fn host_of_endpoint(endpoint: &str) -> String {
1264 endpoint
1265 .trim()
1266 .trim_start_matches("wss://")
1267 .trim_start_matches("ws://")
1268 .split(['/', ':'])
1269 .next()
1270 .unwrap_or(endpoint)
1271 .to_string()
1272}
1273
1274async fn drive(
1285 client: &mut dyn bamboo_subagent::ChildLink,
1286 parent_session_id: &str,
1287 child_session_id: &str,
1288 child_attempt: u32,
1289 approval_registry: Option<&super::approval_registry::SharedApprovalRegistry>,
1290 approval_decider: Option<&Arc<dyn ChildApprovalDecider>>,
1291 approval_reviewer: Option<&Arc<dyn ChildApprovalReviewer>>,
1292 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
1293 event_tx: &mpsc::Sender<AgentEvent>,
1294 cancel_token: &CancellationToken,
1295 live_rx: &mut mpsc::UnboundedReceiver<ParentFrame>,
1296 first_frame_timeout: Option<Duration>,
1297) -> crate::runtime::runner::Result<Option<String>> {
1298 let mut got_first_frame = false;
1305 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
1306 loop {
1307 tokio::select! {
1308 _ = cancel_token.cancelled() => {
1309 break;
1311 }
1312 _ = async {
1313 match first_frame_watch.as_mut() {
1314 Some(s) => s.as_mut().await,
1315 None => std::future::pending::<()>().await,
1316 }
1317 }, if !got_first_frame => {
1318 return Err(AgentError::WorkerUnresponsive(format!(
1319 "child {child_session_id} produced no frame within {:?}",
1320 first_frame_timeout.unwrap_or_default()
1321 )));
1322 }
1323 Some(frame) = live_rx.recv() => {
1324 if client.send(frame).await.is_err() {
1326 tracing::warn!("live steering frame could not be sent; connection failing");
1327 }
1328 }
1329 frame = client.next_frame() => {
1330 got_first_frame = true;
1333 first_frame_watch = None;
1334 match frame {
1335 Ok(Some(ChildFrame::Event { event })) => {
1336 if let Ok(ev) = serde_json::from_value::<AgentEvent>(event) {
1338 let _ = event_tx.send(ev).await;
1339 }
1340 }
1341 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
1342 if let Some(reviewer) = approval_reviewer
1348 .cloned()
1349 .or_else(child_approval_reviewer)
1350 {
1351 let child = child_session_id.to_string();
1359 let parent = parent_session_id.to_string();
1360 let req_id = id.clone();
1361 let body = body.clone();
1362 let registry = approval_registry.cloned();
1363 tokio::spawn(async move {
1364 let approved = tokio::time::timeout(
1365 CHILD_APPROVAL_TIMEOUT,
1366 reviewer.review(&parent, &child, &body),
1367 )
1368 .await
1369 .unwrap_or(false);
1370 super::live::deliver_approval_scoped(
1371 registry.as_ref(),
1372 &child,
1373 child_attempt,
1374 &req_id,
1375 approved,
1376 );
1377 });
1378 } else if approval_decider.is_some() {
1379 let approved =
1383 decide_child_approval(approval_decider, child_session_id, &body)
1384 .await;
1385 if client
1386 .send(ParentFrame::ApprovalReply { id, approved })
1387 .await
1388 .is_err()
1389 {
1390 tracing::warn!(
1391 "failed to answer approval_request; connection failing"
1392 );
1393 }
1394 } else if let Some(host) = escalation_bridge.clone() {
1395 let child = child_session_id.to_string();
1402 let req_id = id.clone();
1403 let body = body.clone();
1404 let registry = approval_registry.cloned();
1405 tokio::spawn(async move {
1406 let approved = match tokio::time::timeout(
1407 CHILD_APPROVAL_TIMEOUT,
1408 host.approval_call(body),
1409 )
1410 .await
1411 {
1412 Ok(Ok(reply)) => reply
1413 .get("approved")
1414 .and_then(|v| v.as_bool())
1415 .unwrap_or(false),
1416 _ => false,
1418 };
1419 super::live::deliver_approval_scoped(
1420 registry.as_ref(),
1421 &child,
1422 child_attempt,
1423 &req_id,
1424 approved,
1425 );
1426 });
1427 } else {
1428 tracing::warn!(
1432 parent_session_id,
1433 child_session_id,
1434 request_id = %id,
1435 "forced-ask request has no parent-agent reviewer; denying"
1436 );
1437 if client
1438 .send(ParentFrame::ApprovalReply {
1439 id,
1440 approved: false,
1441 })
1442 .await
1443 .is_err()
1444 {
1445 tracing::warn!(
1446 "failed to send fail-closed approval reply; connection failing"
1447 );
1448 }
1449 }
1450 }
1451 Ok(Some(ChildFrame::Terminal { status, result, error, .. })) => {
1452 return match status {
1453 TerminalStatus::Completed => Ok(result),
1454 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
1455 TerminalStatus::Error => Err(AgentError::LLM(
1456 error.unwrap_or_else(|| "actor child errored".to_string()),
1457 )),
1458 TerminalStatus::Suspended => Err(AgentError::LLM(
1463 "nested sub-agent suspend received but resume transport is not wired"
1464 .to_string(),
1465 )),
1466 };
1467 }
1468 Ok(None) => {
1469 return Err(AgentError::LLM(
1470 "actor child closed before terminal".to_string(),
1471 ));
1472 }
1473 Err(e) => {
1474 return Err(AgentError::LLM(format!("actor transport error: {e}")));
1475 }
1476 }
1477 }
1478 }
1479 }
1480
1481 let _ = client.send(ParentFrame::Cancel).await;
1483 Err(AgentError::Cancelled)
1484}
1485
1486fn project_id_for_actor_run(
1488 session: &Session,
1489) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
1490 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
1491 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
1492 Ok(Some(project_id))
1493 }
1494 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
1495 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
1496 Err(AgentError::LLM(format!(
1497 "child session carries an invalid Project identity '{raw}': {message}"
1498 )))
1499 }
1500 }
1501}
1502
1503fn extract_assignment(session: &Session) -> String {
1504 session
1505 .messages
1506 .iter()
1507 .rev()
1508 .find(|m| matches!(m.role, Role::User))
1509 .map(|m| m.content.clone())
1510 .unwrap_or_else(|| {
1511 session
1512 .metadata
1513 .get("title")
1514 .cloned()
1515 .unwrap_or_else(|| "Execute task".to_string())
1516 })
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521 use super::*;
1522
1523 #[derive(Default)]
1524 struct RecordingCodexTokenAuthority {
1525 issued_for: std::sync::Mutex<Vec<String>>,
1526 revoked: std::sync::Mutex<Vec<String>>,
1527 }
1528
1529 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
1530 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
1531 self.issued_for
1532 .lock()
1533 .expect("issued fixture lock")
1534 .push(session_id.to_string());
1535 Ok(IssuedCodexRunToken {
1536 token_id: format!("id-{session_id}"),
1537 token: format!("bcx1_secret-{session_id}"),
1538 })
1539 }
1540
1541 fn revoke(&self, token_id: &str) {
1542 self.revoked
1543 .lock()
1544 .expect("revoked fixture lock")
1545 .push(token_id.to_string());
1546 }
1547 }
1548
1549 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
1550 let bamboo_mode = auth_mode == Some("bamboo")
1551 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
1552 ExecutorSpec::Codex {
1553 binary: None,
1554 model: None,
1555 mode: None,
1556 sandbox: None,
1557 inherit_user_config,
1558 auth_mode: auth_mode.map(str::to_string),
1559 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
1560 wire_api: Some("responses".to_string()),
1561 provider_key_ref: None,
1562 forward_env: None,
1563 approval_policy: None,
1564 network_access: None,
1565 allow_danger_bypass: None,
1566 permission_profile: None,
1567 workspace_owned: None,
1568 }
1569 }
1570
1571 #[test]
1572 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
1573 let project = tempfile::tempdir().unwrap();
1574 let managed = project.path().join(".bamboo/worktree/child-571");
1575 std::fs::create_dir_all(&managed).unwrap();
1576 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
1577 let marker = project
1578 .path()
1579 .join(".bamboo/worktree/.bamboo-owned/child-571");
1580 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
1581 std::fs::write(&marker, "bamboo/child-571").unwrap();
1582 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
1583 let nested = managed.join("nested/path");
1584 std::fs::create_dir_all(&nested).unwrap();
1585 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
1586
1587 let arbitrary = tempfile::tempdir().unwrap();
1588 assert!(!workspace_is_bamboo_owned(
1589 arbitrary.path().to_str().unwrap()
1590 ));
1591 }
1592
1593 #[test]
1594 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
1595 let authority = Arc::new(RecordingCodexTokenAuthority::default());
1596 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
1597
1598 let (secrets, guard) = build_codex_run_secrets(
1599 &codex_executor(Some("bamboo"), None),
1600 Some(authority_dyn),
1601 "child-570",
1602 )
1603 .unwrap();
1604
1605 let token = secrets
1606 .codex_provider_token
1607 .as_ref()
1608 .expect("bamboo mode mints a token");
1609 assert_eq!(token.expose(), "bcx1_secret-child-570");
1610 assert!(!format!("{token:?}").contains("secret-child-570"));
1611 assert_eq!(
1612 authority.issued_for.lock().unwrap().as_slice(),
1613 ["child-570"]
1614 );
1615 assert!(authority.revoked.lock().unwrap().is_empty());
1616
1617 drop(guard);
1618 assert_eq!(
1619 authority.revoked.lock().unwrap().as_slice(),
1620 ["id-child-570"]
1621 );
1622 }
1623
1624 #[test]
1625 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
1626 let authority = Arc::new(RecordingCodexTokenAuthority::default());
1627 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
1628 let (secrets, guard) = build_codex_run_secrets(
1629 &codex_executor(Some("custom"), None),
1630 Some(authority_dyn),
1631 "child-custom",
1632 )
1633 .unwrap();
1634 assert!(secrets.codex_provider_token.is_none());
1635 assert!(guard.is_none());
1636 assert!(authority.issued_for.lock().unwrap().is_empty());
1637
1638 let error = build_codex_run_secrets(
1639 &codex_executor(Some("bamboo"), None),
1640 None,
1641 "child-no-authority",
1642 )
1643 .err()
1644 .expect("bamboo mode without an authority must fail closed");
1645 assert!(error.to_string().contains("per-run token authority"));
1646 }
1647
1648 #[test]
1649 fn codex_provisioning_never_leaks_the_session_provider_credential() {
1650 let credentials = vec![ScopedCredential {
1651 provider: "openai".to_string(),
1652 api_key: "upstream-secret-must-not-cross".to_string(),
1653 base_url: None,
1654 provider_type: Some("openai".to_string()),
1655 credential_ref: Some("provider.openai.api_key".to_string()),
1656 }];
1657
1658 for (mode, label) in [
1659 (Some("inherit"), "inherit"),
1660 (Some("api_key"), "api_key"),
1661 (Some("bamboo"), "bamboo"),
1662 (None, "default-bamboo"),
1663 ] {
1664 let runner = ActorChildRunner::new(
1665 format!("codex-{label}-test"),
1666 PathBuf::from("/bin/false"),
1667 Vec::new(),
1668 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
1669 codex_executor(mode, None),
1670 credentials.clone(),
1671 "openai".to_string(),
1672 1,
1673 );
1674 let mut session = Session::new(format!("child-{label}"), "model");
1675 session.add_message(bamboo_agent_core::Message::user("test"));
1676 let spec = runner.build_spec(
1677 &session,
1678 &crate::runtime::execution::SpawnJob {
1679 parent_session_id: "parent".to_string(),
1680 child_session_id: format!("child-{label}"),
1681 model: "gpt-5.4".to_string(),
1682 disabled_tools: None,
1683 },
1684 );
1685 assert!(
1686 spec.secrets.provider_credentials.is_empty(),
1687 "{label} Codex must not receive the session provider key"
1688 );
1689 }
1690 }
1691
1692 #[test]
1693 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
1694 let credentials = vec![
1695 ScopedCredential {
1696 provider: "openai".to_string(),
1697 api_key: "selected-openai-secret".to_string(),
1698 base_url: None,
1699 provider_type: Some("openai".to_string()),
1700 credential_ref: Some("provider.openai.api_key".to_string()),
1701 },
1702 ScopedCredential {
1703 provider: "other".to_string(),
1704 api_key: "unrelated-secret".to_string(),
1705 base_url: None,
1706 provider_type: Some("openai".to_string()),
1707 credential_ref: Some("provider.other.api_key".to_string()),
1708 },
1709 ];
1710 let runner = ActorChildRunner::new(
1711 "echo-test".to_string(),
1712 PathBuf::from("/bin/false"),
1713 Vec::new(),
1714 std::env::temp_dir().join("bamboo-echo-provider-570"),
1715 ExecutorSpec::Echo,
1716 credentials,
1717 "openai".to_string(),
1718 1,
1719 );
1720 let spec = runner.build_spec(
1721 &Session::new("child-echo", "model"),
1722 &crate::runtime::execution::SpawnJob {
1723 parent_session_id: "parent".to_string(),
1724 child_session_id: "child-echo".to_string(),
1725 model: "gpt-5.4".to_string(),
1726 disabled_tools: None,
1727 },
1728 );
1729
1730 assert_eq!(spec.secrets.provider_credentials.len(), 1);
1731 assert_eq!(
1732 spec.secrets.provider_credentials[0].api_key,
1733 "selected-openai-secret"
1734 );
1735 }
1736
1737 #[test]
1738 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
1739 let mut executor = codex_executor(Some("custom"), None);
1740 if let ExecutorSpec::Codex {
1741 base_url,
1742 provider_key_ref,
1743 ..
1744 } = &mut executor
1745 {
1746 *base_url = Some("https://provider.example/v1".to_string());
1747 *provider_key_ref = Some("provider.custom.api_key".to_string());
1748 }
1749 let credentials = vec![
1750 ScopedCredential {
1751 provider: "openai".to_string(),
1752 api_key: "session-provider-secret".to_string(),
1753 base_url: None,
1754 provider_type: Some("openai".to_string()),
1755 credential_ref: Some("provider.openai.api_key".to_string()),
1756 },
1757 ScopedCredential {
1758 provider: "custom".to_string(),
1759 api_key: "selected-secret".to_string(),
1760 base_url: None,
1761 provider_type: Some("openai".to_string()),
1762 credential_ref: Some("provider.custom.api_key".to_string()),
1763 },
1764 ScopedCredential {
1765 provider: "other".to_string(),
1766 api_key: "unrelated-secret".to_string(),
1767 base_url: None,
1768 provider_type: Some("openai".to_string()),
1769 credential_ref: Some("provider.other.api_key".to_string()),
1770 },
1771 ];
1772 let runner = ActorChildRunner::new(
1773 "codex-test".to_string(),
1774 PathBuf::from("/bin/false"),
1775 Vec::new(),
1776 std::env::temp_dir().join("bamboo-codex-570"),
1777 executor,
1778 credentials,
1779 "openai".to_string(),
1780 1,
1781 );
1782 let mut session = Session::new("child-custom", "model");
1783 session.add_message(bamboo_agent_core::Message::user("test"));
1784 let spec = runner.build_spec(
1785 &session,
1786 &crate::runtime::execution::SpawnJob {
1787 parent_session_id: "parent".to_string(),
1788 child_session_id: "child-custom".to_string(),
1789 model: "gpt-5.4".to_string(),
1790 disabled_tools: None,
1791 },
1792 );
1793
1794 assert_eq!(spec.secrets.provider_credentials.len(), 1);
1795 assert_eq!(
1796 spec.secrets.provider_credentials[0]
1797 .credential_ref
1798 .as_deref(),
1799 Some("provider.custom.api_key")
1800 );
1801 assert_eq!(
1802 spec.secrets.provider_credentials[0].api_key,
1803 "selected-secret"
1804 );
1805 }
1806
1807 fn spec_with(
1808 role: &str,
1809 provider: &str,
1810 model: &str,
1811 workspace: Option<&str>,
1812 disabled: Option<Vec<&str>>,
1813 ) -> ProvisionSpec {
1814 let mut spec = ProvisionSpec::new(
1815 ChildIdentity {
1816 child_id: "c".into(),
1817 parent_id: None,
1818 project_key: None,
1819 role: role.into(),
1820 depth: 0,
1821 },
1822 ExecutorSpec::Echo,
1823 "/tmp/fab".into(),
1824 );
1825 spec.workspace = workspace.map(|w| w.to_string());
1826 spec.model = Some(ModelRefSpec {
1827 provider: provider.into(),
1828 model: model.into(),
1829 });
1830 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
1831 spec
1832 }
1833
1834 #[test]
1835 fn fingerprint_matches_interchangeable_children() {
1836 let a = spec_with(
1839 "explorer",
1840 "p",
1841 "m",
1842 Some("/ws"),
1843 Some(vec!["Bash", "Edit"]),
1844 );
1845 let mut b = spec_with(
1846 "explorer",
1847 "p",
1848 "m",
1849 Some("/ws"),
1850 Some(vec!["Edit", "Bash"]),
1851 );
1852 b.identity.child_id = "other".into();
1853 assert_eq!(
1854 ActorChildRunner::fingerprint(&a),
1855 ActorChildRunner::fingerprint(&b)
1856 );
1857 }
1858
1859 #[test]
1860 fn fingerprint_separates_distinct_runtimes() {
1861 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
1862 let base_fp = ActorChildRunner::fingerprint(&base);
1863 assert_ne!(
1865 base_fp,
1866 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
1867 );
1868 assert_ne!(
1869 base_fp,
1870 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
1871 );
1872 assert_ne!(
1873 base_fp,
1874 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
1875 );
1876 assert_ne!(
1877 base_fp,
1878 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
1879 );
1880 assert_ne!(
1881 base_fp,
1882 ActorChildRunner::fingerprint(&spec_with(
1883 "explorer",
1884 "p",
1885 "m",
1886 Some("/ws"),
1887 Some(vec!["Bash"])
1888 ))
1889 );
1890 }
1891
1892 #[test]
1893 fn fingerprint_splits_on_baked_capabilities() {
1894 let base_fp =
1899 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
1900
1901 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
1902 depth.identity.depth = 2;
1903 assert_ne!(
1904 base_fp,
1905 ActorChildRunner::fingerprint(&depth),
1906 "depth must split"
1907 );
1908
1909 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
1910 nested.capabilities.nested_spawn = true;
1911 assert_ne!(
1912 base_fp,
1913 ActorChildRunner::fingerprint(&nested),
1914 "nested_spawn must split"
1915 );
1916
1917 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
1918 bypass.capabilities.bypass = true;
1919 assert_ne!(
1920 base_fp,
1921 ActorChildRunner::fingerprint(&bypass),
1922 "bypass must split"
1923 );
1924
1925 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
1926 enforce.capabilities.enforce_permissions = true;
1927 assert_ne!(
1928 base_fp,
1929 ActorChildRunner::fingerprint(&enforce),
1930 "enforce_permissions must split"
1931 );
1932
1933 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
1934 cap.capabilities.max_spawn_depth = Some(8);
1935 assert_ne!(
1936 base_fp,
1937 ActorChildRunner::fingerprint(&cap),
1938 "max_spawn_depth must split"
1939 );
1940
1941 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
1945 nha.capabilities.no_human_approver = true;
1946 assert_ne!(
1947 base_fp,
1948 ActorChildRunner::fingerprint(&nha),
1949 "no_human_approver must split"
1950 );
1951
1952 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
1955 gro.capabilities.guardian_read_only = true;
1956 assert_ne!(
1957 base_fp,
1958 ActorChildRunner::fingerprint(&gro),
1959 "guardian_read_only must split"
1960 );
1961 }
1962
1963 #[test]
1964 fn fingerprint_splits_codex_exec_and_app_server_workers() {
1965 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
1966 exec.executor = codex_executor(Some("inherit"), None);
1967 let mut app_server = exec.clone();
1968 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
1969 *mode = Some("app_server".to_string());
1970 }
1971 assert_ne!(
1972 ActorChildRunner::fingerprint(&exec),
1973 ActorChildRunner::fingerprint(&app_server)
1974 );
1975 }
1976
1977 struct StaticDecider(bool);
1978
1979 #[async_trait]
1980 impl ChildApprovalDecider for StaticDecider {
1981 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
1982 self.0
1983 }
1984 }
1985
1986 struct RecordingReviewer {
1987 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
1988 }
1989
1990 #[async_trait]
1991 impl ChildApprovalReviewer for RecordingReviewer {
1992 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
1993 let _ = self
1994 .reviewed
1995 .send((parent.to_string(), child.to_string(), request.clone()));
1996 true
1997 }
1998 }
1999
2000 struct SilentLink;
2005 #[async_trait]
2006 impl bamboo_subagent::ChildLink for SilentLink {
2007 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
2008 Ok(())
2009 }
2010 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
2011 std::future::pending().await
2012 }
2013 }
2014
2015 struct InstantTerminalLink {
2017 done: bool,
2018 }
2019
2020 struct ApprovalRoundTripLink {
2021 step: u8,
2022 approval_reply: Option<(String, bool)>,
2023 }
2024
2025 #[async_trait]
2026 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
2027 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
2028 if let ParentFrame::ApprovalReply { id, approved } = frame {
2029 self.approval_reply = Some((id, approved));
2030 self.step = 2;
2031 }
2032 Ok(())
2033 }
2034
2035 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
2036 match self.step {
2037 0 => {
2038 self.step = 1;
2039 Ok(Some(ChildFrame::ApprovalRequest {
2040 id: "approval-1".into(),
2041 body: serde_json::json!({
2042 "tool_name": "Bash",
2043 "permission": "execute",
2044 "resource": "rm -rf target",
2045 "permission_request": {"reason_code": "hard_dangerous"}
2046 }),
2047 }))
2048 }
2049 1 => std::future::pending().await,
2050 2 => {
2051 self.step = 3;
2052 Ok(Some(ChildFrame::Terminal {
2053 status: TerminalStatus::Completed,
2054 result: Some("done".into()),
2055 error: None,
2056 transcript: vec![],
2057 }))
2058 }
2059 _ => std::future::pending().await,
2060 }
2061 }
2062 }
2063
2064 #[tokio::test]
2065 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
2066 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
2067 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
2068 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
2069 reviewed: review_tx,
2070 });
2071 let cancel = CancellationToken::new();
2072 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2073 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
2074 let mut link = ApprovalRoundTripLink {
2075 step: 0,
2076 approval_reply: None,
2077 };
2078
2079 let result = tokio::time::timeout(
2080 Duration::from_secs(1),
2081 drive(
2082 &mut link,
2083 "parent-reviewer",
2084 "child-reviewer",
2085 0,
2086 None,
2087 None,
2088 Some(&reviewer),
2089 None,
2090 &event_tx,
2091 &cancel,
2092 &mut live_rx,
2093 None,
2094 ),
2095 )
2096 .await
2097 .expect("worker must receive the reviewer verdict before terminating");
2098
2099 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
2100 assert_eq!(
2101 link.approval_reply,
2102 Some(("approval-1".to_string(), true)),
2103 "reviewer verdict must traverse the live route back to the worker"
2104 );
2105 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
2106 .await
2107 .expect("reviewer should be invoked off-loop")
2108 .expect("review channel should remain open");
2109 assert_eq!(parent, "parent-reviewer");
2110 assert_eq!(child, "child-reviewer");
2111 assert_eq!(
2112 body.pointer("/permission_request/reason_code")
2113 .and_then(serde_json::Value::as_str),
2114 Some("hard_dangerous")
2115 );
2116 assert!(
2117 event_rx.try_recv().is_err(),
2118 "must not emit a human-review event"
2119 );
2120 drop(live_guard);
2121 }
2122
2123 #[tokio::test]
2124 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
2125 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
2126 let cancel = CancellationToken::new();
2127 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2128 let mut link = ApprovalRoundTripLink {
2129 step: 0,
2130 approval_reply: None,
2131 };
2132
2133 let result = tokio::time::timeout(
2134 Duration::from_secs(1),
2135 drive(
2136 &mut link,
2137 "parent-no-reviewer",
2138 "child-no-reviewer",
2139 0,
2140 None,
2141 None,
2142 None,
2143 None,
2144 &event_tx,
2145 &cancel,
2146 &mut live_rx,
2147 None,
2148 ),
2149 )
2150 .await
2151 .expect("fail-closed reply must unblock the child immediately");
2152
2153 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
2154 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
2155 assert!(
2156 event_rx.try_recv().is_err(),
2157 "missing parent review must not surface a manual approval event"
2158 );
2159 }
2160 #[async_trait]
2161 impl bamboo_subagent::ChildLink for InstantTerminalLink {
2162 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
2163 Ok(())
2164 }
2165 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
2166 if self.done {
2167 std::future::pending().await
2168 } else {
2169 self.done = true;
2170 Ok(Some(ChildFrame::Terminal {
2171 status: TerminalStatus::Completed,
2172 result: Some("done".into()),
2173 error: None,
2174 transcript: vec![],
2175 }))
2176 }
2177 }
2178 }
2179
2180 #[tokio::test]
2181 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
2182 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
2183 let cancel = CancellationToken::new();
2184 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2185 let mut link = SilentLink;
2186 let r = drive(
2187 &mut link,
2188 "parent-x",
2189 "child-x",
2190 0,
2191 None,
2192 None,
2193 None,
2194 None,
2195 &event_tx,
2196 &cancel,
2197 &mut live_rx,
2198 Some(Duration::from_millis(100)),
2199 )
2200 .await;
2201 assert!(
2202 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
2203 "a silent worker must trip the first-frame watchdog, got {r:?}"
2204 );
2205 }
2206
2207 #[tokio::test]
2208 async fn drive_does_not_trip_when_a_frame_arrives() {
2209 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
2210 let cancel = CancellationToken::new();
2211 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
2212 let mut link = InstantTerminalLink { done: false };
2213 let r = drive(
2216 &mut link,
2217 "parent-y",
2218 "child-y",
2219 0,
2220 None,
2221 None,
2222 None,
2223 None,
2224 &event_tx,
2225 &cancel,
2226 &mut live_rx,
2227 Some(Duration::from_millis(50)),
2228 )
2229 .await;
2230 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
2231 }
2232
2233 #[tokio::test]
2234 async fn child_approval_fails_closed_without_decider() {
2235 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
2237 assert!(!decide_child_approval(None, "child-1", &body).await);
2238 }
2239
2240 #[tokio::test]
2241 async fn child_approval_honors_wired_decider() {
2242 let body =
2243 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
2244 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
2245 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
2246 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
2247 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
2248 }
2249
2250 use crate::runtime::execution::SpawnJob;
2253 use bamboo_agent_core::Session;
2254
2255 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
2258 ActorChildRunner::new(
2259 "test-actor".into(),
2260 PathBuf::from("/bin/false"),
2261 vec![],
2262 std::env::temp_dir().join("bamboo-test-fab-193"),
2263 ExecutorSpec::Echo,
2264 vec![],
2265 "anthropic".into(),
2266 4,
2267 )
2268 .with_remote_placements(placements)
2269 }
2270
2271 fn session_of_role(role: &str, assignment: &str) -> Session {
2274 let mut s = Session::new("child-1", "test-model");
2275 s.metadata
2276 .insert("subagent_type".to_string(), role.to_string());
2277 s.add_message(bamboo_agent_core::Message::user(assignment));
2278 s
2279 }
2280
2281 fn job_for(child: &str) -> SpawnJob {
2282 SpawnJob {
2283 parent_session_id: "parent-1".into(),
2284 child_session_id: child.into(),
2285 model: String::new(),
2286 disabled_tools: None,
2287 }
2288 }
2289
2290 #[derive(Default)]
2291 struct RecordingChildSessionPort {
2292 saved: std::sync::Mutex<Option<Session>>,
2293 }
2294
2295 impl RecordingChildSessionPort {
2296 fn saved_child(&self) -> Session {
2297 self.saved
2298 .lock()
2299 .expect("saved-child fixture lock")
2300 .clone()
2301 .expect("create_child_action must save the child")
2302 }
2303 }
2304
2305 #[async_trait]
2306 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
2307 async fn load_root_session(
2308 &self,
2309 _root_id: &str,
2310 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
2311 unreachable!("create_child_action does not load the root")
2312 }
2313
2314 async fn load_child_for_parent(
2315 &self,
2316 _parent_id: &str,
2317 _child_id: &str,
2318 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
2319 unreachable!("create_child_action does not reload the child")
2320 }
2321
2322 async fn save_child_session(
2323 &self,
2324 child: &mut Session,
2325 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2326 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
2327 Ok(())
2328 }
2329
2330 async fn save_child_session_authoritative_flags(
2331 &self,
2332 _child: &mut Session,
2333 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2334 unreachable!("new-child creation uses the ordinary save")
2335 }
2336
2337 async fn is_child_running(&self, _child_id: &str) -> bool {
2338 false
2339 }
2340
2341 async fn list_children(
2342 &self,
2343 _parent_id: &str,
2344 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
2345 Vec::new()
2346 }
2347
2348 async fn enqueue_child_run(
2349 &self,
2350 _parent: &Session,
2351 _child: &Session,
2352 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2353 unreachable!("fixture creates the child with auto_run=false")
2354 }
2355
2356 async fn cancel_child_run_and_wait(
2357 &self,
2358 _child_id: &str,
2359 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2360 unreachable!("create_child_action does not cancel")
2361 }
2362
2363 async fn delete_child_session(
2364 &self,
2365 _parent_id: &str,
2366 _child_id: &str,
2367 ) -> Result<
2368 crate::session_app::child_session::DeleteChildResult,
2369 crate::session_app::child_session::ChildSessionError,
2370 > {
2371 unreachable!("create_child_action does not delete")
2372 }
2373
2374 async fn get_child_runner_info(
2375 &self,
2376 _child_id: &str,
2377 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
2378 None
2379 }
2380
2381 async fn register_parent_wait_for_child(
2382 &self,
2383 _parent_session_id: &str,
2384 _child_session_id: &str,
2385 _tool_call_id: Option<&str>,
2386 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
2387 unreachable!("create_child_action does not register a wait")
2388 }
2389
2390 async fn register_parent_wait_for_children(
2391 &self,
2392 _parent_session_id: &str,
2393 _child_session_ids: &[String],
2394 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
2395 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
2396 unreachable!("create_child_action does not register a wait")
2397 }
2398
2399 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
2400 Vec::new()
2401 }
2402
2403 async fn find_resident_child(
2404 &self,
2405 _root_session_id: &str,
2406 _resident_name: &str,
2407 ) -> Option<String> {
2408 None
2409 }
2410
2411 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
2412 }
2413
2414 #[test]
2415 fn build_spec_sets_remote_placement_for_matching_role() {
2416 let mut placements = HashMap::new();
2417 placements.insert(
2418 "explorer".to_string(),
2419 ResolvedRemotePlacement {
2420 endpoint: "wss://gpu-host:8443".into(),
2421 token: Some("T-secret".into()),
2422 ca_cert_file: None,
2423 host_label: None,
2424 },
2425 );
2426 let runner = bogus_runner(placements);
2427
2428 let s = session_of_role("explorer", "do the thing");
2430 let spec = runner.build_spec(&s, &job_for("child-1"));
2431 match &spec.placement {
2432 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
2433 other => panic!("expected Remote, got {other:?}"),
2434 }
2435 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-secret"));
2436 }
2437
2438 #[test]
2439 fn build_spec_leaves_local_for_unmatched_role() {
2440 let mut placements = HashMap::new();
2441 placements.insert(
2442 "explorer".to_string(),
2443 ResolvedRemotePlacement {
2444 endpoint: "wss://gpu-host:8443".into(),
2445 token: Some("T".into()),
2446 ca_cert_file: None,
2447 host_label: None,
2448 },
2449 );
2450 let runner = bogus_runner(placements);
2451
2452 let s = session_of_role("writer", "do the thing");
2454 let spec = runner.build_spec(&s, &job_for("child-1"));
2455 assert_eq!(spec.placement, Placement::Local);
2456 assert!(spec.secrets.worker_auth_token.is_none());
2457 }
2458
2459 #[test]
2460 fn build_spec_local_when_no_placements() {
2461 let runner = bogus_runner(HashMap::new());
2462 let s = session_of_role("explorer", "do the thing");
2463 let spec = runner.build_spec(&s, &job_for("child-1"));
2464 assert_eq!(spec.placement, Placement::Local);
2465 assert!(spec.secrets.worker_auth_token.is_none());
2466 }
2467
2468 #[tokio::test]
2469 async fn build_spec_preserves_inherited_bypass_for_child_worker() {
2470 let runner = bogus_runner(HashMap::new());
2474 let mut parent = Session::new("parent-bypass", "test-model");
2475 parent
2476 .agent_runtime_state
2477 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
2478 .bypass_permissions = true;
2479 let workspace = tempfile::tempdir().expect("workspace fixture");
2480 let port = RecordingChildSessionPort::default();
2481 let child_id = format!("child-bypass-{}", uuid::Uuid::new_v4());
2482 crate::session_app::child_session::create_child_action(
2483 &port,
2484 crate::session_app::child_session::CreateChildInput {
2485 parent_session: parent,
2486 child_id: child_id.clone(),
2487 title: "Bypassed child".to_string(),
2488 responsibility: "Run ordinary commands".to_string(),
2489 assignment_prompt: "run an ordinary command".to_string(),
2490 subagent_type: "explorer".to_string(),
2491 workspace: workspace.path().to_string_lossy().into_owned(),
2492 model_override: None,
2493 model_ref_override: None,
2494 runtime_metadata: HashMap::new(),
2495 auto_run: false,
2496 reasoning_effort: None,
2497 lifecycle: None,
2498 resident_name: None,
2499 resident_context: None,
2500 disabled_tools: None,
2501 context_fork: None,
2502 },
2503 )
2504 .await
2505 .expect("create inherited-bypass child");
2506 let child = port.saved_child();
2507
2508 assert!(
2509 child
2510 .agent_runtime_state
2511 .as_ref()
2512 .is_some_and(|state| state.bypass_permissions),
2513 "create_child_action must inherit bypass from the parent"
2514 );
2515
2516 let spec = runner.build_spec(&child, &job_for(&child_id));
2517
2518 assert!(spec.capabilities.bypass, "child worker must inherit bypass");
2519 assert!(
2520 spec.capabilities.enforce_permissions,
2521 "forced-ask evaluation must remain active under bypass"
2522 );
2523 }
2524
2525 #[tokio::test]
2526 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
2527 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
2528 let workspace = tempfile::tempdir().expect("workspace fixture");
2529
2530 for (role, lifecycle, resident_name) in [
2531 ("explorer", None, None),
2532 ("resident", Some("resident"), Some("stable-reviewer")),
2533 ("guardian", None, None),
2534 ] {
2535 let mut parent = Session::new(format!("parent-{role}"), "test-model");
2536 parent.set_project_id_meta(project_id.to_string());
2537 let port = RecordingChildSessionPort::default();
2538 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
2539 crate::session_app::child_session::create_child_action(
2540 &port,
2541 crate::session_app::child_session::CreateChildInput {
2542 parent_session: parent,
2543 child_id: child_id.clone(),
2544 title: format!("{role} child"),
2545 responsibility: "Review the assigned work".to_string(),
2546 assignment_prompt: "inspect the change".to_string(),
2547 subagent_type: role.to_string(),
2548 workspace: workspace.path().to_string_lossy().into_owned(),
2549 model_override: None,
2550 model_ref_override: None,
2551 runtime_metadata: HashMap::new(),
2552 auto_run: false,
2553 reasoning_effort: None,
2554 lifecycle: lifecycle.map(str::to_string),
2555 resident_name: resident_name.map(str::to_string),
2556 resident_context: None,
2557 disabled_tools: None,
2558 context_fork: None,
2559 },
2560 )
2561 .await
2562 .expect("create Project-inheriting child");
2563 let child = port.saved_child();
2564 assert_eq!(
2565 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
2566 Some(project_id.clone()),
2567 "{role} child must inherit its parent's Project"
2568 );
2569
2570 assert_eq!(
2571 project_id_for_actor_run(&child).expect("valid actor Project identity"),
2572 Some(project_id.clone()),
2573 "{role} actor RunSpec must preserve inherited Project identity"
2574 );
2575 }
2576 }
2577
2578 #[test]
2579 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
2580 assert_eq!(placement_metadata(&Placement::Local, None), None);
2582
2583 let r = placement_metadata(
2585 &Placement::Remote {
2586 endpoint: "wss://10.0.0.5:8443/stream".into(),
2587 },
2588 None,
2589 )
2590 .unwrap();
2591 assert!(r.contains(r#""kind":"remote""#), "{r}");
2592 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
2593
2594 let labeled = placement_metadata(
2596 &Placement::Remote {
2597 endpoint: "ws://169.254.230.101:8899".into(),
2598 },
2599 Some("mini"),
2600 )
2601 .unwrap();
2602 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
2603
2604 let s = placement_metadata(
2606 &Placement::Schedulable {
2607 pool: "explorers".into(),
2608 },
2609 Some("mini"),
2610 )
2611 .unwrap();
2612 assert!(s.contains(r#""kind":"remote""#), "{s}");
2613 assert!(s.contains(r#""host":"mini""#), "{s}");
2614
2615 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
2617 assert_eq!(p.kind, "remote");
2618 assert_eq!(p.host, "mini");
2619 }
2620
2621 #[tokio::test]
2628 async fn execute_external_child_routes_role_to_remote_worker_without_spawning() {
2629 let token = "remote-test-token";
2631 let server = bamboo_subagent::transport::WsServer::bind_with_token(
2632 (std::net::Ipv4Addr::LOCALHOST, 0).into(),
2633 Some(token.to_string()),
2634 )
2635 .await
2636 .expect("bind resident worker");
2637 let endpoint = server.ws_endpoint(); let srv = tokio::spawn(async move {
2639 let _ = server
2641 .serve(Arc::new(bamboo_subagent::executor::EchoExecutor))
2642 .await;
2643 });
2644
2645 let mut placements = HashMap::new();
2647 placements.insert(
2648 "explorer".to_string(),
2649 ResolvedRemotePlacement {
2650 endpoint: endpoint.clone(),
2651 token: Some(token.to_string()),
2652 ca_cert_file: None,
2653 host_label: Some("mini-e2e".into()), },
2655 );
2656 let runner = bogus_runner(placements);
2657
2658 let mut session = session_of_role("explorer", "hello remote");
2660 let job = job_for("child-1");
2661 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(64);
2662 let cancel = CancellationToken::new();
2663
2664 let result = tokio::time::timeout(
2665 Duration::from_secs(10),
2666 runner.execute_external_child(&mut session, &job, event_tx, cancel),
2667 )
2668 .await
2669 .expect("run did not hang")
2670 .expect("remote run succeeded (connected to resident worker, did not spawn)");
2671
2672 let _ = result;
2673 let last = session
2676 .messages
2677 .iter()
2678 .rev()
2679 .find(|m| matches!(m.role, Role::Assistant))
2680 .expect("an assistant reply was written back");
2681 assert!(
2682 last.content.contains("echo:"),
2683 "expected echo reply, got {:?}",
2684 last.content
2685 );
2686
2687 let placement = session
2690 .metadata
2691 .get("placement")
2692 .expect("remote child session stamped with a placement");
2693 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
2694 assert!(placement.contains(r#""host":"mini-e2e""#), "{placement}");
2695
2696 let mut saw_event = false;
2699 while let Ok(Some(_ev)) =
2700 tokio::time::timeout(Duration::from_millis(50), event_rx.recv()).await
2701 {
2702 saw_event = true;
2703 }
2704 let _ = saw_event;
2705
2706 srv.abort();
2707 }
2708
2709 fn bogus_sched_runner(
2715 remote: HashMap<String, ResolvedRemotePlacement>,
2716 sched: HashMap<String, ResolvedSchedulablePlacement>,
2717 ) -> ActorChildRunner {
2718 ActorChildRunner::new(
2719 "test-actor".into(),
2720 PathBuf::from("/bin/false"),
2721 vec![],
2722 std::env::temp_dir().join("bamboo-test-fab-181"),
2723 ExecutorSpec::Echo,
2724 vec![],
2725 "anthropic".into(),
2726 4,
2727 )
2728 .with_remote_placements(remote)
2729 .with_schedulable_placements(sched)
2730 }
2731
2732 fn sched_placement(
2733 pool: &str,
2734 _registry_url: impl Into<String>,
2735 ) -> ResolvedSchedulablePlacement {
2736 ResolvedSchedulablePlacement {
2737 pool: pool.into(),
2738 host_label: None,
2739 }
2740 }
2741
2742 #[test]
2743 fn build_spec_sets_schedulable_placement_for_matching_role() {
2744 let mut sched = HashMap::new();
2745 sched.insert(
2746 "explorer".to_string(),
2747 sched_placement("gpu-pool", "unused"),
2748 );
2749 let runner = bogus_sched_runner(HashMap::new(), sched);
2750
2751 let s = session_of_role("explorer", "do the thing");
2752 let spec = runner.build_spec(&s, &job_for("child-1"));
2753 match &spec.placement {
2754 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
2755 other => panic!("expected Schedulable, got {other:?}"),
2756 }
2757 assert!(spec.secrets.worker_auth_token.is_none());
2759 }
2760
2761 #[test]
2762 fn build_spec_remote_wins_when_role_in_both_maps() {
2763 let mut remote = HashMap::new();
2766 remote.insert(
2767 "explorer".to_string(),
2768 ResolvedRemotePlacement {
2769 endpoint: "wss://fixed-host:8443".into(),
2770 token: Some("T-remote".into()),
2771 ca_cert_file: None,
2772 host_label: None,
2773 },
2774 );
2775 let mut sched = HashMap::new();
2776 sched.insert(
2777 "explorer".to_string(),
2778 sched_placement("gpu-pool", "https://control-plane:9562"),
2779 );
2780 let runner = bogus_sched_runner(remote, sched);
2781
2782 let s = session_of_role("explorer", "do the thing");
2783 let spec = runner.build_spec(&s, &job_for("child-1"));
2784 match &spec.placement {
2785 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
2786 other => panic!("expected Remote (precedence), got {other:?}"),
2787 }
2788 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-remote"));
2789 }
2790
2791 #[test]
2792 fn build_spec_local_for_unmatched_schedulable_role() {
2793 let mut sched = HashMap::new();
2794 sched.insert(
2795 "explorer".to_string(),
2796 sched_placement("gpu-pool", "https://control-plane:9562"),
2797 );
2798 let runner = bogus_sched_runner(HashMap::new(), sched);
2799 let s = session_of_role("writer", "do the thing");
2800 let spec = runner.build_spec(&s, &job_for("child-1"));
2801 assert_eq!(spec.placement, Placement::Local);
2802 assert!(spec.secrets.worker_auth_token.is_none());
2803 }
2804
2805 #[test]
2810 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
2811 let mut remote = HashMap::new();
2813 remote.insert(
2814 "explorer".to_string(),
2815 ResolvedRemotePlacement {
2816 endpoint: "ws://169.254.230.101:8899".into(),
2817 token: None,
2818 ca_cert_file: None,
2819 host_label: Some("mini".into()),
2820 },
2821 );
2822 let runner = bogus_runner(remote);
2823 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
2824 let stamp = runner
2825 .placement_stamp_for(&spec)
2826 .expect("remote child is stamped");
2827 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
2828 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
2829
2830 let mut remote_nolabel = HashMap::new();
2832 remote_nolabel.insert(
2833 "explorer".to_string(),
2834 ResolvedRemotePlacement {
2835 endpoint: "ws://169.254.230.101:8899".into(),
2836 token: None,
2837 ca_cert_file: None,
2838 host_label: None,
2839 },
2840 );
2841 let r2 = bogus_runner(remote_nolabel);
2842 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
2843 assert!(r2
2844 .placement_stamp_for(&spec2)
2845 .unwrap()
2846 .contains(r#""host":"169.254.230.101""#));
2847
2848 let mut sched = HashMap::new();
2850 sched.insert(
2851 "mac-mini-monitor".to_string(),
2852 ResolvedSchedulablePlacement {
2853 pool: "mac-mini-monitor".into(),
2854 host_label: Some("mini".into()),
2855 },
2856 );
2857 let sr = bogus_sched_runner(HashMap::new(), sched);
2858 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
2859 let stamp3 = sr
2860 .placement_stamp_for(&spec3)
2861 .expect("scheduled child is stamped");
2862 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
2863 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
2864
2865 let local = bogus_runner(HashMap::new());
2867 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
2868 assert_eq!(local.placement_stamp_for(&spec4), None);
2869 }
2870
2871 async fn start_bus() -> (String, tempfile::TempDir) {
2874 let dir = tempfile::tempdir().unwrap();
2875 let core = std::sync::Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
2876 let server = std::sync::Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
2877 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2878 let addr = listener.local_addr().unwrap();
2879 tokio::spawn(async move {
2880 let _ = server.serve(listener).await;
2881 });
2882 (format!("ws://{addr}"), dir)
2883 }
2884
2885 async fn join_pool(endpoint: &str, id: &str, pool: &str) -> bamboo_broker::BrokerClient {
2886 let mut c = bamboo_broker::BrokerClient::connect(
2887 endpoint,
2888 bamboo_subagent::AgentRef {
2889 session_id: id.into(),
2890 role: Some(pool.into()),
2891 },
2892 "t",
2893 )
2894 .await
2895 .unwrap();
2896 c.subscribe().await.unwrap();
2897 c
2898 }
2899
2900 fn sched_runner_on_bus(endpoint: &str, child_role: &str, pool: &str) -> ActorChildRunner {
2901 let mut sched = HashMap::new();
2902 sched.insert(child_role.to_string(), sched_placement(pool, "unused"));
2903 bogus_sched_runner(HashMap::new(), sched).with_bus(Some(bamboo_subagent::BusEndpoint {
2904 endpoint: endpoint.into(),
2905 token: "t".into(),
2906 }))
2907 }
2908
2909 #[tokio::test]
2910 async fn resolve_schedulable_picks_a_live_bus_worker() {
2911 let (endpoint, _dir) = start_bus().await;
2912 let _w = join_pool(&endpoint, "w-gpu", "gpu-pool").await;
2913 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
2914
2915 let mailbox = runner
2916 .resolve_schedulable_worker("explorer")
2917 .await
2918 .expect("a live pool worker is found on the bus");
2919 assert_eq!(mailbox, "w-gpu");
2920 }
2921
2922 #[tokio::test]
2923 async fn resolve_schedulable_round_robins_over_pool_workers() {
2924 let (endpoint, _dir) = start_bus().await;
2925 let _a = join_pool(&endpoint, "w-a", "gpu-pool").await;
2926 let _b = join_pool(&endpoint, "w-b", "gpu-pool").await;
2927 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
2928
2929 let mut picked = std::collections::HashSet::new();
2931 for _ in 0..6 {
2932 picked.insert(runner.resolve_schedulable_worker("explorer").await.unwrap());
2933 }
2934 assert_eq!(
2935 picked,
2936 ["w-a".to_string(), "w-b".to_string()].into_iter().collect(),
2937 "round-robin must cover every connected pool worker"
2938 );
2939 }
2940
2941 #[tokio::test]
2942 async fn resolve_schedulable_errors_on_empty_pool() {
2943 let (endpoint, _dir) = start_bus().await;
2944 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
2946
2947 let err = runner
2948 .resolve_schedulable_worker("explorer")
2949 .await
2950 .expect_err("an empty pool is terminal — no local fallback")
2951 .to_string();
2952 assert!(err.contains("no live worker in pool"), "got: {err}");
2953 assert!(err.contains("NOT spawning"), "got: {err}");
2954 }
2955
2956 #[tokio::test]
2963 async fn execute_external_child_runs_schedulable_over_bus_and_stamps_node_label() {
2964 let (endpoint, _dir) = start_bus().await;
2965
2966 let ep = endpoint.clone();
2968 let worker = tokio::spawn(async move {
2969 let _ = bamboo_broker::serve_executor(
2970 &ep,
2971 bamboo_subagent::AgentRef {
2972 session_id: "mmm-worker".into(),
2973 role: Some("mac-mini-monitor".into()),
2974 },
2975 "t",
2976 std::sync::Arc::new(bamboo_subagent::executor::EchoExecutor),
2977 )
2978 .await;
2979 });
2980
2981 let mut probe = bamboo_broker::BrokerClient::connect(
2984 &endpoint,
2985 bamboo_subagent::AgentRef {
2986 session_id: "probe".into(),
2987 role: None,
2988 },
2989 "t",
2990 )
2991 .await
2992 .unwrap();
2993 let mut ready = false;
2994 for _ in 0..100 {
2995 if probe
2996 .list_connected("mac-mini-monitor")
2997 .await
2998 .unwrap()
2999 .iter()
3000 .any(|id| id == "mmm-worker")
3001 {
3002 ready = true;
3003 break;
3004 }
3005 tokio::time::sleep(Duration::from_millis(30)).await;
3006 }
3007 assert!(ready, "worker never joined the pool");
3008
3009 let mut sched = HashMap::new();
3012 sched.insert(
3013 "mac-mini-monitor".to_string(),
3014 ResolvedSchedulablePlacement {
3015 pool: "mac-mini-monitor".into(),
3016 host_label: Some("mini".into()),
3017 },
3018 );
3019 let runner = bogus_sched_runner(HashMap::new(), sched).with_bus(Some(
3020 bamboo_subagent::BusEndpoint {
3021 endpoint: endpoint.clone(),
3022 token: "t".into(),
3023 },
3024 ));
3025
3026 let mut session = session_of_role("mac-mini-monitor", "hello scheduled");
3027 let job = job_for("child-1");
3028 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(64);
3029 let cancel = CancellationToken::new();
3030
3031 tokio::time::timeout(
3032 Duration::from_secs(10),
3033 runner.execute_external_child(&mut session, &job, event_tx, cancel),
3034 )
3035 .await
3036 .expect("run did not hang")
3037 .expect("schedulable run succeeded over the bus (no local spawn)");
3038
3039 let last = session
3041 .messages
3042 .iter()
3043 .rev()
3044 .find(|m| matches!(m.role, Role::Assistant))
3045 .expect("an assistant reply was written back");
3046 assert!(last.content.contains("echo:"), "got {:?}", last.content);
3047
3048 let placement = session
3050 .metadata
3051 .get("placement")
3052 .expect("scheduled child session stamped with a placement");
3053 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
3054 assert!(placement.contains(r#""host":"mini""#), "{placement}");
3055
3056 worker.abort();
3057 }
3058}