1use std::collections::{HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, AtomicU64, 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 ActorEventBatch, AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame,
29 PermissionPolicyContext, RunSpec, 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 = 200;
42
43pub const MAX_SPAWN_DEPTH: u32 = 4;
49
50const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
52
53const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
57
58const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
61
62const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
66
67const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
74
75fn active_scoped_session_deny_count(
76 config: &bamboo_tools::permission::PermissionConfig,
77 session_id: &str,
78) -> usize {
79 config
80 .temporary_grants()
81 .into_iter()
82 .filter(|grant| {
83 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
84 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
85 && grant.session_id.as_deref() == Some(session_id)
86 })
87 .count()
88}
89
90fn ensure_no_active_scoped_session_denies(
91 config: &bamboo_tools::permission::PermissionConfig,
92 session_id: &str,
93) -> Result<(), AgentError> {
94 let count = active_scoped_session_deny_count(config, session_id);
95 if count == 0 {
96 Ok(())
97 } else {
98 Err(AgentError::LLM(format!(
99 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
100 )))
101 }
102}
103
104pub struct IssuedCodexRunToken {
107 pub token_id: String,
108 pub token: String,
109}
110
111pub trait CodexRunTokenAuthority: Send + Sync + 'static {
114 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
115 fn revoke(&self, token_id: &str);
116}
117
118struct CodexRunTokenGuard {
119 authority: Arc<dyn CodexRunTokenAuthority>,
120 token_id: String,
121}
122
123impl Drop for CodexRunTokenGuard {
124 fn drop(&mut self) {
125 self.authority.revoke(&self.token_id);
126 }
127}
128
129fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
130 matches!(
131 executor,
132 ExecutorSpec::Codex {
133 auth_mode: Some(mode),
134 ..
135 } if mode == "bamboo"
136 ) || matches!(
137 executor,
138 ExecutorSpec::Codex {
139 auth_mode: None,
140 inherit_user_config,
141 ..
142 } if !inherit_user_config.unwrap_or(false)
143 )
144}
145
146fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
147 match executor {
148 ExecutorSpec::ClaudeCode {
149 permission_mode, ..
150 } => permission_mode
151 .as_deref()
152 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
153 ExecutorSpec::Codex {
154 permission_profile,
155 sandbox,
156 ..
157 } => {
158 permission_profile
159 .as_deref()
160 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
161 || sandbox
162 .as_deref()
163 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
164 }
165 _ => false,
166 }
167}
168
169fn expected_permission_executor_mapping(
177 executor: &ExecutorSpec,
178 resolution: bamboo_domain::PermissionModeResolution,
179 has_explicit_deny: bool,
180) -> Result<Option<String>, AgentError> {
181 let mapping = match executor {
182 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
183 ExecutorSpec::BambooRuntime => {
184 format!("bamboo_runtime:{}", resolution.effective.as_str())
185 }
186 ExecutorSpec::ClaudeCode {
187 permission_mode, ..
188 } => {
189 if has_explicit_deny {
190 "claude_code:blocked_explicit_deny".to_string()
191 } else {
192 let mode = match resolution.effective {
193 bamboo_domain::PermissionMode::Plan => "plan",
194 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
195 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
196 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
197 bamboo_domain::PermissionMode::Default
198 | bamboo_domain::PermissionMode::BypassPermissions => {
199 permission_mode.as_deref().unwrap_or("default")
200 }
201 };
202 format!("claude_code:permission_mode={mode}")
203 }
204 }
205 ExecutorSpec::Codex {
206 mode,
207 sandbox,
208 approval_policy,
209 allow_danger_bypass,
210 ..
211 } => match mode.as_deref().unwrap_or("exec") {
212 "exec" => {
213 let approval_policy = expected_codex_exec_approval_policy(
214 sandbox.as_deref(),
215 approval_policy.as_deref(),
216 allow_danger_bypass.unwrap_or(false),
217 resolution,
218 )?;
219 if has_explicit_deny {
220 "codex_exec:blocked_explicit_deny".to_string()
221 } else {
222 format!("codex_exec:approval_policy={approval_policy}")
223 }
224 }
225 "app_server" => {
226 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
227 return Err(AgentError::LLM(
228 "invalid Codex app-server permission posture configuration".to_string(),
229 ));
230 }
231 if has_explicit_deny {
232 "codex_app_server:blocked_explicit_deny".to_string()
233 } else {
234 let approval_policy = if resolution.suppress_approval_prompts()
235 || resolution.effective == bamboo_domain::PermissionMode::Plan
236 {
237 "never"
238 } else {
239 "on-request"
240 };
241 format!("codex_app_server:approvalPolicy={approval_policy}")
242 }
243 }
244 _ => {
245 return Err(AgentError::LLM(
246 "unsupported Codex executor mode for permission posture contract".to_string(),
247 ));
248 }
249 },
250 };
251 Ok(Some(mapping))
252}
253
254fn expected_codex_exec_approval_policy(
255 sandbox: Option<&str>,
256 approval_policy: Option<&str>,
257 allow_danger_bypass: bool,
258 resolution: bamboo_domain::PermissionModeResolution,
259) -> Result<&'static str, AgentError> {
260 let configured = match approval_policy {
261 None | Some("never") => "never",
262 Some("on-failure") => "on-failure",
263 Some(_) => {
264 return Err(AgentError::LLM(
265 "invalid Codex exec permission posture configuration".to_string(),
266 ));
267 }
268 };
269 if resolution.suppress_approval_prompts()
270 || resolution.effective == bamboo_domain::PermissionMode::Plan
271 {
272 return Ok("never");
273 }
274 match sandbox {
275 Some("danger-full-access") => Ok("never"),
276 Some("read-only") | Some("workspace-write") => Ok(configured),
277 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
278 None => Ok(configured),
279 Some(_) => Err(AgentError::LLM(
280 "invalid Codex exec permission posture configuration".to_string(),
281 )),
282 }
283}
284
285fn workspace_is_bamboo_owned(raw: &str) -> bool {
286 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
287 let configured_root = bamboo_config::paths::resolve_workspace_root();
288 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
289 if workspace.starts_with(&configured_root) {
290 return true;
291 }
292
293 workspace.ancestors().any(|candidate| {
298 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
299 return false;
300 };
301 let Some(worktree_root) = candidate.parent() else {
302 return false;
303 };
304 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
305 || worktree_root.parent().and_then(Path::file_name)
306 != Some(std::ffi::OsStr::new(".bamboo"))
307 {
308 return false;
309 }
310 let marker = worktree_root.join(".bamboo-owned").join(name);
311 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
312 })
313}
314
315fn build_codex_run_secrets(
316 executor: &ExecutorSpec,
317 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
318 child_session_id: &str,
319) -> Result<
320 (
321 bamboo_subagent::proto::RunSecrets,
322 Option<CodexRunTokenGuard>,
323 ),
324 AgentError,
325> {
326 if !executor_uses_bamboo_codex(executor) {
327 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
328 }
329
330 let authority = authority.ok_or_else(|| {
331 AgentError::LLM(
332 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
333 )
334 })?;
335 let issued = authority
336 .issue(child_session_id)
337 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
338 let guard = CodexRunTokenGuard {
339 authority,
340 token_id: issued.token_id,
341 };
342 Ok((
343 bamboo_subagent::proto::RunSecrets {
344 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
345 },
346 Some(guard),
347 ))
348}
349
350struct PooledWorker {
356 worker: SpawnedChild,
357 mailbox_id: String,
359 parked_at: Option<Instant>,
362}
363
364type WorkerPool = HashMap<String, Vec<PooledWorker>>;
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367enum PoolReapReason {
368 ProcessExited,
369 IdleTimeout,
370}
371
372impl PoolReapReason {
373 fn as_str(self) -> &'static str {
374 match self {
375 Self::ProcessExited => "process_exited_while_parked",
376 Self::IdleTimeout => "pool_idle_timeout",
377 }
378 }
379}
380
381fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
382 parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
383}
384
385fn idle_pool_limit_reason(
386 pool: &WorkerPool,
387 key: &str,
388 max_per_key: usize,
389 max_total: usize,
390) -> Option<&'static str> {
391 if pool.get(key).map_or(0, Vec::len) >= max_per_key {
392 Some("pool_per_key_limit")
393 } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
394 Some("pool_global_limit")
395 } else {
396 None
397 }
398}
399
400fn take_reapable_workers(
401 pool: &mut WorkerPool,
402 now: Instant,
403 timeout: Duration,
404) -> Vec<(String, PoolReapReason, PooledWorker)> {
405 let mut reaped = Vec::new();
406 for (key, bucket) in pool.iter_mut() {
407 let mut retained = Vec::with_capacity(bucket.len());
408 for mut worker in std::mem::take(bucket) {
409 let reason = if !worker.worker.is_alive() {
410 Some(PoolReapReason::ProcessExited)
411 } else if parked_worker_expired(worker.parked_at, now, timeout) {
412 Some(PoolReapReason::IdleTimeout)
413 } else {
414 None
415 };
416 if let Some(reason) = reason {
417 reaped.push((key.clone(), reason, worker));
418 } else {
419 retained.push(worker);
420 }
421 }
422 *bucket = retained;
423 }
424 pool.retain(|_, bucket| !bucket.is_empty());
425 reaped
426}
427
428async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
429 let now = Instant::now();
430 let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
431 let reaped = {
432 let mut pool = pool.lock().await;
433 take_reapable_workers(&mut pool, now, timeout)
434 };
435 let count = reaped.len();
436 for (pool_key, reason, worker) in reaped {
437 let idle_ms = worker
438 .parked_at
439 .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
440 tracing::info!(
441 pool_key,
442 mailbox_id = %worker.mailbox_id,
443 worker_pid = ?worker.worker.pid(),
444 worker_idle_ms = ?idle_ms,
445 shutdown_reason = reason.as_str(),
446 "reaping parked sub-agent worker"
447 );
448 worker.worker.kill().await;
449 }
450 count
451}
452
453#[derive(Debug, Clone)]
459pub struct ResolvedRemotePlacement {
460 pub endpoint: String,
461 pub token: Option<String>,
462 pub ca_cert_file: Option<PathBuf>,
463 pub host_label: Option<String>,
467}
468
469#[derive(Debug, Clone)]
476pub struct ResolvedSchedulablePlacement {
477 pub pool: String,
478 pub host_label: Option<String>,
482}
483
484enum PlacementKind {
490 Local,
491 Remote,
492 Schedulable,
493}
494
495fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
504 if !spec.capabilities.read_only_enforced() {
505 return Ok(());
506 }
507
508 if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
509 return Err(AgentError::LLM(format!(
510 "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
511 spec.identity.role
512 )));
513 }
514
515 if !matches!(spec.placement, Placement::Local) {
516 return Err(AgentError::LLM(format!(
517 "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
518 spec.identity.role
519 )));
520 }
521
522 Ok(())
523}
524
525pub struct ActorChildRunner {
527 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
528 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
529 agent_id: String,
530 worker_bin: PathBuf,
531 worker_args: Vec<String>,
532 fabric_dir: PathBuf,
533 executor: ExecutorSpec,
534 credentials: Vec<ScopedCredential>,
537 default_provider: String,
539 bus: Option<bamboo_subagent::BusEndpoint>,
542 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
546 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
552 max_idle_per_key: usize,
553 max_idle_total: usize,
554 pool_reaper_started: AtomicBool,
555 next_execution_epoch: AtomicU64,
559 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
563 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
567 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
578 remote_placements: HashMap<String, ResolvedRemotePlacement>,
584 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
592 schedule_cursor: Arc<std::sync::Mutex<HashMap<String, usize>>>,
597 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
599 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
602}
603
604#[async_trait]
614pub trait ChildApprovalDecider: Send + Sync {
615 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
618}
619
620async fn decide_child_approval(
623 decider: Option<&Arc<dyn ChildApprovalDecider>>,
624 child_session_id: &str,
625 request: &serde_json::Value,
626) -> bool {
627 match decider {
628 Some(decider) => decider.decide(child_session_id, request).await,
629 None => false,
630 }
631}
632
633const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
637
638#[async_trait]
647pub trait ChildApprovalReviewer: Send + Sync {
648 async fn review(
651 &self,
652 parent_session_id: &str,
653 child_session_id: &str,
654 request: &serde_json::Value,
655 ) -> bool;
656}
657
658fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
659 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
660 &SLOT
661}
662
663pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
665 let _ = child_approval_reviewer_slot().set(reviewer);
666}
667
668pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
670 child_approval_reviewer_slot().get().cloned()
671}
672
673impl ActorChildRunner {
674 #[allow(clippy::too_many_arguments)]
675 pub fn new(
676 agent_id: String,
677 worker_bin: PathBuf,
678 worker_args: Vec<String>,
679 fabric_dir: PathBuf,
680 executor: ExecutorSpec,
681 credentials: Vec<ScopedCredential>,
682 default_provider: String,
683 max_concurrent: usize,
684 ) -> Self {
685 Self {
686 approval_registry: None,
687 permission_config: None,
688 agent_id,
689 worker_bin,
690 worker_args,
691 fabric_dir,
692 executor,
693 credentials,
694 default_provider,
695 bus: None,
696 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
697 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
698 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
699 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
700 pool_reaper_started: AtomicBool::new(false),
701 next_execution_epoch: AtomicU64::new(0),
702 approval_decider: None,
703 approval_reviewer: None,
704 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
705 remote_placements: HashMap::new(),
706 schedulable_placements: HashMap::new(),
707 schedule_cursor: Arc::new(std::sync::Mutex::new(HashMap::new())),
708 codex_run_tokens: None,
709 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
710 }
711 }
712
713 pub fn with_approval_registry(
714 mut self,
715 registry: super::approval_registry::SharedApprovalRegistry,
716 ) -> Self {
717 self.approval_registry = Some(registry);
718 self
719 }
720
721 pub fn with_permission_config(
722 mut self,
723 config: Arc<bamboo_tools::permission::PermissionConfig>,
724 ) -> Self {
725 self.permission_config = Some(config);
726 self
727 }
728
729 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
734 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
735 self
736 }
737
738 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
741 self.approval_decider = Some(decider);
742 self
743 }
744
745 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
746 self.approval_reviewer = Some(reviewer);
747 self
748 }
749
750 pub fn with_codex_run_tokens(
751 mut self,
752 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
753 ) -> Self {
754 self.codex_run_tokens = authority;
755 self
756 }
757
758 pub fn with_remote_placements(
764 mut self,
765 placements: HashMap<String, ResolvedRemotePlacement>,
766 ) -> Self {
767 self.remote_placements = placements;
768 self
769 }
770
771 pub fn with_schedulable_placements(
777 mut self,
778 placements: HashMap<String, ResolvedSchedulablePlacement>,
779 ) -> Self {
780 self.schedulable_placements = placements;
781 self
782 }
783
784 fn fingerprint(spec: &ProvisionSpec) -> String {
802 let role = spec.identity.role.as_str();
803 let (provider, model) = spec
804 .model
805 .as_ref()
806 .map(|m| (m.provider.as_str(), m.model.as_str()))
807 .unwrap_or(("", ""));
808 let workspace = spec.workspace.as_deref().unwrap_or("");
809 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
810 tools.sort();
811 let caps = &spec.capabilities;
812 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
815 format!(
816 "{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}ro={}\u{1}gro={}\u{1}executor={executor}",
817 tools.join(","),
818 spec.identity.depth,
819 caps.nested_spawn,
820 caps.permission_requested_mode,
821 caps.permission_effective_mode,
822 caps.bypass,
823 caps.auto_approve_permissions,
824 caps.enforce_permissions,
825 caps.max_spawn_depth.unwrap_or(0),
826 caps.no_human_approver,
833 caps.read_only,
836 caps.guardian_read_only,
839 )
840 }
841
842 fn ensure_pool_reaper(&self) {
845 if self
846 .pool_reaper_started
847 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
848 .is_err()
849 {
850 return;
851 }
852 let pool = Arc::downgrade(&self.pool);
853 tokio::spawn(async move {
854 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
855 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
856 interval.tick().await;
859 loop {
860 interval.tick().await;
861 let Some(pool) = pool.upgrade() else {
862 break;
863 };
864 let _ = reap_worker_pool_once(&pool).await;
865 }
866 });
867 }
868
869 async fn acquire_bus_worker(
875 &self,
876 key: &str,
877 spec: &ProvisionSpec,
878 ) -> crate::runtime::runner::Result<PooledWorker> {
879 self.ensure_pool_reaper();
880 loop {
884 let candidate = {
885 let mut pool = self.pool.lock().await;
886 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
887 if pool.get(key).is_some_and(Vec::is_empty) {
888 pool.remove(key);
889 }
890 candidate
891 };
892 let Some(mut candidate) = candidate else {
893 break;
894 };
895 let now = Instant::now();
896 let expired = parked_worker_expired(
897 candidate.parked_at,
898 now,
899 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
900 );
901 if candidate.worker.is_alive() && !expired {
902 candidate.parked_at = None;
903 return Ok(candidate);
904 }
905 tracing::info!(
906 pool_key = key,
907 mailbox_id = %candidate.mailbox_id,
908 worker_pid = ?candidate.worker.pid(),
909 shutdown_reason = if expired {
910 PoolReapReason::IdleTimeout.as_str()
911 } else {
912 PoolReapReason::ProcessExited.as_str()
913 },
914 "discarding unusable parked sub-agent worker during checkout"
915 );
916 candidate.worker.kill().await;
917 }
918
919 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
920 .await
921 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
922 let mailbox_id = spawned.record.agent_id.clone();
923 Ok(PooledWorker {
924 worker: spawned,
925 mailbox_id,
926 parked_at: None,
927 })
928 }
929
930 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
934 self.ensure_pool_reaper();
935 if !worker.worker.is_alive() {
936 tracing::info!(
937 pool_key = key,
938 mailbox_id = %worker.mailbox_id,
939 worker_pid = ?worker.worker.pid(),
940 shutdown_reason = "process_exited_before_park",
941 "discarding sub-agent worker instead of parking"
942 );
943 worker.worker.kill().await;
944 return;
945 }
946 worker.parked_at = Some(Instant::now());
947 let mut pool = self.pool.lock().await;
948 let key_count = pool.get(key).map_or(0, Vec::len);
949 let total_count = pool.values().map(Vec::len).sum::<usize>();
950 let limit_reason =
951 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
952 if let Some(shutdown_reason) = limit_reason {
953 drop(pool);
954 tracing::info!(
955 pool_key = key,
956 mailbox_id = %worker.mailbox_id,
957 worker_pid = ?worker.worker.pid(),
958 idle_workers_for_key = key_count,
959 idle_workers_total = total_count,
960 max_idle_per_key = self.max_idle_per_key,
961 max_idle_total = self.max_idle_total,
962 shutdown_reason,
963 "discarding sub-agent worker because the warm pool is full"
964 );
965 worker.worker.kill().await;
966 return;
967 }
968 pool.entry(key.to_string()).or_default().push(worker);
969 }
970
971 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
973 let mut spec = ProvisionSpec::new(
974 ChildIdentity {
975 child_id: job.child_session_id.clone(),
976 parent_id: Some(job.parent_session_id.clone()),
977 project_key: None,
978 role: session
979 .metadata
980 .get("subagent_type")
981 .cloned()
982 .unwrap_or_else(|| "worker".to_string()),
983 depth: session.spawn_depth,
988 },
989 self.executor.clone(),
990 self.fabric_dir.to_string_lossy().into_owned(),
991 );
992 spec.workspace = session.workspace.clone();
993 if let ExecutorSpec::Codex {
994 workspace_owned, ..
995 } = &mut spec.executor
996 {
997 *workspace_owned = Some(
998 spec.workspace
999 .as_deref()
1000 .is_some_and(workspace_is_bamboo_owned),
1001 );
1002 }
1003 spec.bus = self.bus.clone();
1006 spec.model = session
1009 .model_ref
1010 .as_ref()
1011 .map(|r| ModelRefSpec {
1012 provider: r.provider.clone(),
1013 model: r.model.clone(),
1014 })
1015 .or_else(|| {
1016 let m = job.model.trim();
1017 (!m.is_empty()).then(|| ModelRefSpec {
1018 provider: self.default_provider.clone(),
1019 model: m.to_string(),
1020 })
1021 });
1022 spec.disabled_tools = job.disabled_tools.clone();
1023 match &spec.executor {
1024 ExecutorSpec::Codex {
1030 auth_mode,
1031 provider_key_ref,
1032 ..
1033 } => {
1034 if auth_mode.as_deref() == Some("custom") {
1035 if let Some(reference) = provider_key_ref {
1036 if let Some(credential) = self.credentials.iter().find(|credential| {
1037 credential.credential_ref.as_deref() == Some(reference)
1038 }) {
1039 spec.secrets.provider_credentials.push(credential.clone());
1040 } else {
1041 tracing::warn!(
1042 "actor child {}: custom Codex credential reference '{}' did not resolve",
1043 job.child_session_id,
1044 reference
1045 );
1046 }
1047 } else {
1048 tracing::warn!(
1049 "actor child {}: custom Codex executor has no credential reference",
1050 job.child_session_id
1051 );
1052 }
1053 }
1054 }
1055 _ => {
1058 let provider = spec
1059 .model
1060 .as_ref()
1061 .map(|model| model.provider.as_str())
1062 .filter(|provider| !provider.trim().is_empty())
1063 .unwrap_or(&self.default_provider);
1064 if let Some(credential) = self
1065 .credentials
1066 .iter()
1067 .find(|credential| credential.provider == provider)
1068 {
1069 spec.secrets.provider_credentials.push(credential.clone());
1070 } else {
1071 tracing::warn!(
1072 "actor child {}: no credential found for provider '{}'",
1073 job.child_session_id,
1074 provider
1075 );
1076 }
1077 }
1078 }
1079 spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
1086 spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
1087 spec.capabilities.enforce_permissions = true;
1093 let requested_permission_mode = session
1098 .agent_runtime_state
1099 .as_ref()
1100 .map(|state| state.effective_permission_mode())
1101 .unwrap_or_default();
1102 let configured_permission_mode = self
1103 .permission_config
1104 .as_ref()
1105 .map(|config| config.mode())
1106 .unwrap_or_default();
1107 spec.capabilities.no_human_approver = session
1112 .agent_runtime_state
1113 .as_ref()
1114 .is_some_and(|s| s.no_human_approver);
1115 let typed_read_only = session
1121 .agent_runtime_state
1122 .as_ref()
1123 .is_some_and(|state| state.read_only);
1124 let legacy_guardian_read_only = spec.identity.role == "guardian"
1125 && spec.disabled_tools.as_ref().is_some_and(|disabled| {
1126 ["Edit", "Write", "SubAgent", "WebFetch"]
1127 .iter()
1128 .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
1129 });
1130 let read_only = typed_read_only || legacy_guardian_read_only;
1131 spec.capabilities.read_only = read_only;
1132 spec.capabilities.guardian_read_only = read_only;
1136 if spec.capabilities.read_only_enforced() {
1137 spec.capabilities.nested_spawn = false;
1140 spec.disabled_tools
1145 .get_or_insert_with(Default::default)
1146 .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
1147 match &mut spec.executor {
1148 ExecutorSpec::ClaudeCode {
1149 permission_mode,
1150 inherit_user_config,
1151 ..
1152 } => {
1153 *permission_mode = Some("plan".to_string());
1154 *inherit_user_config = Some(false);
1157 }
1158 ExecutorSpec::Codex {
1159 mode,
1160 sandbox,
1161 inherit_user_config,
1162 approval_policy,
1163 network_access,
1164 allow_danger_bypass,
1165 permission_profile,
1166 ..
1167 } => {
1168 *sandbox = Some("read-only".to_string());
1169 *inherit_user_config = Some(false);
1170 *approval_policy = Some(
1173 if mode.as_deref() == Some("app_server") {
1174 "on-request"
1175 } else {
1176 "never"
1177 }
1178 .to_string(),
1179 );
1180 *network_access = Some(false);
1181 *allow_danger_bypass = Some(false);
1182 *permission_profile = Some("read-only".to_string());
1183 }
1184 _ => {}
1185 }
1186 }
1187 let read_only_overlay = session
1188 .agent_runtime_state
1189 .as_ref()
1190 .is_some_and(|state| state.plan_mode.is_some())
1191 || spec.capabilities.read_only_enforced()
1192 || executor_has_read_only_permission_profile(&spec.executor);
1193 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1194 requested_permission_mode,
1195 configured_permission_mode,
1196 read_only_overlay,
1197 );
1198 spec.capabilities.bypass = permission_resolution.bypass_permissions();
1199 spec.capabilities.auto_approve_permissions =
1200 permission_resolution.suppress_approval_prompts();
1201 spec.capabilities.permission_requested_mode =
1202 permission_resolution.requested.as_str().to_string();
1203 spec.capabilities.permission_effective_mode =
1204 permission_resolution.effective.as_str().to_string();
1205 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
1212 spec.placement = Placement::Remote {
1213 endpoint: placement.endpoint.clone(),
1214 };
1215 spec.secrets.worker_auth_token = placement.token.clone();
1216 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
1217 {
1218 spec.placement = Placement::Schedulable {
1226 pool: placement.pool.clone(),
1227 };
1228 }
1229 spec
1230 }
1231
1232 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
1238 let host_label = match &spec.placement {
1239 Placement::Remote { .. } => self
1240 .remote_placements
1241 .get(spec.identity.role.as_str())
1242 .and_then(|p| p.host_label.as_deref()),
1243 Placement::Schedulable { .. } => self
1244 .schedulable_placements
1245 .get(spec.identity.role.as_str())
1246 .and_then(|p| p.host_label.as_deref()),
1247 Placement::Local => None,
1248 };
1249 placement_metadata(&spec.placement, host_label)
1250 }
1251
1252 async fn resolve_schedulable_worker(
1259 &self,
1260 role: &str,
1261 ) -> std::result::Result<String, AgentError> {
1262 let pool = self
1263 .schedulable_placements
1264 .get(role)
1265 .ok_or_else(|| {
1266 AgentError::LLM(format!(
1267 "schedulable placement for role '{role}' vanished before scheduling"
1268 ))
1269 })?
1270 .pool
1271 .clone();
1272 let bus = self.bus.as_ref().ok_or_else(|| {
1273 AgentError::LLM(format!(
1274 "schedulable role '{role}': no mailbox bus configured (subagents.broker)"
1275 ))
1276 })?;
1277
1278 let mut q = bamboo_broker::BrokerClient::connect(
1281 &bus.endpoint,
1282 bamboo_subagent::AgentRef {
1283 session_id: format!("sched-q-{role}"),
1284 role: None,
1285 },
1286 &bus.token,
1287 )
1288 .await
1289 .map_err(|e| {
1290 AgentError::LLM(format!(
1291 "schedulable role '{role}': bus connect failed: {e}"
1292 ))
1293 })?;
1294 let candidates = q.list_connected(&pool).await.map_err(|e| {
1295 AgentError::LLM(format!(
1296 "schedulable role '{role}': bus presence query failed: {e}"
1297 ))
1298 })?;
1299
1300 if candidates.is_empty() {
1301 return Err(AgentError::LLM(format!(
1302 "schedulable role '{role}': no live worker in pool '{pool}' on the bus \
1303 (NOT spawning a local subprocess — a schedulable role has no local fallback)"
1304 )));
1305 }
1306
1307 let idx = {
1312 let mut cursors = self.schedule_cursor.lock().recover_poison();
1313 let cursor = cursors.entry(pool.clone()).or_insert(0);
1314 let i = *cursor % candidates.len();
1315 *cursor = cursor.wrapping_add(1);
1316 i
1317 };
1318 Ok(candidates[idx].clone())
1319 }
1320}
1321
1322#[async_trait]
1323impl ExternalChildRunner for ActorChildRunner {
1324 async fn should_handle(&self, session: &Session) -> bool {
1325 session.metadata.get("runtime.kind") == Some(&"external".to_string())
1326 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
1327 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
1328 }
1329
1330 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
1331 *self.escalation_bridge.lock().recover_poison() = bridge;
1332 }
1333
1334 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
1335 *self.session_inbox_runtime.lock().recover_poison() = binding;
1336 }
1337
1338 async fn execute_external_child(
1339 &self,
1340 session: &mut Session,
1341 job: &SpawnJob,
1342 event_tx: mpsc::Sender<AgentEvent>,
1343 cancel_token: CancellationToken,
1344 ) -> crate::runtime::runner::Result<()> {
1345 let escalation = self.escalation_bridge.lock().recover_poison().clone();
1354 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
1355 let assignment = extract_assignment(session);
1356 let mut spec = self.build_spec(session, job);
1357 spec.reusable = true;
1360 if spec.limits.idle_timeout_secs.is_none() {
1361 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
1362 }
1363 ensure_read_only_activation_is_enforceable(&spec)?;
1364 let pool_key = Self::fingerprint(&spec);
1365
1366 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
1371 {
1372 return Err(AgentError::LLM(
1373 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
1374 .to_string(),
1375 ));
1376 }
1377 let project_id = project_id_for_actor_run(session)?;
1378 let requested_permission_mode = session
1379 .agent_runtime_state
1380 .as_ref()
1381 .map(|state| state.effective_permission_mode())
1382 .unwrap_or_default();
1383 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
1388 ensure_no_active_scoped_session_denies(config, &session.id)?;
1389 let read_only_overlay = session
1390 .agent_runtime_state
1391 .as_ref()
1392 .is_some_and(|state| state.plan_mode.is_some())
1393 || spec.capabilities.read_only_enforced()
1394 || executor_has_read_only_permission_profile(&spec.executor);
1395 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1396 requested_permission_mode,
1397 config.mode(),
1398 read_only_overlay,
1399 );
1400 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
1401 AgentError::LLM(format!(
1402 "failed to serialize permission policy for external executor: {error}"
1403 ))
1404 })?;
1405 Some(PermissionPolicyContext {
1406 revision: config.policy_revision(),
1407 requested_mode: resolution.requested.as_str().to_string(),
1408 effective_mode: resolution.effective.as_str().to_string(),
1409 bypass_permissions: resolution.bypass_permissions(),
1410 auto_approve_permissions: resolution.suppress_approval_prompts(),
1411 session_id: session.id.clone(),
1412 workspace_path: session.workspace.clone(),
1413 inherit_session_grants: false,
1414 policy,
1415 })
1416 } else {
1417 None
1418 };
1419 let provisioned_permission =
1420 spec.capabilities.permission_resolution().map_err(|error| {
1421 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
1422 })?;
1423 let policy_resolution = permission_policy
1424 .as_ref()
1425 .map(|context| {
1426 context.resolved_modes().map(|(requested, effective)| {
1427 bamboo_domain::PermissionModeResolution {
1428 requested,
1429 effective,
1430 }
1431 })
1432 })
1433 .transpose()
1434 .map_err(|error| {
1435 AgentError::LLM(format!("invalid host permission posture: {error}"))
1436 })?;
1437 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
1438 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
1439 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
1440 .is_some()
1441 });
1442 let expected_executor_mapping = expected_permission_executor_mapping(
1443 &spec.executor,
1444 policy_resolution.unwrap_or(provisioned_permission),
1445 has_explicit_deny,
1446 )?;
1447 let expected_permission_posture =
1448 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
1449 policy_revision: permission_policy
1450 .as_ref()
1451 .map(|context| context.revision)
1452 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
1453 .unwrap_or_default(),
1454 resolution: policy_resolution.unwrap_or(provisioned_permission),
1455 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
1456 executor_mapping,
1457 });
1458
1459 let _slot = self
1464 .concurrency
1465 .acquire()
1466 .await
1467 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".to_string()))?;
1468
1469 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
1476 &spec.executor,
1477 self.codex_run_tokens.clone(),
1478 &job.child_session_id,
1479 )?;
1480
1481 let kind = match spec.placement {
1489 Placement::Remote { .. } => PlacementKind::Remote,
1490 Placement::Schedulable { .. } => PlacementKind::Schedulable,
1491 Placement::Local => PlacementKind::Local,
1492 };
1493 let remote = !matches!(kind, PlacementKind::Local);
1494
1495 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
1501 session
1502 .metadata
1503 .insert("placement".to_string(), placement_meta);
1504 }
1505
1506 let mut attempt = 0u8;
1513 let (result, actor) = loop {
1514 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
1515 let (actor, mut client) = match kind {
1516 PlacementKind::Remote => {
1517 let placement = self
1521 .remote_placements
1522 .get(spec.identity.role.as_str())
1523 .ok_or_else(|| {
1524 AgentError::LLM(format!(
1525 "remote placement for role '{}' vanished before connect",
1526 spec.identity.role
1527 ))
1528 })?;
1529 let endpoint = placement.endpoint.clone();
1530 let trust_cfg = match placement.ca_cert_file.as_deref() {
1533 Some(path) => Some(client_config_trusting_cert(path).map_err(|e| {
1534 AgentError::LLM(format!(
1535 "remote worker CA cert '{}': {e}",
1536 path.display()
1537 ))
1538 })?),
1539 None => None,
1540 };
1541 let client = ChildClient::connect_with_auth_tls(
1542 &endpoint,
1543 placement.token.as_deref(),
1544 trust_cfg,
1545 )
1546 .await
1547 .map_err(|e| {
1548 AgentError::LLM(format!("remote actor connect to '{endpoint}' failed: {e}"))
1549 })?;
1550 let record = AgentRecord {
1553 agent_id: job.child_session_id.clone(),
1554 role: spec.identity.role.clone(),
1555 labels: Vec::new(),
1556 endpoint: endpoint.clone(),
1557 pid: 0,
1558 version: String::new(),
1559 started_at: chrono::Utc::now(),
1560 lease_expires_at: chrono::Utc::now(),
1561 };
1562 let _ = endpoint;
1563 let actor = PooledWorker {
1564 worker: SpawnedChild::remote(record),
1565 mailbox_id: job.child_session_id.clone(),
1566 parked_at: None,
1567 };
1568 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(client);
1569 (actor, client)
1570 }
1571 PlacementKind::Schedulable => {
1572 let bus = self.bus.as_ref().ok_or_else(|| {
1579 AgentError::LLM(
1580 "schedulable sub-agents require a mailbox bus (subagents.broker)"
1581 .to_string(),
1582 )
1583 })?;
1584 let mailbox_id = self
1585 .resolve_schedulable_worker(spec.identity.role.as_str())
1586 .await?;
1587 let parent = bamboo_subagent::AgentRef {
1588 session_id: format!("p-{}", job.child_session_id),
1589 role: None,
1590 };
1591 let link = bamboo_broker::BrokerChildLink::connect(
1592 &bus.endpoint,
1593 parent,
1594 &bus.token,
1595 mailbox_id.clone(),
1596 )
1597 .await
1598 .map_err(|e| {
1599 AgentError::LLM(format!(
1600 "schedulable link connect to '{mailbox_id}' failed: {e}"
1601 ))
1602 })?;
1603 let actor = PooledWorker {
1606 worker: SpawnedChild::remote(AgentRecord {
1607 agent_id: mailbox_id.clone(),
1608 role: spec.identity.role.clone(),
1609 labels: Vec::new(),
1610 endpoint: bus.endpoint.clone(),
1611 pid: 0,
1612 version: String::new(),
1613 started_at: chrono::Utc::now(),
1614 lease_expires_at: chrono::Utc::now(),
1615 }),
1616 mailbox_id,
1617 parked_at: None,
1618 };
1619 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1620 (actor, client)
1621 }
1622 PlacementKind::Local => {
1623 let bus = self.bus.as_ref().ok_or_else(|| {
1630 AgentError::LLM(
1631 "local sub-agents require a mailbox bus (subagents.broker); none is \
1632 configured and the bus could not be embedded"
1633 .to_string(),
1634 )
1635 })?;
1636 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
1637 let parent = bamboo_subagent::AgentRef {
1638 session_id: format!("p-{}", job.child_session_id),
1639 role: None,
1640 };
1641 let link = bamboo_broker::BrokerChildLink::connect(
1642 &bus.endpoint,
1643 parent,
1644 &bus.token,
1645 actor.mailbox_id.clone(),
1646 )
1647 .await
1648 .map_err(|e| {
1649 AgentError::LLM(format!("broker child link connect failed: {e}"))
1650 })?;
1651 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1652 (actor, client)
1653 }
1654 };
1655
1656 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
1661 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
1662 Some(binding) => {
1663 let run_id = binding
1664 .router
1665 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
1666 .await;
1667 if run_id.is_none() {
1668 tracing::debug!(
1669 session_id = %job.child_session_id,
1670 "actor driver had no current SessionInbox activation owner to bind"
1671 );
1672 }
1673 run_id
1674 }
1675 None => None,
1676 };
1677 drop(delivery_tx);
1678 let initial_pairs = match (
1679 session_inbox_runtime.as_ref(),
1680 bound_activation_run_id.as_deref(),
1681 ) {
1682 (Some(binding), Some(run_id)) => {
1683 match claim_canonical_deliveries(binding, session, run_id, usize::MAX).await {
1684 Ok(deliveries) => deliveries,
1685 Err(error) => {
1686 binding
1687 .router
1688 .detach_delivery_sink(&job.child_session_id, run_id)
1689 .await;
1690 if !remote {
1691 actor.worker.kill().await;
1692 }
1693 return Err(error);
1694 }
1695 }
1696 }
1697 _ => Vec::new(),
1698 };
1699 let initial_session_messages = initial_pairs
1700 .iter()
1701 .map(|(_, delivery)| delivery.clone())
1702 .collect::<Vec<_>>();
1703 let initial_inflight_claims = initial_pairs
1704 .into_iter()
1705 .map(|(claim, _)| claim)
1706 .collect::<VecDeque<_>>();
1707 let messages = session
1710 .messages
1711 .iter()
1712 .filter_map(|message| serde_json::to_value(message).ok())
1713 .collect();
1714
1715 if let Err(e) = client
1716 .send(ParentFrame::Run(RunSpec {
1717 assignment: assignment.clone(),
1719 logical_session: Some(logical_identity_for_actor_run(session, job)),
1720 project_id: project_id.clone(),
1721 reasoning_effort: None,
1722 permission_policy: permission_policy.clone(),
1723 messages,
1724 activation_run_id: bound_activation_run_id.clone(),
1725 execution_epoch,
1726 initial_session_messages,
1727 secrets: run_secrets.clone(),
1728 }))
1729 .await
1730 {
1731 if let (Some(binding), Some(run_id)) = (
1732 session_inbox_runtime.as_ref(),
1733 bound_activation_run_id.as_deref(),
1734 ) {
1735 binding
1736 .router
1737 .detach_delivery_sink(&job.child_session_id, run_id)
1738 .await;
1739 }
1740 if !remote {
1741 actor.worker.kill().await;
1742 }
1743 return Err(AgentError::LLM(format!("actor run dispatch failed: {e}")));
1744 }
1745
1746 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
1750 let live_guard = super::live::register(
1751 &job.child_session_id,
1752 live_tx,
1753 attempt as u32,
1754 self.approval_registry.clone(),
1755 );
1756
1757 let result = drive(ActorDriveContext {
1758 client: &mut *client,
1759 parent_session_id: &job.parent_session_id,
1760 child_session_id: &job.child_session_id,
1761 child_attempt: attempt as u32,
1762 approval_registry: self.approval_registry.as_ref(),
1763 approval_decider: self.approval_decider.as_ref(),
1764 approval_reviewer: self.approval_reviewer.as_ref(),
1765 escalation_bridge: escalation.clone(),
1766 event_tx: &event_tx,
1767 cancel_token: &cancel_token,
1768 live_rx: &mut live_rx,
1769 delivery_rx: &mut delivery_rx,
1770 logical_session: session,
1771 expected_permission_posture: expected_permission_posture.clone(),
1772 session_inbox_runtime: session_inbox_runtime.as_ref(),
1773 activation_run_id: bound_activation_run_id.as_deref(),
1774 execution_epoch,
1775 expected_source_actor_id: &actor.mailbox_id,
1776 initial_inflight_claims,
1777 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
1784 })
1785 .await;
1786 if let (Some(binding), Some(run_id)) = (
1787 session_inbox_runtime.as_ref(),
1788 bound_activation_run_id.as_deref(),
1789 ) {
1790 binding
1791 .router
1792 .detach_delivery_sink(&job.child_session_id, run_id)
1793 .await;
1794 }
1795 drop(live_guard);
1801 drop(client);
1804
1805 if attempt == 0 && matches!(result, Err(AgentError::WorkerUnresponsive(_))) {
1812 match kind {
1813 PlacementKind::Local => {
1814 tracing::warn!(
1815 "actor child {} got no first frame; reaping the worker and respawning once",
1816 job.child_session_id
1817 );
1818 actor.worker.kill().await;
1819 attempt += 1;
1820 continue;
1821 }
1822 PlacementKind::Schedulable => {
1823 tracing::warn!(
1824 "scheduled actor child {} got no first frame; re-selecting a pool worker",
1825 job.child_session_id
1826 );
1827 drop(actor);
1828 attempt += 1;
1829 continue;
1830 }
1831 PlacementKind::Remote => {}
1832 }
1833 }
1834 break (result, actor);
1835 };
1836
1837 if remote {
1841 drop(actor);
1842 } else {
1843 match &result {
1844 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
1845 Err(_) => actor.worker.kill().await,
1846 }
1847 }
1848
1849 match result {
1853 Ok(Some(text)) => {
1854 if !text.is_empty() {
1855 session.add_message(bamboo_agent_core::Message::assistant(text, None));
1856 }
1857 Ok(())
1858 }
1859 Ok(None) => Ok(()),
1860 Err(e) => Err(e),
1861 }
1862 }
1863}
1864
1865fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
1871 let value = match placement {
1874 Placement::Local => return None,
1875 Placement::Remote { endpoint } => serde_json::json!({
1876 "kind": "remote",
1877 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
1878 }),
1879 Placement::Schedulable { pool } => serde_json::json!({
1880 "kind": "remote",
1881 "host": host_label.unwrap_or(pool),
1882 }),
1883 };
1884 serde_json::to_string(&value).ok()
1885}
1886
1887fn host_of_endpoint(endpoint: &str) -> String {
1889 endpoint
1890 .trim()
1891 .trim_start_matches("wss://")
1892 .trim_start_matches("ws://")
1893 .split(['/', ':'])
1894 .next()
1895 .unwrap_or(endpoint)
1896 .to_string()
1897}
1898
1899async fn reconcile_already_admitted_claim(
1900 binding: &SessionInboxRuntimeBinding,
1901 session: &mut Session,
1902 claim: &SessionInboxClaim,
1903) -> crate::runtime::runner::Result<()> {
1904 let latest = binding
1905 .storage
1906 .load_session(&session.id)
1907 .await
1908 .map_err(|error| {
1909 AgentError::LLM(format!(
1910 "load canonical SessionInbox checkpoint for {}: {error}",
1911 session.id
1912 ))
1913 })?
1914 .ok_or_else(|| {
1915 AgentError::LLM(format!(
1916 "canonical SessionInbox target disappeared: {}",
1917 session.id
1918 ))
1919 })?;
1920 let Some(message) = latest
1921 .messages
1922 .iter()
1923 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1924 .cloned()
1925 else {
1926 return Err(AgentError::LLM(format!(
1927 "canonical admitted receipt for {} exists without transcript message {}",
1928 session.id, claim.envelope.id
1929 )));
1930 };
1931 if let Some(existing) = session
1932 .messages
1933 .iter_mut()
1934 .find(|existing| existing.id == message.id)
1935 {
1936 *existing = message;
1937 } else {
1938 session.add_message(message);
1939 }
1940 bamboo_domain::merge_session_inbox_admission(session, &latest);
1941 binding
1942 .inbox
1943 .ack(&session.id, claim)
1944 .await
1945 .map_err(|error| {
1946 AgentError::LLM(format!(
1947 "ack recovered canonical SessionInbox claim {}: {error}",
1948 claim.envelope.id
1949 ))
1950 })
1951}
1952
1953async fn checkpoint_and_ack_canonical_claim(
1957 binding: &SessionInboxRuntimeBinding,
1958 session: &mut Session,
1959 claim: &SessionInboxClaim,
1960) -> crate::runtime::runner::Result<()> {
1961 if claim.envelope.target_session_id != session.id {
1962 return Err(AgentError::LLM(format!(
1963 "canonical SessionInbox claim target {} does not match active logical session {}",
1964 claim.envelope.target_session_id, session.id
1965 )));
1966 }
1967 if binding
1968 .inbox
1969 .was_admitted(&session.id, &claim.envelope.id)
1970 .await
1971 .map_err(|error| {
1972 AgentError::LLM(format!(
1973 "inspect canonical admitted receipt {}: {error}",
1974 claim.envelope.id
1975 ))
1976 })?
1977 {
1978 return reconcile_already_admitted_claim(binding, session, claim).await;
1979 }
1980
1981 let transcript_has_id = session
1982 .messages
1983 .iter()
1984 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1985 if session
1986 .messages
1987 .iter()
1988 .any(|message| message.id == claim.envelope.id.as_str())
1989 && !transcript_has_id
1990 {
1991 return Err(AgentError::LLM(format!(
1992 "canonical SessionInbox id {} collides with a non-matching transcript message",
1993 claim.envelope.id
1994 )));
1995 }
1996 let cursor_has_id = session
1997 .session_inbox_admission()
1998 .is_some_and(|state| state.contains(&claim.envelope.id));
1999 if cursor_has_id && !transcript_has_id {
2000 return Err(AgentError::LLM(format!(
2001 "canonical SessionInbox cursor exists without transcript message {}",
2002 claim.envelope.id
2003 )));
2004 }
2005 let before = session.clone();
2006 if !transcript_has_id {
2007 let message = claim.envelope.to_provider_message().map_err(|error| {
2008 AgentError::LLM(format!(
2009 "translate canonical SessionInbox envelope {}: {error}",
2010 claim.envelope.id
2011 ))
2012 })?;
2013 session.add_message(message);
2014 }
2015 session
2016 .session_inbox_admission_mut()
2017 .record(claim.envelope.id.clone(), claim.generation);
2018 session.updated_at = chrono::Utc::now();
2019
2020 if let Err(error) = binding
2021 .persistence
2022 .checkpoint_runtime_session(session)
2023 .await
2024 {
2025 *session = before;
2026 return Err(AgentError::LLM(format!(
2027 "checkpoint canonical SessionInbox claim {}: {error}",
2028 claim.envelope.id
2029 )));
2030 }
2031 if !session
2032 .messages
2033 .iter()
2034 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
2035 {
2036 *session = before;
2037 return Err(AgentError::LLM(format!(
2038 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
2039 claim.envelope.id
2040 )));
2041 }
2042 binding
2043 .inbox
2044 .ack(&session.id, claim)
2045 .await
2046 .map_err(|error| {
2047 AgentError::LLM(format!(
2048 "ack canonical SessionInbox claim {} after checkpoint: {error}",
2049 claim.envelope.id
2050 ))
2051 })
2052}
2053
2054async fn checkpoint_claim_context_before_dispatch(
2065 binding: &SessionInboxRuntimeBinding,
2066 session: &mut Session,
2067 claims: &[SessionInboxClaim],
2068) -> crate::runtime::runner::Result<()> {
2069 if claims.is_empty() {
2070 return Ok(());
2071 }
2072 let before = session.clone();
2073 for claim in claims {
2074 if claim.envelope.target_session_id != session.id {
2075 return Err(AgentError::LLM(format!(
2076 "canonical SessionInbox claim target {} does not match actor session {}",
2077 claim.envelope.target_session_id, session.id
2078 )));
2079 }
2080 let matching = session
2081 .messages
2082 .iter()
2083 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
2084 if session
2085 .messages
2086 .iter()
2087 .any(|message| message.id == claim.envelope.id.as_str())
2088 && !matching
2089 {
2090 return Err(AgentError::LLM(format!(
2091 "canonical SessionInbox id {} collides before actor dispatch",
2092 claim.envelope.id
2093 )));
2094 }
2095 if session
2096 .session_inbox_admission()
2097 .is_some_and(|state| state.contains(&claim.envelope.id))
2098 && !matching
2099 {
2100 return Err(AgentError::LLM(format!(
2101 "canonical SessionInbox cursor exists without transcript proof for {}",
2102 claim.envelope.id
2103 )));
2104 }
2105 if !matching {
2106 let message = claim.envelope.to_provider_message().map_err(|error| {
2107 AgentError::LLM(format!(
2108 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
2109 claim.envelope.id
2110 ))
2111 })?;
2112 session.add_message(message);
2113 }
2114 }
2115 session.updated_at = chrono::Utc::now();
2116 if let Err(error) = binding
2117 .persistence
2118 .checkpoint_runtime_session(session)
2119 .await
2120 {
2121 *session = before;
2122 return Err(AgentError::LLM(format!(
2123 "checkpoint canonical SessionInbox actor context: {error}"
2124 )));
2125 }
2126 for claim in claims {
2127 if !session
2128 .messages
2129 .iter()
2130 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
2131 {
2132 *session = before;
2133 return Err(AgentError::LLM(format!(
2134 "actor context checkpoint lost typed transcript proof for {}",
2135 claim.envelope.id
2136 )));
2137 }
2138 }
2139 Ok(())
2140}
2141
2142async fn claim_canonical_deliveries(
2143 binding: &SessionInboxRuntimeBinding,
2144 session: &mut Session,
2145 activation_run_id: &str,
2146 limit: usize,
2147) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
2148 let claims = binding
2149 .inbox
2150 .claim_for_turn(&session.id, limit, Some(activation_run_id))
2151 .await
2152 .map_err(|error| {
2153 AgentError::LLM(format!(
2154 "claim canonical SessionInbox for active actor {}: {error}",
2155 session.id
2156 ))
2157 })?;
2158 if claims.is_empty() {
2159 return Ok(Vec::new());
2160 }
2161 let interrupt_generation = binding
2162 .inbox
2163 .inspect(&session.id)
2164 .await
2165 .map_err(|error| {
2166 AgentError::LLM(format!(
2167 "inspect canonical SessionInbox activation policy for {}: {error}",
2168 session.id
2169 ))
2170 })?
2171 .interrupt_generation;
2172 let mut unconfirmed = Vec::with_capacity(claims.len());
2173 for claim in claims {
2174 if binding
2175 .inbox
2176 .was_admitted(&session.id, &claim.envelope.id)
2177 .await
2178 .map_err(|error| {
2179 AgentError::LLM(format!(
2180 "inspect canonical SessionInbox claim {}: {error}",
2181 claim.envelope.id
2182 ))
2183 })?
2184 {
2185 reconcile_already_admitted_claim(binding, session, &claim).await?;
2186 continue;
2187 }
2188 unconfirmed.push(claim);
2189 }
2190 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed).await?;
2191
2192 let mut deliveries = Vec::with_capacity(unconfirmed.len());
2193 for claim in unconfirmed {
2194 if session
2199 .session_inbox_admission()
2200 .is_some_and(|state| state.contains(&claim.envelope.id))
2201 {
2202 binding
2203 .inbox
2204 .ack(&session.id, &claim)
2205 .await
2206 .map_err(|error| {
2207 AgentError::LLM(format!(
2208 "finish confirmed canonical SessionInbox ack {}: {error}",
2209 claim.envelope.id
2210 ))
2211 })?;
2212 continue;
2213 }
2214 let activation_policy = if claim.generation <= interrupt_generation {
2215 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait
2216 } else {
2217 bamboo_domain::SessionActivationPolicy::RespectSpecificWait
2218 };
2219 let delivery = SessionMessageDelivery {
2220 target_session_id: session.id.clone(),
2221 envelope: claim.envelope.clone(),
2222 canonical_claim_generation: claim.generation,
2223 activation_run_id: activation_run_id.to_string(),
2224 activation_policy,
2225 };
2226 deliveries.push((claim, delivery));
2227 }
2228 Ok(deliveries)
2229}
2230
2231async fn forward_next_canonical_claim(
2232 client: &mut dyn bamboo_subagent::ChildLink,
2233 binding: &SessionInboxRuntimeBinding,
2234 session: &mut Session,
2235 activation_run_id: &str,
2236 inflight: &mut VecDeque<SessionInboxClaim>,
2237) -> crate::runtime::runner::Result<()> {
2238 if !inflight.is_empty() {
2239 return Ok(());
2240 }
2241 let Some((claim, delivery)) =
2242 claim_canonical_deliveries(binding, session, activation_run_id, 1)
2243 .await?
2244 .pop()
2245 else {
2246 return Ok(());
2247 };
2248 client
2249 .send(ParentFrame::SessionMessage { delivery })
2250 .await
2251 .map_err(|error| {
2252 AgentError::LLM(format!(
2253 "forward canonical SessionInbox claim {} to active actor: {error}",
2254 claim.envelope.id
2255 ))
2256 })?;
2257 inflight.push_back(claim);
2258 Ok(())
2259}
2260
2261struct ActorDriveContext<'a> {
2263 client: &'a mut dyn bamboo_subagent::ChildLink,
2264 parent_session_id: &'a str,
2265 child_session_id: &'a str,
2266 child_attempt: u32,
2267 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
2268 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
2269 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
2270 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
2271 event_tx: &'a mpsc::Sender<AgentEvent>,
2272 cancel_token: &'a CancellationToken,
2273 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
2274 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
2275 logical_session: &'a mut Session,
2276 expected_permission_posture: Option<ExpectedPermissionPosture>,
2277 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
2278 activation_run_id: Option<&'a str>,
2279 execution_epoch: u64,
2280 expected_source_actor_id: &'a str,
2281 initial_inflight_claims: VecDeque<SessionInboxClaim>,
2282 first_frame_timeout: Option<Duration>,
2283}
2284
2285#[derive(Debug, Clone, PartialEq, Eq)]
2286struct ExpectedPermissionPosture {
2287 policy_revision: u64,
2288 resolution: bamboo_domain::PermissionModeResolution,
2289 expected_audit_revision: Option<u64>,
2290 executor_mapping: String,
2291}
2292
2293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2294enum PermissionPostureHandshake {
2295 NotRequired,
2297 Awaiting,
2300 Confirmed,
2302}
2303
2304impl PermissionPostureHandshake {
2305 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
2306 if expected.is_some() {
2307 Self::Awaiting
2308 } else {
2309 Self::NotRequired
2310 }
2311 }
2312
2313 fn is_awaiting(self) -> bool {
2314 self == Self::Awaiting
2315 }
2316
2317 fn posture_was_confirmed(self) -> bool {
2318 self == Self::Confirmed
2319 }
2320}
2321
2322fn permission_posture_seed_from_event(
2323 session: &Session,
2324 event: &AgentEvent,
2325) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
2326 let AgentEvent::PermissionPostureActivated {
2327 session_id,
2328 policy_revision,
2329 requested_mode,
2330 effective_mode,
2331 executor_mapping,
2332 } = event
2333 else {
2334 return Ok(None);
2335 };
2336 if session_id != &session.id {
2337 return Err("permission posture event targets a different logical session".to_string());
2338 }
2339 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
2340 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
2341 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
2342 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
2343 let resolution = bamboo_domain::PermissionModeResolution {
2344 requested,
2345 effective,
2346 };
2347 if !resolution.is_consistent() {
2348 return Err("permission posture event has an inconsistent mode pair".to_string());
2349 }
2350 let current_requested = session
2351 .agent_runtime_state
2352 .as_ref()
2353 .map(|state| state.effective_permission_mode())
2354 .unwrap_or_default();
2355 if current_requested != requested {
2356 return Err("permission posture event is stale for the host typed mode".to_string());
2357 }
2358 let mapping_chars = executor_mapping.chars().count();
2359 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
2360 return Err("permission posture event has an invalid executor mapping".to_string());
2361 }
2362 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
2363 *policy_revision,
2364 resolution,
2365 executor_mapping,
2366 )))
2367}
2368
2369fn validate_actor_event_batch(
2370 batch: &ActorEventBatch,
2371 logical_session: &Session,
2372 parent_session_id: &str,
2373 activation_run_id: Option<&str>,
2374 execution_epoch: u64,
2375 expected_source_actor_id: &str,
2376) -> Result<(), AgentError> {
2377 batch
2378 .validate()
2379 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
2380 let identity = batch.logical_session.as_ref().ok_or_else(|| {
2381 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
2382 })?;
2383 let expected_identity = LogicalSessionIdentity {
2388 session_id: logical_session.id.clone(),
2389 parent_session_id: logical_session
2390 .parent_session_id
2391 .clone()
2392 .or_else(|| Some(parent_session_id.to_string())),
2393 root_session_id: if logical_session.root_session_id.trim().is_empty() {
2394 parent_session_id.to_string()
2395 } else {
2396 logical_session.root_session_id.clone()
2397 },
2398 };
2399 if identity != &expected_identity {
2400 return Err(AgentError::LLM(
2401 "actor event batch targets a different logical session".to_string(),
2402 ));
2403 }
2404 if batch.activation_id.as_deref() != activation_run_id {
2405 return Err(AgentError::LLM(
2406 "actor event batch belongs to a stale activation".to_string(),
2407 ));
2408 }
2409 if batch.execution_epoch != execution_epoch {
2410 return Err(AgentError::LLM(
2411 "actor event batch belongs to a stale execution epoch".to_string(),
2412 ));
2413 }
2414 if batch
2415 .source_actor_id
2416 .as_deref()
2417 .is_some_and(|source| source != expected_source_actor_id)
2418 {
2419 return Err(AgentError::LLM(
2420 "actor event batch declares a different physical actor".to_string(),
2421 ));
2422 }
2423 Ok(())
2424}
2425
2426#[allow(clippy::too_many_arguments)]
2427async fn process_actor_event(
2428 event: serde_json::Value,
2429 strict_permission_events: bool,
2430 permission_handshake: &mut PermissionPostureHandshake,
2431 expected_permission_posture: Option<&ExpectedPermissionPosture>,
2432 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
2433 logical_session: &mut Session,
2434 event_tx: &mpsc::Sender<AgentEvent>,
2435) -> crate::runtime::runner::Result<()> {
2436 let event = match serde_json::from_value::<AgentEvent>(event) {
2437 Ok(event) => event,
2438 Err(error) if strict_permission_events => {
2439 return Err(AgentError::LLM(format!(
2440 "actor emitted malformed AgentEvent under a typed permission posture contract: {error}"
2441 )));
2442 }
2443 Err(_) => return Ok(()),
2444 };
2445 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
2446 if permission_handshake.posture_was_confirmed() {
2447 return Err(AgentError::LLM(
2448 "actor emitted a duplicate permission posture activation".to_string(),
2449 ));
2450 }
2451 let seed = permission_posture_seed_from_event(logical_session, &event)
2452 .map_err(AgentError::LLM)?
2453 .ok_or_else(|| {
2454 AgentError::LLM(
2455 "actor permission posture event did not decode as a posture".to_string(),
2456 )
2457 })?;
2458 if let Some(expected) = expected_permission_posture {
2459 if seed.policy_revision != expected.policy_revision
2460 || seed.resolution != expected.resolution
2461 {
2462 return Err(AgentError::LLM(
2463 "permission posture event does not match the host-dispatched policy"
2464 .to_string(),
2465 ));
2466 }
2467 if seed.executor_mapping() != expected.executor_mapping {
2468 return Err(AgentError::LLM(
2469 "permission posture event does not match the host-dispatched executor mapping"
2470 .to_string(),
2471 ));
2472 }
2473 }
2474 if let Some(binding) = session_inbox_runtime {
2475 let saved = binding
2476 .persistence
2477 .record_permission_posture_activation(
2478 &logical_session.id,
2479 expected_permission_posture
2480 .and_then(|expected| expected.expected_audit_revision),
2481 &seed,
2482 )
2483 .await
2484 .map_err(|error| {
2485 AgentError::LLM(format!(
2486 "persist child permission posture bootstrap: {error}"
2487 ))
2488 })?
2489 .ok_or_else(|| {
2490 AgentError::LLM(
2491 "persist child permission posture bootstrap: session not found".to_string(),
2492 )
2493 })?;
2494 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
2495 .ok_or_else(|| {
2496 AgentError::LLM(
2497 "persisted child permission posture audit is incomplete".to_string(),
2498 )
2499 })?;
2500 snapshot.write_to(&mut logical_session.metadata);
2501 } else {
2502 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
2505 .map_err(|error| {
2506 AgentError::LLM(format!(
2507 "record in-memory child permission posture: {error}"
2508 ))
2509 })?;
2510 }
2511 *permission_handshake = PermissionPostureHandshake::Confirmed;
2514 } else if permission_handshake.is_awaiting() {
2515 return Err(AgentError::LLM(
2516 "actor emitted an execution event before permission posture confirmation".to_string(),
2517 ));
2518 }
2519 let _ = event_tx.send(event).await;
2520 Ok(())
2521}
2522
2523async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
2534 let ActorDriveContext {
2535 client,
2536 parent_session_id,
2537 child_session_id,
2538 child_attempt,
2539 approval_registry,
2540 approval_decider,
2541 approval_reviewer,
2542 escalation_bridge,
2543 event_tx,
2544 cancel_token,
2545 live_rx,
2546 delivery_rx,
2547 logical_session,
2548 expected_permission_posture,
2549 session_inbox_runtime,
2550 activation_run_id,
2551 execution_epoch,
2552 expected_source_actor_id,
2553 initial_inflight_claims,
2554 first_frame_timeout,
2555 } = context;
2556
2557 let mut got_first_frame = false;
2564 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
2565 let mut inflight_claims = initial_inflight_claims;
2566 let strict_permission_events = expected_permission_posture.is_some();
2567 let mut permission_handshake =
2568 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
2569 let mut next_actor_event_seq = 1u64;
2570 loop {
2571 tokio::select! {
2572 _ = cancel_token.cancelled() => {
2573 break;
2575 }
2576 _ = async {
2577 match first_frame_watch.as_mut() {
2578 Some(s) => s.as_mut().await,
2579 None => std::future::pending::<()>().await,
2580 }
2581 }, if !got_first_frame => {
2582 return Err(AgentError::WorkerUnresponsive(format!(
2583 "child {child_session_id} produced no frame within {:?}",
2584 first_frame_timeout.unwrap_or_default()
2585 )));
2586 }
2587 Some(_generation) = delivery_rx.recv(),
2588 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
2589 {
2590 forward_next_canonical_claim(
2591 client,
2592 session_inbox_runtime.expect("guarded"),
2593 logical_session,
2594 activation_run_id.expect("guarded"),
2595 &mut inflight_claims,
2596 )
2597 .await?;
2598 }
2599 Some(frame) = live_rx.recv() => {
2600 if client.send(frame).await.is_err() {
2602 tracing::warn!("live steering frame could not be sent; connection failing");
2603 }
2604 }
2605 frame = client.next_frame() => {
2606 got_first_frame = true;
2609 first_frame_watch = None;
2610 match frame {
2611 Ok(Some(ChildFrame::Event { event })) => {
2612 process_actor_event(
2616 event,
2617 strict_permission_events,
2618 &mut permission_handshake,
2619 expected_permission_posture.as_ref(),
2620 session_inbox_runtime,
2621 logical_session,
2622 event_tx,
2623 )
2624 .await?;
2625 }
2626 Ok(Some(ChildFrame::EventBatch { batch })) => {
2627 validate_actor_event_batch(
2628 &batch,
2629 logical_session,
2630 parent_session_id,
2631 activation_run_id,
2632 execution_epoch,
2633 expected_source_actor_id,
2634 )?;
2635 if batch.last_seq < next_actor_event_seq {
2636 continue;
2637 }
2638 if batch.first_seq > next_actor_event_seq {
2639 tracing::warn!(
2640 child_session_id,
2641 expected_seq = next_actor_event_seq,
2642 received_seq = batch.first_seq,
2643 "actor event sequence gap; authoritative session snapshot is required"
2644 );
2645 }
2646 let skip = next_actor_event_seq
2647 .saturating_sub(batch.first_seq)
2648 .min(batch.events.len() as u64) as usize;
2649 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
2650 let seq = batch.first_seq + offset as u64;
2651 process_actor_event(
2652 event,
2653 strict_permission_events,
2654 &mut permission_handshake,
2655 expected_permission_posture.as_ref(),
2656 session_inbox_runtime,
2657 logical_session,
2658 event_tx,
2659 )
2660 .await?;
2661 next_actor_event_seq = seq.saturating_add(1);
2662 }
2663 }
2664 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
2665 if permission_handshake.is_awaiting() {
2666 return Err(AgentError::LLM(
2667 "actor requested approval before permission posture confirmation"
2668 .to_string(),
2669 ));
2670 }
2671 if let Some(reviewer) = approval_reviewer
2677 .cloned()
2678 .or_else(child_approval_reviewer)
2679 {
2680 let child = child_session_id.to_string();
2688 let parent = parent_session_id.to_string();
2689 let req_id = id.clone();
2690 let body = body.clone();
2691 let registry = approval_registry.cloned();
2692 tokio::spawn(async move {
2693 let approved = tokio::time::timeout(
2694 CHILD_APPROVAL_TIMEOUT,
2695 reviewer.review(&parent, &child, &body),
2696 )
2697 .await
2698 .unwrap_or(false);
2699 super::live::deliver_approval_scoped(
2700 registry.as_ref(),
2701 &child,
2702 child_attempt,
2703 &req_id,
2704 approved,
2705 );
2706 });
2707 } else if approval_decider.is_some() {
2708 let approved =
2712 decide_child_approval(approval_decider, child_session_id, &body)
2713 .await;
2714 if client
2715 .send(ParentFrame::ApprovalReply { id, approved })
2716 .await
2717 .is_err()
2718 {
2719 tracing::warn!(
2720 "failed to answer approval_request; connection failing"
2721 );
2722 }
2723 } else if let Some(host) = escalation_bridge.clone() {
2724 let child = child_session_id.to_string();
2731 let req_id = id.clone();
2732 let body = body.clone();
2733 let registry = approval_registry.cloned();
2734 tokio::spawn(async move {
2735 let approved = match tokio::time::timeout(
2736 CHILD_APPROVAL_TIMEOUT,
2737 host.approval_call(body),
2738 )
2739 .await
2740 {
2741 Ok(Ok(reply)) => reply
2742 .get("approved")
2743 .and_then(|v| v.as_bool())
2744 .unwrap_or(false),
2745 _ => false,
2747 };
2748 super::live::deliver_approval_scoped(
2749 registry.as_ref(),
2750 &child,
2751 child_attempt,
2752 &req_id,
2753 approved,
2754 );
2755 });
2756 } else {
2757 tracing::warn!(
2761 parent_session_id,
2762 child_session_id,
2763 request_id = %id,
2764 "forced-ask request has no parent-agent reviewer; denying"
2765 );
2766 if client
2767 .send(ParentFrame::ApprovalReply {
2768 id,
2769 approved: false,
2770 })
2771 .await
2772 .is_err()
2773 {
2774 tracing::warn!(
2775 "failed to send fail-closed approval reply; connection failing"
2776 );
2777 }
2778 }
2779 }
2780 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
2781 let Some(binding) = session_inbox_runtime else {
2782 tracing::warn!(
2783 child_session_id,
2784 "ignoring SessionInbox confirmation without a runtime binding"
2785 );
2786 continue;
2787 };
2788 let Some(bound_run_id) = activation_run_id else {
2789 tracing::warn!(
2790 child_session_id,
2791 "ignoring SessionInbox confirmation without an activation owner"
2792 );
2793 continue;
2794 };
2795 let Some(claim) = inflight_claims.front() else {
2796 tracing::warn!(
2797 child_session_id,
2798 envelope_id = %confirmation.envelope_id,
2799 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
2800 );
2801 continue;
2802 };
2803 let exact = confirmation.target_session_id == logical_session.id
2804 && confirmation.envelope_id == claim.envelope.id.as_str()
2805 && confirmation.canonical_claim_generation == claim.generation
2806 && confirmation.activation_run_id == bound_run_id;
2807 if !exact
2808 || !binding
2809 .router
2810 .owns_run(&logical_session.id, bound_run_id)
2811 .await
2812 {
2813 tracing::warn!(
2814 child_session_id,
2815 expected_target = %logical_session.id,
2816 received_target = %confirmation.target_session_id,
2817 expected_envelope_id = %claim.envelope.id,
2818 received_envelope_id = %confirmation.envelope_id,
2819 expected_generation = claim.generation,
2820 received_generation = confirmation.canonical_claim_generation,
2821 expected_run_id = bound_run_id,
2822 received_run_id = %confirmation.activation_run_id,
2823 "rejecting stale or mismatched SessionInbox admission confirmation"
2824 );
2825 continue;
2826 }
2827 let claim = inflight_claims
2828 .pop_front()
2829 .expect("validated in-flight canonical claim");
2830 checkpoint_and_ack_canonical_claim(binding, logical_session, &claim)
2833 .await?;
2834 if inflight_claims.is_empty() {
2838 forward_next_canonical_claim(
2839 client,
2840 binding,
2841 logical_session,
2842 bound_run_id,
2843 &mut inflight_claims,
2844 )
2845 .await?;
2846 }
2847 }
2848 Ok(Some(ChildFrame::Terminal { status, result, error, .. })) => {
2849 if permission_handshake.is_awaiting() {
2850 return Err(AgentError::LLM(
2851 "actor terminated before permission posture confirmation"
2852 .to_string(),
2853 ));
2854 }
2855 if let Some(claim) = inflight_claims.front() {
2856 return Err(AgentError::LLM(format!(
2857 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
2858 claim.envelope.id
2859 )));
2860 }
2861 return match status {
2862 TerminalStatus::Completed => Ok(result),
2863 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
2864 TerminalStatus::Error => Err(AgentError::LLM(
2865 error.unwrap_or_else(|| "actor child errored".to_string()),
2866 )),
2867 TerminalStatus::Suspended => Err(AgentError::LLM(
2872 "nested sub-agent suspend received but resume transport is not wired"
2873 .to_string(),
2874 )),
2875 };
2876 }
2877 Ok(None) => {
2878 return Err(AgentError::LLM(
2879 "actor child closed before terminal".to_string(),
2880 ));
2881 }
2882 Err(e) => {
2883 return Err(AgentError::LLM(format!("actor transport error: {e}")));
2884 }
2885 }
2886 }
2887 }
2888 }
2889
2890 let _ = client.send(ParentFrame::Cancel).await;
2892 Err(AgentError::Cancelled)
2893}
2894
2895fn project_id_for_actor_run(
2897 session: &Session,
2898) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
2899 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
2900 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
2901 Ok(Some(project_id))
2902 }
2903 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
2904 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
2905 Err(AgentError::LLM(format!(
2906 "child session carries an invalid Project identity '{raw}': {message}"
2907 )))
2908 }
2909 }
2910}
2911
2912fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
2913 LogicalSessionIdentity {
2914 session_id: session.id.clone(),
2915 parent_session_id: session
2916 .parent_session_id
2917 .clone()
2918 .or_else(|| Some(job.parent_session_id.clone())),
2919 root_session_id: if session.root_session_id.trim().is_empty() {
2920 job.parent_session_id.clone()
2921 } else {
2922 session.root_session_id.clone()
2923 },
2924 }
2925}
2926
2927fn extract_assignment(session: &Session) -> String {
2928 session
2929 .messages
2930 .iter()
2931 .rev()
2932 .find(|m| matches!(m.role, Role::User))
2933 .map(|m| m.content.clone())
2934 .unwrap_or_else(|| {
2935 session
2936 .metadata
2937 .get("title")
2938 .cloned()
2939 .unwrap_or_else(|| "Execute task".to_string())
2940 })
2941}
2942
2943#[cfg(test)]
2944mod tests {
2945 use super::*;
2946 use crate::SessionActivationRouter;
2947 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
2948
2949 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
2950 PooledWorker {
2951 worker: SpawnedChild::remote(AgentRecord {
2952 agent_id: mailbox_id.to_string(),
2953 role: "test".to_string(),
2954 labels: Vec::new(),
2955 endpoint: "ws://127.0.0.1:1".to_string(),
2956 pid: 0,
2957 version: String::new(),
2958 started_at: chrono::Utc::now(),
2959 lease_expires_at: chrono::Utc::now(),
2960 }),
2961 mailbox_id: mailbox_id.to_string(),
2962 parked_at: None,
2963 }
2964 }
2965
2966 #[test]
2967 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
2968 let now = Instant::now();
2969 let timeout = Duration::from_secs(300);
2970 assert!(!parked_worker_expired(None, now, timeout));
2971 assert!(!parked_worker_expired(
2972 Some(now - timeout + Duration::from_millis(1)),
2973 now,
2974 timeout
2975 ));
2976 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
2977 }
2978
2979 #[test]
2980 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
2981 let mut pool = WorkerPool::new();
2982 pool.insert(
2983 "fingerprint-a".to_string(),
2984 vec![processless_pool_worker("a")],
2985 );
2986 pool.insert(
2987 "fingerprint-b".to_string(),
2988 vec![processless_pool_worker("b")],
2989 );
2990
2991 assert_eq!(
2992 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
2993 Some("pool_global_limit")
2994 );
2995 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
2996 assert_eq!(
2997 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
2998 Some("pool_per_key_limit")
2999 );
3000 }
3001
3002 #[test]
3003 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
3004 let mut pool = WorkerPool::new();
3005 pool.insert(
3006 "fingerprint-a".to_string(),
3007 vec![processless_pool_worker("dead")],
3008 );
3009
3010 let reaped = take_reapable_workers(
3011 &mut pool,
3012 Instant::now(),
3013 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
3014 );
3015
3016 assert!(pool.is_empty());
3017 assert_eq!(reaped.len(), 1);
3018 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
3019 }
3020
3021 #[test]
3022 fn actor_preflight_counts_only_current_session_scoped_denies() {
3023 let config = bamboo_tools::permission::PermissionConfig::new();
3024 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
3025 config.deny_scoped_session_permission(
3026 "target-session",
3027 bamboo_tools::permission::PermissionType::ExecuteCommand,
3028 secret_matcher,
3029 );
3030 config.deny_scoped_session_permission(
3031 "other-session",
3032 bamboo_tools::permission::PermissionType::WriteFile,
3033 "/other/**",
3034 );
3035
3036 assert_eq!(
3037 active_scoped_session_deny_count(&config, "target-session"),
3038 1
3039 );
3040 assert_eq!(
3041 active_scoped_session_deny_count(&config, "clean-session"),
3042 0
3043 );
3044 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
3045 .unwrap_err()
3046 .to_string();
3047 assert!(!error.contains(secret_matcher));
3048 ensure_no_active_scoped_session_denies(&config, "clean-session")
3049 .expect("another session's deny must not block this activation");
3050 }
3051
3052 #[test]
3053 fn permission_posture_event_rejects_oversized_executor_mapping() {
3054 let session = Session::new("mapping-bound", "model");
3055 let event = AgentEvent::PermissionPostureActivated {
3056 session_id: session.id.clone(),
3057 policy_revision: 1,
3058 requested_mode: "default".to_string(),
3059 effective_mode: "default".to_string(),
3060 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
3061 };
3062
3063 assert!(permission_posture_seed_from_event(&session, &event)
3064 .unwrap_err()
3065 .contains("executor mapping"));
3066 }
3067
3068 #[test]
3069 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
3070 let mut session = Session::new("host-resigns-audit", "model");
3071 let hostile_timestamp = "9".repeat(1024);
3072 let event: AgentEvent = serde_json::from_value(serde_json::json!({
3073 "type": "permission_posture_activated",
3074 "session_id": session.id.clone(),
3075 "policy_revision": 31,
3076 "requested_mode": "default",
3077 "effective_mode": "default",
3078 "executor_mapping": "codex_exec:approval_policy=never",
3079 "audit_revision": u64::MAX,
3080 "transitioned_at": hostile_timestamp,
3081 }))
3082 .expect("unknown remote audit fields are ignored by the typed event");
3083 let seed = permission_posture_seed_from_event(&session, &event)
3084 .unwrap()
3085 .expect("permission event");
3086
3087 let host_revision =
3088 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
3089 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3090 .expect("host-generated complete audit");
3091 assert_eq!(host_audit.audit_revision, host_revision);
3092 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
3093 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
3094 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
3095 }
3096
3097 struct ActorFaultingPersistence {
3098 inner: Arc<bamboo_storage::LockedSessionStore>,
3099 fail_checkpoint_once: std::sync::atomic::AtomicBool,
3100 }
3101
3102 #[async_trait]
3103 impl RuntimeSessionPersistence for ActorFaultingPersistence {
3104 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
3105 self.inner.merge_save_runtime(session).await
3106 }
3107
3108 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
3109 if self
3110 .fail_checkpoint_once
3111 .swap(false, std::sync::atomic::Ordering::SeqCst)
3112 {
3113 return Err(std::io::Error::other("injected actor checkpoint failure"));
3114 }
3115 self.inner.checkpoint_runtime_session(session).await
3116 }
3117
3118 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
3119 self.inner.storage().load_session(session_id).await
3120 }
3121 }
3122
3123 struct ActorFailBeforeAckInbox {
3124 inner: Arc<dyn SessionInboxPort>,
3125 fail_once: std::sync::atomic::AtomicBool,
3126 }
3127
3128 #[async_trait]
3129 impl SessionInboxPort for ActorFailBeforeAckInbox {
3130 async fn deliver(
3131 &self,
3132 envelope: &bamboo_domain::SessionMessageEnvelope,
3133 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
3134 self.inner.deliver(envelope).await
3135 }
3136
3137 async fn mark_activation_eligible(
3138 &self,
3139 target_session_id: &str,
3140 generation: u64,
3141 policy: bamboo_domain::SessionActivationPolicy,
3142 ) -> Result<(), bamboo_domain::SessionInboxError> {
3143 self.inner
3144 .mark_activation_eligible(target_session_id, generation, policy)
3145 .await
3146 }
3147
3148 async fn claim(
3149 &self,
3150 target_session_id: &str,
3151 limit: usize,
3152 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
3153 self.inner.claim(target_session_id, limit).await
3154 }
3155
3156 async fn was_admitted(
3157 &self,
3158 target_session_id: &str,
3159 id: &bamboo_domain::SessionMessageId,
3160 ) -> Result<bool, bamboo_domain::SessionInboxError> {
3161 self.inner.was_admitted(target_session_id, id).await
3162 }
3163
3164 async fn ack(
3165 &self,
3166 target_session_id: &str,
3167 claim: &SessionInboxClaim,
3168 ) -> Result<(), bamboo_domain::SessionInboxError> {
3169 if self
3170 .fail_once
3171 .swap(false, std::sync::atomic::Ordering::SeqCst)
3172 {
3173 return Err(bamboo_domain::SessionInboxError::Storage(
3174 "injected actor pre-ack failure".to_string(),
3175 ));
3176 }
3177 self.inner.ack(target_session_id, claim).await
3178 }
3179
3180 async fn inspect(
3181 &self,
3182 target_session_id: &str,
3183 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
3184 self.inner.inspect(target_session_id).await
3185 }
3186 }
3187
3188 async fn actor_inbox_fixture(
3189 session_id: &str,
3190 ) -> (
3191 tempfile::TempDir,
3192 Arc<bamboo_storage::SessionStoreV2>,
3193 Arc<bamboo_storage::LockedSessionStore>,
3194 Arc<dyn SessionInboxPort>,
3195 Session,
3196 SessionInboxClaim,
3197 ) {
3198 let temp = tempfile::tempdir().unwrap();
3199 let store = Arc::new(
3200 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3201 .await
3202 .unwrap(),
3203 );
3204 let storage: Arc<dyn Storage> = store.clone();
3205 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
3206 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3207 store.clone(),
3208 bamboo_domain::SessionInboxLimits::default(),
3209 ));
3210 let session = Session::new(session_id, "model");
3211 store.save_session(&session).await.unwrap();
3212 let mut envelope =
3213 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
3214 envelope.id =
3215 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
3216 let receipt = inbox.deliver(&envelope).await.unwrap();
3217 inbox
3218 .mark_activation_eligible(
3219 session_id,
3220 receipt.generation,
3221 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3222 )
3223 .await
3224 .unwrap();
3225 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
3226 (temp, store, locked, inbox, session, claim)
3227 }
3228
3229 fn actor_binding(
3230 store: Arc<bamboo_storage::SessionStoreV2>,
3231 inbox: Arc<dyn SessionInboxPort>,
3232 persistence: Arc<dyn RuntimeSessionPersistence>,
3233 ) -> SessionInboxRuntimeBinding {
3234 let storage: Arc<dyn Storage> = store;
3235 SessionInboxRuntimeBinding {
3236 router: SessionActivationRouter::new(),
3237 inbox,
3238 storage,
3239 persistence,
3240 }
3241 }
3242
3243 #[tokio::test]
3244 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
3245 let (_temp, store, locked, inbox, mut session, claim) =
3246 actor_inbox_fixture("actor-live-id-collision").await;
3247 let mut forged = claim.envelope.to_provider_message().unwrap();
3248 forged.metadata = Some(serde_json::json!({
3249 "session_message": {
3250 "id": claim.envelope.id,
3251 "target_session_id": "different-session"
3252 }
3253 }));
3254 session.add_message(forged);
3255 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3256 let binding = actor_binding(store, inbox.clone(), persistence);
3257
3258 assert!(
3259 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3260 .await
3261 .is_err()
3262 );
3263 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3264 assert!(!inbox
3265 .was_admitted(&session.id, &claim.envelope.id)
3266 .await
3267 .unwrap());
3268 }
3269
3270 #[tokio::test]
3271 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
3272 let (_temp, store, locked, inbox, mut session, claim) =
3273 actor_inbox_fixture("actor-durable-id-collision").await;
3274 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3275 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
3276 forged.id = claim.envelope.id.to_string();
3277 concurrent.add_message(forged);
3278 store.save_session(&concurrent).await.unwrap();
3279 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3280 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3281
3282 assert!(
3283 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3284 .await
3285 .is_err()
3286 );
3287 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3288 assert!(!inbox
3289 .was_admitted(&session.id, &claim.envelope.id)
3290 .await
3291 .unwrap());
3292 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3293 assert!(!durable.messages.iter().any(|message| {
3294 bamboo_domain::is_matching_session_message(message, &claim.envelope)
3295 }));
3296 }
3297
3298 #[tokio::test]
3299 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
3300 let (_temp, store, locked, inbox, mut session, claim) =
3301 actor_inbox_fixture("actor-durable-typed-body-collision").await;
3302 let mut different = claim.envelope.clone();
3303 different.body = bamboo_domain::SessionMessageBody::Content(
3304 bamboo_domain::SessionMessageContent::text("forged actor body"),
3305 );
3306 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3307 concurrent.add_message(different.to_provider_message().unwrap());
3308 store.save_session(&concurrent).await.unwrap();
3309 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3310 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3311
3312 assert!(
3313 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3314 .await
3315 .is_err()
3316 );
3317 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3318 assert!(!inbox
3319 .was_admitted(&session.id, &claim.envelope.id)
3320 .await
3321 .unwrap());
3322 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3323 assert!(!durable
3324 .messages
3325 .iter()
3326 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
3327 }
3328
3329 #[tokio::test]
3330 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
3331 let (_temp, store, locked, inbox, mut session, claim) =
3332 actor_inbox_fixture("actor-checkpoint-failure").await;
3333 let envelope_id = claim.envelope.id.clone();
3334 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
3335 inner: locked.clone(),
3336 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
3337 });
3338 let binding = actor_binding(store.clone(), inbox.clone(), fault);
3339
3340 assert!(
3341 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3342 .await
3343 .is_err()
3344 );
3345 assert!(!session
3346 .messages
3347 .iter()
3348 .any(|message| message.id == envelope_id.as_str()));
3349 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3350 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
3351
3352 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3353 store.clone(),
3354 bamboo_domain::SessionInboxLimits::default(),
3355 ));
3356 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3357 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3358 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3359 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
3360 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3361 .await
3362 .unwrap();
3363 assert_eq!(
3364 restarted
3365 .messages
3366 .iter()
3367 .filter(|message| message.id == envelope_id.as_str())
3368 .count(),
3369 1
3370 );
3371 assert!(reopened
3372 .was_admitted(&session.id, &envelope_id)
3373 .await
3374 .unwrap());
3375 let backlog = reopened.inspect(&session.id).await.unwrap();
3376 assert_eq!(backlog.pending + backlog.claimed, 0);
3377 }
3378
3379 #[tokio::test]
3380 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
3381 let (_temp, store, locked, real_inbox, mut session, claim) =
3382 actor_inbox_fixture("actor-pre-ack-failure").await;
3383 let envelope_id = claim.envelope.id.clone();
3384 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
3385 inner: real_inbox.clone(),
3386 fail_once: std::sync::atomic::AtomicBool::new(true),
3387 });
3388 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
3389 let binding = actor_binding(store.clone(), faulted, persistence);
3390
3391 assert!(
3392 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3393 .await
3394 .is_err()
3395 );
3396 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3397 assert_eq!(
3398 durable
3399 .messages
3400 .iter()
3401 .filter(|message| message.id == envelope_id.as_str())
3402 .count(),
3403 1
3404 );
3405 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
3406 assert!(!real_inbox
3407 .was_admitted(&session.id, &envelope_id)
3408 .await
3409 .unwrap());
3410
3411 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3412 store.clone(),
3413 bamboo_domain::SessionInboxLimits::default(),
3414 ));
3415 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3416 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3417 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3418 let mut restarted = durable;
3419 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3420 .await
3421 .unwrap();
3422 assert_eq!(
3423 restarted
3424 .messages
3425 .iter()
3426 .filter(|message| message.id == envelope_id.as_str())
3427 .count(),
3428 1
3429 );
3430 assert!(reopened
3431 .was_admitted(&session.id, &envelope_id)
3432 .await
3433 .unwrap());
3434 let backlog = reopened.inspect(&session.id).await.unwrap();
3435 assert_eq!(backlog.pending + backlog.claimed, 0);
3436 }
3437
3438 struct ConfirmationSequenceLink {
3439 frames: VecDeque<ChildFrame>,
3440 sent: Vec<ParentFrame>,
3441 }
3442
3443 #[async_trait]
3444 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
3445 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
3446 self.sent.push(frame);
3447 Ok(())
3448 }
3449
3450 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
3451 match self.frames.pop_front() {
3452 Some(frame) => Ok(Some(frame)),
3453 None => std::future::pending().await,
3454 }
3455 }
3456 }
3457
3458 fn admission_confirmation(
3459 session_id: &str,
3460 claim: &SessionInboxClaim,
3461 run_id: &str,
3462 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3463 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3464 target_session_id: session_id.to_string(),
3465 envelope_id: claim.envelope.id.to_string(),
3466 canonical_claim_generation: claim.generation,
3467 activation_run_id: run_id.to_string(),
3468 }
3469 }
3470
3471 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
3472 ExpectedPermissionPosture {
3473 policy_revision,
3474 resolution: bamboo_domain::PermissionModeResolution {
3475 requested: bamboo_domain::SessionPermissionMode::Default,
3476 effective: bamboo_domain::PermissionMode::Default,
3477 },
3478 expected_audit_revision: None,
3479 executor_mapping: "test_actor:permission_mode=default".to_string(),
3480 }
3481 }
3482
3483 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
3484 ChildFrame::Event {
3485 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3486 session_id: session_id.to_string(),
3487 policy_revision,
3488 requested_mode: "default".to_string(),
3489 effective_mode: "default".to_string(),
3490 executor_mapping: "test_actor:permission_mode=default".to_string(),
3491 })
3492 .expect("serialize permission posture event"),
3493 }
3494 }
3495
3496 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
3497 ChildFrame::Event {
3498 event: serde_json::to_value(event).expect("serialize actor event"),
3499 }
3500 }
3501
3502 #[test]
3503 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
3504 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
3505 let mut batch = ActorEventBatch {
3506 logical_session: Some(LogicalSessionIdentity {
3507 session_id: session.id.clone(),
3508 parent_session_id: session.parent_session_id.clone(),
3509 root_session_id: session.root_session_id.clone(),
3510 }),
3511 activation_id: Some("activation-7".into()),
3512 execution_epoch: 11,
3513 source_node_id: Some("node-a".into()),
3514 source_actor_id: Some("worker-a".into()),
3515 first_seq: 1,
3516 last_seq: 1,
3517 qos: bamboo_subagent::ActorEventQos::Ephemeral,
3518 events: vec![serde_json::json!({"type":"token","content":"ok"})],
3519 };
3520 validate_actor_event_batch(
3521 &batch,
3522 &session,
3523 "logical-parent",
3524 Some("activation-7"),
3525 11,
3526 "worker-a",
3527 )
3528 .unwrap();
3529
3530 batch.execution_epoch = 10;
3531 assert!(validate_actor_event_batch(
3532 &batch,
3533 &session,
3534 "logical-parent",
3535 Some("activation-7"),
3536 11,
3537 "worker-a",
3538 )
3539 .unwrap_err()
3540 .to_string()
3541 .contains("execution epoch"));
3542 batch.execution_epoch = 11;
3543 batch.activation_id = Some("replaced-activation".into());
3544 assert!(validate_actor_event_batch(
3545 &batch,
3546 &session,
3547 "logical-parent",
3548 Some("activation-7"),
3549 11,
3550 "worker-a",
3551 )
3552 .unwrap_err()
3553 .to_string()
3554 .contains("stale activation"));
3555 batch.activation_id = Some("activation-7".into());
3556 batch
3557 .logical_session
3558 .as_mut()
3559 .expect("logical identity")
3560 .session_id = "foreign-child".into();
3561 assert!(validate_actor_event_batch(
3562 &batch,
3563 &session,
3564 "logical-parent",
3565 Some("activation-7"),
3566 11,
3567 "worker-a",
3568 )
3569 .unwrap_err()
3570 .to_string()
3571 .contains("different logical session"));
3572 batch
3573 .logical_session
3574 .as_mut()
3575 .expect("logical identity")
3576 .session_id = session.id.clone();
3577 batch.source_actor_id = Some("worker-b".into());
3578 assert!(validate_actor_event_batch(
3579 &batch,
3580 &session,
3581 "logical-parent",
3582 Some("activation-7"),
3583 11,
3584 "worker-a",
3585 )
3586 .unwrap_err()
3587 .to_string()
3588 .contains("different physical actor"));
3589 }
3590
3591 fn completed_actor_frame() -> ChildFrame {
3592 ChildFrame::Terminal {
3593 status: TerminalStatus::Completed,
3594 result: Some("done".to_string()),
3595 error: None,
3596 transcript: Vec::new(),
3597 }
3598 }
3599
3600 async fn drive_permission_handshake_frames(
3601 session_id: &str,
3602 frames: impl IntoIterator<Item = ChildFrame>,
3603 expected: ExpectedPermissionPosture,
3604 ) -> (
3605 crate::runtime::runner::Result<Option<String>>,
3606 Session,
3607 Vec<AgentEvent>,
3608 Vec<ParentFrame>,
3609 ) {
3610 let mut link = ConfirmationSequenceLink {
3611 frames: frames.into_iter().collect(),
3612 sent: Vec::new(),
3613 };
3614 let (event_tx, mut event_rx) = mpsc::channel(16);
3615 let cancel = CancellationToken::new();
3616 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3617 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3618 let mut session = Session::new(session_id, "model");
3619 let result = drive(ActorDriveContext {
3620 client: &mut link,
3621 parent_session_id: "permission-parent",
3622 child_session_id: session_id,
3623 child_attempt: 0,
3624 approval_registry: None,
3625 approval_decider: None,
3626 approval_reviewer: None,
3627 escalation_bridge: None,
3628 event_tx: &event_tx,
3629 cancel_token: &cancel,
3630 live_rx: &mut live_rx,
3631 delivery_rx: &mut delivery_rx,
3632 logical_session: &mut session,
3633 expected_permission_posture: Some(expected),
3634 session_inbox_runtime: None,
3635 activation_run_id: None,
3636 execution_epoch: 0,
3637 expected_source_actor_id: session_id,
3638 initial_inflight_claims: VecDeque::new(),
3639 first_frame_timeout: Some(Duration::from_secs(1)),
3640 })
3641 .await;
3642 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
3643 (result, session, events, link.sent)
3644 }
3645
3646 #[tokio::test]
3647 async fn actor_permission_handshake_rejects_terminal_without_posture() {
3648 let session_id = "permission-missing";
3649 let (result, session, events, _) = drive_permission_handshake_frames(
3650 session_id,
3651 [completed_actor_frame()],
3652 expected_default_permission_posture(7),
3653 )
3654 .await;
3655
3656 assert!(result
3657 .unwrap_err()
3658 .to_string()
3659 .contains("terminated before permission posture confirmation"));
3660 assert!(events.is_empty());
3661 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3662 }
3663
3664 #[tokio::test]
3665 async fn actor_permission_handshake_rejects_malformed_agent_event() {
3666 let session_id = "permission-malformed";
3667 let (result, session, events, _) = drive_permission_handshake_frames(
3668 session_id,
3669 [ChildFrame::Event {
3670 event: serde_json::json!({"type": "token", "content": 42}),
3671 }],
3672 expected_default_permission_posture(7),
3673 )
3674 .await;
3675
3676 assert!(result
3677 .unwrap_err()
3678 .to_string()
3679 .contains("malformed AgentEvent"));
3680 assert!(events.is_empty());
3681 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3682 }
3683
3684 #[tokio::test]
3685 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
3686 let session_id = "permission-early-event";
3687 let early_events = [
3688 (
3689 "progress",
3690 AgentEvent::RunnerProgress {
3691 session_id: session_id.to_string(),
3692 round_count: 1,
3693 },
3694 ),
3695 (
3696 "token",
3697 AgentEvent::Token {
3698 content: "must-not-forward".to_string(),
3699 },
3700 ),
3701 (
3702 "tool",
3703 AgentEvent::ToolStart {
3704 tool_call_id: "early-tool".to_string(),
3705 tool_name: "Read".to_string(),
3706 arguments: serde_json::json!({"file_path": "README.md"}),
3707 },
3708 ),
3709 ];
3710 for (kind, event) in early_events {
3711 let (result, session, events, _) = drive_permission_handshake_frames(
3712 session_id,
3713 [
3714 actor_event_frame(event),
3715 permission_posture_frame(session_id, 7),
3716 completed_actor_frame(),
3717 ],
3718 expected_default_permission_posture(7),
3719 )
3720 .await;
3721
3722 assert!(
3723 result
3724 .unwrap_err()
3725 .to_string()
3726 .contains("execution event before permission posture confirmation"),
3727 "{kind} must fail closed before posture"
3728 );
3729 assert!(events.is_empty(), "{kind} must not be forwarded");
3730 assert!(
3731 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3732 "{kind} must not advance the permission audit"
3733 );
3734 }
3735 }
3736
3737 #[tokio::test]
3738 async fn actor_permission_handshake_rejects_approval_before_posture() {
3739 let session_id = "permission-early-approval";
3740 let (result, session, events, sent) = drive_permission_handshake_frames(
3741 session_id,
3742 [ChildFrame::ApprovalRequest {
3743 id: "approval-before-posture".to_string(),
3744 body: serde_json::json!({
3745 "tool_name": "Bash",
3746 "permission": "execute",
3747 "resource": "echo must-not-run"
3748 }),
3749 }],
3750 expected_default_permission_posture(7),
3751 )
3752 .await;
3753
3754 assert!(result
3755 .unwrap_err()
3756 .to_string()
3757 .contains("requested approval before permission posture confirmation"));
3758 assert!(events.is_empty());
3759 assert!(
3760 sent.is_empty(),
3761 "an unconfirmed actor must receive no approval reply"
3762 );
3763 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3764 }
3765
3766 #[tokio::test]
3767 async fn actor_permission_handshake_rejects_mismatched_posture() {
3768 let session_id = "permission-mismatch";
3769 let (result, session, events, _) = drive_permission_handshake_frames(
3770 session_id,
3771 [permission_posture_frame(session_id, 8)],
3772 expected_default_permission_posture(7),
3773 )
3774 .await;
3775
3776 assert!(result
3777 .unwrap_err()
3778 .to_string()
3779 .contains("does not match the host-dispatched policy"));
3780 assert!(events.is_empty());
3781 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3782 }
3783
3784 #[tokio::test]
3785 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
3786 let session_id = "permission-hostile-mapping";
3787 for hostile_mapping in [
3788 "wrong_executor:permission_mode=default",
3789 "test_actor:permission_mode=default;credential=must-not-persist",
3790 ] {
3791 let frame = ChildFrame::Event {
3792 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3793 session_id: session_id.to_string(),
3794 policy_revision: 7,
3795 requested_mode: "default".to_string(),
3796 effective_mode: "default".to_string(),
3797 executor_mapping: hostile_mapping.to_string(),
3798 })
3799 .expect("serialize hostile posture fixture"),
3800 };
3801 let (result, session, events, _) = drive_permission_handshake_frames(
3802 session_id,
3803 [frame],
3804 expected_default_permission_posture(7),
3805 )
3806 .await;
3807
3808 let error = result.unwrap_err().to_string();
3809 assert!(error.contains("host-dispatched executor mapping"));
3810 assert!(
3811 !error.contains(hostile_mapping),
3812 "untrusted mapping must not be reflected in host errors"
3813 );
3814 assert!(events.is_empty());
3815 assert!(
3816 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3817 "untrusted mapping must not reach durable or in-memory audit state"
3818 );
3819 }
3820 }
3821
3822 #[tokio::test]
3823 async fn actor_permission_handshake_rejects_duplicate_posture() {
3824 let session_id = "permission-duplicate";
3825 let (result, session, events, _) = drive_permission_handshake_frames(
3826 session_id,
3827 [
3828 permission_posture_frame(session_id, 7),
3829 permission_posture_frame(session_id, 7),
3830 completed_actor_frame(),
3831 ],
3832 expected_default_permission_posture(7),
3833 )
3834 .await;
3835
3836 assert!(result
3837 .unwrap_err()
3838 .to_string()
3839 .contains("duplicate permission posture activation"));
3840 assert_eq!(
3841 events.len(),
3842 1,
3843 "only the confirmed posture may be forwarded"
3844 );
3845 assert!(matches!(
3846 events[0],
3847 AgentEvent::PermissionPostureActivated { .. }
3848 ));
3849 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3850 .expect("the first matching posture must be recorded");
3851 assert_eq!(audit.policy_revision, 7);
3852 }
3853
3854 #[tokio::test]
3855 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
3856 let session_id = "permission-happy";
3857 let (result, session, events, _) = drive_permission_handshake_frames(
3858 session_id,
3859 [
3860 permission_posture_frame(session_id, 7),
3861 actor_event_frame(AgentEvent::RunnerProgress {
3862 session_id: session_id.to_string(),
3863 round_count: 1,
3864 }),
3865 actor_event_frame(AgentEvent::Token {
3866 content: "working".to_string(),
3867 }),
3868 actor_event_frame(AgentEvent::ToolStart {
3869 tool_call_id: "tool-1".to_string(),
3870 tool_name: "Read".to_string(),
3871 arguments: serde_json::json!({"file_path": "README.md"}),
3872 }),
3873 completed_actor_frame(),
3874 ],
3875 expected_default_permission_posture(7),
3876 )
3877 .await;
3878
3879 assert_eq!(result.unwrap().as_deref(), Some("done"));
3880 assert!(matches!(
3881 events.as_slice(),
3882 [
3883 AgentEvent::PermissionPostureActivated { .. },
3884 AgentEvent::RunnerProgress { .. },
3885 AgentEvent::Token { .. },
3886 AgentEvent::ToolStart { .. }
3887 ]
3888 ));
3889 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3890 .expect("matching posture must be recorded before execution events are accepted");
3891 assert_eq!(audit.policy_revision, 7);
3892 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
3893 }
3894
3895 #[tokio::test]
3896 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
3897 let temp = tempfile::tempdir().unwrap();
3898 let store = Arc::new(
3899 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3900 .await
3901 .unwrap(),
3902 );
3903 let storage: Arc<dyn Storage> = store.clone();
3904 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
3905 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3906 store.clone(),
3907 bamboo_domain::SessionInboxLimits::default(),
3908 ));
3909 let session_id = "actor-confirmation-order";
3910 let run_id = "actor-run-current";
3911 let mut session = Session::new(session_id, "model");
3912 store.save_session(&session).await.unwrap();
3913 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
3914 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
3915 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
3916 inbox.deliver(&envelope).await.unwrap();
3917 }
3918 inbox
3919 .mark_activation_eligible(
3920 session_id,
3921 2,
3922 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3923 )
3924 .await
3925 .unwrap();
3926 let router = SessionActivationRouter::new();
3927 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
3928 let binding = SessionInboxRuntimeBinding {
3929 router,
3930 inbox: inbox.clone(),
3931 storage,
3932 persistence: locked,
3933 };
3934 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX)
3935 .await
3936 .unwrap();
3937 assert_eq!(
3938 pairs
3939 .iter()
3940 .map(|(claim, _)| claim.envelope.id.as_str())
3941 .collect::<Vec<_>>(),
3942 vec!["actor-first", "actor-second"]
3943 );
3944 let seeded = store.load_session(session_id).await.unwrap().unwrap();
3945 assert_eq!(
3946 seeded
3947 .messages
3948 .iter()
3949 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3950 .map(|message| message.id.as_str())
3951 .collect::<Vec<_>>(),
3952 vec!["actor-first", "actor-second"],
3953 "host context must be durable before actor dispatch"
3954 );
3955 for (claim, _) in &pairs {
3956 assert_eq!(
3957 seeded
3958 .messages
3959 .iter()
3960 .filter(|message| bamboo_domain::is_matching_session_message(
3961 message,
3962 &claim.envelope
3963 ))
3964 .count(),
3965 1,
3966 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
3967 claim.envelope.id
3968 );
3969 }
3970 assert!(
3971 seeded.session_inbox_admission().is_none_or(|cursor| {
3972 !cursor.contains(&pairs[0].0.envelope.id)
3973 && !cursor.contains(&pairs[1].0.envelope.id)
3974 }),
3975 "pre-dispatch transcript seeding must not forge worker confirmation"
3976 );
3977 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
3978 let claims = pairs
3979 .into_iter()
3980 .map(|(claim, _)| claim)
3981 .collect::<VecDeque<_>>();
3982 let first = claims[0].clone();
3983 let second = claims[1].clone();
3984 let mut stale = admission_confirmation(session_id, &first, "stale-run");
3985 stale.canonical_claim_generation = second.generation;
3986 let mut link = ConfirmationSequenceLink {
3987 frames: VecDeque::from([
3988 ChildFrame::SessionMessageAdmitted {
3989 confirmation: stale,
3990 },
3991 ChildFrame::SessionMessageAdmitted {
3992 confirmation: admission_confirmation(session_id, &first, run_id),
3993 },
3994 ChildFrame::SessionMessageAdmitted {
3995 confirmation: admission_confirmation(session_id, &second, run_id),
3996 },
3997 ChildFrame::Terminal {
3998 status: TerminalStatus::Completed,
3999 result: Some("done".to_string()),
4000 error: None,
4001 transcript: Vec::new(),
4002 },
4003 ]),
4004 sent: Vec::new(),
4005 };
4006 let (event_tx, _event_rx) = mpsc::channel(8);
4007 let cancel = CancellationToken::new();
4008 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
4009 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4010 let result = drive(ActorDriveContext {
4011 client: &mut link,
4012 parent_session_id: "parent",
4013 child_session_id: session_id,
4014 child_attempt: 0,
4015 approval_registry: None,
4016 approval_decider: None,
4017 approval_reviewer: None,
4018 escalation_bridge: None,
4019 event_tx: &event_tx,
4020 cancel_token: &cancel,
4021 live_rx: &mut live_rx,
4022 delivery_rx: &mut delivery_rx,
4023 logical_session: &mut session,
4024 expected_permission_posture: None,
4025 session_inbox_runtime: Some(&binding),
4026 activation_run_id: Some(run_id),
4027 execution_epoch: 0,
4028 expected_source_actor_id: session_id,
4029 initial_inflight_claims: claims,
4030 first_frame_timeout: Some(Duration::from_secs(1)),
4031 })
4032 .await
4033 .unwrap();
4034 assert_eq!(result.as_deref(), Some("done"));
4035 assert_eq!(
4036 session
4037 .messages
4038 .iter()
4039 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
4040 .map(|message| message.id.as_str())
4041 .collect::<Vec<_>>(),
4042 vec!["actor-first", "actor-second"]
4043 );
4044 let backlog = inbox.inspect(session_id).await.unwrap();
4045 assert_eq!(backlog.pending + backlog.claimed, 0);
4046 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
4047 let cursor = confirmed
4048 .session_inbox_admission()
4049 .expect("exact worker confirmation must checkpoint the admission cursor");
4050 assert!(cursor.contains(&first.envelope.id));
4051 assert!(cursor.contains(&second.envelope.id));
4052 for claim in [&first, &second] {
4053 assert_eq!(
4054 confirmed
4055 .messages
4056 .iter()
4057 .filter(|message| bamboo_domain::is_matching_session_message(
4058 message,
4059 &claim.envelope
4060 ))
4061 .count(),
4062 1,
4063 "confirmation must retain one exact canonical marker for {}",
4064 claim.envelope.id
4065 );
4066 }
4067 assert!(inbox
4068 .was_admitted(session_id, &first.envelope.id)
4069 .await
4070 .unwrap());
4071 assert!(inbox
4072 .was_admitted(session_id, &second.envelope.id)
4073 .await
4074 .unwrap());
4075 owner_registration.begin_finalization().await;
4076 owner_registration.finish(2).await.unwrap();
4077 }
4078
4079 #[derive(Default)]
4080 struct RecordingCodexTokenAuthority {
4081 issued_for: std::sync::Mutex<Vec<String>>,
4082 revoked: std::sync::Mutex<Vec<String>>,
4083 }
4084
4085 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
4086 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
4087 self.issued_for
4088 .lock()
4089 .expect("issued fixture lock")
4090 .push(session_id.to_string());
4091 Ok(IssuedCodexRunToken {
4092 token_id: format!("id-{session_id}"),
4093 token: format!("bcx1_secret-{session_id}"),
4094 })
4095 }
4096
4097 fn revoke(&self, token_id: &str) {
4098 self.revoked
4099 .lock()
4100 .expect("revoked fixture lock")
4101 .push(token_id.to_string());
4102 }
4103 }
4104
4105 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
4106 let bamboo_mode = auth_mode == Some("bamboo")
4107 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
4108 ExecutorSpec::Codex {
4109 binary: None,
4110 model: None,
4111 mode: None,
4112 sandbox: None,
4113 inherit_user_config,
4114 auth_mode: auth_mode.map(str::to_string),
4115 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
4116 wire_api: Some("responses".to_string()),
4117 provider_key_ref: None,
4118 forward_env: None,
4119 approval_policy: None,
4120 network_access: None,
4121 allow_danger_bypass: None,
4122 permission_profile: None,
4123 workspace_owned: None,
4124 }
4125 }
4126
4127 fn permission_resolution(
4128 requested: bamboo_domain::SessionPermissionMode,
4129 effective: bamboo_domain::PermissionMode,
4130 ) -> bamboo_domain::PermissionModeResolution {
4131 bamboo_domain::PermissionModeResolution {
4132 requested,
4133 effective,
4134 }
4135 }
4136
4137 #[test]
4138 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
4139 use bamboo_domain::{PermissionMode, SessionPermissionMode};
4140
4141 let default =
4142 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
4143 assert_eq!(
4144 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
4145 .unwrap()
4146 .as_deref(),
4147 Some("bamboo_runtime:default")
4148 );
4149 assert_eq!(
4150 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
4151 None,
4152 "transport-only Echo must not claim the typed permission contract"
4153 );
4154 assert_eq!(
4155 expected_permission_executor_mapping(
4156 &ExecutorSpec::CliAdapter {
4157 command: "must-not-appear-in-contract".to_string(),
4158 args: vec!["credential-like-argument".to_string()],
4159 },
4160 default,
4161 false,
4162 )
4163 .unwrap(),
4164 None,
4165 "unimplemented CliAdapter must not leak command data into a contract"
4166 );
4167
4168 let claude = ExecutorSpec::ClaudeCode {
4169 binary: None,
4170 model: None,
4171 permission_mode: Some("default".to_string()),
4172 inherit_user_config: None,
4173 forward_env: None,
4174 };
4175 for (resolution, mapping) in [
4176 (
4177 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
4178 "claude_code:permission_mode=plan",
4179 ),
4180 (
4181 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4182 "claude_code:permission_mode=bypassPermissions",
4183 ),
4184 (
4185 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
4186 "claude_code:permission_mode=acceptEdits",
4187 ),
4188 (
4189 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
4190 "claude_code:permission_mode=dontAsk",
4191 ),
4192 (
4193 permission_resolution(
4194 SessionPermissionMode::Bypass,
4195 PermissionMode::BypassPermissions,
4196 ),
4197 "claude_code:permission_mode=default",
4198 ),
4199 ] {
4200 assert_eq!(
4201 expected_permission_executor_mapping(&claude, resolution, false)
4202 .unwrap()
4203 .as_deref(),
4204 Some(mapping)
4205 );
4206 }
4207 assert_eq!(
4208 expected_permission_executor_mapping(&claude, default, true)
4209 .unwrap()
4210 .as_deref(),
4211 Some("claude_code:blocked_explicit_deny")
4212 );
4213
4214 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
4215 if let ExecutorSpec::Codex {
4216 approval_policy, ..
4217 } = &mut codex_exec
4218 {
4219 *approval_policy = Some("on-failure".to_string());
4220 }
4221 assert_eq!(
4222 expected_permission_executor_mapping(&codex_exec, default, false)
4223 .unwrap()
4224 .as_deref(),
4225 Some("codex_exec:approval_policy=on-failure")
4226 );
4227 assert_eq!(
4228 expected_permission_executor_mapping(
4229 &codex_exec,
4230 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4231 false,
4232 )
4233 .unwrap()
4234 .as_deref(),
4235 Some("codex_exec:approval_policy=never")
4236 );
4237 assert_eq!(
4238 expected_permission_executor_mapping(&codex_exec, default, true)
4239 .unwrap()
4240 .as_deref(),
4241 Some("codex_exec:blocked_explicit_deny")
4242 );
4243
4244 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
4245 if let ExecutorSpec::Codex {
4246 mode,
4247 approval_policy,
4248 ..
4249 } = &mut codex_app_server
4250 {
4251 *mode = Some("app_server".to_string());
4252 *approval_policy = Some("on-request".to_string());
4253 }
4254 assert_eq!(
4255 expected_permission_executor_mapping(&codex_app_server, default, false)
4256 .unwrap()
4257 .as_deref(),
4258 Some("codex_app_server:approvalPolicy=on-request")
4259 );
4260 assert_eq!(
4261 expected_permission_executor_mapping(
4262 &codex_app_server,
4263 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4264 false,
4265 )
4266 .unwrap()
4267 .as_deref(),
4268 Some("codex_app_server:approvalPolicy=never")
4269 );
4270 assert_eq!(
4271 expected_permission_executor_mapping(&codex_app_server, default, true)
4272 .unwrap()
4273 .as_deref(),
4274 Some("codex_app_server:blocked_explicit_deny")
4275 );
4276 }
4277
4278 #[test]
4279 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
4280 let project = tempfile::tempdir().unwrap();
4281 let managed = project.path().join(".bamboo/worktree/child-571");
4282 std::fs::create_dir_all(&managed).unwrap();
4283 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
4284 let marker = project
4285 .path()
4286 .join(".bamboo/worktree/.bamboo-owned/child-571");
4287 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
4288 std::fs::write(&marker, "bamboo/child-571").unwrap();
4289 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
4290 let nested = managed.join("nested/path");
4291 std::fs::create_dir_all(&nested).unwrap();
4292 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
4293
4294 let arbitrary = tempfile::tempdir().unwrap();
4295 assert!(!workspace_is_bamboo_owned(
4296 arbitrary.path().to_str().unwrap()
4297 ));
4298 }
4299
4300 #[test]
4301 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
4302 let authority = Arc::new(RecordingCodexTokenAuthority::default());
4303 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4304
4305 let (secrets, guard) = build_codex_run_secrets(
4306 &codex_executor(Some("bamboo"), None),
4307 Some(authority_dyn),
4308 "child-570",
4309 )
4310 .unwrap();
4311
4312 let token = secrets
4313 .codex_provider_token
4314 .as_ref()
4315 .expect("bamboo mode mints a token");
4316 assert_eq!(token.expose(), "bcx1_secret-child-570");
4317 assert!(!format!("{token:?}").contains("secret-child-570"));
4318 assert_eq!(
4319 authority.issued_for.lock().unwrap().as_slice(),
4320 ["child-570"]
4321 );
4322 assert!(authority.revoked.lock().unwrap().is_empty());
4323
4324 drop(guard);
4325 assert_eq!(
4326 authority.revoked.lock().unwrap().as_slice(),
4327 ["id-child-570"]
4328 );
4329 }
4330
4331 #[test]
4332 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
4333 let authority = Arc::new(RecordingCodexTokenAuthority::default());
4334 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4335 let (secrets, guard) = build_codex_run_secrets(
4336 &codex_executor(Some("custom"), None),
4337 Some(authority_dyn),
4338 "child-custom",
4339 )
4340 .unwrap();
4341 assert!(secrets.codex_provider_token.is_none());
4342 assert!(guard.is_none());
4343 assert!(authority.issued_for.lock().unwrap().is_empty());
4344
4345 let error = build_codex_run_secrets(
4346 &codex_executor(Some("bamboo"), None),
4347 None,
4348 "child-no-authority",
4349 )
4350 .err()
4351 .expect("bamboo mode without an authority must fail closed");
4352 assert!(error.to_string().contains("per-run token authority"));
4353 }
4354
4355 #[test]
4356 fn codex_provisioning_never_leaks_the_session_provider_credential() {
4357 let credentials = vec![ScopedCredential {
4358 provider: "openai".to_string(),
4359 api_key: "upstream-secret-must-not-cross".to_string(),
4360 base_url: None,
4361 provider_type: Some("openai".to_string()),
4362 credential_ref: Some("provider.openai.api_key".to_string()),
4363 }];
4364
4365 for (mode, label) in [
4366 (Some("inherit"), "inherit"),
4367 (Some("api_key"), "api_key"),
4368 (Some("bamboo"), "bamboo"),
4369 (None, "default-bamboo"),
4370 ] {
4371 let runner = ActorChildRunner::new(
4372 format!("codex-{label}-test"),
4373 PathBuf::from("/bin/false"),
4374 Vec::new(),
4375 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
4376 codex_executor(mode, None),
4377 credentials.clone(),
4378 "openai".to_string(),
4379 1,
4380 );
4381 let mut session = Session::new(format!("child-{label}"), "model");
4382 session.add_message(bamboo_agent_core::Message::user("test"));
4383 let spec = runner.build_spec(
4384 &session,
4385 &crate::runtime::execution::SpawnJob {
4386 parent_session_id: "parent".to_string(),
4387 child_session_id: format!("child-{label}"),
4388 model: "gpt-5.4".to_string(),
4389 disabled_tools: None,
4390 },
4391 );
4392 assert!(
4393 spec.secrets.provider_credentials.is_empty(),
4394 "{label} Codex must not receive the session provider key"
4395 );
4396 }
4397 }
4398
4399 #[test]
4400 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
4401 let credentials = vec![
4402 ScopedCredential {
4403 provider: "openai".to_string(),
4404 api_key: "selected-openai-secret".to_string(),
4405 base_url: None,
4406 provider_type: Some("openai".to_string()),
4407 credential_ref: Some("provider.openai.api_key".to_string()),
4408 },
4409 ScopedCredential {
4410 provider: "other".to_string(),
4411 api_key: "unrelated-secret".to_string(),
4412 base_url: None,
4413 provider_type: Some("openai".to_string()),
4414 credential_ref: Some("provider.other.api_key".to_string()),
4415 },
4416 ];
4417 let runner = ActorChildRunner::new(
4418 "echo-test".to_string(),
4419 PathBuf::from("/bin/false"),
4420 Vec::new(),
4421 std::env::temp_dir().join("bamboo-echo-provider-570"),
4422 ExecutorSpec::Echo,
4423 credentials,
4424 "openai".to_string(),
4425 1,
4426 );
4427 let spec = runner.build_spec(
4428 &Session::new("child-echo", "model"),
4429 &crate::runtime::execution::SpawnJob {
4430 parent_session_id: "parent".to_string(),
4431 child_session_id: "child-echo".to_string(),
4432 model: "gpt-5.4".to_string(),
4433 disabled_tools: None,
4434 },
4435 );
4436
4437 assert_eq!(spec.secrets.provider_credentials.len(), 1);
4438 assert_eq!(
4439 spec.secrets.provider_credentials[0].api_key,
4440 "selected-openai-secret"
4441 );
4442 }
4443
4444 #[test]
4445 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
4446 let mut executor = codex_executor(Some("custom"), None);
4447 if let ExecutorSpec::Codex {
4448 base_url,
4449 provider_key_ref,
4450 ..
4451 } = &mut executor
4452 {
4453 *base_url = Some("https://provider.example/v1".to_string());
4454 *provider_key_ref = Some("provider.custom.api_key".to_string());
4455 }
4456 let credentials = vec![
4457 ScopedCredential {
4458 provider: "openai".to_string(),
4459 api_key: "session-provider-secret".to_string(),
4460 base_url: None,
4461 provider_type: Some("openai".to_string()),
4462 credential_ref: Some("provider.openai.api_key".to_string()),
4463 },
4464 ScopedCredential {
4465 provider: "custom".to_string(),
4466 api_key: "selected-secret".to_string(),
4467 base_url: None,
4468 provider_type: Some("openai".to_string()),
4469 credential_ref: Some("provider.custom.api_key".to_string()),
4470 },
4471 ScopedCredential {
4472 provider: "other".to_string(),
4473 api_key: "unrelated-secret".to_string(),
4474 base_url: None,
4475 provider_type: Some("openai".to_string()),
4476 credential_ref: Some("provider.other.api_key".to_string()),
4477 },
4478 ];
4479 let runner = ActorChildRunner::new(
4480 "codex-test".to_string(),
4481 PathBuf::from("/bin/false"),
4482 Vec::new(),
4483 std::env::temp_dir().join("bamboo-codex-570"),
4484 executor,
4485 credentials,
4486 "openai".to_string(),
4487 1,
4488 );
4489 let mut session = Session::new("child-custom", "model");
4490 session.add_message(bamboo_agent_core::Message::user("test"));
4491 let spec = runner.build_spec(
4492 &session,
4493 &crate::runtime::execution::SpawnJob {
4494 parent_session_id: "parent".to_string(),
4495 child_session_id: "child-custom".to_string(),
4496 model: "gpt-5.4".to_string(),
4497 disabled_tools: None,
4498 },
4499 );
4500
4501 assert_eq!(spec.secrets.provider_credentials.len(), 1);
4502 assert_eq!(
4503 spec.secrets.provider_credentials[0]
4504 .credential_ref
4505 .as_deref(),
4506 Some("provider.custom.api_key")
4507 );
4508 assert_eq!(
4509 spec.secrets.provider_credentials[0].api_key,
4510 "selected-secret"
4511 );
4512 }
4513
4514 fn spec_with(
4515 role: &str,
4516 provider: &str,
4517 model: &str,
4518 workspace: Option<&str>,
4519 disabled: Option<Vec<&str>>,
4520 ) -> ProvisionSpec {
4521 let mut spec = ProvisionSpec::new(
4522 ChildIdentity {
4523 child_id: "c".into(),
4524 parent_id: None,
4525 project_key: None,
4526 role: role.into(),
4527 depth: 0,
4528 },
4529 ExecutorSpec::Echo,
4530 "/tmp/fab".into(),
4531 );
4532 spec.workspace = workspace.map(|w| w.to_string());
4533 spec.model = Some(ModelRefSpec {
4534 provider: provider.into(),
4535 model: model.into(),
4536 });
4537 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
4538 spec
4539 }
4540
4541 #[test]
4542 fn fingerprint_matches_interchangeable_children() {
4543 let a = spec_with(
4546 "explorer",
4547 "p",
4548 "m",
4549 Some("/ws"),
4550 Some(vec!["Bash", "Edit"]),
4551 );
4552 let mut b = spec_with(
4553 "explorer",
4554 "p",
4555 "m",
4556 Some("/ws"),
4557 Some(vec!["Edit", "Bash"]),
4558 );
4559 b.identity.child_id = "other".into();
4560 assert_eq!(
4561 ActorChildRunner::fingerprint(&a),
4562 ActorChildRunner::fingerprint(&b)
4563 );
4564 }
4565
4566 #[test]
4567 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
4568 let mut session =
4569 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
4570 session.root_session_id = "logical-root-681".to_string();
4571 let job = SpawnJob {
4572 parent_session_id: "logical-parent-681".to_string(),
4573 child_session_id: "logical-child-681".to_string(),
4574 model: "model".to_string(),
4575 disabled_tools: None,
4576 };
4577 let expected = LogicalSessionIdentity {
4578 session_id: "logical-child-681".to_string(),
4579 parent_session_id: Some("logical-parent-681".to_string()),
4580 root_session_id: "logical-root-681".to_string(),
4581 };
4582
4583 let placements_and_transport_ids = [
4584 (Placement::Local, "local-mailbox-first"),
4585 (
4586 Placement::Remote {
4587 endpoint: "wss://remote.example/actor".to_string(),
4588 },
4589 "remote-process-44",
4590 ),
4591 (
4592 Placement::Schedulable {
4593 pool: "gpu-pool".to_string(),
4594 },
4595 "scheduled-mailbox-9",
4596 ),
4597 (Placement::Local, "warm-mailbox-reused-77"),
4599 ];
4600 for (placement, transport_id) in placements_and_transport_ids {
4601 let mut provision = spec_with("worker", "provider", "model", None, None);
4602 provision.placement = placement;
4603 provision.identity.child_id = transport_id.to_string();
4604 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
4605 assert_ne!(
4606 provision.identity.child_id, expected.session_id,
4607 "test fixture must prove transport identity is independent"
4608 );
4609 }
4610 }
4611
4612 #[test]
4613 fn fingerprint_separates_distinct_runtimes() {
4614 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
4615 let base_fp = ActorChildRunner::fingerprint(&base);
4616 assert_ne!(
4618 base_fp,
4619 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
4620 );
4621 assert_ne!(
4622 base_fp,
4623 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
4624 );
4625 assert_ne!(
4626 base_fp,
4627 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
4628 );
4629 assert_ne!(
4630 base_fp,
4631 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
4632 );
4633 assert_ne!(
4634 base_fp,
4635 ActorChildRunner::fingerprint(&spec_with(
4636 "explorer",
4637 "p",
4638 "m",
4639 Some("/ws"),
4640 Some(vec!["Bash"])
4641 ))
4642 );
4643 }
4644
4645 #[test]
4646 fn fingerprint_splits_on_baked_capabilities() {
4647 let base_fp =
4652 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
4653
4654 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
4655 depth.identity.depth = 2;
4656 assert_ne!(
4657 base_fp,
4658 ActorChildRunner::fingerprint(&depth),
4659 "depth must split"
4660 );
4661
4662 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
4663 nested.capabilities.nested_spawn = true;
4664 assert_ne!(
4665 base_fp,
4666 ActorChildRunner::fingerprint(&nested),
4667 "nested_spawn must split"
4668 );
4669
4670 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
4671 bypass.capabilities.bypass = true;
4672 assert_ne!(
4673 base_fp,
4674 ActorChildRunner::fingerprint(&bypass),
4675 "bypass must split"
4676 );
4677
4678 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4679 auto.capabilities.auto_approve_permissions = true;
4680 assert_ne!(
4681 base_fp,
4682 ActorChildRunner::fingerprint(&auto),
4683 "auto_approve_permissions must split"
4684 );
4685
4686 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4687 global_auto.capabilities.permission_requested_mode = "default".to_string();
4688 global_auto.capabilities.permission_effective_mode = "auto".to_string();
4689 global_auto.capabilities.auto_approve_permissions = true;
4690 let mut explicit_auto = global_auto.clone();
4691 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
4692 assert_ne!(
4693 ActorChildRunner::fingerprint(&global_auto),
4694 ActorChildRunner::fingerprint(&explicit_auto),
4695 "permission_requested_mode must split global and explicit Auto"
4696 );
4697
4698 let mut plan_overlay = explicit_auto.clone();
4699 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
4700 assert_ne!(
4701 ActorChildRunner::fingerprint(&explicit_auto),
4702 ActorChildRunner::fingerprint(&plan_overlay),
4703 "permission_effective_mode must split Plan overlay from Auto"
4704 );
4705
4706 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
4707 enforce.capabilities.enforce_permissions = true;
4708 assert_ne!(
4709 base_fp,
4710 ActorChildRunner::fingerprint(&enforce),
4711 "enforce_permissions must split"
4712 );
4713
4714 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
4715 cap.capabilities.max_spawn_depth = Some(8);
4716 assert_ne!(
4717 base_fp,
4718 ActorChildRunner::fingerprint(&cap),
4719 "max_spawn_depth must split"
4720 );
4721
4722 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
4726 nha.capabilities.no_human_approver = true;
4727 assert_ne!(
4728 base_fp,
4729 ActorChildRunner::fingerprint(&nha),
4730 "no_human_approver must split"
4731 );
4732
4733 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
4736 read_only.capabilities.read_only = true;
4737 assert_ne!(
4738 base_fp,
4739 ActorChildRunner::fingerprint(&read_only),
4740 "read_only must split"
4741 );
4742
4743 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
4745 gro.capabilities.guardian_read_only = true;
4746 assert_ne!(
4747 base_fp,
4748 ActorChildRunner::fingerprint(&gro),
4749 "guardian_read_only must split"
4750 );
4751 }
4752
4753 #[test]
4754 fn fingerprint_splits_codex_exec_and_app_server_workers() {
4755 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
4756 exec.executor = codex_executor(Some("inherit"), None);
4757 let mut app_server = exec.clone();
4758 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
4759 *mode = Some("app_server".to_string());
4760 }
4761 assert_ne!(
4762 ActorChildRunner::fingerprint(&exec),
4763 ActorChildRunner::fingerprint(&app_server)
4764 );
4765 }
4766
4767 struct StaticDecider(bool);
4768
4769 #[async_trait]
4770 impl ChildApprovalDecider for StaticDecider {
4771 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
4772 self.0
4773 }
4774 }
4775
4776 struct RecordingReviewer {
4777 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
4778 }
4779
4780 #[async_trait]
4781 impl ChildApprovalReviewer for RecordingReviewer {
4782 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
4783 let _ = self
4784 .reviewed
4785 .send((parent.to_string(), child.to_string(), request.clone()));
4786 true
4787 }
4788 }
4789
4790 struct SilentLink;
4795 #[async_trait]
4796 impl bamboo_subagent::ChildLink for SilentLink {
4797 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4798 Ok(())
4799 }
4800 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4801 std::future::pending().await
4802 }
4803 }
4804
4805 struct InstantTerminalLink {
4807 done: bool,
4808 }
4809
4810 struct ApprovalRoundTripLink {
4811 step: u8,
4812 approval_reply: Option<(String, bool)>,
4813 }
4814
4815 #[async_trait]
4816 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
4817 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4818 if let ParentFrame::ApprovalReply { id, approved } = frame {
4819 self.approval_reply = Some((id, approved));
4820 self.step = 2;
4821 }
4822 Ok(())
4823 }
4824
4825 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4826 match self.step {
4827 0 => {
4828 self.step = 1;
4829 Ok(Some(ChildFrame::ApprovalRequest {
4830 id: "approval-1".into(),
4831 body: serde_json::json!({
4832 "tool_name": "Bash",
4833 "permission": "execute",
4834 "resource": "rm -rf target",
4835 "permission_request": {"reason_code": "hard_dangerous"}
4836 }),
4837 }))
4838 }
4839 1 => std::future::pending().await,
4840 2 => {
4841 self.step = 3;
4842 Ok(Some(ChildFrame::Terminal {
4843 status: TerminalStatus::Completed,
4844 result: Some("done".into()),
4845 error: None,
4846 transcript: vec![],
4847 }))
4848 }
4849 _ => std::future::pending().await,
4850 }
4851 }
4852 }
4853
4854 #[tokio::test]
4855 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
4856 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4857 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
4858 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
4859 reviewed: review_tx,
4860 });
4861 let cancel = CancellationToken::new();
4862 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4863 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4864 let mut logical_session = Session::new("child-reviewer", "model");
4865 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
4866 let mut link = ApprovalRoundTripLink {
4867 step: 0,
4868 approval_reply: None,
4869 };
4870
4871 let result = tokio::time::timeout(
4872 Duration::from_secs(1),
4873 drive(ActorDriveContext {
4874 client: &mut link,
4875 parent_session_id: "parent-reviewer",
4876 child_session_id: "child-reviewer",
4877 child_attempt: 0,
4878 approval_registry: None,
4879 approval_decider: None,
4880 approval_reviewer: Some(&reviewer),
4881 escalation_bridge: None,
4882 event_tx: &event_tx,
4883 cancel_token: &cancel,
4884 live_rx: &mut live_rx,
4885 delivery_rx: &mut delivery_rx,
4886 logical_session: &mut logical_session,
4887 expected_permission_posture: None,
4888 session_inbox_runtime: None,
4889 activation_run_id: None,
4890 execution_epoch: 0,
4891 expected_source_actor_id: "child-reviewer",
4892 initial_inflight_claims: VecDeque::new(),
4893 first_frame_timeout: None,
4894 }),
4895 )
4896 .await
4897 .expect("worker must receive the reviewer verdict before terminating");
4898
4899 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4900 assert_eq!(
4901 link.approval_reply,
4902 Some(("approval-1".to_string(), true)),
4903 "reviewer verdict must traverse the live route back to the worker"
4904 );
4905 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
4906 .await
4907 .expect("reviewer should be invoked off-loop")
4908 .expect("review channel should remain open");
4909 assert_eq!(parent, "parent-reviewer");
4910 assert_eq!(child, "child-reviewer");
4911 assert_eq!(
4912 body.pointer("/permission_request/reason_code")
4913 .and_then(serde_json::Value::as_str),
4914 Some("hard_dangerous")
4915 );
4916 assert!(
4917 event_rx.try_recv().is_err(),
4918 "must not emit a human-review event"
4919 );
4920 drop(live_guard);
4921 }
4922
4923 #[tokio::test]
4924 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
4925 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4926 let cancel = CancellationToken::new();
4927 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4928 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4929 let mut logical_session = Session::new("child-no-reviewer", "model");
4930 let mut link = ApprovalRoundTripLink {
4931 step: 0,
4932 approval_reply: None,
4933 };
4934
4935 let result = tokio::time::timeout(
4936 Duration::from_secs(1),
4937 drive(ActorDriveContext {
4938 client: &mut link,
4939 parent_session_id: "parent-no-reviewer",
4940 child_session_id: "child-no-reviewer",
4941 child_attempt: 0,
4942 approval_registry: None,
4943 approval_decider: None,
4944 approval_reviewer: None,
4945 escalation_bridge: None,
4946 event_tx: &event_tx,
4947 cancel_token: &cancel,
4948 live_rx: &mut live_rx,
4949 delivery_rx: &mut delivery_rx,
4950 logical_session: &mut logical_session,
4951 expected_permission_posture: None,
4952 session_inbox_runtime: None,
4953 activation_run_id: None,
4954 execution_epoch: 0,
4955 expected_source_actor_id: "child-no-reviewer",
4956 initial_inflight_claims: VecDeque::new(),
4957 first_frame_timeout: None,
4958 }),
4959 )
4960 .await
4961 .expect("fail-closed reply must unblock the child immediately");
4962
4963 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4964 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
4965 assert!(
4966 event_rx.try_recv().is_err(),
4967 "missing parent review must not surface a manual approval event"
4968 );
4969 }
4970 #[async_trait]
4971 impl bamboo_subagent::ChildLink for InstantTerminalLink {
4972 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4973 Ok(())
4974 }
4975 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4976 if self.done {
4977 std::future::pending().await
4978 } else {
4979 self.done = true;
4980 Ok(Some(ChildFrame::Terminal {
4981 status: TerminalStatus::Completed,
4982 result: Some("done".into()),
4983 error: None,
4984 transcript: vec![],
4985 }))
4986 }
4987 }
4988 }
4989
4990 #[tokio::test]
4991 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
4992 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4993 let cancel = CancellationToken::new();
4994 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4995 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4996 let mut logical_session = Session::new("child-x", "model");
4997 let mut link = SilentLink;
4998 let r = drive(ActorDriveContext {
4999 client: &mut link,
5000 parent_session_id: "parent-x",
5001 child_session_id: "child-x",
5002 child_attempt: 0,
5003 approval_registry: None,
5004 approval_decider: None,
5005 approval_reviewer: None,
5006 escalation_bridge: None,
5007 event_tx: &event_tx,
5008 cancel_token: &cancel,
5009 live_rx: &mut live_rx,
5010 delivery_rx: &mut delivery_rx,
5011 logical_session: &mut logical_session,
5012 expected_permission_posture: None,
5013 session_inbox_runtime: None,
5014 activation_run_id: None,
5015 execution_epoch: 0,
5016 expected_source_actor_id: "child-x",
5017 initial_inflight_claims: VecDeque::new(),
5018 first_frame_timeout: Some(Duration::from_millis(100)),
5019 })
5020 .await;
5021 assert!(
5022 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
5023 "a silent worker must trip the first-frame watchdog, got {r:?}"
5024 );
5025 }
5026
5027 #[tokio::test]
5028 async fn drive_does_not_trip_when_a_frame_arrives() {
5029 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
5030 let cancel = CancellationToken::new();
5031 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
5032 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
5033 let mut logical_session = Session::new("child-y", "model");
5034 let mut link = InstantTerminalLink { done: false };
5035 let r = drive(ActorDriveContext {
5038 client: &mut link,
5039 parent_session_id: "parent-y",
5040 child_session_id: "child-y",
5041 child_attempt: 0,
5042 approval_registry: None,
5043 approval_decider: None,
5044 approval_reviewer: None,
5045 escalation_bridge: None,
5046 event_tx: &event_tx,
5047 cancel_token: &cancel,
5048 live_rx: &mut live_rx,
5049 delivery_rx: &mut delivery_rx,
5050 logical_session: &mut logical_session,
5051 expected_permission_posture: None,
5052 session_inbox_runtime: None,
5053 activation_run_id: None,
5054 execution_epoch: 0,
5055 expected_source_actor_id: "child-y",
5056 initial_inflight_claims: VecDeque::new(),
5057 first_frame_timeout: Some(Duration::from_millis(50)),
5058 })
5059 .await;
5060 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
5061 }
5062
5063 #[tokio::test]
5064 async fn child_approval_fails_closed_without_decider() {
5065 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
5067 assert!(!decide_child_approval(None, "child-1", &body).await);
5068 }
5069
5070 #[tokio::test]
5071 async fn child_approval_honors_wired_decider() {
5072 let body =
5073 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
5074 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
5075 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
5076 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
5077 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
5078 }
5079
5080 use crate::runtime::execution::SpawnJob;
5083 use bamboo_agent_core::Session;
5084
5085 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
5088 ActorChildRunner::new(
5089 "test-actor".into(),
5090 PathBuf::from("/bin/false"),
5091 vec![],
5092 std::env::temp_dir().join("bamboo-test-fab-193"),
5093 ExecutorSpec::Echo,
5094 vec![],
5095 "anthropic".into(),
5096 4,
5097 )
5098 .with_remote_placements(placements)
5099 }
5100
5101 fn session_of_role(role: &str, assignment: &str) -> Session {
5104 let mut s = Session::new("child-1", "test-model");
5105 s.metadata
5106 .insert("subagent_type".to_string(), role.to_string());
5107 s.add_message(bamboo_agent_core::Message::user(assignment));
5108 s
5109 }
5110
5111 fn job_for(child: &str) -> SpawnJob {
5112 SpawnJob {
5113 parent_session_id: "parent-1".into(),
5114 child_session_id: child.into(),
5115 model: String::new(),
5116 disabled_tools: None,
5117 }
5118 }
5119
5120 #[derive(Default)]
5121 struct RecordingChildSessionPort {
5122 saved: std::sync::Mutex<Option<Session>>,
5123 }
5124
5125 impl RecordingChildSessionPort {
5126 fn saved_child(&self) -> Session {
5127 self.saved
5128 .lock()
5129 .expect("saved-child fixture lock")
5130 .clone()
5131 .expect("create_child_action must save the child")
5132 }
5133 }
5134
5135 #[async_trait]
5136 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
5137 async fn load_root_session(
5138 &self,
5139 _root_id: &str,
5140 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
5141 unreachable!("create_child_action does not load the root")
5142 }
5143
5144 async fn load_child_for_parent(
5145 &self,
5146 _parent_id: &str,
5147 _child_id: &str,
5148 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
5149 unreachable!("create_child_action does not reload the child")
5150 }
5151
5152 async fn save_child_session(
5153 &self,
5154 child: &mut Session,
5155 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5156 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
5157 Ok(())
5158 }
5159
5160 async fn save_child_session_authoritative_flags(
5161 &self,
5162 _child: &mut Session,
5163 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5164 unreachable!("new-child creation uses the ordinary save")
5165 }
5166
5167 async fn is_child_running(&self, _child_id: &str) -> bool {
5168 false
5169 }
5170
5171 async fn list_children(
5172 &self,
5173 _parent_id: &str,
5174 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
5175 Vec::new()
5176 }
5177
5178 async fn enqueue_child_run(
5179 &self,
5180 _parent: &Session,
5181 _child: &Session,
5182 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5183 unreachable!("fixture creates the child with auto_run=false")
5184 }
5185
5186 async fn cancel_child_run_and_wait(
5187 &self,
5188 _child_id: &str,
5189 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5190 unreachable!("create_child_action does not cancel")
5191 }
5192
5193 async fn delete_child_session(
5194 &self,
5195 _parent_id: &str,
5196 _child_id: &str,
5197 ) -> Result<
5198 crate::session_app::child_session::DeleteChildResult,
5199 crate::session_app::child_session::ChildSessionError,
5200 > {
5201 unreachable!("create_child_action does not delete")
5202 }
5203
5204 async fn get_child_runner_info(
5205 &self,
5206 _child_id: &str,
5207 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
5208 None
5209 }
5210
5211 async fn register_parent_wait_for_child(
5212 &self,
5213 _parent_session_id: &str,
5214 _child_session_id: &str,
5215 _tool_call_id: Option<&str>,
5216 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5217 unreachable!("create_child_action does not register a wait")
5218 }
5219
5220 async fn register_parent_wait_for_children(
5221 &self,
5222 _parent_session_id: &str,
5223 _child_session_ids: &[String],
5224 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
5225 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
5226 unreachable!("create_child_action does not register a wait")
5227 }
5228
5229 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
5230 Vec::new()
5231 }
5232
5233 async fn find_resident_child(
5234 &self,
5235 _root_session_id: &str,
5236 _resident_name: &str,
5237 ) -> Option<String> {
5238 None
5239 }
5240
5241 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
5242 }
5243
5244 #[test]
5245 fn build_spec_sets_remote_placement_for_matching_role() {
5246 let mut placements = HashMap::new();
5247 placements.insert(
5248 "explorer".to_string(),
5249 ResolvedRemotePlacement {
5250 endpoint: "wss://gpu-host:8443".into(),
5251 token: Some("T-secret".into()),
5252 ca_cert_file: None,
5253 host_label: None,
5254 },
5255 );
5256 let runner = bogus_runner(placements);
5257
5258 let s = session_of_role("explorer", "do the thing");
5260 let spec = runner.build_spec(&s, &job_for("child-1"));
5261 match &spec.placement {
5262 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
5263 other => panic!("expected Remote, got {other:?}"),
5264 }
5265 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-secret"));
5266 }
5267
5268 #[test]
5269 fn build_spec_leaves_local_for_unmatched_role() {
5270 let mut placements = HashMap::new();
5271 placements.insert(
5272 "explorer".to_string(),
5273 ResolvedRemotePlacement {
5274 endpoint: "wss://gpu-host:8443".into(),
5275 token: Some("T".into()),
5276 ca_cert_file: None,
5277 host_label: None,
5278 },
5279 );
5280 let runner = bogus_runner(placements);
5281
5282 let s = session_of_role("writer", "do the thing");
5284 let spec = runner.build_spec(&s, &job_for("child-1"));
5285 assert_eq!(spec.placement, Placement::Local);
5286 assert!(spec.secrets.worker_auth_token.is_none());
5287 }
5288
5289 #[test]
5290 fn build_spec_local_when_no_placements() {
5291 let runner = bogus_runner(HashMap::new());
5292 let s = session_of_role("explorer", "do the thing");
5293 let spec = runner.build_spec(&s, &job_for("child-1"));
5294 assert_eq!(spec.placement, Placement::Local);
5295 assert!(spec.secrets.worker_auth_token.is_none());
5296 }
5297
5298 #[test]
5299 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
5300 for mode in [
5301 bamboo_domain::SessionPermissionMode::Auto,
5302 bamboo_domain::SessionPermissionMode::Bypass,
5303 ] {
5304 let runner = bogus_runner(HashMap::new());
5305 let mut child = session_of_role("planner", "inspect and plan");
5306 let runtime = child
5307 .agent_runtime_state
5308 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
5309 runtime.set_permission_mode(mode);
5310 runtime.read_only = true;
5311
5312 let spec = runner.build_spec(&child, &job_for("read-only-child"));
5313
5314 assert!(spec.capabilities.read_only);
5315 assert!(spec.capabilities.read_only_enforced());
5316 assert!(
5317 spec.capabilities.guardian_read_only,
5318 "the compatibility alias must protect planners on older workers"
5319 );
5320 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
5321 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
5322 assert!(
5323 !spec.capabilities.bypass,
5324 "read-only must win over requested {mode:?}"
5325 );
5326 assert_eq!(
5327 spec.capabilities.auto_approve_permissions,
5328 mode == bamboo_domain::SessionPermissionMode::Auto,
5329 "Auto may suppress prompts but must not widen Plan authorization"
5330 );
5331 let disabled = spec
5332 .disabled_tools
5333 .as_ref()
5334 .expect("read-only state must provision a host-owned denylist");
5335 for tool_name in [
5336 "Edit",
5337 "Write",
5338 "Plan",
5339 "SubAgent",
5340 "session_control",
5341 "request_permissions",
5342 ] {
5343 assert!(
5344 disabled.iter().any(|name| name == tool_name),
5345 "read-only provisioning must disable {tool_name}"
5346 );
5347 }
5348 }
5349
5350 let runner = bogus_runner(HashMap::new());
5351 let cosmetic_planner = runner.build_spec(
5352 &session_of_role("planner", "label only"),
5353 &job_for("cosmetic"),
5354 );
5355 assert!(
5356 !cosmetic_planner.capabilities.read_only_enforced(),
5357 "a cosmetic role label must not change authority"
5358 );
5359 assert!(cosmetic_planner.disabled_tools.is_none());
5360
5361 let cosmetic_guardian = runner.build_spec(
5362 &session_of_role("guardian", "label only"),
5363 &job_for("cosmetic-guardian"),
5364 );
5365 assert!(
5366 !cosmetic_guardian.capabilities.read_only_enforced(),
5367 "a Guardian label without typed state or the legacy denylist is cosmetic"
5368 );
5369
5370 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
5371 let mut legacy_job = job_for("legacy-guardian");
5372 legacy_job.disabled_tools = Some(vec![
5373 "Edit".to_string(),
5374 "Write".to_string(),
5375 "SubAgent".to_string(),
5376 "WebFetch".to_string(),
5377 ]);
5378 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
5379 assert!(
5380 legacy_spec.capabilities.read_only_enforced(),
5381 "an in-flight pre-upgrade Guardian keeps its read-only authority"
5382 );
5383
5384 let mut differently_named = session_of_role("explorer", "typed authority");
5385 differently_named
5386 .agent_runtime_state
5387 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5388 .read_only = true;
5389 let typed = runner.build_spec(&differently_named, &job_for("typed"));
5390 assert!(
5391 typed.capabilities.read_only_enforced(),
5392 "typed state must enforce read-only independently of role"
5393 );
5394 }
5395
5396 #[test]
5397 fn typed_read_only_forces_native_external_executor_profiles() {
5398 let mut planner = session_of_role("planner", "inspect and plan");
5399 planner
5400 .agent_runtime_state
5401 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5402 .read_only = true;
5403
5404 let claude_runner = ActorChildRunner::new(
5405 "test-actor".into(),
5406 PathBuf::from("/bin/false"),
5407 vec![],
5408 std::env::temp_dir().join("bamboo-test-plan-claude"),
5409 ExecutorSpec::ClaudeCode {
5410 binary: None,
5411 model: None,
5412 permission_mode: Some("bypassPermissions".to_string()),
5413 inherit_user_config: Some(true),
5414 forward_env: None,
5415 },
5416 vec![],
5417 "anthropic".into(),
5418 4,
5419 );
5420 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
5421 let disabled = claude_spec
5422 .disabled_tools
5423 .as_ref()
5424 .expect("typed read-only child must receive a host-owned denylist");
5425 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
5426 assert!(
5427 disabled.iter().any(|name| name == tool),
5428 "typed read-only child must not advertise {tool}"
5429 );
5430 }
5431 assert!(matches!(
5432 claude_spec.executor,
5433 ExecutorSpec::ClaudeCode {
5434 permission_mode: Some(ref mode),
5435 inherit_user_config: Some(false),
5436 ..
5437 } if mode == "plan"
5438 ));
5439 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
5440
5441 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
5442 if let ExecutorSpec::Codex {
5443 sandbox,
5444 approval_policy,
5445 network_access,
5446 allow_danger_bypass,
5447 permission_profile,
5448 ..
5449 } = &mut codex_exec
5450 {
5451 *sandbox = Some("danger-full-access".to_string());
5452 *approval_policy = Some("on-failure".to_string());
5453 *network_access = Some(true);
5454 *allow_danger_bypass = Some(true);
5455 *permission_profile = Some("full-access".to_string());
5456 }
5457 let codex_runner = ActorChildRunner::new(
5458 "test-actor".into(),
5459 PathBuf::from("/bin/false"),
5460 vec![],
5461 std::env::temp_dir().join("bamboo-test-plan-codex"),
5462 codex_exec,
5463 vec![],
5464 "openai".into(),
5465 4,
5466 );
5467 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
5468 assert_eq!(
5469 expected_permission_executor_mapping(
5470 &codex_spec.executor,
5471 codex_spec.capabilities.permission_resolution().unwrap(),
5472 false,
5473 )
5474 .unwrap()
5475 .as_deref(),
5476 Some("codex_exec:approval_policy=never")
5477 );
5478 assert!(matches!(
5479 codex_spec.executor,
5480 ExecutorSpec::Codex {
5481 sandbox: Some(ref sandbox),
5482 inherit_user_config: Some(false),
5483 approval_policy: Some(ref approval_policy),
5484 network_access: Some(false),
5485 allow_danger_bypass: Some(false),
5486 permission_profile: Some(ref profile),
5487 ..
5488 } if sandbox == "read-only"
5489 && approval_policy == "never"
5490 && profile == "read-only"
5491 ));
5492
5493 let mut app_server = codex_executor(Some("inherit"), Some(true));
5494 if let ExecutorSpec::Codex {
5495 mode,
5496 sandbox,
5497 approval_policy,
5498 network_access,
5499 allow_danger_bypass,
5500 permission_profile,
5501 ..
5502 } = &mut app_server
5503 {
5504 *mode = Some("app_server".to_string());
5505 *sandbox = Some("danger-full-access".to_string());
5506 *approval_policy = Some("on-request".to_string());
5507 *network_access = Some(true);
5508 *allow_danger_bypass = Some(true);
5509 *permission_profile = Some("full-access".to_string());
5510 }
5511 let app_server_runner = ActorChildRunner::new(
5512 "test-actor".into(),
5513 PathBuf::from("/bin/false"),
5514 vec![],
5515 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
5516 app_server,
5517 vec![],
5518 "openai".into(),
5519 4,
5520 );
5521 let app_server_spec =
5522 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
5523 assert_eq!(
5524 expected_permission_executor_mapping(
5525 &app_server_spec.executor,
5526 app_server_spec
5527 .capabilities
5528 .permission_resolution()
5529 .unwrap(),
5530 false,
5531 )
5532 .unwrap()
5533 .as_deref(),
5534 Some("codex_app_server:approvalPolicy=never")
5535 );
5536 assert!(matches!(
5537 app_server_spec.executor,
5538 ExecutorSpec::Codex {
5539 mode: Some(ref mode),
5540 sandbox: Some(ref sandbox),
5541 inherit_user_config: Some(false),
5542 approval_policy: Some(ref approval_policy),
5543 network_access: Some(false),
5544 allow_danger_bypass: Some(false),
5545 permission_profile: Some(ref profile),
5546 ..
5547 } if mode == "app_server"
5548 && sandbox == "read-only"
5549 && approval_policy == "on-request"
5550 && profile == "read-only"
5551 ));
5552 }
5553
5554 #[test]
5555 fn typed_read_only_rejects_both_codex_executor_modes() {
5556 let mut planner = session_of_role("planner", "inspect and plan");
5557 planner
5558 .agent_runtime_state
5559 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5560 .read_only = true;
5561
5562 for mode in ["exec", "app_server"] {
5563 let mut executor = codex_executor(Some("inherit"), Some(true));
5564 if let ExecutorSpec::Codex {
5565 mode: configured_mode,
5566 ..
5567 } = &mut executor
5568 {
5569 *configured_mode = Some(mode.to_string());
5570 }
5571 let runner = ActorChildRunner::new(
5572 "test-actor".into(),
5573 PathBuf::from("/bin/false"),
5574 vec![],
5575 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
5576 executor,
5577 vec![],
5578 "openai".into(),
5579 4,
5580 );
5581 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
5582 let error = ensure_read_only_activation_is_enforceable(&spec)
5583 .expect_err("Codex command execution must fail closed for read-only activations");
5584 let message = error.to_string();
5585 assert!(message.contains("Codex executor"), "{message}");
5586 assert!(message.contains("command execution"), "{message}");
5587 }
5588 }
5589
5590 #[tokio::test]
5591 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
5592 let mut planner = session_of_role("planner", "inspect and plan");
5593 planner
5594 .agent_runtime_state
5595 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5596 .read_only = true;
5597
5598 let runner = ActorChildRunner::new(
5599 "test-actor".into(),
5600 PathBuf::from("/bin/false"),
5601 vec![],
5602 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
5603 codex_executor(Some("inherit"), Some(true)),
5604 vec![],
5605 "openai".into(),
5606 4,
5607 );
5608 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
5609 let error = runner
5610 .execute_external_child(
5611 &mut planner,
5612 &job_for("planner-codex-no-spawn"),
5613 event_tx,
5614 CancellationToken::new(),
5615 )
5616 .await
5617 .expect_err("typed read-only Codex must fail before invoking /bin/false");
5618 let message = error.to_string();
5619 assert!(message.contains("Codex executor"), "{message}");
5620 assert!(message.contains("command execution"), "{message}");
5621 }
5622
5623 #[test]
5624 fn typed_read_only_rejects_resident_actor_placements() {
5625 let runner = bogus_runner(HashMap::new());
5626 let mut planner = session_of_role("planner", "inspect and plan");
5627 planner
5628 .agent_runtime_state
5629 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5630 .read_only = true;
5631
5632 for placement in [
5633 Placement::Remote {
5634 endpoint: "wss://resident.example/actor".to_string(),
5635 },
5636 Placement::Schedulable {
5637 pool: "resident-pool".to_string(),
5638 },
5639 ] {
5640 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
5641 spec.placement = placement;
5642
5643 let error = ensure_read_only_activation_is_enforceable(&spec)
5644 .expect_err("resident placement must fail closed for read-only activation");
5645 let message = error.to_string();
5646 assert!(message.contains("requires local placement"), "{message}");
5647 assert!(message.contains("read-only tool boundary"), "{message}");
5648 }
5649
5650 let local = runner.build_spec(&planner, &job_for("read-only-local"));
5651 ensure_read_only_activation_is_enforceable(&local)
5652 .expect("a locally provisioned read-only worker is enforceable");
5653 }
5654
5655 #[tokio::test]
5656 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
5657 for (label, mode) in [
5661 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
5662 ("auto", bamboo_domain::SessionPermissionMode::Auto),
5663 ] {
5664 let runner = bogus_runner(HashMap::new());
5665 let mut parent = Session::new(format!("parent-{label}"), "test-model");
5666 parent
5667 .agent_runtime_state
5668 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5669 .set_permission_mode(mode);
5670 let workspace = tempfile::tempdir().expect("workspace fixture");
5671 let port = RecordingChildSessionPort::default();
5672 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
5673 crate::session_app::child_session::create_child_action(
5674 &port,
5675 crate::session_app::child_session::CreateChildInput {
5676 parent_session: parent,
5677 child_id: child_id.clone(),
5678 title: format!("{label} child"),
5679 responsibility: "Run ordinary commands".to_string(),
5680 assignment_prompt: "run an ordinary command".to_string(),
5681 subagent_type: "explorer".to_string(),
5682 workspace: workspace.path().to_string_lossy().into_owned(),
5683 workspace_source: crate::project_context::WorkspaceSource::Explicit,
5684 model_override: None,
5685 model_ref_override: None,
5686 runtime_metadata: HashMap::new(),
5687 read_only: false,
5688 auto_run: false,
5689 reasoning_effort: None,
5690 lifecycle: None,
5691 resident_name: None,
5692 resident_context: None,
5693 disabled_tools: None,
5694 context_fork: None,
5695 },
5696 )
5697 .await
5698 .expect("create inherited-permission child");
5699 let child = port.saved_child();
5700
5701 assert_eq!(
5702 child
5703 .agent_runtime_state
5704 .as_ref()
5705 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
5706 Some(mode),
5707 "create_child_action must inherit {label} from the parent"
5708 );
5709
5710 let spec = runner.build_spec(&child, &job_for(&child_id));
5711
5712 assert_eq!(
5713 spec.capabilities.bypass,
5714 mode == bamboo_domain::SessionPermissionMode::Bypass
5715 );
5716 assert_eq!(
5717 spec.capabilities.auto_approve_permissions,
5718 mode == bamboo_domain::SessionPermissionMode::Auto
5719 );
5720 assert!(
5721 spec.capabilities.enforce_permissions,
5722 "policy evaluation must remain active under {label}"
5723 );
5724 }
5725 }
5726
5727 #[tokio::test]
5728 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
5729 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
5730 let workspace = tempfile::tempdir().expect("workspace fixture");
5731
5732 for (role, lifecycle, resident_name) in [
5733 ("explorer", None, None),
5734 ("resident", Some("resident"), Some("stable-reviewer")),
5735 ("guardian", None, None),
5736 ] {
5737 let mut parent = Session::new(format!("parent-{role}"), "test-model");
5738 parent.set_project_id_meta(project_id.to_string());
5739 let port = RecordingChildSessionPort::default();
5740 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
5741 crate::session_app::child_session::create_child_action(
5742 &port,
5743 crate::session_app::child_session::CreateChildInput {
5744 parent_session: parent,
5745 child_id: child_id.clone(),
5746 title: format!("{role} child"),
5747 responsibility: "Review the assigned work".to_string(),
5748 assignment_prompt: "inspect the change".to_string(),
5749 subagent_type: role.to_string(),
5750 workspace: workspace.path().to_string_lossy().into_owned(),
5751 workspace_source: crate::project_context::WorkspaceSource::Explicit,
5752 model_override: None,
5753 model_ref_override: None,
5754 runtime_metadata: HashMap::new(),
5755 read_only: false,
5756 auto_run: false,
5757 reasoning_effort: None,
5758 lifecycle: lifecycle.map(str::to_string),
5759 resident_name: resident_name.map(str::to_string),
5760 resident_context: None,
5761 disabled_tools: None,
5762 context_fork: None,
5763 },
5764 )
5765 .await
5766 .expect("create Project-inheriting child");
5767 let child = port.saved_child();
5768 assert_eq!(
5769 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
5770 Some(project_id.clone()),
5771 "{role} child must inherit its parent's Project"
5772 );
5773
5774 assert_eq!(
5775 project_id_for_actor_run(&child).expect("valid actor Project identity"),
5776 Some(project_id.clone()),
5777 "{role} actor RunSpec must preserve inherited Project identity"
5778 );
5779 }
5780 }
5781
5782 #[test]
5783 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
5784 assert_eq!(placement_metadata(&Placement::Local, None), None);
5786
5787 let r = placement_metadata(
5789 &Placement::Remote {
5790 endpoint: "wss://10.0.0.5:8443/stream".into(),
5791 },
5792 None,
5793 )
5794 .unwrap();
5795 assert!(r.contains(r#""kind":"remote""#), "{r}");
5796 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
5797
5798 let labeled = placement_metadata(
5800 &Placement::Remote {
5801 endpoint: "ws://169.254.230.101:8899".into(),
5802 },
5803 Some("mini"),
5804 )
5805 .unwrap();
5806 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
5807
5808 let s = placement_metadata(
5810 &Placement::Schedulable {
5811 pool: "explorers".into(),
5812 },
5813 Some("mini"),
5814 )
5815 .unwrap();
5816 assert!(s.contains(r#""kind":"remote""#), "{s}");
5817 assert!(s.contains(r#""host":"mini""#), "{s}");
5818
5819 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
5821 assert_eq!(p.kind, "remote");
5822 assert_eq!(p.host, "mini");
5823 }
5824
5825 #[tokio::test]
5832 async fn execute_external_child_routes_role_to_remote_worker_without_spawning() {
5833 let token = "remote-test-token";
5835 let server = bamboo_subagent::transport::WsServer::bind_with_token(
5836 (std::net::Ipv4Addr::LOCALHOST, 0).into(),
5837 Some(token.to_string()),
5838 )
5839 .await
5840 .expect("bind resident worker");
5841 let endpoint = server.ws_endpoint(); let srv = tokio::spawn(async move {
5843 let _ = server
5845 .serve(Arc::new(bamboo_subagent::executor::EchoExecutor))
5846 .await;
5847 });
5848
5849 let mut placements = HashMap::new();
5851 placements.insert(
5852 "explorer".to_string(),
5853 ResolvedRemotePlacement {
5854 endpoint: endpoint.clone(),
5855 token: Some(token.to_string()),
5856 ca_cert_file: None,
5857 host_label: Some("mini-e2e".into()), },
5859 );
5860 let runner = bogus_runner(placements);
5861
5862 let mut session = session_of_role("explorer", "hello remote");
5864 let job = job_for("child-1");
5865 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(64);
5866 let cancel = CancellationToken::new();
5867
5868 let result = tokio::time::timeout(
5869 Duration::from_secs(10),
5870 runner.execute_external_child(&mut session, &job, event_tx, cancel),
5871 )
5872 .await
5873 .expect("run did not hang")
5874 .expect("remote run succeeded (connected to resident worker, did not spawn)");
5875
5876 let _ = result;
5877 let last = session
5880 .messages
5881 .iter()
5882 .rev()
5883 .find(|m| matches!(m.role, Role::Assistant))
5884 .expect("an assistant reply was written back");
5885 assert!(
5886 last.content.contains("echo:"),
5887 "expected echo reply, got {:?}",
5888 last.content
5889 );
5890
5891 let placement = session
5894 .metadata
5895 .get("placement")
5896 .expect("remote child session stamped with a placement");
5897 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
5898 assert!(placement.contains(r#""host":"mini-e2e""#), "{placement}");
5899
5900 let mut saw_event = false;
5903 while let Ok(Some(_ev)) =
5904 tokio::time::timeout(Duration::from_millis(50), event_rx.recv()).await
5905 {
5906 saw_event = true;
5907 }
5908 let _ = saw_event;
5909
5910 srv.abort();
5911 }
5912
5913 fn bogus_sched_runner(
5919 remote: HashMap<String, ResolvedRemotePlacement>,
5920 sched: HashMap<String, ResolvedSchedulablePlacement>,
5921 ) -> ActorChildRunner {
5922 ActorChildRunner::new(
5923 "test-actor".into(),
5924 PathBuf::from("/bin/false"),
5925 vec![],
5926 std::env::temp_dir().join("bamboo-test-fab-181"),
5927 ExecutorSpec::Echo,
5928 vec![],
5929 "anthropic".into(),
5930 4,
5931 )
5932 .with_remote_placements(remote)
5933 .with_schedulable_placements(sched)
5934 }
5935
5936 fn sched_placement(
5937 pool: &str,
5938 _registry_url: impl Into<String>,
5939 ) -> ResolvedSchedulablePlacement {
5940 ResolvedSchedulablePlacement {
5941 pool: pool.into(),
5942 host_label: None,
5943 }
5944 }
5945
5946 #[test]
5947 fn build_spec_sets_schedulable_placement_for_matching_role() {
5948 let mut sched = HashMap::new();
5949 sched.insert(
5950 "explorer".to_string(),
5951 sched_placement("gpu-pool", "unused"),
5952 );
5953 let runner = bogus_sched_runner(HashMap::new(), sched);
5954
5955 let s = session_of_role("explorer", "do the thing");
5956 let spec = runner.build_spec(&s, &job_for("child-1"));
5957 match &spec.placement {
5958 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
5959 other => panic!("expected Schedulable, got {other:?}"),
5960 }
5961 assert!(spec.secrets.worker_auth_token.is_none());
5963 }
5964
5965 #[test]
5966 fn build_spec_remote_wins_when_role_in_both_maps() {
5967 let mut remote = HashMap::new();
5970 remote.insert(
5971 "explorer".to_string(),
5972 ResolvedRemotePlacement {
5973 endpoint: "wss://fixed-host:8443".into(),
5974 token: Some("T-remote".into()),
5975 ca_cert_file: None,
5976 host_label: None,
5977 },
5978 );
5979 let mut sched = HashMap::new();
5980 sched.insert(
5981 "explorer".to_string(),
5982 sched_placement("gpu-pool", "https://control-plane:9562"),
5983 );
5984 let runner = bogus_sched_runner(remote, sched);
5985
5986 let s = session_of_role("explorer", "do the thing");
5987 let spec = runner.build_spec(&s, &job_for("child-1"));
5988 match &spec.placement {
5989 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
5990 other => panic!("expected Remote (precedence), got {other:?}"),
5991 }
5992 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-remote"));
5993 }
5994
5995 #[test]
5996 fn build_spec_local_for_unmatched_schedulable_role() {
5997 let mut sched = HashMap::new();
5998 sched.insert(
5999 "explorer".to_string(),
6000 sched_placement("gpu-pool", "https://control-plane:9562"),
6001 );
6002 let runner = bogus_sched_runner(HashMap::new(), sched);
6003 let s = session_of_role("writer", "do the thing");
6004 let spec = runner.build_spec(&s, &job_for("child-1"));
6005 assert_eq!(spec.placement, Placement::Local);
6006 assert!(spec.secrets.worker_auth_token.is_none());
6007 }
6008
6009 #[test]
6014 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
6015 let mut remote = HashMap::new();
6017 remote.insert(
6018 "explorer".to_string(),
6019 ResolvedRemotePlacement {
6020 endpoint: "ws://169.254.230.101:8899".into(),
6021 token: None,
6022 ca_cert_file: None,
6023 host_label: Some("mini".into()),
6024 },
6025 );
6026 let runner = bogus_runner(remote);
6027 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
6028 let stamp = runner
6029 .placement_stamp_for(&spec)
6030 .expect("remote child is stamped");
6031 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
6032 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
6033
6034 let mut remote_nolabel = HashMap::new();
6036 remote_nolabel.insert(
6037 "explorer".to_string(),
6038 ResolvedRemotePlacement {
6039 endpoint: "ws://169.254.230.101:8899".into(),
6040 token: None,
6041 ca_cert_file: None,
6042 host_label: None,
6043 },
6044 );
6045 let r2 = bogus_runner(remote_nolabel);
6046 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
6047 assert!(r2
6048 .placement_stamp_for(&spec2)
6049 .unwrap()
6050 .contains(r#""host":"169.254.230.101""#));
6051
6052 let mut sched = HashMap::new();
6054 sched.insert(
6055 "mac-mini-monitor".to_string(),
6056 ResolvedSchedulablePlacement {
6057 pool: "mac-mini-monitor".into(),
6058 host_label: Some("mini".into()),
6059 },
6060 );
6061 let sr = bogus_sched_runner(HashMap::new(), sched);
6062 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
6063 let stamp3 = sr
6064 .placement_stamp_for(&spec3)
6065 .expect("scheduled child is stamped");
6066 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
6067 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
6068
6069 let local = bogus_runner(HashMap::new());
6071 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
6072 assert_eq!(local.placement_stamp_for(&spec4), None);
6073 }
6074
6075 async fn start_bus() -> (String, tempfile::TempDir) {
6078 let dir = tempfile::tempdir().unwrap();
6079 let core = std::sync::Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
6080 let server = std::sync::Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
6081 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6082 let addr = listener.local_addr().unwrap();
6083 tokio::spawn(async move {
6084 let _ = server.serve(listener).await;
6085 });
6086 (format!("ws://{addr}"), dir)
6087 }
6088
6089 async fn join_pool(endpoint: &str, id: &str, pool: &str) -> bamboo_broker::BrokerClient {
6090 let mut c = bamboo_broker::BrokerClient::connect(
6091 endpoint,
6092 bamboo_subagent::AgentRef {
6093 session_id: id.into(),
6094 role: Some(pool.into()),
6095 },
6096 "t",
6097 )
6098 .await
6099 .unwrap();
6100 c.subscribe().await.unwrap();
6101 c
6102 }
6103
6104 fn sched_runner_on_bus(endpoint: &str, child_role: &str, pool: &str) -> ActorChildRunner {
6105 let mut sched = HashMap::new();
6106 sched.insert(child_role.to_string(), sched_placement(pool, "unused"));
6107 bogus_sched_runner(HashMap::new(), sched).with_bus(Some(bamboo_subagent::BusEndpoint {
6108 endpoint: endpoint.into(),
6109 token: "t".into(),
6110 }))
6111 }
6112
6113 #[tokio::test]
6114 async fn resolve_schedulable_picks_a_live_bus_worker() {
6115 let (endpoint, _dir) = start_bus().await;
6116 let _w = join_pool(&endpoint, "w-gpu", "gpu-pool").await;
6117 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
6118
6119 let mailbox = runner
6120 .resolve_schedulable_worker("explorer")
6121 .await
6122 .expect("a live pool worker is found on the bus");
6123 assert_eq!(mailbox, "w-gpu");
6124 }
6125
6126 #[tokio::test]
6127 async fn resolve_schedulable_round_robins_over_pool_workers() {
6128 let (endpoint, _dir) = start_bus().await;
6129 let _a = join_pool(&endpoint, "w-a", "gpu-pool").await;
6130 let _b = join_pool(&endpoint, "w-b", "gpu-pool").await;
6131 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
6132
6133 let mut picked = std::collections::HashSet::new();
6135 for _ in 0..6 {
6136 picked.insert(runner.resolve_schedulable_worker("explorer").await.unwrap());
6137 }
6138 assert_eq!(
6139 picked,
6140 ["w-a".to_string(), "w-b".to_string()].into_iter().collect(),
6141 "round-robin must cover every connected pool worker"
6142 );
6143 }
6144
6145 #[tokio::test]
6146 async fn resolve_schedulable_errors_on_empty_pool() {
6147 let (endpoint, _dir) = start_bus().await;
6148 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
6150
6151 let err = runner
6152 .resolve_schedulable_worker("explorer")
6153 .await
6154 .expect_err("an empty pool is terminal — no local fallback")
6155 .to_string();
6156 assert!(err.contains("no live worker in pool"), "got: {err}");
6157 assert!(err.contains("NOT spawning"), "got: {err}");
6158 }
6159
6160 #[tokio::test]
6167 async fn execute_external_child_runs_schedulable_over_bus_and_stamps_node_label() {
6168 let (endpoint, _dir) = start_bus().await;
6169
6170 let ep = endpoint.clone();
6172 let worker = tokio::spawn(async move {
6173 let _ = bamboo_broker::serve_executor(
6174 &ep,
6175 bamboo_subagent::AgentRef {
6176 session_id: "mmm-worker".into(),
6177 role: Some("mac-mini-monitor".into()),
6178 },
6179 "t",
6180 std::sync::Arc::new(bamboo_subagent::executor::EchoExecutor),
6181 )
6182 .await;
6183 });
6184
6185 let mut probe = bamboo_broker::BrokerClient::connect(
6188 &endpoint,
6189 bamboo_subagent::AgentRef {
6190 session_id: "probe".into(),
6191 role: None,
6192 },
6193 "t",
6194 )
6195 .await
6196 .unwrap();
6197 let mut ready = false;
6198 for _ in 0..100 {
6199 if probe
6200 .list_connected("mac-mini-monitor")
6201 .await
6202 .unwrap()
6203 .iter()
6204 .any(|id| id == "mmm-worker")
6205 {
6206 ready = true;
6207 break;
6208 }
6209 tokio::time::sleep(Duration::from_millis(30)).await;
6210 }
6211 assert!(ready, "worker never joined the pool");
6212
6213 let mut sched = HashMap::new();
6216 sched.insert(
6217 "mac-mini-monitor".to_string(),
6218 ResolvedSchedulablePlacement {
6219 pool: "mac-mini-monitor".into(),
6220 host_label: Some("mini".into()),
6221 },
6222 );
6223 let runner = bogus_sched_runner(HashMap::new(), sched).with_bus(Some(
6224 bamboo_subagent::BusEndpoint {
6225 endpoint: endpoint.clone(),
6226 token: "t".into(),
6227 },
6228 ));
6229
6230 let mut session = session_of_role("mac-mini-monitor", "hello scheduled");
6231 let job = job_for("child-1");
6232 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(64);
6233 let cancel = CancellationToken::new();
6234
6235 tokio::time::timeout(
6236 Duration::from_secs(10),
6237 runner.execute_external_child(&mut session, &job, event_tx, cancel),
6238 )
6239 .await
6240 .expect("run did not hang")
6241 .expect("schedulable run succeeded over the bus (no local spawn)");
6242
6243 let last = session
6245 .messages
6246 .iter()
6247 .rev()
6248 .find(|m| matches!(m.role, Role::Assistant))
6249 .expect("an assistant reply was written back");
6250 assert!(last.content.contains("echo:"), "got {:?}", last.content);
6251
6252 let placement = session
6254 .metadata
6255 .get("placement")
6256 .expect("scheduled child session stamped with a placement");
6257 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
6258 assert!(placement.contains(r#""host":"mini""#), "{placement}");
6259
6260 worker.abort();
6261 }
6262}