1use std::collections::{HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use async_trait::async_trait;
20use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
21use bamboo_domain::poison::PoisonRecover;
22use bamboo_domain::SessionInboxClaim;
23use tokio::sync::mpsc;
24use tokio_util::sync::CancellationToken;
25
26use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
27use bamboo_subagent::proto::{
28 AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame, PermissionPolicyContext, RunSpec,
29 SessionMessageDelivery, TerminalStatus,
30};
31use bamboo_subagent::provision::{
32 ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
33};
34use bamboo_subagent::transport::{client_config_trusting_cert, ChildClient};
35
36use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
37
38pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 8;
40
41pub const MAX_SPAWN_DEPTH: u32 = 4;
47
48const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
50
51const DEFAULT_MAX_IDLE_TOTAL: usize = DEFAULT_MAX_CONCURRENT_ACTORS;
55
56const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
59
60const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
64
65const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
72
73fn active_scoped_session_deny_count(
74 config: &bamboo_tools::permission::PermissionConfig,
75 session_id: &str,
76) -> usize {
77 config
78 .temporary_grants()
79 .into_iter()
80 .filter(|grant| {
81 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
82 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
83 && grant.session_id.as_deref() == Some(session_id)
84 })
85 .count()
86}
87
88fn ensure_no_active_scoped_session_denies(
89 config: &bamboo_tools::permission::PermissionConfig,
90 session_id: &str,
91) -> Result<(), AgentError> {
92 let count = active_scoped_session_deny_count(config, session_id);
93 if count == 0 {
94 Ok(())
95 } else {
96 Err(AgentError::LLM(format!(
97 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
98 )))
99 }
100}
101
102pub struct IssuedCodexRunToken {
105 pub token_id: String,
106 pub token: String,
107}
108
109pub trait CodexRunTokenAuthority: Send + Sync + 'static {
112 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
113 fn revoke(&self, token_id: &str);
114}
115
116struct CodexRunTokenGuard {
117 authority: Arc<dyn CodexRunTokenAuthority>,
118 token_id: String,
119}
120
121impl Drop for CodexRunTokenGuard {
122 fn drop(&mut self) {
123 self.authority.revoke(&self.token_id);
124 }
125}
126
127fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
128 matches!(
129 executor,
130 ExecutorSpec::Codex {
131 auth_mode: Some(mode),
132 ..
133 } if mode == "bamboo"
134 ) || matches!(
135 executor,
136 ExecutorSpec::Codex {
137 auth_mode: None,
138 inherit_user_config,
139 ..
140 } if !inherit_user_config.unwrap_or(false)
141 )
142}
143
144fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
145 match executor {
146 ExecutorSpec::ClaudeCode {
147 permission_mode, ..
148 } => permission_mode
149 .as_deref()
150 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
151 ExecutorSpec::Codex {
152 permission_profile,
153 sandbox,
154 ..
155 } => {
156 permission_profile
157 .as_deref()
158 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
159 || sandbox
160 .as_deref()
161 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
162 }
163 _ => false,
164 }
165}
166
167fn expected_permission_executor_mapping(
175 executor: &ExecutorSpec,
176 resolution: bamboo_domain::PermissionModeResolution,
177 has_explicit_deny: bool,
178) -> Result<Option<String>, AgentError> {
179 let mapping = match executor {
180 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
181 ExecutorSpec::BambooRuntime => {
182 format!("bamboo_runtime:{}", resolution.effective.as_str())
183 }
184 ExecutorSpec::ClaudeCode {
185 permission_mode, ..
186 } => {
187 if has_explicit_deny {
188 "claude_code:blocked_explicit_deny".to_string()
189 } else {
190 let mode = match resolution.effective {
191 bamboo_domain::PermissionMode::Plan => "plan",
192 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
193 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
194 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
195 bamboo_domain::PermissionMode::Default
196 | bamboo_domain::PermissionMode::BypassPermissions => {
197 permission_mode.as_deref().unwrap_or("default")
198 }
199 };
200 format!("claude_code:permission_mode={mode}")
201 }
202 }
203 ExecutorSpec::Codex {
204 mode,
205 sandbox,
206 approval_policy,
207 allow_danger_bypass,
208 ..
209 } => match mode.as_deref().unwrap_or("exec") {
210 "exec" => {
211 let approval_policy = expected_codex_exec_approval_policy(
212 sandbox.as_deref(),
213 approval_policy.as_deref(),
214 allow_danger_bypass.unwrap_or(false),
215 resolution,
216 )?;
217 if has_explicit_deny {
218 "codex_exec:blocked_explicit_deny".to_string()
219 } else {
220 format!("codex_exec:approval_policy={approval_policy}")
221 }
222 }
223 "app_server" => {
224 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
225 return Err(AgentError::LLM(
226 "invalid Codex app-server permission posture configuration".to_string(),
227 ));
228 }
229 if has_explicit_deny {
230 "codex_app_server:blocked_explicit_deny".to_string()
231 } else {
232 let approval_policy = if resolution.suppress_approval_prompts()
233 || resolution.effective == bamboo_domain::PermissionMode::Plan
234 {
235 "never"
236 } else {
237 "on-request"
238 };
239 format!("codex_app_server:approvalPolicy={approval_policy}")
240 }
241 }
242 _ => {
243 return Err(AgentError::LLM(
244 "unsupported Codex executor mode for permission posture contract".to_string(),
245 ));
246 }
247 },
248 };
249 Ok(Some(mapping))
250}
251
252fn expected_codex_exec_approval_policy(
253 sandbox: Option<&str>,
254 approval_policy: Option<&str>,
255 allow_danger_bypass: bool,
256 resolution: bamboo_domain::PermissionModeResolution,
257) -> Result<&'static str, AgentError> {
258 let configured = match approval_policy {
259 None | Some("never") => "never",
260 Some("on-failure") => "on-failure",
261 Some(_) => {
262 return Err(AgentError::LLM(
263 "invalid Codex exec permission posture configuration".to_string(),
264 ));
265 }
266 };
267 if resolution.suppress_approval_prompts()
268 || resolution.effective == bamboo_domain::PermissionMode::Plan
269 {
270 return Ok("never");
271 }
272 match sandbox {
273 Some("danger-full-access") => Ok("never"),
274 Some("read-only") | Some("workspace-write") => Ok(configured),
275 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
276 None => Ok(configured),
277 Some(_) => Err(AgentError::LLM(
278 "invalid Codex exec permission posture configuration".to_string(),
279 )),
280 }
281}
282
283fn workspace_is_bamboo_owned(raw: &str) -> bool {
284 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
285 let configured_root = bamboo_config::paths::resolve_workspace_root();
286 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
287 if workspace.starts_with(&configured_root) {
288 return true;
289 }
290
291 workspace.ancestors().any(|candidate| {
296 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
297 return false;
298 };
299 let Some(worktree_root) = candidate.parent() else {
300 return false;
301 };
302 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
303 || worktree_root.parent().and_then(Path::file_name)
304 != Some(std::ffi::OsStr::new(".bamboo"))
305 {
306 return false;
307 }
308 let marker = worktree_root.join(".bamboo-owned").join(name);
309 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
310 })
311}
312
313fn build_codex_run_secrets(
314 executor: &ExecutorSpec,
315 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
316 child_session_id: &str,
317) -> Result<
318 (
319 bamboo_subagent::proto::RunSecrets,
320 Option<CodexRunTokenGuard>,
321 ),
322 AgentError,
323> {
324 if !executor_uses_bamboo_codex(executor) {
325 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
326 }
327
328 let authority = authority.ok_or_else(|| {
329 AgentError::LLM(
330 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
331 )
332 })?;
333 let issued = authority
334 .issue(child_session_id)
335 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
336 let guard = CodexRunTokenGuard {
337 authority,
338 token_id: issued.token_id,
339 };
340 Ok((
341 bamboo_subagent::proto::RunSecrets {
342 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
343 },
344 Some(guard),
345 ))
346}
347
348struct PooledWorker {
354 worker: SpawnedChild,
355 mailbox_id: String,
357 parked_at: Option<Instant>,
360}
361
362type WorkerPool = HashMap<String, Vec<PooledWorker>>;
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365enum PoolReapReason {
366 ProcessExited,
367 IdleTimeout,
368}
369
370impl PoolReapReason {
371 fn as_str(self) -> &'static str {
372 match self {
373 Self::ProcessExited => "process_exited_while_parked",
374 Self::IdleTimeout => "pool_idle_timeout",
375 }
376 }
377}
378
379fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
380 parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
381}
382
383fn idle_pool_limit_reason(
384 pool: &WorkerPool,
385 key: &str,
386 max_per_key: usize,
387 max_total: usize,
388) -> Option<&'static str> {
389 if pool.get(key).map_or(0, Vec::len) >= max_per_key {
390 Some("pool_per_key_limit")
391 } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
392 Some("pool_global_limit")
393 } else {
394 None
395 }
396}
397
398fn take_reapable_workers(
399 pool: &mut WorkerPool,
400 now: Instant,
401 timeout: Duration,
402) -> Vec<(String, PoolReapReason, PooledWorker)> {
403 let mut reaped = Vec::new();
404 for (key, bucket) in pool.iter_mut() {
405 let mut retained = Vec::with_capacity(bucket.len());
406 for mut worker in std::mem::take(bucket) {
407 let reason = if !worker.worker.is_alive() {
408 Some(PoolReapReason::ProcessExited)
409 } else if parked_worker_expired(worker.parked_at, now, timeout) {
410 Some(PoolReapReason::IdleTimeout)
411 } else {
412 None
413 };
414 if let Some(reason) = reason {
415 reaped.push((key.clone(), reason, worker));
416 } else {
417 retained.push(worker);
418 }
419 }
420 *bucket = retained;
421 }
422 pool.retain(|_, bucket| !bucket.is_empty());
423 reaped
424}
425
426async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
427 let now = Instant::now();
428 let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
429 let reaped = {
430 let mut pool = pool.lock().await;
431 take_reapable_workers(&mut pool, now, timeout)
432 };
433 let count = reaped.len();
434 for (pool_key, reason, worker) in reaped {
435 let idle_ms = worker
436 .parked_at
437 .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
438 tracing::info!(
439 pool_key,
440 mailbox_id = %worker.mailbox_id,
441 worker_pid = ?worker.worker.pid(),
442 worker_idle_ms = ?idle_ms,
443 shutdown_reason = reason.as_str(),
444 "reaping parked sub-agent worker"
445 );
446 worker.worker.kill().await;
447 }
448 count
449}
450
451#[derive(Debug, Clone)]
457pub struct ResolvedRemotePlacement {
458 pub endpoint: String,
459 pub token: Option<String>,
460 pub ca_cert_file: Option<PathBuf>,
461 pub host_label: Option<String>,
465}
466
467#[derive(Debug, Clone)]
474pub struct ResolvedSchedulablePlacement {
475 pub pool: String,
476 pub host_label: Option<String>,
480}
481
482enum PlacementKind {
488 Local,
489 Remote,
490 Schedulable,
491}
492
493pub struct ActorChildRunner {
495 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
496 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
497 agent_id: String,
498 worker_bin: PathBuf,
499 worker_args: Vec<String>,
500 fabric_dir: PathBuf,
501 executor: ExecutorSpec,
502 credentials: Vec<ScopedCredential>,
505 default_provider: String,
507 bus: Option<bamboo_subagent::BusEndpoint>,
510 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
514 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
520 max_idle_per_key: usize,
521 max_idle_total: usize,
522 pool_reaper_started: AtomicBool,
523 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
527 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
531 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
542 remote_placements: HashMap<String, ResolvedRemotePlacement>,
548 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
556 schedule_cursor: Arc<std::sync::Mutex<HashMap<String, usize>>>,
561 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
563 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
566}
567
568#[async_trait]
578pub trait ChildApprovalDecider: Send + Sync {
579 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
582}
583
584async fn decide_child_approval(
587 decider: Option<&Arc<dyn ChildApprovalDecider>>,
588 child_session_id: &str,
589 request: &serde_json::Value,
590) -> bool {
591 match decider {
592 Some(decider) => decider.decide(child_session_id, request).await,
593 None => false,
594 }
595}
596
597const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
601
602#[async_trait]
611pub trait ChildApprovalReviewer: Send + Sync {
612 async fn review(
615 &self,
616 parent_session_id: &str,
617 child_session_id: &str,
618 request: &serde_json::Value,
619 ) -> bool;
620}
621
622fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
623 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
624 &SLOT
625}
626
627pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
629 let _ = child_approval_reviewer_slot().set(reviewer);
630}
631
632pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
634 child_approval_reviewer_slot().get().cloned()
635}
636
637impl ActorChildRunner {
638 #[allow(clippy::too_many_arguments)]
639 pub fn new(
640 agent_id: String,
641 worker_bin: PathBuf,
642 worker_args: Vec<String>,
643 fabric_dir: PathBuf,
644 executor: ExecutorSpec,
645 credentials: Vec<ScopedCredential>,
646 default_provider: String,
647 max_concurrent: usize,
648 ) -> Self {
649 Self {
650 approval_registry: None,
651 permission_config: None,
652 agent_id,
653 worker_bin,
654 worker_args,
655 fabric_dir,
656 executor,
657 credentials,
658 default_provider,
659 bus: None,
660 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
661 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
662 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
663 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
664 pool_reaper_started: AtomicBool::new(false),
665 approval_decider: None,
666 approval_reviewer: None,
667 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
668 remote_placements: HashMap::new(),
669 schedulable_placements: HashMap::new(),
670 schedule_cursor: Arc::new(std::sync::Mutex::new(HashMap::new())),
671 codex_run_tokens: None,
672 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
673 }
674 }
675
676 pub fn with_approval_registry(
677 mut self,
678 registry: super::approval_registry::SharedApprovalRegistry,
679 ) -> Self {
680 self.approval_registry = Some(registry);
681 self
682 }
683
684 pub fn with_permission_config(
685 mut self,
686 config: Arc<bamboo_tools::permission::PermissionConfig>,
687 ) -> Self {
688 self.permission_config = Some(config);
689 self
690 }
691
692 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
697 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
698 self
699 }
700
701 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
704 self.approval_decider = Some(decider);
705 self
706 }
707
708 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
709 self.approval_reviewer = Some(reviewer);
710 self
711 }
712
713 pub fn with_codex_run_tokens(
714 mut self,
715 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
716 ) -> Self {
717 self.codex_run_tokens = authority;
718 self
719 }
720
721 pub fn with_remote_placements(
727 mut self,
728 placements: HashMap<String, ResolvedRemotePlacement>,
729 ) -> Self {
730 self.remote_placements = placements;
731 self
732 }
733
734 pub fn with_schedulable_placements(
740 mut self,
741 placements: HashMap<String, ResolvedSchedulablePlacement>,
742 ) -> Self {
743 self.schedulable_placements = placements;
744 self
745 }
746
747 fn fingerprint(spec: &ProvisionSpec) -> String {
765 let role = spec.identity.role.as_str();
766 let (provider, model) = spec
767 .model
768 .as_ref()
769 .map(|m| (m.provider.as_str(), m.model.as_str()))
770 .unwrap_or(("", ""));
771 let workspace = spec.workspace.as_deref().unwrap_or("");
772 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
773 tools.sort();
774 let caps = &spec.capabilities;
775 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
778 format!(
779 "{role}\u{1}{provider}\u{1}{model}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}pr={}\u{1}pe={}\u{1}by={}\u{1}auto={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}gro={}\u{1}executor={executor}",
780 tools.join(","),
781 spec.identity.depth,
782 caps.nested_spawn,
783 caps.permission_requested_mode,
784 caps.permission_effective_mode,
785 caps.bypass,
786 caps.auto_approve_permissions,
787 caps.enforce_permissions,
788 caps.max_spawn_depth.unwrap_or(0),
789 caps.no_human_approver,
796 caps.guardian_read_only,
800 )
801 }
802
803 fn ensure_pool_reaper(&self) {
806 if self
807 .pool_reaper_started
808 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
809 .is_err()
810 {
811 return;
812 }
813 let pool = Arc::downgrade(&self.pool);
814 tokio::spawn(async move {
815 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
816 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
817 interval.tick().await;
820 loop {
821 interval.tick().await;
822 let Some(pool) = pool.upgrade() else {
823 break;
824 };
825 let _ = reap_worker_pool_once(&pool).await;
826 }
827 });
828 }
829
830 async fn acquire_bus_worker(
836 &self,
837 key: &str,
838 spec: &ProvisionSpec,
839 ) -> crate::runtime::runner::Result<PooledWorker> {
840 self.ensure_pool_reaper();
841 loop {
845 let candidate = {
846 let mut pool = self.pool.lock().await;
847 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
848 if pool.get(key).is_some_and(Vec::is_empty) {
849 pool.remove(key);
850 }
851 candidate
852 };
853 let Some(mut candidate) = candidate else {
854 break;
855 };
856 let now = Instant::now();
857 let expired = parked_worker_expired(
858 candidate.parked_at,
859 now,
860 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
861 );
862 if candidate.worker.is_alive() && !expired {
863 candidate.parked_at = None;
864 return Ok(candidate);
865 }
866 tracing::info!(
867 pool_key = key,
868 mailbox_id = %candidate.mailbox_id,
869 worker_pid = ?candidate.worker.pid(),
870 shutdown_reason = if expired {
871 PoolReapReason::IdleTimeout.as_str()
872 } else {
873 PoolReapReason::ProcessExited.as_str()
874 },
875 "discarding unusable parked sub-agent worker during checkout"
876 );
877 candidate.worker.kill().await;
878 }
879
880 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
881 .await
882 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
883 let mailbox_id = spawned.record.agent_id.clone();
884 Ok(PooledWorker {
885 worker: spawned,
886 mailbox_id,
887 parked_at: None,
888 })
889 }
890
891 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
895 self.ensure_pool_reaper();
896 if !worker.worker.is_alive() {
897 tracing::info!(
898 pool_key = key,
899 mailbox_id = %worker.mailbox_id,
900 worker_pid = ?worker.worker.pid(),
901 shutdown_reason = "process_exited_before_park",
902 "discarding sub-agent worker instead of parking"
903 );
904 worker.worker.kill().await;
905 return;
906 }
907 worker.parked_at = Some(Instant::now());
908 let mut pool = self.pool.lock().await;
909 let key_count = pool.get(key).map_or(0, Vec::len);
910 let total_count = pool.values().map(Vec::len).sum::<usize>();
911 let limit_reason =
912 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
913 if let Some(shutdown_reason) = limit_reason {
914 drop(pool);
915 tracing::info!(
916 pool_key = key,
917 mailbox_id = %worker.mailbox_id,
918 worker_pid = ?worker.worker.pid(),
919 idle_workers_for_key = key_count,
920 idle_workers_total = total_count,
921 max_idle_per_key = self.max_idle_per_key,
922 max_idle_total = self.max_idle_total,
923 shutdown_reason,
924 "discarding sub-agent worker because the warm pool is full"
925 );
926 worker.worker.kill().await;
927 return;
928 }
929 pool.entry(key.to_string()).or_default().push(worker);
930 }
931
932 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
934 let mut spec = ProvisionSpec::new(
935 ChildIdentity {
936 child_id: job.child_session_id.clone(),
937 parent_id: Some(job.parent_session_id.clone()),
938 project_key: None,
939 role: session
940 .metadata
941 .get("subagent_type")
942 .cloned()
943 .unwrap_or_else(|| "worker".to_string()),
944 depth: session.spawn_depth,
949 },
950 self.executor.clone(),
951 self.fabric_dir.to_string_lossy().into_owned(),
952 );
953 spec.workspace = session.workspace.clone();
954 if let ExecutorSpec::Codex {
955 workspace_owned, ..
956 } = &mut spec.executor
957 {
958 *workspace_owned = Some(
959 spec.workspace
960 .as_deref()
961 .is_some_and(workspace_is_bamboo_owned),
962 );
963 }
964 spec.bus = self.bus.clone();
967 spec.model = session
970 .model_ref
971 .as_ref()
972 .map(|r| ModelRefSpec {
973 provider: r.provider.clone(),
974 model: r.model.clone(),
975 })
976 .or_else(|| {
977 let m = job.model.trim();
978 (!m.is_empty()).then(|| ModelRefSpec {
979 provider: self.default_provider.clone(),
980 model: m.to_string(),
981 })
982 });
983 spec.disabled_tools = job.disabled_tools.clone();
984 match &spec.executor {
985 ExecutorSpec::Codex {
991 auth_mode,
992 provider_key_ref,
993 ..
994 } => {
995 if auth_mode.as_deref() == Some("custom") {
996 if let Some(reference) = provider_key_ref {
997 if let Some(credential) = self.credentials.iter().find(|credential| {
998 credential.credential_ref.as_deref() == Some(reference)
999 }) {
1000 spec.secrets.provider_credentials.push(credential.clone());
1001 } else {
1002 tracing::warn!(
1003 "actor child {}: custom Codex credential reference '{}' did not resolve",
1004 job.child_session_id,
1005 reference
1006 );
1007 }
1008 } else {
1009 tracing::warn!(
1010 "actor child {}: custom Codex executor has no credential reference",
1011 job.child_session_id
1012 );
1013 }
1014 }
1015 }
1016 _ => {
1019 let provider = spec
1020 .model
1021 .as_ref()
1022 .map(|model| model.provider.as_str())
1023 .filter(|provider| !provider.trim().is_empty())
1024 .unwrap_or(&self.default_provider);
1025 if let Some(credential) = self
1026 .credentials
1027 .iter()
1028 .find(|credential| credential.provider == provider)
1029 {
1030 spec.secrets.provider_credentials.push(credential.clone());
1031 } else {
1032 tracing::warn!(
1033 "actor child {}: no credential found for provider '{}'",
1034 job.child_session_id,
1035 provider
1036 );
1037 }
1038 }
1039 }
1040 spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
1047 spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
1048 spec.capabilities.enforce_permissions = true;
1054 let requested_permission_mode = session
1059 .agent_runtime_state
1060 .as_ref()
1061 .map(|state| state.effective_permission_mode())
1062 .unwrap_or_default();
1063 let configured_permission_mode = self
1064 .permission_config
1065 .as_ref()
1066 .map(|config| config.mode())
1067 .unwrap_or_default();
1068 spec.capabilities.no_human_approver = session
1073 .agent_runtime_state
1074 .as_ref()
1075 .is_some_and(|s| s.no_human_approver);
1076 spec.capabilities.guardian_read_only =
1086 session.metadata.get("subagent_type").map(String::as_str) == Some("guardian");
1087 if spec.capabilities.guardian_read_only {
1088 if let ExecutorSpec::Codex {
1089 permission_profile, ..
1090 } = &mut spec.executor
1091 {
1092 *permission_profile = Some("read-only".to_string());
1093 }
1094 }
1095 let read_only_overlay = session
1096 .agent_runtime_state
1097 .as_ref()
1098 .is_some_and(|state| state.plan_mode.is_some())
1099 || spec.capabilities.guardian_read_only
1100 || executor_has_read_only_permission_profile(&spec.executor);
1101 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1102 requested_permission_mode,
1103 configured_permission_mode,
1104 read_only_overlay,
1105 );
1106 spec.capabilities.bypass = permission_resolution.bypass_permissions();
1107 spec.capabilities.auto_approve_permissions =
1108 permission_resolution.suppress_approval_prompts();
1109 spec.capabilities.permission_requested_mode =
1110 permission_resolution.requested.as_str().to_string();
1111 spec.capabilities.permission_effective_mode =
1112 permission_resolution.effective.as_str().to_string();
1113 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
1120 spec.placement = Placement::Remote {
1121 endpoint: placement.endpoint.clone(),
1122 };
1123 spec.secrets.worker_auth_token = placement.token.clone();
1124 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
1125 {
1126 spec.placement = Placement::Schedulable {
1134 pool: placement.pool.clone(),
1135 };
1136 }
1137 spec
1138 }
1139
1140 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
1146 let host_label = match &spec.placement {
1147 Placement::Remote { .. } => self
1148 .remote_placements
1149 .get(spec.identity.role.as_str())
1150 .and_then(|p| p.host_label.as_deref()),
1151 Placement::Schedulable { .. } => self
1152 .schedulable_placements
1153 .get(spec.identity.role.as_str())
1154 .and_then(|p| p.host_label.as_deref()),
1155 Placement::Local => None,
1156 };
1157 placement_metadata(&spec.placement, host_label)
1158 }
1159
1160 async fn resolve_schedulable_worker(
1167 &self,
1168 role: &str,
1169 ) -> std::result::Result<String, AgentError> {
1170 let pool = self
1171 .schedulable_placements
1172 .get(role)
1173 .ok_or_else(|| {
1174 AgentError::LLM(format!(
1175 "schedulable placement for role '{role}' vanished before scheduling"
1176 ))
1177 })?
1178 .pool
1179 .clone();
1180 let bus = self.bus.as_ref().ok_or_else(|| {
1181 AgentError::LLM(format!(
1182 "schedulable role '{role}': no mailbox bus configured (subagents.broker)"
1183 ))
1184 })?;
1185
1186 let mut q = bamboo_broker::BrokerClient::connect(
1189 &bus.endpoint,
1190 bamboo_subagent::AgentRef {
1191 session_id: format!("sched-q-{role}"),
1192 role: None,
1193 },
1194 &bus.token,
1195 )
1196 .await
1197 .map_err(|e| {
1198 AgentError::LLM(format!(
1199 "schedulable role '{role}': bus connect failed: {e}"
1200 ))
1201 })?;
1202 let candidates = q.list_connected(&pool).await.map_err(|e| {
1203 AgentError::LLM(format!(
1204 "schedulable role '{role}': bus presence query failed: {e}"
1205 ))
1206 })?;
1207
1208 if candidates.is_empty() {
1209 return Err(AgentError::LLM(format!(
1210 "schedulable role '{role}': no live worker in pool '{pool}' on the bus \
1211 (NOT spawning a local subprocess — a schedulable role has no local fallback)"
1212 )));
1213 }
1214
1215 let idx = {
1220 let mut cursors = self.schedule_cursor.lock().recover_poison();
1221 let cursor = cursors.entry(pool.clone()).or_insert(0);
1222 let i = *cursor % candidates.len();
1223 *cursor = cursor.wrapping_add(1);
1224 i
1225 };
1226 Ok(candidates[idx].clone())
1227 }
1228}
1229
1230#[async_trait]
1231impl ExternalChildRunner for ActorChildRunner {
1232 async fn should_handle(&self, session: &Session) -> bool {
1233 session.metadata.get("runtime.kind") == Some(&"external".to_string())
1234 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
1235 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
1236 }
1237
1238 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
1239 *self.escalation_bridge.lock().recover_poison() = bridge;
1240 }
1241
1242 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
1243 *self.session_inbox_runtime.lock().recover_poison() = binding;
1244 }
1245
1246 async fn execute_external_child(
1247 &self,
1248 session: &mut Session,
1249 job: &SpawnJob,
1250 event_tx: mpsc::Sender<AgentEvent>,
1251 cancel_token: CancellationToken,
1252 ) -> crate::runtime::runner::Result<()> {
1253 let escalation = self.escalation_bridge.lock().recover_poison().clone();
1262 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
1263 let assignment = extract_assignment(session);
1264 let mut spec = self.build_spec(session, job);
1265 spec.reusable = true;
1268 if spec.limits.idle_timeout_secs.is_none() {
1269 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
1270 }
1271 let pool_key = Self::fingerprint(&spec);
1272
1273 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
1278 {
1279 return Err(AgentError::LLM(
1280 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
1281 .to_string(),
1282 ));
1283 }
1284 let project_id = project_id_for_actor_run(session)?;
1285 let requested_permission_mode = session
1286 .agent_runtime_state
1287 .as_ref()
1288 .map(|state| state.effective_permission_mode())
1289 .unwrap_or_default();
1290 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
1295 ensure_no_active_scoped_session_denies(config, &session.id)?;
1296 let read_only_overlay = session
1297 .agent_runtime_state
1298 .as_ref()
1299 .is_some_and(|state| state.plan_mode.is_some())
1300 || spec.capabilities.guardian_read_only
1301 || executor_has_read_only_permission_profile(&spec.executor);
1302 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1303 requested_permission_mode,
1304 config.mode(),
1305 read_only_overlay,
1306 );
1307 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
1308 AgentError::LLM(format!(
1309 "failed to serialize permission policy for external executor: {error}"
1310 ))
1311 })?;
1312 Some(PermissionPolicyContext {
1313 revision: config.policy_revision(),
1314 requested_mode: resolution.requested.as_str().to_string(),
1315 effective_mode: resolution.effective.as_str().to_string(),
1316 bypass_permissions: resolution.bypass_permissions(),
1317 auto_approve_permissions: resolution.suppress_approval_prompts(),
1318 session_id: session.id.clone(),
1319 workspace_path: session.workspace.clone(),
1320 inherit_session_grants: false,
1321 policy,
1322 })
1323 } else {
1324 None
1325 };
1326 let provisioned_permission =
1327 spec.capabilities.permission_resolution().map_err(|error| {
1328 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
1329 })?;
1330 let policy_resolution = permission_policy
1331 .as_ref()
1332 .map(|context| {
1333 context.resolved_modes().map(|(requested, effective)| {
1334 bamboo_domain::PermissionModeResolution {
1335 requested,
1336 effective,
1337 }
1338 })
1339 })
1340 .transpose()
1341 .map_err(|error| {
1342 AgentError::LLM(format!("invalid host permission posture: {error}"))
1343 })?;
1344 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
1345 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
1346 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
1347 .is_some()
1348 });
1349 let expected_executor_mapping = expected_permission_executor_mapping(
1350 &spec.executor,
1351 policy_resolution.unwrap_or(provisioned_permission),
1352 has_explicit_deny,
1353 )?;
1354 let expected_permission_posture =
1355 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
1356 policy_revision: permission_policy
1357 .as_ref()
1358 .map(|context| context.revision)
1359 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
1360 .unwrap_or_default(),
1361 resolution: policy_resolution.unwrap_or(provisioned_permission),
1362 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
1363 executor_mapping,
1364 });
1365
1366 let _slot = self
1371 .concurrency
1372 .acquire()
1373 .await
1374 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".to_string()))?;
1375
1376 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
1383 &spec.executor,
1384 self.codex_run_tokens.clone(),
1385 &job.child_session_id,
1386 )?;
1387
1388 let kind = match spec.placement {
1396 Placement::Remote { .. } => PlacementKind::Remote,
1397 Placement::Schedulable { .. } => PlacementKind::Schedulable,
1398 Placement::Local => PlacementKind::Local,
1399 };
1400 let remote = !matches!(kind, PlacementKind::Local);
1401
1402 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
1408 session
1409 .metadata
1410 .insert("placement".to_string(), placement_meta);
1411 }
1412
1413 let mut attempt = 0u8;
1420 let (result, actor) = loop {
1421 let (actor, mut client) = match kind {
1422 PlacementKind::Remote => {
1423 let placement = self
1427 .remote_placements
1428 .get(spec.identity.role.as_str())
1429 .ok_or_else(|| {
1430 AgentError::LLM(format!(
1431 "remote placement for role '{}' vanished before connect",
1432 spec.identity.role
1433 ))
1434 })?;
1435 let endpoint = placement.endpoint.clone();
1436 let trust_cfg = match placement.ca_cert_file.as_deref() {
1439 Some(path) => Some(client_config_trusting_cert(path).map_err(|e| {
1440 AgentError::LLM(format!(
1441 "remote worker CA cert '{}': {e}",
1442 path.display()
1443 ))
1444 })?),
1445 None => None,
1446 };
1447 let client = ChildClient::connect_with_auth_tls(
1448 &endpoint,
1449 placement.token.as_deref(),
1450 trust_cfg,
1451 )
1452 .await
1453 .map_err(|e| {
1454 AgentError::LLM(format!("remote actor connect to '{endpoint}' failed: {e}"))
1455 })?;
1456 let record = AgentRecord {
1459 agent_id: job.child_session_id.clone(),
1460 role: spec.identity.role.clone(),
1461 labels: Vec::new(),
1462 endpoint: endpoint.clone(),
1463 pid: 0,
1464 version: String::new(),
1465 started_at: chrono::Utc::now(),
1466 lease_expires_at: chrono::Utc::now(),
1467 };
1468 let _ = endpoint;
1469 let actor = PooledWorker {
1470 worker: SpawnedChild::remote(record),
1471 mailbox_id: job.child_session_id.clone(),
1472 parked_at: None,
1473 };
1474 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(client);
1475 (actor, client)
1476 }
1477 PlacementKind::Schedulable => {
1478 let bus = self.bus.as_ref().ok_or_else(|| {
1485 AgentError::LLM(
1486 "schedulable sub-agents require a mailbox bus (subagents.broker)"
1487 .to_string(),
1488 )
1489 })?;
1490 let mailbox_id = self
1491 .resolve_schedulable_worker(spec.identity.role.as_str())
1492 .await?;
1493 let parent = bamboo_subagent::AgentRef {
1494 session_id: format!("p-{}", job.child_session_id),
1495 role: None,
1496 };
1497 let link = bamboo_broker::BrokerChildLink::connect(
1498 &bus.endpoint,
1499 parent,
1500 &bus.token,
1501 mailbox_id.clone(),
1502 )
1503 .await
1504 .map_err(|e| {
1505 AgentError::LLM(format!(
1506 "schedulable link connect to '{mailbox_id}' failed: {e}"
1507 ))
1508 })?;
1509 let actor = PooledWorker {
1512 worker: SpawnedChild::remote(AgentRecord {
1513 agent_id: mailbox_id.clone(),
1514 role: spec.identity.role.clone(),
1515 labels: Vec::new(),
1516 endpoint: bus.endpoint.clone(),
1517 pid: 0,
1518 version: String::new(),
1519 started_at: chrono::Utc::now(),
1520 lease_expires_at: chrono::Utc::now(),
1521 }),
1522 mailbox_id,
1523 parked_at: None,
1524 };
1525 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1526 (actor, client)
1527 }
1528 PlacementKind::Local => {
1529 let bus = self.bus.as_ref().ok_or_else(|| {
1536 AgentError::LLM(
1537 "local sub-agents require a mailbox bus (subagents.broker); none is \
1538 configured and the bus could not be embedded"
1539 .to_string(),
1540 )
1541 })?;
1542 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
1543 let parent = bamboo_subagent::AgentRef {
1544 session_id: format!("p-{}", job.child_session_id),
1545 role: None,
1546 };
1547 let link = bamboo_broker::BrokerChildLink::connect(
1548 &bus.endpoint,
1549 parent,
1550 &bus.token,
1551 actor.mailbox_id.clone(),
1552 )
1553 .await
1554 .map_err(|e| {
1555 AgentError::LLM(format!("broker child link connect failed: {e}"))
1556 })?;
1557 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1558 (actor, client)
1559 }
1560 };
1561
1562 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
1567 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
1568 Some(binding) => {
1569 let run_id = binding
1570 .router
1571 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
1572 .await;
1573 if run_id.is_none() {
1574 tracing::debug!(
1575 session_id = %job.child_session_id,
1576 "actor driver had no current SessionInbox activation owner to bind"
1577 );
1578 }
1579 run_id
1580 }
1581 None => None,
1582 };
1583 drop(delivery_tx);
1584 let initial_pairs = match (
1585 session_inbox_runtime.as_ref(),
1586 bound_activation_run_id.as_deref(),
1587 ) {
1588 (Some(binding), Some(run_id)) => {
1589 match claim_canonical_deliveries(binding, session, run_id, usize::MAX).await {
1590 Ok(deliveries) => deliveries,
1591 Err(error) => {
1592 binding
1593 .router
1594 .detach_delivery_sink(&job.child_session_id, run_id)
1595 .await;
1596 if !remote {
1597 actor.worker.kill().await;
1598 }
1599 return Err(error);
1600 }
1601 }
1602 }
1603 _ => Vec::new(),
1604 };
1605 let initial_session_messages = initial_pairs
1606 .iter()
1607 .map(|(_, delivery)| delivery.clone())
1608 .collect::<Vec<_>>();
1609 let initial_inflight_claims = initial_pairs
1610 .into_iter()
1611 .map(|(claim, _)| claim)
1612 .collect::<VecDeque<_>>();
1613 let messages = session
1616 .messages
1617 .iter()
1618 .filter_map(|message| serde_json::to_value(message).ok())
1619 .collect();
1620
1621 if let Err(e) = client
1622 .send(ParentFrame::Run(RunSpec {
1623 assignment: assignment.clone(),
1625 logical_session: Some(logical_identity_for_actor_run(session, job)),
1626 project_id: project_id.clone(),
1627 reasoning_effort: None,
1628 permission_policy: permission_policy.clone(),
1629 messages,
1630 activation_run_id: bound_activation_run_id.clone(),
1631 initial_session_messages,
1632 secrets: run_secrets.clone(),
1633 }))
1634 .await
1635 {
1636 if let (Some(binding), Some(run_id)) = (
1637 session_inbox_runtime.as_ref(),
1638 bound_activation_run_id.as_deref(),
1639 ) {
1640 binding
1641 .router
1642 .detach_delivery_sink(&job.child_session_id, run_id)
1643 .await;
1644 }
1645 if !remote {
1646 actor.worker.kill().await;
1647 }
1648 return Err(AgentError::LLM(format!("actor run dispatch failed: {e}")));
1649 }
1650
1651 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
1655 let live_guard = super::live::register(
1656 &job.child_session_id,
1657 live_tx,
1658 attempt as u32,
1659 self.approval_registry.clone(),
1660 );
1661
1662 let result = drive(ActorDriveContext {
1663 client: &mut *client,
1664 parent_session_id: &job.parent_session_id,
1665 child_session_id: &job.child_session_id,
1666 child_attempt: attempt as u32,
1667 approval_registry: self.approval_registry.as_ref(),
1668 approval_decider: self.approval_decider.as_ref(),
1669 approval_reviewer: self.approval_reviewer.as_ref(),
1670 escalation_bridge: escalation.clone(),
1671 event_tx: &event_tx,
1672 cancel_token: &cancel_token,
1673 live_rx: &mut live_rx,
1674 delivery_rx: &mut delivery_rx,
1675 logical_session: session,
1676 expected_permission_posture: expected_permission_posture.clone(),
1677 session_inbox_runtime: session_inbox_runtime.as_ref(),
1678 activation_run_id: bound_activation_run_id.as_deref(),
1679 initial_inflight_claims,
1680 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
1687 })
1688 .await;
1689 if let (Some(binding), Some(run_id)) = (
1690 session_inbox_runtime.as_ref(),
1691 bound_activation_run_id.as_deref(),
1692 ) {
1693 binding
1694 .router
1695 .detach_delivery_sink(&job.child_session_id, run_id)
1696 .await;
1697 }
1698 drop(live_guard);
1704 drop(client);
1707
1708 if attempt == 0 && matches!(result, Err(AgentError::WorkerUnresponsive(_))) {
1715 match kind {
1716 PlacementKind::Local => {
1717 tracing::warn!(
1718 "actor child {} got no first frame; reaping the worker and respawning once",
1719 job.child_session_id
1720 );
1721 actor.worker.kill().await;
1722 attempt += 1;
1723 continue;
1724 }
1725 PlacementKind::Schedulable => {
1726 tracing::warn!(
1727 "scheduled actor child {} got no first frame; re-selecting a pool worker",
1728 job.child_session_id
1729 );
1730 drop(actor);
1731 attempt += 1;
1732 continue;
1733 }
1734 PlacementKind::Remote => {}
1735 }
1736 }
1737 break (result, actor);
1738 };
1739
1740 if remote {
1744 drop(actor);
1745 } else {
1746 match &result {
1747 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
1748 Err(_) => actor.worker.kill().await,
1749 }
1750 }
1751
1752 match result {
1756 Ok(Some(text)) => {
1757 if !text.is_empty() {
1758 session.add_message(bamboo_agent_core::Message::assistant(text, None));
1759 }
1760 Ok(())
1761 }
1762 Ok(None) => Ok(()),
1763 Err(e) => Err(e),
1764 }
1765 }
1766}
1767
1768fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
1774 let value = match placement {
1777 Placement::Local => return None,
1778 Placement::Remote { endpoint } => serde_json::json!({
1779 "kind": "remote",
1780 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
1781 }),
1782 Placement::Schedulable { pool } => serde_json::json!({
1783 "kind": "remote",
1784 "host": host_label.unwrap_or(pool),
1785 }),
1786 };
1787 serde_json::to_string(&value).ok()
1788}
1789
1790fn host_of_endpoint(endpoint: &str) -> String {
1792 endpoint
1793 .trim()
1794 .trim_start_matches("wss://")
1795 .trim_start_matches("ws://")
1796 .split(['/', ':'])
1797 .next()
1798 .unwrap_or(endpoint)
1799 .to_string()
1800}
1801
1802async fn reconcile_already_admitted_claim(
1803 binding: &SessionInboxRuntimeBinding,
1804 session: &mut Session,
1805 claim: &SessionInboxClaim,
1806) -> crate::runtime::runner::Result<()> {
1807 let latest = binding
1808 .storage
1809 .load_session(&session.id)
1810 .await
1811 .map_err(|error| {
1812 AgentError::LLM(format!(
1813 "load canonical SessionInbox checkpoint for {}: {error}",
1814 session.id
1815 ))
1816 })?
1817 .ok_or_else(|| {
1818 AgentError::LLM(format!(
1819 "canonical SessionInbox target disappeared: {}",
1820 session.id
1821 ))
1822 })?;
1823 let Some(message) = latest
1824 .messages
1825 .iter()
1826 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1827 .cloned()
1828 else {
1829 return Err(AgentError::LLM(format!(
1830 "canonical admitted receipt for {} exists without transcript message {}",
1831 session.id, claim.envelope.id
1832 )));
1833 };
1834 if let Some(existing) = session
1835 .messages
1836 .iter_mut()
1837 .find(|existing| existing.id == message.id)
1838 {
1839 *existing = message;
1840 } else {
1841 session.add_message(message);
1842 }
1843 bamboo_domain::merge_session_inbox_admission(session, &latest);
1844 binding
1845 .inbox
1846 .ack(&session.id, claim)
1847 .await
1848 .map_err(|error| {
1849 AgentError::LLM(format!(
1850 "ack recovered canonical SessionInbox claim {}: {error}",
1851 claim.envelope.id
1852 ))
1853 })
1854}
1855
1856async fn checkpoint_and_ack_canonical_claim(
1860 binding: &SessionInboxRuntimeBinding,
1861 session: &mut Session,
1862 claim: &SessionInboxClaim,
1863) -> crate::runtime::runner::Result<()> {
1864 if claim.envelope.target_session_id != session.id {
1865 return Err(AgentError::LLM(format!(
1866 "canonical SessionInbox claim target {} does not match active logical session {}",
1867 claim.envelope.target_session_id, session.id
1868 )));
1869 }
1870 if binding
1871 .inbox
1872 .was_admitted(&session.id, &claim.envelope.id)
1873 .await
1874 .map_err(|error| {
1875 AgentError::LLM(format!(
1876 "inspect canonical admitted receipt {}: {error}",
1877 claim.envelope.id
1878 ))
1879 })?
1880 {
1881 return reconcile_already_admitted_claim(binding, session, claim).await;
1882 }
1883
1884 let transcript_has_id = session
1885 .messages
1886 .iter()
1887 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1888 if session
1889 .messages
1890 .iter()
1891 .any(|message| message.id == claim.envelope.id.as_str())
1892 && !transcript_has_id
1893 {
1894 return Err(AgentError::LLM(format!(
1895 "canonical SessionInbox id {} collides with a non-matching transcript message",
1896 claim.envelope.id
1897 )));
1898 }
1899 let cursor_has_id = session
1900 .session_inbox_admission()
1901 .is_some_and(|state| state.contains(&claim.envelope.id));
1902 if cursor_has_id && !transcript_has_id {
1903 return Err(AgentError::LLM(format!(
1904 "canonical SessionInbox cursor exists without transcript message {}",
1905 claim.envelope.id
1906 )));
1907 }
1908 let before = session.clone();
1909 if !transcript_has_id {
1910 let message = claim.envelope.to_provider_message().map_err(|error| {
1911 AgentError::LLM(format!(
1912 "translate canonical SessionInbox envelope {}: {error}",
1913 claim.envelope.id
1914 ))
1915 })?;
1916 session.add_message(message);
1917 }
1918 session
1919 .session_inbox_admission_mut()
1920 .record(claim.envelope.id.clone(), claim.generation);
1921 session.updated_at = chrono::Utc::now();
1922
1923 if let Err(error) = binding
1924 .persistence
1925 .checkpoint_runtime_session(session)
1926 .await
1927 {
1928 *session = before;
1929 return Err(AgentError::LLM(format!(
1930 "checkpoint canonical SessionInbox claim {}: {error}",
1931 claim.envelope.id
1932 )));
1933 }
1934 if !session
1935 .messages
1936 .iter()
1937 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1938 {
1939 *session = before;
1940 return Err(AgentError::LLM(format!(
1941 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
1942 claim.envelope.id
1943 )));
1944 }
1945 binding
1946 .inbox
1947 .ack(&session.id, claim)
1948 .await
1949 .map_err(|error| {
1950 AgentError::LLM(format!(
1951 "ack canonical SessionInbox claim {} after checkpoint: {error}",
1952 claim.envelope.id
1953 ))
1954 })
1955}
1956
1957async fn checkpoint_claim_context_before_dispatch(
1968 binding: &SessionInboxRuntimeBinding,
1969 session: &mut Session,
1970 claims: &[SessionInboxClaim],
1971) -> crate::runtime::runner::Result<()> {
1972 if claims.is_empty() {
1973 return Ok(());
1974 }
1975 let before = session.clone();
1976 for claim in claims {
1977 if claim.envelope.target_session_id != session.id {
1978 return Err(AgentError::LLM(format!(
1979 "canonical SessionInbox claim target {} does not match actor session {}",
1980 claim.envelope.target_session_id, session.id
1981 )));
1982 }
1983 let matching = session
1984 .messages
1985 .iter()
1986 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1987 if session
1988 .messages
1989 .iter()
1990 .any(|message| message.id == claim.envelope.id.as_str())
1991 && !matching
1992 {
1993 return Err(AgentError::LLM(format!(
1994 "canonical SessionInbox id {} collides before actor dispatch",
1995 claim.envelope.id
1996 )));
1997 }
1998 if session
1999 .session_inbox_admission()
2000 .is_some_and(|state| state.contains(&claim.envelope.id))
2001 && !matching
2002 {
2003 return Err(AgentError::LLM(format!(
2004 "canonical SessionInbox cursor exists without transcript proof for {}",
2005 claim.envelope.id
2006 )));
2007 }
2008 if !matching {
2009 let message = claim.envelope.to_provider_message().map_err(|error| {
2010 AgentError::LLM(format!(
2011 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
2012 claim.envelope.id
2013 ))
2014 })?;
2015 session.add_message(message);
2016 }
2017 }
2018 session.updated_at = chrono::Utc::now();
2019 if let Err(error) = binding
2020 .persistence
2021 .checkpoint_runtime_session(session)
2022 .await
2023 {
2024 *session = before;
2025 return Err(AgentError::LLM(format!(
2026 "checkpoint canonical SessionInbox actor context: {error}"
2027 )));
2028 }
2029 for claim in claims {
2030 if !session
2031 .messages
2032 .iter()
2033 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
2034 {
2035 *session = before;
2036 return Err(AgentError::LLM(format!(
2037 "actor context checkpoint lost typed transcript proof for {}",
2038 claim.envelope.id
2039 )));
2040 }
2041 }
2042 Ok(())
2043}
2044
2045async fn claim_canonical_deliveries(
2046 binding: &SessionInboxRuntimeBinding,
2047 session: &mut Session,
2048 activation_run_id: &str,
2049 limit: usize,
2050) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
2051 let claims = binding
2052 .inbox
2053 .claim(&session.id, limit)
2054 .await
2055 .map_err(|error| {
2056 AgentError::LLM(format!(
2057 "claim canonical SessionInbox for active actor {}: {error}",
2058 session.id
2059 ))
2060 })?;
2061 if claims.is_empty() {
2062 return Ok(Vec::new());
2063 }
2064 let interrupt_generation = binding
2065 .inbox
2066 .inspect(&session.id)
2067 .await
2068 .map_err(|error| {
2069 AgentError::LLM(format!(
2070 "inspect canonical SessionInbox activation policy for {}: {error}",
2071 session.id
2072 ))
2073 })?
2074 .interrupt_generation;
2075 let mut unconfirmed = Vec::with_capacity(claims.len());
2076 for claim in claims {
2077 if binding
2078 .inbox
2079 .was_admitted(&session.id, &claim.envelope.id)
2080 .await
2081 .map_err(|error| {
2082 AgentError::LLM(format!(
2083 "inspect canonical SessionInbox claim {}: {error}",
2084 claim.envelope.id
2085 ))
2086 })?
2087 {
2088 reconcile_already_admitted_claim(binding, session, &claim).await?;
2089 continue;
2090 }
2091 unconfirmed.push(claim);
2092 }
2093 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed).await?;
2094
2095 let mut deliveries = Vec::with_capacity(unconfirmed.len());
2096 for claim in unconfirmed {
2097 if session
2102 .session_inbox_admission()
2103 .is_some_and(|state| state.contains(&claim.envelope.id))
2104 {
2105 binding
2106 .inbox
2107 .ack(&session.id, &claim)
2108 .await
2109 .map_err(|error| {
2110 AgentError::LLM(format!(
2111 "finish confirmed canonical SessionInbox ack {}: {error}",
2112 claim.envelope.id
2113 ))
2114 })?;
2115 continue;
2116 }
2117 let activation_policy = if claim.generation <= interrupt_generation {
2118 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait
2119 } else {
2120 bamboo_domain::SessionActivationPolicy::RespectSpecificWait
2121 };
2122 let delivery = SessionMessageDelivery {
2123 target_session_id: session.id.clone(),
2124 envelope: claim.envelope.clone(),
2125 canonical_claim_generation: claim.generation,
2126 activation_run_id: activation_run_id.to_string(),
2127 activation_policy,
2128 };
2129 deliveries.push((claim, delivery));
2130 }
2131 Ok(deliveries)
2132}
2133
2134async fn forward_next_canonical_claim(
2135 client: &mut dyn bamboo_subagent::ChildLink,
2136 binding: &SessionInboxRuntimeBinding,
2137 session: &mut Session,
2138 activation_run_id: &str,
2139 inflight: &mut VecDeque<SessionInboxClaim>,
2140) -> crate::runtime::runner::Result<()> {
2141 if !inflight.is_empty() {
2142 return Ok(());
2143 }
2144 let Some((claim, delivery)) =
2145 claim_canonical_deliveries(binding, session, activation_run_id, 1)
2146 .await?
2147 .pop()
2148 else {
2149 return Ok(());
2150 };
2151 client
2152 .send(ParentFrame::SessionMessage { delivery })
2153 .await
2154 .map_err(|error| {
2155 AgentError::LLM(format!(
2156 "forward canonical SessionInbox claim {} to active actor: {error}",
2157 claim.envelope.id
2158 ))
2159 })?;
2160 inflight.push_back(claim);
2161 Ok(())
2162}
2163
2164struct ActorDriveContext<'a> {
2166 client: &'a mut dyn bamboo_subagent::ChildLink,
2167 parent_session_id: &'a str,
2168 child_session_id: &'a str,
2169 child_attempt: u32,
2170 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
2171 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
2172 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
2173 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
2174 event_tx: &'a mpsc::Sender<AgentEvent>,
2175 cancel_token: &'a CancellationToken,
2176 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
2177 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
2178 logical_session: &'a mut Session,
2179 expected_permission_posture: Option<ExpectedPermissionPosture>,
2180 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
2181 activation_run_id: Option<&'a str>,
2182 initial_inflight_claims: VecDeque<SessionInboxClaim>,
2183 first_frame_timeout: Option<Duration>,
2184}
2185
2186#[derive(Debug, Clone, PartialEq, Eq)]
2187struct ExpectedPermissionPosture {
2188 policy_revision: u64,
2189 resolution: bamboo_domain::PermissionModeResolution,
2190 expected_audit_revision: Option<u64>,
2191 executor_mapping: String,
2192}
2193
2194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2195enum PermissionPostureHandshake {
2196 NotRequired,
2198 Awaiting,
2201 Confirmed,
2203}
2204
2205impl PermissionPostureHandshake {
2206 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
2207 if expected.is_some() {
2208 Self::Awaiting
2209 } else {
2210 Self::NotRequired
2211 }
2212 }
2213
2214 fn is_awaiting(self) -> bool {
2215 self == Self::Awaiting
2216 }
2217
2218 fn posture_was_confirmed(self) -> bool {
2219 self == Self::Confirmed
2220 }
2221}
2222
2223fn permission_posture_seed_from_event(
2224 session: &Session,
2225 event: &AgentEvent,
2226) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
2227 let AgentEvent::PermissionPostureActivated {
2228 session_id,
2229 policy_revision,
2230 requested_mode,
2231 effective_mode,
2232 executor_mapping,
2233 } = event
2234 else {
2235 return Ok(None);
2236 };
2237 if session_id != &session.id {
2238 return Err("permission posture event targets a different logical session".to_string());
2239 }
2240 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
2241 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
2242 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
2243 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
2244 let resolution = bamboo_domain::PermissionModeResolution {
2245 requested,
2246 effective,
2247 };
2248 if !resolution.is_consistent() {
2249 return Err("permission posture event has an inconsistent mode pair".to_string());
2250 }
2251 let current_requested = session
2252 .agent_runtime_state
2253 .as_ref()
2254 .map(|state| state.effective_permission_mode())
2255 .unwrap_or_default();
2256 if current_requested != requested {
2257 return Err("permission posture event is stale for the host typed mode".to_string());
2258 }
2259 let mapping_chars = executor_mapping.chars().count();
2260 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
2261 return Err("permission posture event has an invalid executor mapping".to_string());
2262 }
2263 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
2264 *policy_revision,
2265 resolution,
2266 executor_mapping,
2267 )))
2268}
2269
2270async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
2281 let ActorDriveContext {
2282 client,
2283 parent_session_id,
2284 child_session_id,
2285 child_attempt,
2286 approval_registry,
2287 approval_decider,
2288 approval_reviewer,
2289 escalation_bridge,
2290 event_tx,
2291 cancel_token,
2292 live_rx,
2293 delivery_rx,
2294 logical_session,
2295 expected_permission_posture,
2296 session_inbox_runtime,
2297 activation_run_id,
2298 initial_inflight_claims,
2299 first_frame_timeout,
2300 } = context;
2301
2302 let mut got_first_frame = false;
2309 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
2310 let mut inflight_claims = initial_inflight_claims;
2311 let strict_permission_events = expected_permission_posture.is_some();
2312 let mut permission_handshake =
2313 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
2314 loop {
2315 tokio::select! {
2316 _ = cancel_token.cancelled() => {
2317 break;
2319 }
2320 _ = async {
2321 match first_frame_watch.as_mut() {
2322 Some(s) => s.as_mut().await,
2323 None => std::future::pending::<()>().await,
2324 }
2325 }, if !got_first_frame => {
2326 return Err(AgentError::WorkerUnresponsive(format!(
2327 "child {child_session_id} produced no frame within {:?}",
2328 first_frame_timeout.unwrap_or_default()
2329 )));
2330 }
2331 Some(_generation) = delivery_rx.recv(),
2332 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
2333 {
2334 forward_next_canonical_claim(
2335 client,
2336 session_inbox_runtime.expect("guarded"),
2337 logical_session,
2338 activation_run_id.expect("guarded"),
2339 &mut inflight_claims,
2340 )
2341 .await?;
2342 }
2343 Some(frame) = live_rx.recv() => {
2344 if client.send(frame).await.is_err() {
2346 tracing::warn!("live steering frame could not be sent; connection failing");
2347 }
2348 }
2349 frame = client.next_frame() => {
2350 got_first_frame = true;
2353 first_frame_watch = None;
2354 match frame {
2355 Ok(Some(ChildFrame::Event { event })) => {
2356 let ev = match serde_json::from_value::<AgentEvent>(event) {
2358 Ok(ev) => ev,
2359 Err(error) if strict_permission_events => {
2360 return Err(AgentError::LLM(format!(
2361 "actor emitted malformed AgentEvent under a typed permission posture contract: {error}"
2362 )));
2363 }
2364 Err(_) => continue,
2365 };
2366 if matches!(&ev, AgentEvent::PermissionPostureActivated { .. }) {
2367 if permission_handshake.posture_was_confirmed() {
2368 return Err(AgentError::LLM(
2369 "actor emitted a duplicate permission posture activation"
2370 .to_string(),
2371 ));
2372 }
2373 let seed = permission_posture_seed_from_event(logical_session, &ev)
2374 .map_err(AgentError::LLM)?
2375 .ok_or_else(|| {
2376 AgentError::LLM(
2377 "actor permission posture event did not decode as a posture"
2378 .to_string(),
2379 )
2380 })?;
2381 if let Some(expected) = expected_permission_posture.as_ref() {
2382 if seed.policy_revision != expected.policy_revision
2383 || seed.resolution != expected.resolution
2384 {
2385 return Err(AgentError::LLM(
2386 "permission posture event does not match the host-dispatched policy"
2387 .to_string(),
2388 ));
2389 }
2390 if seed.executor_mapping() != expected.executor_mapping {
2391 return Err(AgentError::LLM(
2392 "permission posture event does not match the host-dispatched executor mapping"
2393 .to_string(),
2394 ));
2395 }
2396 }
2397 if let Some(binding) = session_inbox_runtime {
2398 let saved = binding
2399 .persistence
2400 .record_permission_posture_activation(
2401 &logical_session.id,
2402 expected_permission_posture
2403 .as_ref()
2404 .and_then(|expected| expected.expected_audit_revision),
2405 &seed,
2406 )
2407 .await
2408 .map_err(|error| {
2409 AgentError::LLM(format!(
2410 "persist child permission posture bootstrap: {error}"
2411 ))
2412 })?
2413 .ok_or_else(|| {
2414 AgentError::LLM(
2415 "persist child permission posture bootstrap: session not found"
2416 .to_string(),
2417 )
2418 })?;
2419 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(
2420 &saved.metadata,
2421 )
2422 .ok_or_else(|| {
2423 AgentError::LLM(
2424 "persisted child permission posture audit is incomplete"
2425 .to_string(),
2426 )
2427 })?;
2428 snapshot.write_to(&mut logical_session.metadata);
2429 } else {
2430 bamboo_domain::record_permission_audit(
2433 &mut logical_session.metadata,
2434 &seed,
2435 None,
2436 )
2437 .map_err(|error| {
2438 AgentError::LLM(format!(
2439 "record in-memory child permission posture: {error}"
2440 ))
2441 })?;
2442 }
2443 permission_handshake = PermissionPostureHandshake::Confirmed;
2446 } else if permission_handshake.is_awaiting() {
2447 return Err(AgentError::LLM(
2448 "actor emitted an execution event before permission posture confirmation"
2449 .to_string(),
2450 ));
2451 }
2452 let _ = event_tx.send(ev).await;
2453 }
2454 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
2455 if permission_handshake.is_awaiting() {
2456 return Err(AgentError::LLM(
2457 "actor requested approval before permission posture confirmation"
2458 .to_string(),
2459 ));
2460 }
2461 if let Some(reviewer) = approval_reviewer
2467 .cloned()
2468 .or_else(child_approval_reviewer)
2469 {
2470 let child = child_session_id.to_string();
2478 let parent = parent_session_id.to_string();
2479 let req_id = id.clone();
2480 let body = body.clone();
2481 let registry = approval_registry.cloned();
2482 tokio::spawn(async move {
2483 let approved = tokio::time::timeout(
2484 CHILD_APPROVAL_TIMEOUT,
2485 reviewer.review(&parent, &child, &body),
2486 )
2487 .await
2488 .unwrap_or(false);
2489 super::live::deliver_approval_scoped(
2490 registry.as_ref(),
2491 &child,
2492 child_attempt,
2493 &req_id,
2494 approved,
2495 );
2496 });
2497 } else if approval_decider.is_some() {
2498 let approved =
2502 decide_child_approval(approval_decider, child_session_id, &body)
2503 .await;
2504 if client
2505 .send(ParentFrame::ApprovalReply { id, approved })
2506 .await
2507 .is_err()
2508 {
2509 tracing::warn!(
2510 "failed to answer approval_request; connection failing"
2511 );
2512 }
2513 } else if let Some(host) = escalation_bridge.clone() {
2514 let child = child_session_id.to_string();
2521 let req_id = id.clone();
2522 let body = body.clone();
2523 let registry = approval_registry.cloned();
2524 tokio::spawn(async move {
2525 let approved = match tokio::time::timeout(
2526 CHILD_APPROVAL_TIMEOUT,
2527 host.approval_call(body),
2528 )
2529 .await
2530 {
2531 Ok(Ok(reply)) => reply
2532 .get("approved")
2533 .and_then(|v| v.as_bool())
2534 .unwrap_or(false),
2535 _ => false,
2537 };
2538 super::live::deliver_approval_scoped(
2539 registry.as_ref(),
2540 &child,
2541 child_attempt,
2542 &req_id,
2543 approved,
2544 );
2545 });
2546 } else {
2547 tracing::warn!(
2551 parent_session_id,
2552 child_session_id,
2553 request_id = %id,
2554 "forced-ask request has no parent-agent reviewer; denying"
2555 );
2556 if client
2557 .send(ParentFrame::ApprovalReply {
2558 id,
2559 approved: false,
2560 })
2561 .await
2562 .is_err()
2563 {
2564 tracing::warn!(
2565 "failed to send fail-closed approval reply; connection failing"
2566 );
2567 }
2568 }
2569 }
2570 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
2571 let Some(binding) = session_inbox_runtime else {
2572 tracing::warn!(
2573 child_session_id,
2574 "ignoring SessionInbox confirmation without a runtime binding"
2575 );
2576 continue;
2577 };
2578 let Some(bound_run_id) = activation_run_id else {
2579 tracing::warn!(
2580 child_session_id,
2581 "ignoring SessionInbox confirmation without an activation owner"
2582 );
2583 continue;
2584 };
2585 let Some(claim) = inflight_claims.front() else {
2586 tracing::warn!(
2587 child_session_id,
2588 envelope_id = %confirmation.envelope_id,
2589 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
2590 );
2591 continue;
2592 };
2593 let exact = confirmation.target_session_id == logical_session.id
2594 && confirmation.envelope_id == claim.envelope.id.as_str()
2595 && confirmation.canonical_claim_generation == claim.generation
2596 && confirmation.activation_run_id == bound_run_id;
2597 if !exact
2598 || !binding
2599 .router
2600 .owns_run(&logical_session.id, bound_run_id)
2601 .await
2602 {
2603 tracing::warn!(
2604 child_session_id,
2605 expected_target = %logical_session.id,
2606 received_target = %confirmation.target_session_id,
2607 expected_envelope_id = %claim.envelope.id,
2608 received_envelope_id = %confirmation.envelope_id,
2609 expected_generation = claim.generation,
2610 received_generation = confirmation.canonical_claim_generation,
2611 expected_run_id = bound_run_id,
2612 received_run_id = %confirmation.activation_run_id,
2613 "rejecting stale or mismatched SessionInbox admission confirmation"
2614 );
2615 continue;
2616 }
2617 let claim = inflight_claims
2618 .pop_front()
2619 .expect("validated in-flight canonical claim");
2620 checkpoint_and_ack_canonical_claim(binding, logical_session, &claim)
2623 .await?;
2624 if inflight_claims.is_empty() {
2628 forward_next_canonical_claim(
2629 client,
2630 binding,
2631 logical_session,
2632 bound_run_id,
2633 &mut inflight_claims,
2634 )
2635 .await?;
2636 }
2637 }
2638 Ok(Some(ChildFrame::Terminal { status, result, error, .. })) => {
2639 if permission_handshake.is_awaiting() {
2640 return Err(AgentError::LLM(
2641 "actor terminated before permission posture confirmation"
2642 .to_string(),
2643 ));
2644 }
2645 if let Some(claim) = inflight_claims.front() {
2646 return Err(AgentError::LLM(format!(
2647 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
2648 claim.envelope.id
2649 )));
2650 }
2651 return match status {
2652 TerminalStatus::Completed => Ok(result),
2653 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
2654 TerminalStatus::Error => Err(AgentError::LLM(
2655 error.unwrap_or_else(|| "actor child errored".to_string()),
2656 )),
2657 TerminalStatus::Suspended => Err(AgentError::LLM(
2662 "nested sub-agent suspend received but resume transport is not wired"
2663 .to_string(),
2664 )),
2665 };
2666 }
2667 Ok(None) => {
2668 return Err(AgentError::LLM(
2669 "actor child closed before terminal".to_string(),
2670 ));
2671 }
2672 Err(e) => {
2673 return Err(AgentError::LLM(format!("actor transport error: {e}")));
2674 }
2675 }
2676 }
2677 }
2678 }
2679
2680 let _ = client.send(ParentFrame::Cancel).await;
2682 Err(AgentError::Cancelled)
2683}
2684
2685fn project_id_for_actor_run(
2687 session: &Session,
2688) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
2689 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
2690 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
2691 Ok(Some(project_id))
2692 }
2693 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
2694 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
2695 Err(AgentError::LLM(format!(
2696 "child session carries an invalid Project identity '{raw}': {message}"
2697 )))
2698 }
2699 }
2700}
2701
2702fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
2703 LogicalSessionIdentity {
2704 session_id: session.id.clone(),
2705 parent_session_id: session
2706 .parent_session_id
2707 .clone()
2708 .or_else(|| Some(job.parent_session_id.clone())),
2709 root_session_id: if session.root_session_id.trim().is_empty() {
2710 job.parent_session_id.clone()
2711 } else {
2712 session.root_session_id.clone()
2713 },
2714 }
2715}
2716
2717fn extract_assignment(session: &Session) -> String {
2718 session
2719 .messages
2720 .iter()
2721 .rev()
2722 .find(|m| matches!(m.role, Role::User))
2723 .map(|m| m.content.clone())
2724 .unwrap_or_else(|| {
2725 session
2726 .metadata
2727 .get("title")
2728 .cloned()
2729 .unwrap_or_else(|| "Execute task".to_string())
2730 })
2731}
2732
2733#[cfg(test)]
2734mod tests {
2735 use super::*;
2736 use crate::SessionActivationRouter;
2737 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
2738
2739 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
2740 PooledWorker {
2741 worker: SpawnedChild::remote(AgentRecord {
2742 agent_id: mailbox_id.to_string(),
2743 role: "test".to_string(),
2744 labels: Vec::new(),
2745 endpoint: "ws://127.0.0.1:1".to_string(),
2746 pid: 0,
2747 version: String::new(),
2748 started_at: chrono::Utc::now(),
2749 lease_expires_at: chrono::Utc::now(),
2750 }),
2751 mailbox_id: mailbox_id.to_string(),
2752 parked_at: None,
2753 }
2754 }
2755
2756 #[test]
2757 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
2758 let now = Instant::now();
2759 let timeout = Duration::from_secs(300);
2760 assert!(!parked_worker_expired(None, now, timeout));
2761 assert!(!parked_worker_expired(
2762 Some(now - timeout + Duration::from_millis(1)),
2763 now,
2764 timeout
2765 ));
2766 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
2767 }
2768
2769 #[test]
2770 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
2771 let mut pool = WorkerPool::new();
2772 pool.insert(
2773 "fingerprint-a".to_string(),
2774 vec![processless_pool_worker("a")],
2775 );
2776 pool.insert(
2777 "fingerprint-b".to_string(),
2778 vec![processless_pool_worker("b")],
2779 );
2780
2781 assert_eq!(
2782 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
2783 Some("pool_global_limit")
2784 );
2785 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
2786 assert_eq!(
2787 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
2788 Some("pool_per_key_limit")
2789 );
2790 }
2791
2792 #[test]
2793 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
2794 let mut pool = WorkerPool::new();
2795 pool.insert(
2796 "fingerprint-a".to_string(),
2797 vec![processless_pool_worker("dead")],
2798 );
2799
2800 let reaped = take_reapable_workers(
2801 &mut pool,
2802 Instant::now(),
2803 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2804 );
2805
2806 assert!(pool.is_empty());
2807 assert_eq!(reaped.len(), 1);
2808 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
2809 }
2810
2811 #[test]
2812 fn actor_preflight_counts_only_current_session_scoped_denies() {
2813 let config = bamboo_tools::permission::PermissionConfig::new();
2814 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
2815 config.deny_scoped_session_permission(
2816 "target-session",
2817 bamboo_tools::permission::PermissionType::ExecuteCommand,
2818 secret_matcher,
2819 );
2820 config.deny_scoped_session_permission(
2821 "other-session",
2822 bamboo_tools::permission::PermissionType::WriteFile,
2823 "/other/**",
2824 );
2825
2826 assert_eq!(
2827 active_scoped_session_deny_count(&config, "target-session"),
2828 1
2829 );
2830 assert_eq!(
2831 active_scoped_session_deny_count(&config, "clean-session"),
2832 0
2833 );
2834 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
2835 .unwrap_err()
2836 .to_string();
2837 assert!(!error.contains(secret_matcher));
2838 ensure_no_active_scoped_session_denies(&config, "clean-session")
2839 .expect("another session's deny must not block this activation");
2840 }
2841
2842 #[test]
2843 fn permission_posture_event_rejects_oversized_executor_mapping() {
2844 let session = Session::new("mapping-bound", "model");
2845 let event = AgentEvent::PermissionPostureActivated {
2846 session_id: session.id.clone(),
2847 policy_revision: 1,
2848 requested_mode: "default".to_string(),
2849 effective_mode: "default".to_string(),
2850 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
2851 };
2852
2853 assert!(permission_posture_seed_from_event(&session, &event)
2854 .unwrap_err()
2855 .contains("executor mapping"));
2856 }
2857
2858 #[test]
2859 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
2860 let mut session = Session::new("host-resigns-audit", "model");
2861 let hostile_timestamp = "9".repeat(1024);
2862 let event: AgentEvent = serde_json::from_value(serde_json::json!({
2863 "type": "permission_posture_activated",
2864 "session_id": session.id.clone(),
2865 "policy_revision": 31,
2866 "requested_mode": "default",
2867 "effective_mode": "default",
2868 "executor_mapping": "codex_exec:approval_policy=never",
2869 "audit_revision": u64::MAX,
2870 "transitioned_at": hostile_timestamp,
2871 }))
2872 .expect("unknown remote audit fields are ignored by the typed event");
2873 let seed = permission_posture_seed_from_event(&session, &event)
2874 .unwrap()
2875 .expect("permission event");
2876
2877 let host_revision =
2878 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
2879 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
2880 .expect("host-generated complete audit");
2881 assert_eq!(host_audit.audit_revision, host_revision);
2882 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
2883 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
2884 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
2885 }
2886
2887 struct ActorFaultingPersistence {
2888 inner: Arc<bamboo_storage::LockedSessionStore>,
2889 fail_checkpoint_once: std::sync::atomic::AtomicBool,
2890 }
2891
2892 #[async_trait]
2893 impl RuntimeSessionPersistence for ActorFaultingPersistence {
2894 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2895 self.inner.merge_save_runtime(session).await
2896 }
2897
2898 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
2899 if self
2900 .fail_checkpoint_once
2901 .swap(false, std::sync::atomic::Ordering::SeqCst)
2902 {
2903 return Err(std::io::Error::other("injected actor checkpoint failure"));
2904 }
2905 self.inner.checkpoint_runtime_session(session).await
2906 }
2907
2908 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
2909 self.inner.storage().load_session(session_id).await
2910 }
2911 }
2912
2913 struct ActorFailBeforeAckInbox {
2914 inner: Arc<dyn SessionInboxPort>,
2915 fail_once: std::sync::atomic::AtomicBool,
2916 }
2917
2918 #[async_trait]
2919 impl SessionInboxPort for ActorFailBeforeAckInbox {
2920 async fn deliver(
2921 &self,
2922 envelope: &bamboo_domain::SessionMessageEnvelope,
2923 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
2924 self.inner.deliver(envelope).await
2925 }
2926
2927 async fn mark_activation_eligible(
2928 &self,
2929 target_session_id: &str,
2930 generation: u64,
2931 policy: bamboo_domain::SessionActivationPolicy,
2932 ) -> Result<(), bamboo_domain::SessionInboxError> {
2933 self.inner
2934 .mark_activation_eligible(target_session_id, generation, policy)
2935 .await
2936 }
2937
2938 async fn claim(
2939 &self,
2940 target_session_id: &str,
2941 limit: usize,
2942 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
2943 self.inner.claim(target_session_id, limit).await
2944 }
2945
2946 async fn was_admitted(
2947 &self,
2948 target_session_id: &str,
2949 id: &bamboo_domain::SessionMessageId,
2950 ) -> Result<bool, bamboo_domain::SessionInboxError> {
2951 self.inner.was_admitted(target_session_id, id).await
2952 }
2953
2954 async fn ack(
2955 &self,
2956 target_session_id: &str,
2957 claim: &SessionInboxClaim,
2958 ) -> Result<(), bamboo_domain::SessionInboxError> {
2959 if self
2960 .fail_once
2961 .swap(false, std::sync::atomic::Ordering::SeqCst)
2962 {
2963 return Err(bamboo_domain::SessionInboxError::Storage(
2964 "injected actor pre-ack failure".to_string(),
2965 ));
2966 }
2967 self.inner.ack(target_session_id, claim).await
2968 }
2969
2970 async fn inspect(
2971 &self,
2972 target_session_id: &str,
2973 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
2974 self.inner.inspect(target_session_id).await
2975 }
2976 }
2977
2978 async fn actor_inbox_fixture(
2979 session_id: &str,
2980 ) -> (
2981 tempfile::TempDir,
2982 Arc<bamboo_storage::SessionStoreV2>,
2983 Arc<bamboo_storage::LockedSessionStore>,
2984 Arc<dyn SessionInboxPort>,
2985 Session,
2986 SessionInboxClaim,
2987 ) {
2988 let temp = tempfile::tempdir().unwrap();
2989 let store = Arc::new(
2990 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
2991 .await
2992 .unwrap(),
2993 );
2994 let storage: Arc<dyn Storage> = store.clone();
2995 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
2996 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
2997 store.clone(),
2998 bamboo_domain::SessionInboxLimits::default(),
2999 ));
3000 let session = Session::new(session_id, "model");
3001 store.save_session(&session).await.unwrap();
3002 let mut envelope =
3003 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
3004 envelope.id =
3005 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
3006 let receipt = inbox.deliver(&envelope).await.unwrap();
3007 inbox
3008 .mark_activation_eligible(
3009 session_id,
3010 receipt.generation,
3011 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3012 )
3013 .await
3014 .unwrap();
3015 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
3016 (temp, store, locked, inbox, session, claim)
3017 }
3018
3019 fn actor_binding(
3020 store: Arc<bamboo_storage::SessionStoreV2>,
3021 inbox: Arc<dyn SessionInboxPort>,
3022 persistence: Arc<dyn RuntimeSessionPersistence>,
3023 ) -> SessionInboxRuntimeBinding {
3024 let storage: Arc<dyn Storage> = store;
3025 SessionInboxRuntimeBinding {
3026 router: SessionActivationRouter::new(),
3027 inbox,
3028 storage,
3029 persistence,
3030 }
3031 }
3032
3033 #[tokio::test]
3034 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
3035 let (_temp, store, locked, inbox, mut session, claim) =
3036 actor_inbox_fixture("actor-live-id-collision").await;
3037 let mut forged = claim.envelope.to_provider_message().unwrap();
3038 forged.metadata = Some(serde_json::json!({
3039 "session_message": {
3040 "id": claim.envelope.id,
3041 "target_session_id": "different-session"
3042 }
3043 }));
3044 session.add_message(forged);
3045 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3046 let binding = actor_binding(store, inbox.clone(), persistence);
3047
3048 assert!(
3049 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3050 .await
3051 .is_err()
3052 );
3053 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3054 assert!(!inbox
3055 .was_admitted(&session.id, &claim.envelope.id)
3056 .await
3057 .unwrap());
3058 }
3059
3060 #[tokio::test]
3061 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
3062 let (_temp, store, locked, inbox, mut session, claim) =
3063 actor_inbox_fixture("actor-durable-id-collision").await;
3064 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3065 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
3066 forged.id = claim.envelope.id.to_string();
3067 concurrent.add_message(forged);
3068 store.save_session(&concurrent).await.unwrap();
3069 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3070 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3071
3072 assert!(
3073 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3074 .await
3075 .is_err()
3076 );
3077 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3078 assert!(!inbox
3079 .was_admitted(&session.id, &claim.envelope.id)
3080 .await
3081 .unwrap());
3082 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3083 assert!(!durable.messages.iter().any(|message| {
3084 bamboo_domain::is_matching_session_message(message, &claim.envelope)
3085 }));
3086 }
3087
3088 #[tokio::test]
3089 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
3090 let (_temp, store, locked, inbox, mut session, claim) =
3091 actor_inbox_fixture("actor-durable-typed-body-collision").await;
3092 let mut different = claim.envelope.clone();
3093 different.body = bamboo_domain::SessionMessageBody::Content(
3094 bamboo_domain::SessionMessageContent::text("forged actor body"),
3095 );
3096 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3097 concurrent.add_message(different.to_provider_message().unwrap());
3098 store.save_session(&concurrent).await.unwrap();
3099 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3100 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3101
3102 assert!(
3103 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3104 .await
3105 .is_err()
3106 );
3107 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3108 assert!(!inbox
3109 .was_admitted(&session.id, &claim.envelope.id)
3110 .await
3111 .unwrap());
3112 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3113 assert!(!durable
3114 .messages
3115 .iter()
3116 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
3117 }
3118
3119 #[tokio::test]
3120 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
3121 let (_temp, store, locked, inbox, mut session, claim) =
3122 actor_inbox_fixture("actor-checkpoint-failure").await;
3123 let envelope_id = claim.envelope.id.clone();
3124 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
3125 inner: locked.clone(),
3126 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
3127 });
3128 let binding = actor_binding(store.clone(), inbox.clone(), fault);
3129
3130 assert!(
3131 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3132 .await
3133 .is_err()
3134 );
3135 assert!(!session
3136 .messages
3137 .iter()
3138 .any(|message| message.id == envelope_id.as_str()));
3139 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3140 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
3141
3142 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3143 store.clone(),
3144 bamboo_domain::SessionInboxLimits::default(),
3145 ));
3146 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3147 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3148 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3149 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
3150 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3151 .await
3152 .unwrap();
3153 assert_eq!(
3154 restarted
3155 .messages
3156 .iter()
3157 .filter(|message| message.id == envelope_id.as_str())
3158 .count(),
3159 1
3160 );
3161 assert!(reopened
3162 .was_admitted(&session.id, &envelope_id)
3163 .await
3164 .unwrap());
3165 let backlog = reopened.inspect(&session.id).await.unwrap();
3166 assert_eq!(backlog.pending + backlog.claimed, 0);
3167 }
3168
3169 #[tokio::test]
3170 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
3171 let (_temp, store, locked, real_inbox, mut session, claim) =
3172 actor_inbox_fixture("actor-pre-ack-failure").await;
3173 let envelope_id = claim.envelope.id.clone();
3174 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
3175 inner: real_inbox.clone(),
3176 fail_once: std::sync::atomic::AtomicBool::new(true),
3177 });
3178 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
3179 let binding = actor_binding(store.clone(), faulted, persistence);
3180
3181 assert!(
3182 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3183 .await
3184 .is_err()
3185 );
3186 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3187 assert_eq!(
3188 durable
3189 .messages
3190 .iter()
3191 .filter(|message| message.id == envelope_id.as_str())
3192 .count(),
3193 1
3194 );
3195 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
3196 assert!(!real_inbox
3197 .was_admitted(&session.id, &envelope_id)
3198 .await
3199 .unwrap());
3200
3201 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3202 store.clone(),
3203 bamboo_domain::SessionInboxLimits::default(),
3204 ));
3205 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3206 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3207 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3208 let mut restarted = durable;
3209 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3210 .await
3211 .unwrap();
3212 assert_eq!(
3213 restarted
3214 .messages
3215 .iter()
3216 .filter(|message| message.id == envelope_id.as_str())
3217 .count(),
3218 1
3219 );
3220 assert!(reopened
3221 .was_admitted(&session.id, &envelope_id)
3222 .await
3223 .unwrap());
3224 let backlog = reopened.inspect(&session.id).await.unwrap();
3225 assert_eq!(backlog.pending + backlog.claimed, 0);
3226 }
3227
3228 struct ConfirmationSequenceLink {
3229 frames: VecDeque<ChildFrame>,
3230 sent: Vec<ParentFrame>,
3231 }
3232
3233 #[async_trait]
3234 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
3235 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
3236 self.sent.push(frame);
3237 Ok(())
3238 }
3239
3240 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
3241 match self.frames.pop_front() {
3242 Some(frame) => Ok(Some(frame)),
3243 None => std::future::pending().await,
3244 }
3245 }
3246 }
3247
3248 fn admission_confirmation(
3249 session_id: &str,
3250 claim: &SessionInboxClaim,
3251 run_id: &str,
3252 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3253 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3254 target_session_id: session_id.to_string(),
3255 envelope_id: claim.envelope.id.to_string(),
3256 canonical_claim_generation: claim.generation,
3257 activation_run_id: run_id.to_string(),
3258 }
3259 }
3260
3261 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
3262 ExpectedPermissionPosture {
3263 policy_revision,
3264 resolution: bamboo_domain::PermissionModeResolution {
3265 requested: bamboo_domain::SessionPermissionMode::Default,
3266 effective: bamboo_domain::PermissionMode::Default,
3267 },
3268 expected_audit_revision: None,
3269 executor_mapping: "test_actor:permission_mode=default".to_string(),
3270 }
3271 }
3272
3273 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
3274 ChildFrame::Event {
3275 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3276 session_id: session_id.to_string(),
3277 policy_revision,
3278 requested_mode: "default".to_string(),
3279 effective_mode: "default".to_string(),
3280 executor_mapping: "test_actor:permission_mode=default".to_string(),
3281 })
3282 .expect("serialize permission posture event"),
3283 }
3284 }
3285
3286 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
3287 ChildFrame::Event {
3288 event: serde_json::to_value(event).expect("serialize actor event"),
3289 }
3290 }
3291
3292 fn completed_actor_frame() -> ChildFrame {
3293 ChildFrame::Terminal {
3294 status: TerminalStatus::Completed,
3295 result: Some("done".to_string()),
3296 error: None,
3297 transcript: Vec::new(),
3298 }
3299 }
3300
3301 async fn drive_permission_handshake_frames(
3302 session_id: &str,
3303 frames: impl IntoIterator<Item = ChildFrame>,
3304 expected: ExpectedPermissionPosture,
3305 ) -> (
3306 crate::runtime::runner::Result<Option<String>>,
3307 Session,
3308 Vec<AgentEvent>,
3309 Vec<ParentFrame>,
3310 ) {
3311 let mut link = ConfirmationSequenceLink {
3312 frames: frames.into_iter().collect(),
3313 sent: Vec::new(),
3314 };
3315 let (event_tx, mut event_rx) = mpsc::channel(16);
3316 let cancel = CancellationToken::new();
3317 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3318 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3319 let mut session = Session::new(session_id, "model");
3320 let result = drive(ActorDriveContext {
3321 client: &mut link,
3322 parent_session_id: "permission-parent",
3323 child_session_id: session_id,
3324 child_attempt: 0,
3325 approval_registry: None,
3326 approval_decider: None,
3327 approval_reviewer: None,
3328 escalation_bridge: None,
3329 event_tx: &event_tx,
3330 cancel_token: &cancel,
3331 live_rx: &mut live_rx,
3332 delivery_rx: &mut delivery_rx,
3333 logical_session: &mut session,
3334 expected_permission_posture: Some(expected),
3335 session_inbox_runtime: None,
3336 activation_run_id: None,
3337 initial_inflight_claims: VecDeque::new(),
3338 first_frame_timeout: Some(Duration::from_secs(1)),
3339 })
3340 .await;
3341 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
3342 (result, session, events, link.sent)
3343 }
3344
3345 #[tokio::test]
3346 async fn actor_permission_handshake_rejects_terminal_without_posture() {
3347 let session_id = "permission-missing";
3348 let (result, session, events, _) = drive_permission_handshake_frames(
3349 session_id,
3350 [completed_actor_frame()],
3351 expected_default_permission_posture(7),
3352 )
3353 .await;
3354
3355 assert!(result
3356 .unwrap_err()
3357 .to_string()
3358 .contains("terminated before permission posture confirmation"));
3359 assert!(events.is_empty());
3360 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3361 }
3362
3363 #[tokio::test]
3364 async fn actor_permission_handshake_rejects_malformed_agent_event() {
3365 let session_id = "permission-malformed";
3366 let (result, session, events, _) = drive_permission_handshake_frames(
3367 session_id,
3368 [ChildFrame::Event {
3369 event: serde_json::json!({"type": "token", "content": 42}),
3370 }],
3371 expected_default_permission_posture(7),
3372 )
3373 .await;
3374
3375 assert!(result
3376 .unwrap_err()
3377 .to_string()
3378 .contains("malformed AgentEvent"));
3379 assert!(events.is_empty());
3380 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3381 }
3382
3383 #[tokio::test]
3384 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
3385 let session_id = "permission-early-event";
3386 let early_events = [
3387 (
3388 "progress",
3389 AgentEvent::RunnerProgress {
3390 session_id: session_id.to_string(),
3391 round_count: 1,
3392 },
3393 ),
3394 (
3395 "token",
3396 AgentEvent::Token {
3397 content: "must-not-forward".to_string(),
3398 },
3399 ),
3400 (
3401 "tool",
3402 AgentEvent::ToolStart {
3403 tool_call_id: "early-tool".to_string(),
3404 tool_name: "Read".to_string(),
3405 arguments: serde_json::json!({"file_path": "README.md"}),
3406 },
3407 ),
3408 ];
3409 for (kind, event) in early_events {
3410 let (result, session, events, _) = drive_permission_handshake_frames(
3411 session_id,
3412 [
3413 actor_event_frame(event),
3414 permission_posture_frame(session_id, 7),
3415 completed_actor_frame(),
3416 ],
3417 expected_default_permission_posture(7),
3418 )
3419 .await;
3420
3421 assert!(
3422 result
3423 .unwrap_err()
3424 .to_string()
3425 .contains("execution event before permission posture confirmation"),
3426 "{kind} must fail closed before posture"
3427 );
3428 assert!(events.is_empty(), "{kind} must not be forwarded");
3429 assert!(
3430 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3431 "{kind} must not advance the permission audit"
3432 );
3433 }
3434 }
3435
3436 #[tokio::test]
3437 async fn actor_permission_handshake_rejects_approval_before_posture() {
3438 let session_id = "permission-early-approval";
3439 let (result, session, events, sent) = drive_permission_handshake_frames(
3440 session_id,
3441 [ChildFrame::ApprovalRequest {
3442 id: "approval-before-posture".to_string(),
3443 body: serde_json::json!({
3444 "tool_name": "Bash",
3445 "permission": "execute",
3446 "resource": "echo must-not-run"
3447 }),
3448 }],
3449 expected_default_permission_posture(7),
3450 )
3451 .await;
3452
3453 assert!(result
3454 .unwrap_err()
3455 .to_string()
3456 .contains("requested approval before permission posture confirmation"));
3457 assert!(events.is_empty());
3458 assert!(
3459 sent.is_empty(),
3460 "an unconfirmed actor must receive no approval reply"
3461 );
3462 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3463 }
3464
3465 #[tokio::test]
3466 async fn actor_permission_handshake_rejects_mismatched_posture() {
3467 let session_id = "permission-mismatch";
3468 let (result, session, events, _) = drive_permission_handshake_frames(
3469 session_id,
3470 [permission_posture_frame(session_id, 8)],
3471 expected_default_permission_posture(7),
3472 )
3473 .await;
3474
3475 assert!(result
3476 .unwrap_err()
3477 .to_string()
3478 .contains("does not match the host-dispatched policy"));
3479 assert!(events.is_empty());
3480 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3481 }
3482
3483 #[tokio::test]
3484 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
3485 let session_id = "permission-hostile-mapping";
3486 for hostile_mapping in [
3487 "wrong_executor:permission_mode=default",
3488 "test_actor:permission_mode=default;credential=must-not-persist",
3489 ] {
3490 let frame = ChildFrame::Event {
3491 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3492 session_id: session_id.to_string(),
3493 policy_revision: 7,
3494 requested_mode: "default".to_string(),
3495 effective_mode: "default".to_string(),
3496 executor_mapping: hostile_mapping.to_string(),
3497 })
3498 .expect("serialize hostile posture fixture"),
3499 };
3500 let (result, session, events, _) = drive_permission_handshake_frames(
3501 session_id,
3502 [frame],
3503 expected_default_permission_posture(7),
3504 )
3505 .await;
3506
3507 let error = result.unwrap_err().to_string();
3508 assert!(error.contains("host-dispatched executor mapping"));
3509 assert!(
3510 !error.contains(hostile_mapping),
3511 "untrusted mapping must not be reflected in host errors"
3512 );
3513 assert!(events.is_empty());
3514 assert!(
3515 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3516 "untrusted mapping must not reach durable or in-memory audit state"
3517 );
3518 }
3519 }
3520
3521 #[tokio::test]
3522 async fn actor_permission_handshake_rejects_duplicate_posture() {
3523 let session_id = "permission-duplicate";
3524 let (result, session, events, _) = drive_permission_handshake_frames(
3525 session_id,
3526 [
3527 permission_posture_frame(session_id, 7),
3528 permission_posture_frame(session_id, 7),
3529 completed_actor_frame(),
3530 ],
3531 expected_default_permission_posture(7),
3532 )
3533 .await;
3534
3535 assert!(result
3536 .unwrap_err()
3537 .to_string()
3538 .contains("duplicate permission posture activation"));
3539 assert_eq!(
3540 events.len(),
3541 1,
3542 "only the confirmed posture may be forwarded"
3543 );
3544 assert!(matches!(
3545 events[0],
3546 AgentEvent::PermissionPostureActivated { .. }
3547 ));
3548 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3549 .expect("the first matching posture must be recorded");
3550 assert_eq!(audit.policy_revision, 7);
3551 }
3552
3553 #[tokio::test]
3554 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
3555 let session_id = "permission-happy";
3556 let (result, session, events, _) = drive_permission_handshake_frames(
3557 session_id,
3558 [
3559 permission_posture_frame(session_id, 7),
3560 actor_event_frame(AgentEvent::RunnerProgress {
3561 session_id: session_id.to_string(),
3562 round_count: 1,
3563 }),
3564 actor_event_frame(AgentEvent::Token {
3565 content: "working".to_string(),
3566 }),
3567 actor_event_frame(AgentEvent::ToolStart {
3568 tool_call_id: "tool-1".to_string(),
3569 tool_name: "Read".to_string(),
3570 arguments: serde_json::json!({"file_path": "README.md"}),
3571 }),
3572 completed_actor_frame(),
3573 ],
3574 expected_default_permission_posture(7),
3575 )
3576 .await;
3577
3578 assert_eq!(result.unwrap().as_deref(), Some("done"));
3579 assert!(matches!(
3580 events.as_slice(),
3581 [
3582 AgentEvent::PermissionPostureActivated { .. },
3583 AgentEvent::RunnerProgress { .. },
3584 AgentEvent::Token { .. },
3585 AgentEvent::ToolStart { .. }
3586 ]
3587 ));
3588 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3589 .expect("matching posture must be recorded before execution events are accepted");
3590 assert_eq!(audit.policy_revision, 7);
3591 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
3592 }
3593
3594 #[tokio::test]
3595 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
3596 let temp = tempfile::tempdir().unwrap();
3597 let store = Arc::new(
3598 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3599 .await
3600 .unwrap(),
3601 );
3602 let storage: Arc<dyn Storage> = store.clone();
3603 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
3604 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3605 store.clone(),
3606 bamboo_domain::SessionInboxLimits::default(),
3607 ));
3608 let session_id = "actor-confirmation-order";
3609 let run_id = "actor-run-current";
3610 let mut session = Session::new(session_id, "model");
3611 store.save_session(&session).await.unwrap();
3612 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
3613 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
3614 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
3615 inbox.deliver(&envelope).await.unwrap();
3616 }
3617 inbox
3618 .mark_activation_eligible(
3619 session_id,
3620 2,
3621 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3622 )
3623 .await
3624 .unwrap();
3625 let router = SessionActivationRouter::new();
3626 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
3627 let binding = SessionInboxRuntimeBinding {
3628 router,
3629 inbox: inbox.clone(),
3630 storage,
3631 persistence: locked,
3632 };
3633 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX)
3634 .await
3635 .unwrap();
3636 assert_eq!(
3637 pairs
3638 .iter()
3639 .map(|(claim, _)| claim.envelope.id.as_str())
3640 .collect::<Vec<_>>(),
3641 vec!["actor-first", "actor-second"]
3642 );
3643 let seeded = store.load_session(session_id).await.unwrap().unwrap();
3644 assert_eq!(
3645 seeded
3646 .messages
3647 .iter()
3648 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3649 .map(|message| message.id.as_str())
3650 .collect::<Vec<_>>(),
3651 vec!["actor-first", "actor-second"],
3652 "host context must be durable before actor dispatch"
3653 );
3654 for (claim, _) in &pairs {
3655 assert_eq!(
3656 seeded
3657 .messages
3658 .iter()
3659 .filter(|message| bamboo_domain::is_matching_session_message(
3660 message,
3661 &claim.envelope
3662 ))
3663 .count(),
3664 1,
3665 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
3666 claim.envelope.id
3667 );
3668 }
3669 assert!(
3670 seeded.session_inbox_admission().is_none_or(|cursor| {
3671 !cursor.contains(&pairs[0].0.envelope.id)
3672 && !cursor.contains(&pairs[1].0.envelope.id)
3673 }),
3674 "pre-dispatch transcript seeding must not forge worker confirmation"
3675 );
3676 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
3677 let claims = pairs
3678 .into_iter()
3679 .map(|(claim, _)| claim)
3680 .collect::<VecDeque<_>>();
3681 let first = claims[0].clone();
3682 let second = claims[1].clone();
3683 let mut stale = admission_confirmation(session_id, &first, "stale-run");
3684 stale.canonical_claim_generation = second.generation;
3685 let mut link = ConfirmationSequenceLink {
3686 frames: VecDeque::from([
3687 ChildFrame::SessionMessageAdmitted {
3688 confirmation: stale,
3689 },
3690 ChildFrame::SessionMessageAdmitted {
3691 confirmation: admission_confirmation(session_id, &first, run_id),
3692 },
3693 ChildFrame::SessionMessageAdmitted {
3694 confirmation: admission_confirmation(session_id, &second, run_id),
3695 },
3696 ChildFrame::Terminal {
3697 status: TerminalStatus::Completed,
3698 result: Some("done".to_string()),
3699 error: None,
3700 transcript: Vec::new(),
3701 },
3702 ]),
3703 sent: Vec::new(),
3704 };
3705 let (event_tx, _event_rx) = mpsc::channel(8);
3706 let cancel = CancellationToken::new();
3707 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3708 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3709 let result = drive(ActorDriveContext {
3710 client: &mut link,
3711 parent_session_id: "parent",
3712 child_session_id: session_id,
3713 child_attempt: 0,
3714 approval_registry: None,
3715 approval_decider: None,
3716 approval_reviewer: None,
3717 escalation_bridge: None,
3718 event_tx: &event_tx,
3719 cancel_token: &cancel,
3720 live_rx: &mut live_rx,
3721 delivery_rx: &mut delivery_rx,
3722 logical_session: &mut session,
3723 expected_permission_posture: None,
3724 session_inbox_runtime: Some(&binding),
3725 activation_run_id: Some(run_id),
3726 initial_inflight_claims: claims,
3727 first_frame_timeout: Some(Duration::from_secs(1)),
3728 })
3729 .await
3730 .unwrap();
3731 assert_eq!(result.as_deref(), Some("done"));
3732 assert_eq!(
3733 session
3734 .messages
3735 .iter()
3736 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3737 .map(|message| message.id.as_str())
3738 .collect::<Vec<_>>(),
3739 vec!["actor-first", "actor-second"]
3740 );
3741 let backlog = inbox.inspect(session_id).await.unwrap();
3742 assert_eq!(backlog.pending + backlog.claimed, 0);
3743 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
3744 let cursor = confirmed
3745 .session_inbox_admission()
3746 .expect("exact worker confirmation must checkpoint the admission cursor");
3747 assert!(cursor.contains(&first.envelope.id));
3748 assert!(cursor.contains(&second.envelope.id));
3749 for claim in [&first, &second] {
3750 assert_eq!(
3751 confirmed
3752 .messages
3753 .iter()
3754 .filter(|message| bamboo_domain::is_matching_session_message(
3755 message,
3756 &claim.envelope
3757 ))
3758 .count(),
3759 1,
3760 "confirmation must retain one exact canonical marker for {}",
3761 claim.envelope.id
3762 );
3763 }
3764 assert!(inbox
3765 .was_admitted(session_id, &first.envelope.id)
3766 .await
3767 .unwrap());
3768 assert!(inbox
3769 .was_admitted(session_id, &second.envelope.id)
3770 .await
3771 .unwrap());
3772 owner_registration.begin_finalization().await;
3773 owner_registration.finish(2).await.unwrap();
3774 }
3775
3776 #[derive(Default)]
3777 struct RecordingCodexTokenAuthority {
3778 issued_for: std::sync::Mutex<Vec<String>>,
3779 revoked: std::sync::Mutex<Vec<String>>,
3780 }
3781
3782 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
3783 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
3784 self.issued_for
3785 .lock()
3786 .expect("issued fixture lock")
3787 .push(session_id.to_string());
3788 Ok(IssuedCodexRunToken {
3789 token_id: format!("id-{session_id}"),
3790 token: format!("bcx1_secret-{session_id}"),
3791 })
3792 }
3793
3794 fn revoke(&self, token_id: &str) {
3795 self.revoked
3796 .lock()
3797 .expect("revoked fixture lock")
3798 .push(token_id.to_string());
3799 }
3800 }
3801
3802 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
3803 let bamboo_mode = auth_mode == Some("bamboo")
3804 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
3805 ExecutorSpec::Codex {
3806 binary: None,
3807 model: None,
3808 mode: None,
3809 sandbox: None,
3810 inherit_user_config,
3811 auth_mode: auth_mode.map(str::to_string),
3812 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
3813 wire_api: Some("responses".to_string()),
3814 provider_key_ref: None,
3815 forward_env: None,
3816 approval_policy: None,
3817 network_access: None,
3818 allow_danger_bypass: None,
3819 permission_profile: None,
3820 workspace_owned: None,
3821 }
3822 }
3823
3824 fn permission_resolution(
3825 requested: bamboo_domain::SessionPermissionMode,
3826 effective: bamboo_domain::PermissionMode,
3827 ) -> bamboo_domain::PermissionModeResolution {
3828 bamboo_domain::PermissionModeResolution {
3829 requested,
3830 effective,
3831 }
3832 }
3833
3834 #[test]
3835 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
3836 use bamboo_domain::{PermissionMode, SessionPermissionMode};
3837
3838 let default =
3839 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
3840 assert_eq!(
3841 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
3842 .unwrap()
3843 .as_deref(),
3844 Some("bamboo_runtime:default")
3845 );
3846 assert_eq!(
3847 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
3848 None,
3849 "transport-only Echo must not claim the typed permission contract"
3850 );
3851 assert_eq!(
3852 expected_permission_executor_mapping(
3853 &ExecutorSpec::CliAdapter {
3854 command: "must-not-appear-in-contract".to_string(),
3855 args: vec!["credential-like-argument".to_string()],
3856 },
3857 default,
3858 false,
3859 )
3860 .unwrap(),
3861 None,
3862 "unimplemented CliAdapter must not leak command data into a contract"
3863 );
3864
3865 let claude = ExecutorSpec::ClaudeCode {
3866 binary: None,
3867 model: None,
3868 permission_mode: Some("default".to_string()),
3869 inherit_user_config: None,
3870 forward_env: None,
3871 };
3872 for (resolution, mapping) in [
3873 (
3874 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
3875 "claude_code:permission_mode=plan",
3876 ),
3877 (
3878 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
3879 "claude_code:permission_mode=bypassPermissions",
3880 ),
3881 (
3882 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
3883 "claude_code:permission_mode=acceptEdits",
3884 ),
3885 (
3886 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
3887 "claude_code:permission_mode=dontAsk",
3888 ),
3889 (
3890 permission_resolution(
3891 SessionPermissionMode::Bypass,
3892 PermissionMode::BypassPermissions,
3893 ),
3894 "claude_code:permission_mode=default",
3895 ),
3896 ] {
3897 assert_eq!(
3898 expected_permission_executor_mapping(&claude, resolution, false)
3899 .unwrap()
3900 .as_deref(),
3901 Some(mapping)
3902 );
3903 }
3904 assert_eq!(
3905 expected_permission_executor_mapping(&claude, default, true)
3906 .unwrap()
3907 .as_deref(),
3908 Some("claude_code:blocked_explicit_deny")
3909 );
3910
3911 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
3912 if let ExecutorSpec::Codex {
3913 approval_policy, ..
3914 } = &mut codex_exec
3915 {
3916 *approval_policy = Some("on-failure".to_string());
3917 }
3918 assert_eq!(
3919 expected_permission_executor_mapping(&codex_exec, default, false)
3920 .unwrap()
3921 .as_deref(),
3922 Some("codex_exec:approval_policy=on-failure")
3923 );
3924 assert_eq!(
3925 expected_permission_executor_mapping(
3926 &codex_exec,
3927 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
3928 false,
3929 )
3930 .unwrap()
3931 .as_deref(),
3932 Some("codex_exec:approval_policy=never")
3933 );
3934 assert_eq!(
3935 expected_permission_executor_mapping(&codex_exec, default, true)
3936 .unwrap()
3937 .as_deref(),
3938 Some("codex_exec:blocked_explicit_deny")
3939 );
3940
3941 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
3942 if let ExecutorSpec::Codex {
3943 mode,
3944 approval_policy,
3945 ..
3946 } = &mut codex_app_server
3947 {
3948 *mode = Some("app_server".to_string());
3949 *approval_policy = Some("on-request".to_string());
3950 }
3951 assert_eq!(
3952 expected_permission_executor_mapping(&codex_app_server, default, false)
3953 .unwrap()
3954 .as_deref(),
3955 Some("codex_app_server:approvalPolicy=on-request")
3956 );
3957 assert_eq!(
3958 expected_permission_executor_mapping(
3959 &codex_app_server,
3960 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
3961 false,
3962 )
3963 .unwrap()
3964 .as_deref(),
3965 Some("codex_app_server:approvalPolicy=never")
3966 );
3967 assert_eq!(
3968 expected_permission_executor_mapping(&codex_app_server, default, true)
3969 .unwrap()
3970 .as_deref(),
3971 Some("codex_app_server:blocked_explicit_deny")
3972 );
3973 }
3974
3975 #[test]
3976 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
3977 let project = tempfile::tempdir().unwrap();
3978 let managed = project.path().join(".bamboo/worktree/child-571");
3979 std::fs::create_dir_all(&managed).unwrap();
3980 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
3981 let marker = project
3982 .path()
3983 .join(".bamboo/worktree/.bamboo-owned/child-571");
3984 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
3985 std::fs::write(&marker, "bamboo/child-571").unwrap();
3986 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
3987 let nested = managed.join("nested/path");
3988 std::fs::create_dir_all(&nested).unwrap();
3989 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
3990
3991 let arbitrary = tempfile::tempdir().unwrap();
3992 assert!(!workspace_is_bamboo_owned(
3993 arbitrary.path().to_str().unwrap()
3994 ));
3995 }
3996
3997 #[test]
3998 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
3999 let authority = Arc::new(RecordingCodexTokenAuthority::default());
4000 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4001
4002 let (secrets, guard) = build_codex_run_secrets(
4003 &codex_executor(Some("bamboo"), None),
4004 Some(authority_dyn),
4005 "child-570",
4006 )
4007 .unwrap();
4008
4009 let token = secrets
4010 .codex_provider_token
4011 .as_ref()
4012 .expect("bamboo mode mints a token");
4013 assert_eq!(token.expose(), "bcx1_secret-child-570");
4014 assert!(!format!("{token:?}").contains("secret-child-570"));
4015 assert_eq!(
4016 authority.issued_for.lock().unwrap().as_slice(),
4017 ["child-570"]
4018 );
4019 assert!(authority.revoked.lock().unwrap().is_empty());
4020
4021 drop(guard);
4022 assert_eq!(
4023 authority.revoked.lock().unwrap().as_slice(),
4024 ["id-child-570"]
4025 );
4026 }
4027
4028 #[test]
4029 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
4030 let authority = Arc::new(RecordingCodexTokenAuthority::default());
4031 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4032 let (secrets, guard) = build_codex_run_secrets(
4033 &codex_executor(Some("custom"), None),
4034 Some(authority_dyn),
4035 "child-custom",
4036 )
4037 .unwrap();
4038 assert!(secrets.codex_provider_token.is_none());
4039 assert!(guard.is_none());
4040 assert!(authority.issued_for.lock().unwrap().is_empty());
4041
4042 let error = build_codex_run_secrets(
4043 &codex_executor(Some("bamboo"), None),
4044 None,
4045 "child-no-authority",
4046 )
4047 .err()
4048 .expect("bamboo mode without an authority must fail closed");
4049 assert!(error.to_string().contains("per-run token authority"));
4050 }
4051
4052 #[test]
4053 fn codex_provisioning_never_leaks_the_session_provider_credential() {
4054 let credentials = vec![ScopedCredential {
4055 provider: "openai".to_string(),
4056 api_key: "upstream-secret-must-not-cross".to_string(),
4057 base_url: None,
4058 provider_type: Some("openai".to_string()),
4059 credential_ref: Some("provider.openai.api_key".to_string()),
4060 }];
4061
4062 for (mode, label) in [
4063 (Some("inherit"), "inherit"),
4064 (Some("api_key"), "api_key"),
4065 (Some("bamboo"), "bamboo"),
4066 (None, "default-bamboo"),
4067 ] {
4068 let runner = ActorChildRunner::new(
4069 format!("codex-{label}-test"),
4070 PathBuf::from("/bin/false"),
4071 Vec::new(),
4072 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
4073 codex_executor(mode, None),
4074 credentials.clone(),
4075 "openai".to_string(),
4076 1,
4077 );
4078 let mut session = Session::new(format!("child-{label}"), "model");
4079 session.add_message(bamboo_agent_core::Message::user("test"));
4080 let spec = runner.build_spec(
4081 &session,
4082 &crate::runtime::execution::SpawnJob {
4083 parent_session_id: "parent".to_string(),
4084 child_session_id: format!("child-{label}"),
4085 model: "gpt-5.4".to_string(),
4086 disabled_tools: None,
4087 },
4088 );
4089 assert!(
4090 spec.secrets.provider_credentials.is_empty(),
4091 "{label} Codex must not receive the session provider key"
4092 );
4093 }
4094 }
4095
4096 #[test]
4097 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
4098 let credentials = vec![
4099 ScopedCredential {
4100 provider: "openai".to_string(),
4101 api_key: "selected-openai-secret".to_string(),
4102 base_url: None,
4103 provider_type: Some("openai".to_string()),
4104 credential_ref: Some("provider.openai.api_key".to_string()),
4105 },
4106 ScopedCredential {
4107 provider: "other".to_string(),
4108 api_key: "unrelated-secret".to_string(),
4109 base_url: None,
4110 provider_type: Some("openai".to_string()),
4111 credential_ref: Some("provider.other.api_key".to_string()),
4112 },
4113 ];
4114 let runner = ActorChildRunner::new(
4115 "echo-test".to_string(),
4116 PathBuf::from("/bin/false"),
4117 Vec::new(),
4118 std::env::temp_dir().join("bamboo-echo-provider-570"),
4119 ExecutorSpec::Echo,
4120 credentials,
4121 "openai".to_string(),
4122 1,
4123 );
4124 let spec = runner.build_spec(
4125 &Session::new("child-echo", "model"),
4126 &crate::runtime::execution::SpawnJob {
4127 parent_session_id: "parent".to_string(),
4128 child_session_id: "child-echo".to_string(),
4129 model: "gpt-5.4".to_string(),
4130 disabled_tools: None,
4131 },
4132 );
4133
4134 assert_eq!(spec.secrets.provider_credentials.len(), 1);
4135 assert_eq!(
4136 spec.secrets.provider_credentials[0].api_key,
4137 "selected-openai-secret"
4138 );
4139 }
4140
4141 #[test]
4142 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
4143 let mut executor = codex_executor(Some("custom"), None);
4144 if let ExecutorSpec::Codex {
4145 base_url,
4146 provider_key_ref,
4147 ..
4148 } = &mut executor
4149 {
4150 *base_url = Some("https://provider.example/v1".to_string());
4151 *provider_key_ref = Some("provider.custom.api_key".to_string());
4152 }
4153 let credentials = vec![
4154 ScopedCredential {
4155 provider: "openai".to_string(),
4156 api_key: "session-provider-secret".to_string(),
4157 base_url: None,
4158 provider_type: Some("openai".to_string()),
4159 credential_ref: Some("provider.openai.api_key".to_string()),
4160 },
4161 ScopedCredential {
4162 provider: "custom".to_string(),
4163 api_key: "selected-secret".to_string(),
4164 base_url: None,
4165 provider_type: Some("openai".to_string()),
4166 credential_ref: Some("provider.custom.api_key".to_string()),
4167 },
4168 ScopedCredential {
4169 provider: "other".to_string(),
4170 api_key: "unrelated-secret".to_string(),
4171 base_url: None,
4172 provider_type: Some("openai".to_string()),
4173 credential_ref: Some("provider.other.api_key".to_string()),
4174 },
4175 ];
4176 let runner = ActorChildRunner::new(
4177 "codex-test".to_string(),
4178 PathBuf::from("/bin/false"),
4179 Vec::new(),
4180 std::env::temp_dir().join("bamboo-codex-570"),
4181 executor,
4182 credentials,
4183 "openai".to_string(),
4184 1,
4185 );
4186 let mut session = Session::new("child-custom", "model");
4187 session.add_message(bamboo_agent_core::Message::user("test"));
4188 let spec = runner.build_spec(
4189 &session,
4190 &crate::runtime::execution::SpawnJob {
4191 parent_session_id: "parent".to_string(),
4192 child_session_id: "child-custom".to_string(),
4193 model: "gpt-5.4".to_string(),
4194 disabled_tools: None,
4195 },
4196 );
4197
4198 assert_eq!(spec.secrets.provider_credentials.len(), 1);
4199 assert_eq!(
4200 spec.secrets.provider_credentials[0]
4201 .credential_ref
4202 .as_deref(),
4203 Some("provider.custom.api_key")
4204 );
4205 assert_eq!(
4206 spec.secrets.provider_credentials[0].api_key,
4207 "selected-secret"
4208 );
4209 }
4210
4211 fn spec_with(
4212 role: &str,
4213 provider: &str,
4214 model: &str,
4215 workspace: Option<&str>,
4216 disabled: Option<Vec<&str>>,
4217 ) -> ProvisionSpec {
4218 let mut spec = ProvisionSpec::new(
4219 ChildIdentity {
4220 child_id: "c".into(),
4221 parent_id: None,
4222 project_key: None,
4223 role: role.into(),
4224 depth: 0,
4225 },
4226 ExecutorSpec::Echo,
4227 "/tmp/fab".into(),
4228 );
4229 spec.workspace = workspace.map(|w| w.to_string());
4230 spec.model = Some(ModelRefSpec {
4231 provider: provider.into(),
4232 model: model.into(),
4233 });
4234 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
4235 spec
4236 }
4237
4238 #[test]
4239 fn fingerprint_matches_interchangeable_children() {
4240 let a = spec_with(
4243 "explorer",
4244 "p",
4245 "m",
4246 Some("/ws"),
4247 Some(vec!["Bash", "Edit"]),
4248 );
4249 let mut b = spec_with(
4250 "explorer",
4251 "p",
4252 "m",
4253 Some("/ws"),
4254 Some(vec!["Edit", "Bash"]),
4255 );
4256 b.identity.child_id = "other".into();
4257 assert_eq!(
4258 ActorChildRunner::fingerprint(&a),
4259 ActorChildRunner::fingerprint(&b)
4260 );
4261 }
4262
4263 #[test]
4264 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
4265 let mut session =
4266 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
4267 session.root_session_id = "logical-root-681".to_string();
4268 let job = SpawnJob {
4269 parent_session_id: "logical-parent-681".to_string(),
4270 child_session_id: "logical-child-681".to_string(),
4271 model: "model".to_string(),
4272 disabled_tools: None,
4273 };
4274 let expected = LogicalSessionIdentity {
4275 session_id: "logical-child-681".to_string(),
4276 parent_session_id: Some("logical-parent-681".to_string()),
4277 root_session_id: "logical-root-681".to_string(),
4278 };
4279
4280 let placements_and_transport_ids = [
4281 (Placement::Local, "local-mailbox-first"),
4282 (
4283 Placement::Remote {
4284 endpoint: "wss://remote.example/actor".to_string(),
4285 },
4286 "remote-process-44",
4287 ),
4288 (
4289 Placement::Schedulable {
4290 pool: "gpu-pool".to_string(),
4291 },
4292 "scheduled-mailbox-9",
4293 ),
4294 (Placement::Local, "warm-mailbox-reused-77"),
4296 ];
4297 for (placement, transport_id) in placements_and_transport_ids {
4298 let mut provision = spec_with("worker", "provider", "model", None, None);
4299 provision.placement = placement;
4300 provision.identity.child_id = transport_id.to_string();
4301 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
4302 assert_ne!(
4303 provision.identity.child_id, expected.session_id,
4304 "test fixture must prove transport identity is independent"
4305 );
4306 }
4307 }
4308
4309 #[test]
4310 fn fingerprint_separates_distinct_runtimes() {
4311 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
4312 let base_fp = ActorChildRunner::fingerprint(&base);
4313 assert_ne!(
4315 base_fp,
4316 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
4317 );
4318 assert_ne!(
4319 base_fp,
4320 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
4321 );
4322 assert_ne!(
4323 base_fp,
4324 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
4325 );
4326 assert_ne!(
4327 base_fp,
4328 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
4329 );
4330 assert_ne!(
4331 base_fp,
4332 ActorChildRunner::fingerprint(&spec_with(
4333 "explorer",
4334 "p",
4335 "m",
4336 Some("/ws"),
4337 Some(vec!["Bash"])
4338 ))
4339 );
4340 }
4341
4342 #[test]
4343 fn fingerprint_splits_on_baked_capabilities() {
4344 let base_fp =
4349 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
4350
4351 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
4352 depth.identity.depth = 2;
4353 assert_ne!(
4354 base_fp,
4355 ActorChildRunner::fingerprint(&depth),
4356 "depth must split"
4357 );
4358
4359 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
4360 nested.capabilities.nested_spawn = true;
4361 assert_ne!(
4362 base_fp,
4363 ActorChildRunner::fingerprint(&nested),
4364 "nested_spawn must split"
4365 );
4366
4367 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
4368 bypass.capabilities.bypass = true;
4369 assert_ne!(
4370 base_fp,
4371 ActorChildRunner::fingerprint(&bypass),
4372 "bypass must split"
4373 );
4374
4375 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4376 auto.capabilities.auto_approve_permissions = true;
4377 assert_ne!(
4378 base_fp,
4379 ActorChildRunner::fingerprint(&auto),
4380 "auto_approve_permissions must split"
4381 );
4382
4383 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4384 global_auto.capabilities.permission_requested_mode = "default".to_string();
4385 global_auto.capabilities.permission_effective_mode = "auto".to_string();
4386 global_auto.capabilities.auto_approve_permissions = true;
4387 let mut explicit_auto = global_auto.clone();
4388 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
4389 assert_ne!(
4390 ActorChildRunner::fingerprint(&global_auto),
4391 ActorChildRunner::fingerprint(&explicit_auto),
4392 "permission_requested_mode must split global and explicit Auto"
4393 );
4394
4395 let mut plan_overlay = explicit_auto.clone();
4396 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
4397 assert_ne!(
4398 ActorChildRunner::fingerprint(&explicit_auto),
4399 ActorChildRunner::fingerprint(&plan_overlay),
4400 "permission_effective_mode must split Plan overlay from Auto"
4401 );
4402
4403 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
4404 enforce.capabilities.enforce_permissions = true;
4405 assert_ne!(
4406 base_fp,
4407 ActorChildRunner::fingerprint(&enforce),
4408 "enforce_permissions must split"
4409 );
4410
4411 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
4412 cap.capabilities.max_spawn_depth = Some(8);
4413 assert_ne!(
4414 base_fp,
4415 ActorChildRunner::fingerprint(&cap),
4416 "max_spawn_depth must split"
4417 );
4418
4419 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
4423 nha.capabilities.no_human_approver = true;
4424 assert_ne!(
4425 base_fp,
4426 ActorChildRunner::fingerprint(&nha),
4427 "no_human_approver must split"
4428 );
4429
4430 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
4433 gro.capabilities.guardian_read_only = true;
4434 assert_ne!(
4435 base_fp,
4436 ActorChildRunner::fingerprint(&gro),
4437 "guardian_read_only must split"
4438 );
4439 }
4440
4441 #[test]
4442 fn fingerprint_splits_codex_exec_and_app_server_workers() {
4443 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
4444 exec.executor = codex_executor(Some("inherit"), None);
4445 let mut app_server = exec.clone();
4446 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
4447 *mode = Some("app_server".to_string());
4448 }
4449 assert_ne!(
4450 ActorChildRunner::fingerprint(&exec),
4451 ActorChildRunner::fingerprint(&app_server)
4452 );
4453 }
4454
4455 struct StaticDecider(bool);
4456
4457 #[async_trait]
4458 impl ChildApprovalDecider for StaticDecider {
4459 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
4460 self.0
4461 }
4462 }
4463
4464 struct RecordingReviewer {
4465 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
4466 }
4467
4468 #[async_trait]
4469 impl ChildApprovalReviewer for RecordingReviewer {
4470 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
4471 let _ = self
4472 .reviewed
4473 .send((parent.to_string(), child.to_string(), request.clone()));
4474 true
4475 }
4476 }
4477
4478 struct SilentLink;
4483 #[async_trait]
4484 impl bamboo_subagent::ChildLink for SilentLink {
4485 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4486 Ok(())
4487 }
4488 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4489 std::future::pending().await
4490 }
4491 }
4492
4493 struct InstantTerminalLink {
4495 done: bool,
4496 }
4497
4498 struct ApprovalRoundTripLink {
4499 step: u8,
4500 approval_reply: Option<(String, bool)>,
4501 }
4502
4503 #[async_trait]
4504 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
4505 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4506 if let ParentFrame::ApprovalReply { id, approved } = frame {
4507 self.approval_reply = Some((id, approved));
4508 self.step = 2;
4509 }
4510 Ok(())
4511 }
4512
4513 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4514 match self.step {
4515 0 => {
4516 self.step = 1;
4517 Ok(Some(ChildFrame::ApprovalRequest {
4518 id: "approval-1".into(),
4519 body: serde_json::json!({
4520 "tool_name": "Bash",
4521 "permission": "execute",
4522 "resource": "rm -rf target",
4523 "permission_request": {"reason_code": "hard_dangerous"}
4524 }),
4525 }))
4526 }
4527 1 => std::future::pending().await,
4528 2 => {
4529 self.step = 3;
4530 Ok(Some(ChildFrame::Terminal {
4531 status: TerminalStatus::Completed,
4532 result: Some("done".into()),
4533 error: None,
4534 transcript: vec![],
4535 }))
4536 }
4537 _ => std::future::pending().await,
4538 }
4539 }
4540 }
4541
4542 #[tokio::test]
4543 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
4544 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4545 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
4546 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
4547 reviewed: review_tx,
4548 });
4549 let cancel = CancellationToken::new();
4550 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4551 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4552 let mut logical_session = Session::new("child-reviewer", "model");
4553 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
4554 let mut link = ApprovalRoundTripLink {
4555 step: 0,
4556 approval_reply: None,
4557 };
4558
4559 let result = tokio::time::timeout(
4560 Duration::from_secs(1),
4561 drive(ActorDriveContext {
4562 client: &mut link,
4563 parent_session_id: "parent-reviewer",
4564 child_session_id: "child-reviewer",
4565 child_attempt: 0,
4566 approval_registry: None,
4567 approval_decider: None,
4568 approval_reviewer: Some(&reviewer),
4569 escalation_bridge: None,
4570 event_tx: &event_tx,
4571 cancel_token: &cancel,
4572 live_rx: &mut live_rx,
4573 delivery_rx: &mut delivery_rx,
4574 logical_session: &mut logical_session,
4575 expected_permission_posture: None,
4576 session_inbox_runtime: None,
4577 activation_run_id: None,
4578 initial_inflight_claims: VecDeque::new(),
4579 first_frame_timeout: None,
4580 }),
4581 )
4582 .await
4583 .expect("worker must receive the reviewer verdict before terminating");
4584
4585 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4586 assert_eq!(
4587 link.approval_reply,
4588 Some(("approval-1".to_string(), true)),
4589 "reviewer verdict must traverse the live route back to the worker"
4590 );
4591 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
4592 .await
4593 .expect("reviewer should be invoked off-loop")
4594 .expect("review channel should remain open");
4595 assert_eq!(parent, "parent-reviewer");
4596 assert_eq!(child, "child-reviewer");
4597 assert_eq!(
4598 body.pointer("/permission_request/reason_code")
4599 .and_then(serde_json::Value::as_str),
4600 Some("hard_dangerous")
4601 );
4602 assert!(
4603 event_rx.try_recv().is_err(),
4604 "must not emit a human-review event"
4605 );
4606 drop(live_guard);
4607 }
4608
4609 #[tokio::test]
4610 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
4611 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4612 let cancel = CancellationToken::new();
4613 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4614 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4615 let mut logical_session = Session::new("child-no-reviewer", "model");
4616 let mut link = ApprovalRoundTripLink {
4617 step: 0,
4618 approval_reply: None,
4619 };
4620
4621 let result = tokio::time::timeout(
4622 Duration::from_secs(1),
4623 drive(ActorDriveContext {
4624 client: &mut link,
4625 parent_session_id: "parent-no-reviewer",
4626 child_session_id: "child-no-reviewer",
4627 child_attempt: 0,
4628 approval_registry: None,
4629 approval_decider: None,
4630 approval_reviewer: None,
4631 escalation_bridge: None,
4632 event_tx: &event_tx,
4633 cancel_token: &cancel,
4634 live_rx: &mut live_rx,
4635 delivery_rx: &mut delivery_rx,
4636 logical_session: &mut logical_session,
4637 expected_permission_posture: None,
4638 session_inbox_runtime: None,
4639 activation_run_id: None,
4640 initial_inflight_claims: VecDeque::new(),
4641 first_frame_timeout: None,
4642 }),
4643 )
4644 .await
4645 .expect("fail-closed reply must unblock the child immediately");
4646
4647 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4648 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
4649 assert!(
4650 event_rx.try_recv().is_err(),
4651 "missing parent review must not surface a manual approval event"
4652 );
4653 }
4654 #[async_trait]
4655 impl bamboo_subagent::ChildLink for InstantTerminalLink {
4656 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4657 Ok(())
4658 }
4659 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4660 if self.done {
4661 std::future::pending().await
4662 } else {
4663 self.done = true;
4664 Ok(Some(ChildFrame::Terminal {
4665 status: TerminalStatus::Completed,
4666 result: Some("done".into()),
4667 error: None,
4668 transcript: vec![],
4669 }))
4670 }
4671 }
4672 }
4673
4674 #[tokio::test]
4675 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
4676 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4677 let cancel = CancellationToken::new();
4678 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4679 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4680 let mut logical_session = Session::new("child-x", "model");
4681 let mut link = SilentLink;
4682 let r = drive(ActorDriveContext {
4683 client: &mut link,
4684 parent_session_id: "parent-x",
4685 child_session_id: "child-x",
4686 child_attempt: 0,
4687 approval_registry: None,
4688 approval_decider: None,
4689 approval_reviewer: None,
4690 escalation_bridge: None,
4691 event_tx: &event_tx,
4692 cancel_token: &cancel,
4693 live_rx: &mut live_rx,
4694 delivery_rx: &mut delivery_rx,
4695 logical_session: &mut logical_session,
4696 expected_permission_posture: None,
4697 session_inbox_runtime: None,
4698 activation_run_id: None,
4699 initial_inflight_claims: VecDeque::new(),
4700 first_frame_timeout: Some(Duration::from_millis(100)),
4701 })
4702 .await;
4703 assert!(
4704 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
4705 "a silent worker must trip the first-frame watchdog, got {r:?}"
4706 );
4707 }
4708
4709 #[tokio::test]
4710 async fn drive_does_not_trip_when_a_frame_arrives() {
4711 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4712 let cancel = CancellationToken::new();
4713 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4714 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4715 let mut logical_session = Session::new("child-y", "model");
4716 let mut link = InstantTerminalLink { done: false };
4717 let r = drive(ActorDriveContext {
4720 client: &mut link,
4721 parent_session_id: "parent-y",
4722 child_session_id: "child-y",
4723 child_attempt: 0,
4724 approval_registry: None,
4725 approval_decider: None,
4726 approval_reviewer: None,
4727 escalation_bridge: None,
4728 event_tx: &event_tx,
4729 cancel_token: &cancel,
4730 live_rx: &mut live_rx,
4731 delivery_rx: &mut delivery_rx,
4732 logical_session: &mut logical_session,
4733 expected_permission_posture: None,
4734 session_inbox_runtime: None,
4735 activation_run_id: None,
4736 initial_inflight_claims: VecDeque::new(),
4737 first_frame_timeout: Some(Duration::from_millis(50)),
4738 })
4739 .await;
4740 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
4741 }
4742
4743 #[tokio::test]
4744 async fn child_approval_fails_closed_without_decider() {
4745 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
4747 assert!(!decide_child_approval(None, "child-1", &body).await);
4748 }
4749
4750 #[tokio::test]
4751 async fn child_approval_honors_wired_decider() {
4752 let body =
4753 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
4754 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
4755 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
4756 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
4757 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
4758 }
4759
4760 use crate::runtime::execution::SpawnJob;
4763 use bamboo_agent_core::Session;
4764
4765 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
4768 ActorChildRunner::new(
4769 "test-actor".into(),
4770 PathBuf::from("/bin/false"),
4771 vec![],
4772 std::env::temp_dir().join("bamboo-test-fab-193"),
4773 ExecutorSpec::Echo,
4774 vec![],
4775 "anthropic".into(),
4776 4,
4777 )
4778 .with_remote_placements(placements)
4779 }
4780
4781 fn session_of_role(role: &str, assignment: &str) -> Session {
4784 let mut s = Session::new("child-1", "test-model");
4785 s.metadata
4786 .insert("subagent_type".to_string(), role.to_string());
4787 s.add_message(bamboo_agent_core::Message::user(assignment));
4788 s
4789 }
4790
4791 fn job_for(child: &str) -> SpawnJob {
4792 SpawnJob {
4793 parent_session_id: "parent-1".into(),
4794 child_session_id: child.into(),
4795 model: String::new(),
4796 disabled_tools: None,
4797 }
4798 }
4799
4800 #[derive(Default)]
4801 struct RecordingChildSessionPort {
4802 saved: std::sync::Mutex<Option<Session>>,
4803 }
4804
4805 impl RecordingChildSessionPort {
4806 fn saved_child(&self) -> Session {
4807 self.saved
4808 .lock()
4809 .expect("saved-child fixture lock")
4810 .clone()
4811 .expect("create_child_action must save the child")
4812 }
4813 }
4814
4815 #[async_trait]
4816 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
4817 async fn load_root_session(
4818 &self,
4819 _root_id: &str,
4820 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
4821 unreachable!("create_child_action does not load the root")
4822 }
4823
4824 async fn load_child_for_parent(
4825 &self,
4826 _parent_id: &str,
4827 _child_id: &str,
4828 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
4829 unreachable!("create_child_action does not reload the child")
4830 }
4831
4832 async fn save_child_session(
4833 &self,
4834 child: &mut Session,
4835 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4836 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
4837 Ok(())
4838 }
4839
4840 async fn save_child_session_authoritative_flags(
4841 &self,
4842 _child: &mut Session,
4843 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4844 unreachable!("new-child creation uses the ordinary save")
4845 }
4846
4847 async fn is_child_running(&self, _child_id: &str) -> bool {
4848 false
4849 }
4850
4851 async fn list_children(
4852 &self,
4853 _parent_id: &str,
4854 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
4855 Vec::new()
4856 }
4857
4858 async fn enqueue_child_run(
4859 &self,
4860 _parent: &Session,
4861 _child: &Session,
4862 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4863 unreachable!("fixture creates the child with auto_run=false")
4864 }
4865
4866 async fn cancel_child_run_and_wait(
4867 &self,
4868 _child_id: &str,
4869 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4870 unreachable!("create_child_action does not cancel")
4871 }
4872
4873 async fn delete_child_session(
4874 &self,
4875 _parent_id: &str,
4876 _child_id: &str,
4877 ) -> Result<
4878 crate::session_app::child_session::DeleteChildResult,
4879 crate::session_app::child_session::ChildSessionError,
4880 > {
4881 unreachable!("create_child_action does not delete")
4882 }
4883
4884 async fn get_child_runner_info(
4885 &self,
4886 _child_id: &str,
4887 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
4888 None
4889 }
4890
4891 async fn register_parent_wait_for_child(
4892 &self,
4893 _parent_session_id: &str,
4894 _child_session_id: &str,
4895 _tool_call_id: Option<&str>,
4896 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
4897 unreachable!("create_child_action does not register a wait")
4898 }
4899
4900 async fn register_parent_wait_for_children(
4901 &self,
4902 _parent_session_id: &str,
4903 _child_session_ids: &[String],
4904 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
4905 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
4906 unreachable!("create_child_action does not register a wait")
4907 }
4908
4909 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
4910 Vec::new()
4911 }
4912
4913 async fn find_resident_child(
4914 &self,
4915 _root_session_id: &str,
4916 _resident_name: &str,
4917 ) -> Option<String> {
4918 None
4919 }
4920
4921 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
4922 }
4923
4924 #[test]
4925 fn build_spec_sets_remote_placement_for_matching_role() {
4926 let mut placements = HashMap::new();
4927 placements.insert(
4928 "explorer".to_string(),
4929 ResolvedRemotePlacement {
4930 endpoint: "wss://gpu-host:8443".into(),
4931 token: Some("T-secret".into()),
4932 ca_cert_file: None,
4933 host_label: None,
4934 },
4935 );
4936 let runner = bogus_runner(placements);
4937
4938 let s = session_of_role("explorer", "do the thing");
4940 let spec = runner.build_spec(&s, &job_for("child-1"));
4941 match &spec.placement {
4942 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
4943 other => panic!("expected Remote, got {other:?}"),
4944 }
4945 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-secret"));
4946 }
4947
4948 #[test]
4949 fn build_spec_leaves_local_for_unmatched_role() {
4950 let mut placements = HashMap::new();
4951 placements.insert(
4952 "explorer".to_string(),
4953 ResolvedRemotePlacement {
4954 endpoint: "wss://gpu-host:8443".into(),
4955 token: Some("T".into()),
4956 ca_cert_file: None,
4957 host_label: None,
4958 },
4959 );
4960 let runner = bogus_runner(placements);
4961
4962 let s = session_of_role("writer", "do the thing");
4964 let spec = runner.build_spec(&s, &job_for("child-1"));
4965 assert_eq!(spec.placement, Placement::Local);
4966 assert!(spec.secrets.worker_auth_token.is_none());
4967 }
4968
4969 #[test]
4970 fn build_spec_local_when_no_placements() {
4971 let runner = bogus_runner(HashMap::new());
4972 let s = session_of_role("explorer", "do the thing");
4973 let spec = runner.build_spec(&s, &job_for("child-1"));
4974 assert_eq!(spec.placement, Placement::Local);
4975 assert!(spec.secrets.worker_auth_token.is_none());
4976 }
4977
4978 #[tokio::test]
4979 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
4980 for (label, mode) in [
4984 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
4985 ("auto", bamboo_domain::SessionPermissionMode::Auto),
4986 ] {
4987 let runner = bogus_runner(HashMap::new());
4988 let mut parent = Session::new(format!("parent-{label}"), "test-model");
4989 parent
4990 .agent_runtime_state
4991 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
4992 .set_permission_mode(mode);
4993 let workspace = tempfile::tempdir().expect("workspace fixture");
4994 let port = RecordingChildSessionPort::default();
4995 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
4996 crate::session_app::child_session::create_child_action(
4997 &port,
4998 crate::session_app::child_session::CreateChildInput {
4999 parent_session: parent,
5000 child_id: child_id.clone(),
5001 title: format!("{label} child"),
5002 responsibility: "Run ordinary commands".to_string(),
5003 assignment_prompt: "run an ordinary command".to_string(),
5004 subagent_type: "explorer".to_string(),
5005 workspace: workspace.path().to_string_lossy().into_owned(),
5006 workspace_source: crate::project_context::WorkspaceSource::Explicit,
5007 model_override: None,
5008 model_ref_override: None,
5009 runtime_metadata: HashMap::new(),
5010 auto_run: false,
5011 reasoning_effort: None,
5012 lifecycle: None,
5013 resident_name: None,
5014 resident_context: None,
5015 disabled_tools: None,
5016 context_fork: None,
5017 },
5018 )
5019 .await
5020 .expect("create inherited-permission child");
5021 let child = port.saved_child();
5022
5023 assert_eq!(
5024 child
5025 .agent_runtime_state
5026 .as_ref()
5027 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
5028 Some(mode),
5029 "create_child_action must inherit {label} from the parent"
5030 );
5031
5032 let spec = runner.build_spec(&child, &job_for(&child_id));
5033
5034 assert_eq!(
5035 spec.capabilities.bypass,
5036 mode == bamboo_domain::SessionPermissionMode::Bypass
5037 );
5038 assert_eq!(
5039 spec.capabilities.auto_approve_permissions,
5040 mode == bamboo_domain::SessionPermissionMode::Auto
5041 );
5042 assert!(
5043 spec.capabilities.enforce_permissions,
5044 "policy evaluation must remain active under {label}"
5045 );
5046 }
5047 }
5048
5049 #[tokio::test]
5050 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
5051 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
5052 let workspace = tempfile::tempdir().expect("workspace fixture");
5053
5054 for (role, lifecycle, resident_name) in [
5055 ("explorer", None, None),
5056 ("resident", Some("resident"), Some("stable-reviewer")),
5057 ("guardian", None, None),
5058 ] {
5059 let mut parent = Session::new(format!("parent-{role}"), "test-model");
5060 parent.set_project_id_meta(project_id.to_string());
5061 let port = RecordingChildSessionPort::default();
5062 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
5063 crate::session_app::child_session::create_child_action(
5064 &port,
5065 crate::session_app::child_session::CreateChildInput {
5066 parent_session: parent,
5067 child_id: child_id.clone(),
5068 title: format!("{role} child"),
5069 responsibility: "Review the assigned work".to_string(),
5070 assignment_prompt: "inspect the change".to_string(),
5071 subagent_type: role.to_string(),
5072 workspace: workspace.path().to_string_lossy().into_owned(),
5073 workspace_source: crate::project_context::WorkspaceSource::Explicit,
5074 model_override: None,
5075 model_ref_override: None,
5076 runtime_metadata: HashMap::new(),
5077 auto_run: false,
5078 reasoning_effort: None,
5079 lifecycle: lifecycle.map(str::to_string),
5080 resident_name: resident_name.map(str::to_string),
5081 resident_context: None,
5082 disabled_tools: None,
5083 context_fork: None,
5084 },
5085 )
5086 .await
5087 .expect("create Project-inheriting child");
5088 let child = port.saved_child();
5089 assert_eq!(
5090 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
5091 Some(project_id.clone()),
5092 "{role} child must inherit its parent's Project"
5093 );
5094
5095 assert_eq!(
5096 project_id_for_actor_run(&child).expect("valid actor Project identity"),
5097 Some(project_id.clone()),
5098 "{role} actor RunSpec must preserve inherited Project identity"
5099 );
5100 }
5101 }
5102
5103 #[test]
5104 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
5105 assert_eq!(placement_metadata(&Placement::Local, None), None);
5107
5108 let r = placement_metadata(
5110 &Placement::Remote {
5111 endpoint: "wss://10.0.0.5:8443/stream".into(),
5112 },
5113 None,
5114 )
5115 .unwrap();
5116 assert!(r.contains(r#""kind":"remote""#), "{r}");
5117 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
5118
5119 let labeled = placement_metadata(
5121 &Placement::Remote {
5122 endpoint: "ws://169.254.230.101:8899".into(),
5123 },
5124 Some("mini"),
5125 )
5126 .unwrap();
5127 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
5128
5129 let s = placement_metadata(
5131 &Placement::Schedulable {
5132 pool: "explorers".into(),
5133 },
5134 Some("mini"),
5135 )
5136 .unwrap();
5137 assert!(s.contains(r#""kind":"remote""#), "{s}");
5138 assert!(s.contains(r#""host":"mini""#), "{s}");
5139
5140 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
5142 assert_eq!(p.kind, "remote");
5143 assert_eq!(p.host, "mini");
5144 }
5145
5146 #[tokio::test]
5153 async fn execute_external_child_routes_role_to_remote_worker_without_spawning() {
5154 let token = "remote-test-token";
5156 let server = bamboo_subagent::transport::WsServer::bind_with_token(
5157 (std::net::Ipv4Addr::LOCALHOST, 0).into(),
5158 Some(token.to_string()),
5159 )
5160 .await
5161 .expect("bind resident worker");
5162 let endpoint = server.ws_endpoint(); let srv = tokio::spawn(async move {
5164 let _ = server
5166 .serve(Arc::new(bamboo_subagent::executor::EchoExecutor))
5167 .await;
5168 });
5169
5170 let mut placements = HashMap::new();
5172 placements.insert(
5173 "explorer".to_string(),
5174 ResolvedRemotePlacement {
5175 endpoint: endpoint.clone(),
5176 token: Some(token.to_string()),
5177 ca_cert_file: None,
5178 host_label: Some("mini-e2e".into()), },
5180 );
5181 let runner = bogus_runner(placements);
5182
5183 let mut session = session_of_role("explorer", "hello remote");
5185 let job = job_for("child-1");
5186 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(64);
5187 let cancel = CancellationToken::new();
5188
5189 let result = tokio::time::timeout(
5190 Duration::from_secs(10),
5191 runner.execute_external_child(&mut session, &job, event_tx, cancel),
5192 )
5193 .await
5194 .expect("run did not hang")
5195 .expect("remote run succeeded (connected to resident worker, did not spawn)");
5196
5197 let _ = result;
5198 let last = session
5201 .messages
5202 .iter()
5203 .rev()
5204 .find(|m| matches!(m.role, Role::Assistant))
5205 .expect("an assistant reply was written back");
5206 assert!(
5207 last.content.contains("echo:"),
5208 "expected echo reply, got {:?}",
5209 last.content
5210 );
5211
5212 let placement = session
5215 .metadata
5216 .get("placement")
5217 .expect("remote child session stamped with a placement");
5218 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
5219 assert!(placement.contains(r#""host":"mini-e2e""#), "{placement}");
5220
5221 let mut saw_event = false;
5224 while let Ok(Some(_ev)) =
5225 tokio::time::timeout(Duration::from_millis(50), event_rx.recv()).await
5226 {
5227 saw_event = true;
5228 }
5229 let _ = saw_event;
5230
5231 srv.abort();
5232 }
5233
5234 fn bogus_sched_runner(
5240 remote: HashMap<String, ResolvedRemotePlacement>,
5241 sched: HashMap<String, ResolvedSchedulablePlacement>,
5242 ) -> ActorChildRunner {
5243 ActorChildRunner::new(
5244 "test-actor".into(),
5245 PathBuf::from("/bin/false"),
5246 vec![],
5247 std::env::temp_dir().join("bamboo-test-fab-181"),
5248 ExecutorSpec::Echo,
5249 vec![],
5250 "anthropic".into(),
5251 4,
5252 )
5253 .with_remote_placements(remote)
5254 .with_schedulable_placements(sched)
5255 }
5256
5257 fn sched_placement(
5258 pool: &str,
5259 _registry_url: impl Into<String>,
5260 ) -> ResolvedSchedulablePlacement {
5261 ResolvedSchedulablePlacement {
5262 pool: pool.into(),
5263 host_label: None,
5264 }
5265 }
5266
5267 #[test]
5268 fn build_spec_sets_schedulable_placement_for_matching_role() {
5269 let mut sched = HashMap::new();
5270 sched.insert(
5271 "explorer".to_string(),
5272 sched_placement("gpu-pool", "unused"),
5273 );
5274 let runner = bogus_sched_runner(HashMap::new(), sched);
5275
5276 let s = session_of_role("explorer", "do the thing");
5277 let spec = runner.build_spec(&s, &job_for("child-1"));
5278 match &spec.placement {
5279 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
5280 other => panic!("expected Schedulable, got {other:?}"),
5281 }
5282 assert!(spec.secrets.worker_auth_token.is_none());
5284 }
5285
5286 #[test]
5287 fn build_spec_remote_wins_when_role_in_both_maps() {
5288 let mut remote = HashMap::new();
5291 remote.insert(
5292 "explorer".to_string(),
5293 ResolvedRemotePlacement {
5294 endpoint: "wss://fixed-host:8443".into(),
5295 token: Some("T-remote".into()),
5296 ca_cert_file: None,
5297 host_label: None,
5298 },
5299 );
5300 let mut sched = HashMap::new();
5301 sched.insert(
5302 "explorer".to_string(),
5303 sched_placement("gpu-pool", "https://control-plane:9562"),
5304 );
5305 let runner = bogus_sched_runner(remote, sched);
5306
5307 let s = session_of_role("explorer", "do the thing");
5308 let spec = runner.build_spec(&s, &job_for("child-1"));
5309 match &spec.placement {
5310 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
5311 other => panic!("expected Remote (precedence), got {other:?}"),
5312 }
5313 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-remote"));
5314 }
5315
5316 #[test]
5317 fn build_spec_local_for_unmatched_schedulable_role() {
5318 let mut sched = HashMap::new();
5319 sched.insert(
5320 "explorer".to_string(),
5321 sched_placement("gpu-pool", "https://control-plane:9562"),
5322 );
5323 let runner = bogus_sched_runner(HashMap::new(), sched);
5324 let s = session_of_role("writer", "do the thing");
5325 let spec = runner.build_spec(&s, &job_for("child-1"));
5326 assert_eq!(spec.placement, Placement::Local);
5327 assert!(spec.secrets.worker_auth_token.is_none());
5328 }
5329
5330 #[test]
5335 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
5336 let mut remote = HashMap::new();
5338 remote.insert(
5339 "explorer".to_string(),
5340 ResolvedRemotePlacement {
5341 endpoint: "ws://169.254.230.101:8899".into(),
5342 token: None,
5343 ca_cert_file: None,
5344 host_label: Some("mini".into()),
5345 },
5346 );
5347 let runner = bogus_runner(remote);
5348 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
5349 let stamp = runner
5350 .placement_stamp_for(&spec)
5351 .expect("remote child is stamped");
5352 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
5353 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
5354
5355 let mut remote_nolabel = HashMap::new();
5357 remote_nolabel.insert(
5358 "explorer".to_string(),
5359 ResolvedRemotePlacement {
5360 endpoint: "ws://169.254.230.101:8899".into(),
5361 token: None,
5362 ca_cert_file: None,
5363 host_label: None,
5364 },
5365 );
5366 let r2 = bogus_runner(remote_nolabel);
5367 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
5368 assert!(r2
5369 .placement_stamp_for(&spec2)
5370 .unwrap()
5371 .contains(r#""host":"169.254.230.101""#));
5372
5373 let mut sched = HashMap::new();
5375 sched.insert(
5376 "mac-mini-monitor".to_string(),
5377 ResolvedSchedulablePlacement {
5378 pool: "mac-mini-monitor".into(),
5379 host_label: Some("mini".into()),
5380 },
5381 );
5382 let sr = bogus_sched_runner(HashMap::new(), sched);
5383 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
5384 let stamp3 = sr
5385 .placement_stamp_for(&spec3)
5386 .expect("scheduled child is stamped");
5387 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
5388 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
5389
5390 let local = bogus_runner(HashMap::new());
5392 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
5393 assert_eq!(local.placement_stamp_for(&spec4), None);
5394 }
5395
5396 async fn start_bus() -> (String, tempfile::TempDir) {
5399 let dir = tempfile::tempdir().unwrap();
5400 let core = std::sync::Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
5401 let server = std::sync::Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
5402 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5403 let addr = listener.local_addr().unwrap();
5404 tokio::spawn(async move {
5405 let _ = server.serve(listener).await;
5406 });
5407 (format!("ws://{addr}"), dir)
5408 }
5409
5410 async fn join_pool(endpoint: &str, id: &str, pool: &str) -> bamboo_broker::BrokerClient {
5411 let mut c = bamboo_broker::BrokerClient::connect(
5412 endpoint,
5413 bamboo_subagent::AgentRef {
5414 session_id: id.into(),
5415 role: Some(pool.into()),
5416 },
5417 "t",
5418 )
5419 .await
5420 .unwrap();
5421 c.subscribe().await.unwrap();
5422 c
5423 }
5424
5425 fn sched_runner_on_bus(endpoint: &str, child_role: &str, pool: &str) -> ActorChildRunner {
5426 let mut sched = HashMap::new();
5427 sched.insert(child_role.to_string(), sched_placement(pool, "unused"));
5428 bogus_sched_runner(HashMap::new(), sched).with_bus(Some(bamboo_subagent::BusEndpoint {
5429 endpoint: endpoint.into(),
5430 token: "t".into(),
5431 }))
5432 }
5433
5434 #[tokio::test]
5435 async fn resolve_schedulable_picks_a_live_bus_worker() {
5436 let (endpoint, _dir) = start_bus().await;
5437 let _w = join_pool(&endpoint, "w-gpu", "gpu-pool").await;
5438 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5439
5440 let mailbox = runner
5441 .resolve_schedulable_worker("explorer")
5442 .await
5443 .expect("a live pool worker is found on the bus");
5444 assert_eq!(mailbox, "w-gpu");
5445 }
5446
5447 #[tokio::test]
5448 async fn resolve_schedulable_round_robins_over_pool_workers() {
5449 let (endpoint, _dir) = start_bus().await;
5450 let _a = join_pool(&endpoint, "w-a", "gpu-pool").await;
5451 let _b = join_pool(&endpoint, "w-b", "gpu-pool").await;
5452 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5453
5454 let mut picked = std::collections::HashSet::new();
5456 for _ in 0..6 {
5457 picked.insert(runner.resolve_schedulable_worker("explorer").await.unwrap());
5458 }
5459 assert_eq!(
5460 picked,
5461 ["w-a".to_string(), "w-b".to_string()].into_iter().collect(),
5462 "round-robin must cover every connected pool worker"
5463 );
5464 }
5465
5466 #[tokio::test]
5467 async fn resolve_schedulable_errors_on_empty_pool() {
5468 let (endpoint, _dir) = start_bus().await;
5469 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5471
5472 let err = runner
5473 .resolve_schedulable_worker("explorer")
5474 .await
5475 .expect_err("an empty pool is terminal — no local fallback")
5476 .to_string();
5477 assert!(err.contains("no live worker in pool"), "got: {err}");
5478 assert!(err.contains("NOT spawning"), "got: {err}");
5479 }
5480
5481 #[tokio::test]
5488 async fn execute_external_child_runs_schedulable_over_bus_and_stamps_node_label() {
5489 let (endpoint, _dir) = start_bus().await;
5490
5491 let ep = endpoint.clone();
5493 let worker = tokio::spawn(async move {
5494 let _ = bamboo_broker::serve_executor(
5495 &ep,
5496 bamboo_subagent::AgentRef {
5497 session_id: "mmm-worker".into(),
5498 role: Some("mac-mini-monitor".into()),
5499 },
5500 "t",
5501 std::sync::Arc::new(bamboo_subagent::executor::EchoExecutor),
5502 )
5503 .await;
5504 });
5505
5506 let mut probe = bamboo_broker::BrokerClient::connect(
5509 &endpoint,
5510 bamboo_subagent::AgentRef {
5511 session_id: "probe".into(),
5512 role: None,
5513 },
5514 "t",
5515 )
5516 .await
5517 .unwrap();
5518 let mut ready = false;
5519 for _ in 0..100 {
5520 if probe
5521 .list_connected("mac-mini-monitor")
5522 .await
5523 .unwrap()
5524 .iter()
5525 .any(|id| id == "mmm-worker")
5526 {
5527 ready = true;
5528 break;
5529 }
5530 tokio::time::sleep(Duration::from_millis(30)).await;
5531 }
5532 assert!(ready, "worker never joined the pool");
5533
5534 let mut sched = HashMap::new();
5537 sched.insert(
5538 "mac-mini-monitor".to_string(),
5539 ResolvedSchedulablePlacement {
5540 pool: "mac-mini-monitor".into(),
5541 host_label: Some("mini".into()),
5542 },
5543 );
5544 let runner = bogus_sched_runner(HashMap::new(), sched).with_bus(Some(
5545 bamboo_subagent::BusEndpoint {
5546 endpoint: endpoint.clone(),
5547 token: "t".into(),
5548 },
5549 ));
5550
5551 let mut session = session_of_role("mac-mini-monitor", "hello scheduled");
5552 let job = job_for("child-1");
5553 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(64);
5554 let cancel = CancellationToken::new();
5555
5556 tokio::time::timeout(
5557 Duration::from_secs(10),
5558 runner.execute_external_child(&mut session, &job, event_tx, cancel),
5559 )
5560 .await
5561 .expect("run did not hang")
5562 .expect("schedulable run succeeded over the bus (no local spawn)");
5563
5564 let last = session
5566 .messages
5567 .iter()
5568 .rev()
5569 .find(|m| matches!(m.role, Role::Assistant))
5570 .expect("an assistant reply was written back");
5571 assert!(last.content.contains("echo:"), "got {:?}", last.content);
5572
5573 let placement = session
5575 .metadata
5576 .get("placement")
5577 .expect("scheduled child session stamped with a placement");
5578 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
5579 assert!(placement.contains(r#""host":"mini""#), "{placement}");
5580
5581 worker.abort();
5582 }
5583}