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
495pub struct ActorChildRunner {
497 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
498 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
499 agent_id: String,
500 worker_bin: PathBuf,
501 worker_args: Vec<String>,
502 fabric_dir: PathBuf,
503 executor: ExecutorSpec,
504 credentials: Vec<ScopedCredential>,
507 default_provider: String,
509 bus: Option<bamboo_subagent::BusEndpoint>,
512 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
516 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
522 max_idle_per_key: usize,
523 max_idle_total: usize,
524 pool_reaper_started: AtomicBool,
525 next_execution_epoch: AtomicU64,
529 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
533 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
537 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
548 remote_placements: HashMap<String, ResolvedRemotePlacement>,
554 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
562 schedule_cursor: Arc<std::sync::Mutex<HashMap<String, usize>>>,
567 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
569 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
572}
573
574#[async_trait]
584pub trait ChildApprovalDecider: Send + Sync {
585 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
588}
589
590async fn decide_child_approval(
593 decider: Option<&Arc<dyn ChildApprovalDecider>>,
594 child_session_id: &str,
595 request: &serde_json::Value,
596) -> bool {
597 match decider {
598 Some(decider) => decider.decide(child_session_id, request).await,
599 None => false,
600 }
601}
602
603const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
607
608#[async_trait]
617pub trait ChildApprovalReviewer: Send + Sync {
618 async fn review(
621 &self,
622 parent_session_id: &str,
623 child_session_id: &str,
624 request: &serde_json::Value,
625 ) -> bool;
626}
627
628fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
629 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
630 &SLOT
631}
632
633pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
635 let _ = child_approval_reviewer_slot().set(reviewer);
636}
637
638pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
640 child_approval_reviewer_slot().get().cloned()
641}
642
643impl ActorChildRunner {
644 #[allow(clippy::too_many_arguments)]
645 pub fn new(
646 agent_id: String,
647 worker_bin: PathBuf,
648 worker_args: Vec<String>,
649 fabric_dir: PathBuf,
650 executor: ExecutorSpec,
651 credentials: Vec<ScopedCredential>,
652 default_provider: String,
653 max_concurrent: usize,
654 ) -> Self {
655 Self {
656 approval_registry: None,
657 permission_config: None,
658 agent_id,
659 worker_bin,
660 worker_args,
661 fabric_dir,
662 executor,
663 credentials,
664 default_provider,
665 bus: None,
666 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
667 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
668 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
669 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
670 pool_reaper_started: AtomicBool::new(false),
671 next_execution_epoch: AtomicU64::new(0),
672 approval_decider: None,
673 approval_reviewer: None,
674 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
675 remote_placements: HashMap::new(),
676 schedulable_placements: HashMap::new(),
677 schedule_cursor: Arc::new(std::sync::Mutex::new(HashMap::new())),
678 codex_run_tokens: None,
679 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
680 }
681 }
682
683 pub fn with_approval_registry(
684 mut self,
685 registry: super::approval_registry::SharedApprovalRegistry,
686 ) -> Self {
687 self.approval_registry = Some(registry);
688 self
689 }
690
691 pub fn with_permission_config(
692 mut self,
693 config: Arc<bamboo_tools::permission::PermissionConfig>,
694 ) -> Self {
695 self.permission_config = Some(config);
696 self
697 }
698
699 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
704 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
705 self
706 }
707
708 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
711 self.approval_decider = Some(decider);
712 self
713 }
714
715 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
716 self.approval_reviewer = Some(reviewer);
717 self
718 }
719
720 pub fn with_codex_run_tokens(
721 mut self,
722 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
723 ) -> Self {
724 self.codex_run_tokens = authority;
725 self
726 }
727
728 pub fn with_remote_placements(
734 mut self,
735 placements: HashMap<String, ResolvedRemotePlacement>,
736 ) -> Self {
737 self.remote_placements = placements;
738 self
739 }
740
741 pub fn with_schedulable_placements(
747 mut self,
748 placements: HashMap<String, ResolvedSchedulablePlacement>,
749 ) -> Self {
750 self.schedulable_placements = placements;
751 self
752 }
753
754 fn fingerprint(spec: &ProvisionSpec) -> String {
772 let role = spec.identity.role.as_str();
773 let (provider, model) = spec
774 .model
775 .as_ref()
776 .map(|m| (m.provider.as_str(), m.model.as_str()))
777 .unwrap_or(("", ""));
778 let workspace = spec.workspace.as_deref().unwrap_or("");
779 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
780 tools.sort();
781 let caps = &spec.capabilities;
782 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
785 format!(
786 "{role}\u{1}{provider}\u{1}{model}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}pr={}\u{1}pe={}\u{1}by={}\u{1}auto={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}gro={}\u{1}executor={executor}",
787 tools.join(","),
788 spec.identity.depth,
789 caps.nested_spawn,
790 caps.permission_requested_mode,
791 caps.permission_effective_mode,
792 caps.bypass,
793 caps.auto_approve_permissions,
794 caps.enforce_permissions,
795 caps.max_spawn_depth.unwrap_or(0),
796 caps.no_human_approver,
803 caps.guardian_read_only,
807 )
808 }
809
810 fn ensure_pool_reaper(&self) {
813 if self
814 .pool_reaper_started
815 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
816 .is_err()
817 {
818 return;
819 }
820 let pool = Arc::downgrade(&self.pool);
821 tokio::spawn(async move {
822 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
823 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
824 interval.tick().await;
827 loop {
828 interval.tick().await;
829 let Some(pool) = pool.upgrade() else {
830 break;
831 };
832 let _ = reap_worker_pool_once(&pool).await;
833 }
834 });
835 }
836
837 async fn acquire_bus_worker(
843 &self,
844 key: &str,
845 spec: &ProvisionSpec,
846 ) -> crate::runtime::runner::Result<PooledWorker> {
847 self.ensure_pool_reaper();
848 loop {
852 let candidate = {
853 let mut pool = self.pool.lock().await;
854 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
855 if pool.get(key).is_some_and(Vec::is_empty) {
856 pool.remove(key);
857 }
858 candidate
859 };
860 let Some(mut candidate) = candidate else {
861 break;
862 };
863 let now = Instant::now();
864 let expired = parked_worker_expired(
865 candidate.parked_at,
866 now,
867 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
868 );
869 if candidate.worker.is_alive() && !expired {
870 candidate.parked_at = None;
871 return Ok(candidate);
872 }
873 tracing::info!(
874 pool_key = key,
875 mailbox_id = %candidate.mailbox_id,
876 worker_pid = ?candidate.worker.pid(),
877 shutdown_reason = if expired {
878 PoolReapReason::IdleTimeout.as_str()
879 } else {
880 PoolReapReason::ProcessExited.as_str()
881 },
882 "discarding unusable parked sub-agent worker during checkout"
883 );
884 candidate.worker.kill().await;
885 }
886
887 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
888 .await
889 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
890 let mailbox_id = spawned.record.agent_id.clone();
891 Ok(PooledWorker {
892 worker: spawned,
893 mailbox_id,
894 parked_at: None,
895 })
896 }
897
898 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
902 self.ensure_pool_reaper();
903 if !worker.worker.is_alive() {
904 tracing::info!(
905 pool_key = key,
906 mailbox_id = %worker.mailbox_id,
907 worker_pid = ?worker.worker.pid(),
908 shutdown_reason = "process_exited_before_park",
909 "discarding sub-agent worker instead of parking"
910 );
911 worker.worker.kill().await;
912 return;
913 }
914 worker.parked_at = Some(Instant::now());
915 let mut pool = self.pool.lock().await;
916 let key_count = pool.get(key).map_or(0, Vec::len);
917 let total_count = pool.values().map(Vec::len).sum::<usize>();
918 let limit_reason =
919 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
920 if let Some(shutdown_reason) = limit_reason {
921 drop(pool);
922 tracing::info!(
923 pool_key = key,
924 mailbox_id = %worker.mailbox_id,
925 worker_pid = ?worker.worker.pid(),
926 idle_workers_for_key = key_count,
927 idle_workers_total = total_count,
928 max_idle_per_key = self.max_idle_per_key,
929 max_idle_total = self.max_idle_total,
930 shutdown_reason,
931 "discarding sub-agent worker because the warm pool is full"
932 );
933 worker.worker.kill().await;
934 return;
935 }
936 pool.entry(key.to_string()).or_default().push(worker);
937 }
938
939 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
941 let mut spec = ProvisionSpec::new(
942 ChildIdentity {
943 child_id: job.child_session_id.clone(),
944 parent_id: Some(job.parent_session_id.clone()),
945 project_key: None,
946 role: session
947 .metadata
948 .get("subagent_type")
949 .cloned()
950 .unwrap_or_else(|| "worker".to_string()),
951 depth: session.spawn_depth,
956 },
957 self.executor.clone(),
958 self.fabric_dir.to_string_lossy().into_owned(),
959 );
960 spec.workspace = session.workspace.clone();
961 if let ExecutorSpec::Codex {
962 workspace_owned, ..
963 } = &mut spec.executor
964 {
965 *workspace_owned = Some(
966 spec.workspace
967 .as_deref()
968 .is_some_and(workspace_is_bamboo_owned),
969 );
970 }
971 spec.bus = self.bus.clone();
974 spec.model = session
977 .model_ref
978 .as_ref()
979 .map(|r| ModelRefSpec {
980 provider: r.provider.clone(),
981 model: r.model.clone(),
982 })
983 .or_else(|| {
984 let m = job.model.trim();
985 (!m.is_empty()).then(|| ModelRefSpec {
986 provider: self.default_provider.clone(),
987 model: m.to_string(),
988 })
989 });
990 spec.disabled_tools = job.disabled_tools.clone();
991 match &spec.executor {
992 ExecutorSpec::Codex {
998 auth_mode,
999 provider_key_ref,
1000 ..
1001 } => {
1002 if auth_mode.as_deref() == Some("custom") {
1003 if let Some(reference) = provider_key_ref {
1004 if let Some(credential) = self.credentials.iter().find(|credential| {
1005 credential.credential_ref.as_deref() == Some(reference)
1006 }) {
1007 spec.secrets.provider_credentials.push(credential.clone());
1008 } else {
1009 tracing::warn!(
1010 "actor child {}: custom Codex credential reference '{}' did not resolve",
1011 job.child_session_id,
1012 reference
1013 );
1014 }
1015 } else {
1016 tracing::warn!(
1017 "actor child {}: custom Codex executor has no credential reference",
1018 job.child_session_id
1019 );
1020 }
1021 }
1022 }
1023 _ => {
1026 let provider = spec
1027 .model
1028 .as_ref()
1029 .map(|model| model.provider.as_str())
1030 .filter(|provider| !provider.trim().is_empty())
1031 .unwrap_or(&self.default_provider);
1032 if let Some(credential) = self
1033 .credentials
1034 .iter()
1035 .find(|credential| credential.provider == provider)
1036 {
1037 spec.secrets.provider_credentials.push(credential.clone());
1038 } else {
1039 tracing::warn!(
1040 "actor child {}: no credential found for provider '{}'",
1041 job.child_session_id,
1042 provider
1043 );
1044 }
1045 }
1046 }
1047 spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
1054 spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
1055 spec.capabilities.enforce_permissions = true;
1061 let requested_permission_mode = session
1066 .agent_runtime_state
1067 .as_ref()
1068 .map(|state| state.effective_permission_mode())
1069 .unwrap_or_default();
1070 let configured_permission_mode = self
1071 .permission_config
1072 .as_ref()
1073 .map(|config| config.mode())
1074 .unwrap_or_default();
1075 spec.capabilities.no_human_approver = session
1080 .agent_runtime_state
1081 .as_ref()
1082 .is_some_and(|s| s.no_human_approver);
1083 spec.capabilities.guardian_read_only =
1093 session.metadata.get("subagent_type").map(String::as_str) == Some("guardian");
1094 if spec.capabilities.guardian_read_only {
1095 if let ExecutorSpec::Codex {
1096 permission_profile, ..
1097 } = &mut spec.executor
1098 {
1099 *permission_profile = Some("read-only".to_string());
1100 }
1101 }
1102 let read_only_overlay = session
1103 .agent_runtime_state
1104 .as_ref()
1105 .is_some_and(|state| state.plan_mode.is_some())
1106 || spec.capabilities.guardian_read_only
1107 || executor_has_read_only_permission_profile(&spec.executor);
1108 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1109 requested_permission_mode,
1110 configured_permission_mode,
1111 read_only_overlay,
1112 );
1113 spec.capabilities.bypass = permission_resolution.bypass_permissions();
1114 spec.capabilities.auto_approve_permissions =
1115 permission_resolution.suppress_approval_prompts();
1116 spec.capabilities.permission_requested_mode =
1117 permission_resolution.requested.as_str().to_string();
1118 spec.capabilities.permission_effective_mode =
1119 permission_resolution.effective.as_str().to_string();
1120 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
1127 spec.placement = Placement::Remote {
1128 endpoint: placement.endpoint.clone(),
1129 };
1130 spec.secrets.worker_auth_token = placement.token.clone();
1131 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
1132 {
1133 spec.placement = Placement::Schedulable {
1141 pool: placement.pool.clone(),
1142 };
1143 }
1144 spec
1145 }
1146
1147 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
1153 let host_label = match &spec.placement {
1154 Placement::Remote { .. } => self
1155 .remote_placements
1156 .get(spec.identity.role.as_str())
1157 .and_then(|p| p.host_label.as_deref()),
1158 Placement::Schedulable { .. } => self
1159 .schedulable_placements
1160 .get(spec.identity.role.as_str())
1161 .and_then(|p| p.host_label.as_deref()),
1162 Placement::Local => None,
1163 };
1164 placement_metadata(&spec.placement, host_label)
1165 }
1166
1167 async fn resolve_schedulable_worker(
1174 &self,
1175 role: &str,
1176 ) -> std::result::Result<String, AgentError> {
1177 let pool = self
1178 .schedulable_placements
1179 .get(role)
1180 .ok_or_else(|| {
1181 AgentError::LLM(format!(
1182 "schedulable placement for role '{role}' vanished before scheduling"
1183 ))
1184 })?
1185 .pool
1186 .clone();
1187 let bus = self.bus.as_ref().ok_or_else(|| {
1188 AgentError::LLM(format!(
1189 "schedulable role '{role}': no mailbox bus configured (subagents.broker)"
1190 ))
1191 })?;
1192
1193 let mut q = bamboo_broker::BrokerClient::connect(
1196 &bus.endpoint,
1197 bamboo_subagent::AgentRef {
1198 session_id: format!("sched-q-{role}"),
1199 role: None,
1200 },
1201 &bus.token,
1202 )
1203 .await
1204 .map_err(|e| {
1205 AgentError::LLM(format!(
1206 "schedulable role '{role}': bus connect failed: {e}"
1207 ))
1208 })?;
1209 let candidates = q.list_connected(&pool).await.map_err(|e| {
1210 AgentError::LLM(format!(
1211 "schedulable role '{role}': bus presence query failed: {e}"
1212 ))
1213 })?;
1214
1215 if candidates.is_empty() {
1216 return Err(AgentError::LLM(format!(
1217 "schedulable role '{role}': no live worker in pool '{pool}' on the bus \
1218 (NOT spawning a local subprocess — a schedulable role has no local fallback)"
1219 )));
1220 }
1221
1222 let idx = {
1227 let mut cursors = self.schedule_cursor.lock().recover_poison();
1228 let cursor = cursors.entry(pool.clone()).or_insert(0);
1229 let i = *cursor % candidates.len();
1230 *cursor = cursor.wrapping_add(1);
1231 i
1232 };
1233 Ok(candidates[idx].clone())
1234 }
1235}
1236
1237#[async_trait]
1238impl ExternalChildRunner for ActorChildRunner {
1239 async fn should_handle(&self, session: &Session) -> bool {
1240 session.metadata.get("runtime.kind") == Some(&"external".to_string())
1241 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
1242 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
1243 }
1244
1245 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
1246 *self.escalation_bridge.lock().recover_poison() = bridge;
1247 }
1248
1249 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
1250 *self.session_inbox_runtime.lock().recover_poison() = binding;
1251 }
1252
1253 async fn execute_external_child(
1254 &self,
1255 session: &mut Session,
1256 job: &SpawnJob,
1257 event_tx: mpsc::Sender<AgentEvent>,
1258 cancel_token: CancellationToken,
1259 ) -> crate::runtime::runner::Result<()> {
1260 let escalation = self.escalation_bridge.lock().recover_poison().clone();
1269 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
1270 let assignment = extract_assignment(session);
1271 let mut spec = self.build_spec(session, job);
1272 spec.reusable = true;
1275 if spec.limits.idle_timeout_secs.is_none() {
1276 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
1277 }
1278 let pool_key = Self::fingerprint(&spec);
1279
1280 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
1285 {
1286 return Err(AgentError::LLM(
1287 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
1288 .to_string(),
1289 ));
1290 }
1291 let project_id = project_id_for_actor_run(session)?;
1292 let requested_permission_mode = session
1293 .agent_runtime_state
1294 .as_ref()
1295 .map(|state| state.effective_permission_mode())
1296 .unwrap_or_default();
1297 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
1302 ensure_no_active_scoped_session_denies(config, &session.id)?;
1303 let read_only_overlay = session
1304 .agent_runtime_state
1305 .as_ref()
1306 .is_some_and(|state| state.plan_mode.is_some())
1307 || spec.capabilities.guardian_read_only
1308 || executor_has_read_only_permission_profile(&spec.executor);
1309 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
1310 requested_permission_mode,
1311 config.mode(),
1312 read_only_overlay,
1313 );
1314 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
1315 AgentError::LLM(format!(
1316 "failed to serialize permission policy for external executor: {error}"
1317 ))
1318 })?;
1319 Some(PermissionPolicyContext {
1320 revision: config.policy_revision(),
1321 requested_mode: resolution.requested.as_str().to_string(),
1322 effective_mode: resolution.effective.as_str().to_string(),
1323 bypass_permissions: resolution.bypass_permissions(),
1324 auto_approve_permissions: resolution.suppress_approval_prompts(),
1325 session_id: session.id.clone(),
1326 workspace_path: session.workspace.clone(),
1327 inherit_session_grants: false,
1328 policy,
1329 })
1330 } else {
1331 None
1332 };
1333 let provisioned_permission =
1334 spec.capabilities.permission_resolution().map_err(|error| {
1335 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
1336 })?;
1337 let policy_resolution = permission_policy
1338 .as_ref()
1339 .map(|context| {
1340 context.resolved_modes().map(|(requested, effective)| {
1341 bamboo_domain::PermissionModeResolution {
1342 requested,
1343 effective,
1344 }
1345 })
1346 })
1347 .transpose()
1348 .map_err(|error| {
1349 AgentError::LLM(format!("invalid host permission posture: {error}"))
1350 })?;
1351 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
1352 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
1353 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
1354 .is_some()
1355 });
1356 let expected_executor_mapping = expected_permission_executor_mapping(
1357 &spec.executor,
1358 policy_resolution.unwrap_or(provisioned_permission),
1359 has_explicit_deny,
1360 )?;
1361 let expected_permission_posture =
1362 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
1363 policy_revision: permission_policy
1364 .as_ref()
1365 .map(|context| context.revision)
1366 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
1367 .unwrap_or_default(),
1368 resolution: policy_resolution.unwrap_or(provisioned_permission),
1369 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
1370 executor_mapping,
1371 });
1372
1373 let _slot = self
1378 .concurrency
1379 .acquire()
1380 .await
1381 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".to_string()))?;
1382
1383 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
1390 &spec.executor,
1391 self.codex_run_tokens.clone(),
1392 &job.child_session_id,
1393 )?;
1394
1395 let kind = match spec.placement {
1403 Placement::Remote { .. } => PlacementKind::Remote,
1404 Placement::Schedulable { .. } => PlacementKind::Schedulable,
1405 Placement::Local => PlacementKind::Local,
1406 };
1407 let remote = !matches!(kind, PlacementKind::Local);
1408
1409 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
1415 session
1416 .metadata
1417 .insert("placement".to_string(), placement_meta);
1418 }
1419
1420 let mut attempt = 0u8;
1427 let (result, actor) = loop {
1428 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
1429 let (actor, mut client) = match kind {
1430 PlacementKind::Remote => {
1431 let placement = self
1435 .remote_placements
1436 .get(spec.identity.role.as_str())
1437 .ok_or_else(|| {
1438 AgentError::LLM(format!(
1439 "remote placement for role '{}' vanished before connect",
1440 spec.identity.role
1441 ))
1442 })?;
1443 let endpoint = placement.endpoint.clone();
1444 let trust_cfg = match placement.ca_cert_file.as_deref() {
1447 Some(path) => Some(client_config_trusting_cert(path).map_err(|e| {
1448 AgentError::LLM(format!(
1449 "remote worker CA cert '{}': {e}",
1450 path.display()
1451 ))
1452 })?),
1453 None => None,
1454 };
1455 let client = ChildClient::connect_with_auth_tls(
1456 &endpoint,
1457 placement.token.as_deref(),
1458 trust_cfg,
1459 )
1460 .await
1461 .map_err(|e| {
1462 AgentError::LLM(format!("remote actor connect to '{endpoint}' failed: {e}"))
1463 })?;
1464 let record = AgentRecord {
1467 agent_id: job.child_session_id.clone(),
1468 role: spec.identity.role.clone(),
1469 labels: Vec::new(),
1470 endpoint: endpoint.clone(),
1471 pid: 0,
1472 version: String::new(),
1473 started_at: chrono::Utc::now(),
1474 lease_expires_at: chrono::Utc::now(),
1475 };
1476 let _ = endpoint;
1477 let actor = PooledWorker {
1478 worker: SpawnedChild::remote(record),
1479 mailbox_id: job.child_session_id.clone(),
1480 parked_at: None,
1481 };
1482 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(client);
1483 (actor, client)
1484 }
1485 PlacementKind::Schedulable => {
1486 let bus = self.bus.as_ref().ok_or_else(|| {
1493 AgentError::LLM(
1494 "schedulable sub-agents require a mailbox bus (subagents.broker)"
1495 .to_string(),
1496 )
1497 })?;
1498 let mailbox_id = self
1499 .resolve_schedulable_worker(spec.identity.role.as_str())
1500 .await?;
1501 let parent = bamboo_subagent::AgentRef {
1502 session_id: format!("p-{}", job.child_session_id),
1503 role: None,
1504 };
1505 let link = bamboo_broker::BrokerChildLink::connect(
1506 &bus.endpoint,
1507 parent,
1508 &bus.token,
1509 mailbox_id.clone(),
1510 )
1511 .await
1512 .map_err(|e| {
1513 AgentError::LLM(format!(
1514 "schedulable link connect to '{mailbox_id}' failed: {e}"
1515 ))
1516 })?;
1517 let actor = PooledWorker {
1520 worker: SpawnedChild::remote(AgentRecord {
1521 agent_id: mailbox_id.clone(),
1522 role: spec.identity.role.clone(),
1523 labels: Vec::new(),
1524 endpoint: bus.endpoint.clone(),
1525 pid: 0,
1526 version: String::new(),
1527 started_at: chrono::Utc::now(),
1528 lease_expires_at: chrono::Utc::now(),
1529 }),
1530 mailbox_id,
1531 parked_at: None,
1532 };
1533 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1534 (actor, client)
1535 }
1536 PlacementKind::Local => {
1537 let bus = self.bus.as_ref().ok_or_else(|| {
1544 AgentError::LLM(
1545 "local sub-agents require a mailbox bus (subagents.broker); none is \
1546 configured and the bus could not be embedded"
1547 .to_string(),
1548 )
1549 })?;
1550 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
1551 let parent = bamboo_subagent::AgentRef {
1552 session_id: format!("p-{}", job.child_session_id),
1553 role: None,
1554 };
1555 let link = bamboo_broker::BrokerChildLink::connect(
1556 &bus.endpoint,
1557 parent,
1558 &bus.token,
1559 actor.mailbox_id.clone(),
1560 )
1561 .await
1562 .map_err(|e| {
1563 AgentError::LLM(format!("broker child link connect failed: {e}"))
1564 })?;
1565 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
1566 (actor, client)
1567 }
1568 };
1569
1570 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
1575 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
1576 Some(binding) => {
1577 let run_id = binding
1578 .router
1579 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
1580 .await;
1581 if run_id.is_none() {
1582 tracing::debug!(
1583 session_id = %job.child_session_id,
1584 "actor driver had no current SessionInbox activation owner to bind"
1585 );
1586 }
1587 run_id
1588 }
1589 None => None,
1590 };
1591 drop(delivery_tx);
1592 let initial_pairs = match (
1593 session_inbox_runtime.as_ref(),
1594 bound_activation_run_id.as_deref(),
1595 ) {
1596 (Some(binding), Some(run_id)) => {
1597 match claim_canonical_deliveries(binding, session, run_id, usize::MAX).await {
1598 Ok(deliveries) => deliveries,
1599 Err(error) => {
1600 binding
1601 .router
1602 .detach_delivery_sink(&job.child_session_id, run_id)
1603 .await;
1604 if !remote {
1605 actor.worker.kill().await;
1606 }
1607 return Err(error);
1608 }
1609 }
1610 }
1611 _ => Vec::new(),
1612 };
1613 let initial_session_messages = initial_pairs
1614 .iter()
1615 .map(|(_, delivery)| delivery.clone())
1616 .collect::<Vec<_>>();
1617 let initial_inflight_claims = initial_pairs
1618 .into_iter()
1619 .map(|(claim, _)| claim)
1620 .collect::<VecDeque<_>>();
1621 let messages = session
1624 .messages
1625 .iter()
1626 .filter_map(|message| serde_json::to_value(message).ok())
1627 .collect();
1628
1629 if let Err(e) = client
1630 .send(ParentFrame::Run(RunSpec {
1631 assignment: assignment.clone(),
1633 logical_session: Some(logical_identity_for_actor_run(session, job)),
1634 project_id: project_id.clone(),
1635 reasoning_effort: None,
1636 permission_policy: permission_policy.clone(),
1637 messages,
1638 activation_run_id: bound_activation_run_id.clone(),
1639 execution_epoch,
1640 initial_session_messages,
1641 secrets: run_secrets.clone(),
1642 }))
1643 .await
1644 {
1645 if let (Some(binding), Some(run_id)) = (
1646 session_inbox_runtime.as_ref(),
1647 bound_activation_run_id.as_deref(),
1648 ) {
1649 binding
1650 .router
1651 .detach_delivery_sink(&job.child_session_id, run_id)
1652 .await;
1653 }
1654 if !remote {
1655 actor.worker.kill().await;
1656 }
1657 return Err(AgentError::LLM(format!("actor run dispatch failed: {e}")));
1658 }
1659
1660 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
1664 let live_guard = super::live::register(
1665 &job.child_session_id,
1666 live_tx,
1667 attempt as u32,
1668 self.approval_registry.clone(),
1669 );
1670
1671 let result = drive(ActorDriveContext {
1672 client: &mut *client,
1673 parent_session_id: &job.parent_session_id,
1674 child_session_id: &job.child_session_id,
1675 child_attempt: attempt as u32,
1676 approval_registry: self.approval_registry.as_ref(),
1677 approval_decider: self.approval_decider.as_ref(),
1678 approval_reviewer: self.approval_reviewer.as_ref(),
1679 escalation_bridge: escalation.clone(),
1680 event_tx: &event_tx,
1681 cancel_token: &cancel_token,
1682 live_rx: &mut live_rx,
1683 delivery_rx: &mut delivery_rx,
1684 logical_session: session,
1685 expected_permission_posture: expected_permission_posture.clone(),
1686 session_inbox_runtime: session_inbox_runtime.as_ref(),
1687 activation_run_id: bound_activation_run_id.as_deref(),
1688 execution_epoch,
1689 expected_source_actor_id: &actor.mailbox_id,
1690 initial_inflight_claims,
1691 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
1698 })
1699 .await;
1700 if let (Some(binding), Some(run_id)) = (
1701 session_inbox_runtime.as_ref(),
1702 bound_activation_run_id.as_deref(),
1703 ) {
1704 binding
1705 .router
1706 .detach_delivery_sink(&job.child_session_id, run_id)
1707 .await;
1708 }
1709 drop(live_guard);
1715 drop(client);
1718
1719 if attempt == 0 && matches!(result, Err(AgentError::WorkerUnresponsive(_))) {
1726 match kind {
1727 PlacementKind::Local => {
1728 tracing::warn!(
1729 "actor child {} got no first frame; reaping the worker and respawning once",
1730 job.child_session_id
1731 );
1732 actor.worker.kill().await;
1733 attempt += 1;
1734 continue;
1735 }
1736 PlacementKind::Schedulable => {
1737 tracing::warn!(
1738 "scheduled actor child {} got no first frame; re-selecting a pool worker",
1739 job.child_session_id
1740 );
1741 drop(actor);
1742 attempt += 1;
1743 continue;
1744 }
1745 PlacementKind::Remote => {}
1746 }
1747 }
1748 break (result, actor);
1749 };
1750
1751 if remote {
1755 drop(actor);
1756 } else {
1757 match &result {
1758 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
1759 Err(_) => actor.worker.kill().await,
1760 }
1761 }
1762
1763 match result {
1767 Ok(Some(text)) => {
1768 if !text.is_empty() {
1769 session.add_message(bamboo_agent_core::Message::assistant(text, None));
1770 }
1771 Ok(())
1772 }
1773 Ok(None) => Ok(()),
1774 Err(e) => Err(e),
1775 }
1776 }
1777}
1778
1779fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
1785 let value = match placement {
1788 Placement::Local => return None,
1789 Placement::Remote { endpoint } => serde_json::json!({
1790 "kind": "remote",
1791 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
1792 }),
1793 Placement::Schedulable { pool } => serde_json::json!({
1794 "kind": "remote",
1795 "host": host_label.unwrap_or(pool),
1796 }),
1797 };
1798 serde_json::to_string(&value).ok()
1799}
1800
1801fn host_of_endpoint(endpoint: &str) -> String {
1803 endpoint
1804 .trim()
1805 .trim_start_matches("wss://")
1806 .trim_start_matches("ws://")
1807 .split(['/', ':'])
1808 .next()
1809 .unwrap_or(endpoint)
1810 .to_string()
1811}
1812
1813async fn reconcile_already_admitted_claim(
1814 binding: &SessionInboxRuntimeBinding,
1815 session: &mut Session,
1816 claim: &SessionInboxClaim,
1817) -> crate::runtime::runner::Result<()> {
1818 let latest = binding
1819 .storage
1820 .load_session(&session.id)
1821 .await
1822 .map_err(|error| {
1823 AgentError::LLM(format!(
1824 "load canonical SessionInbox checkpoint for {}: {error}",
1825 session.id
1826 ))
1827 })?
1828 .ok_or_else(|| {
1829 AgentError::LLM(format!(
1830 "canonical SessionInbox target disappeared: {}",
1831 session.id
1832 ))
1833 })?;
1834 let Some(message) = latest
1835 .messages
1836 .iter()
1837 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1838 .cloned()
1839 else {
1840 return Err(AgentError::LLM(format!(
1841 "canonical admitted receipt for {} exists without transcript message {}",
1842 session.id, claim.envelope.id
1843 )));
1844 };
1845 if let Some(existing) = session
1846 .messages
1847 .iter_mut()
1848 .find(|existing| existing.id == message.id)
1849 {
1850 *existing = message;
1851 } else {
1852 session.add_message(message);
1853 }
1854 bamboo_domain::merge_session_inbox_admission(session, &latest);
1855 binding
1856 .inbox
1857 .ack(&session.id, claim)
1858 .await
1859 .map_err(|error| {
1860 AgentError::LLM(format!(
1861 "ack recovered canonical SessionInbox claim {}: {error}",
1862 claim.envelope.id
1863 ))
1864 })
1865}
1866
1867async fn checkpoint_and_ack_canonical_claim(
1871 binding: &SessionInboxRuntimeBinding,
1872 session: &mut Session,
1873 claim: &SessionInboxClaim,
1874) -> crate::runtime::runner::Result<()> {
1875 if claim.envelope.target_session_id != session.id {
1876 return Err(AgentError::LLM(format!(
1877 "canonical SessionInbox claim target {} does not match active logical session {}",
1878 claim.envelope.target_session_id, session.id
1879 )));
1880 }
1881 if binding
1882 .inbox
1883 .was_admitted(&session.id, &claim.envelope.id)
1884 .await
1885 .map_err(|error| {
1886 AgentError::LLM(format!(
1887 "inspect canonical admitted receipt {}: {error}",
1888 claim.envelope.id
1889 ))
1890 })?
1891 {
1892 return reconcile_already_admitted_claim(binding, session, claim).await;
1893 }
1894
1895 let transcript_has_id = session
1896 .messages
1897 .iter()
1898 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1899 if session
1900 .messages
1901 .iter()
1902 .any(|message| message.id == claim.envelope.id.as_str())
1903 && !transcript_has_id
1904 {
1905 return Err(AgentError::LLM(format!(
1906 "canonical SessionInbox id {} collides with a non-matching transcript message",
1907 claim.envelope.id
1908 )));
1909 }
1910 let cursor_has_id = session
1911 .session_inbox_admission()
1912 .is_some_and(|state| state.contains(&claim.envelope.id));
1913 if cursor_has_id && !transcript_has_id {
1914 return Err(AgentError::LLM(format!(
1915 "canonical SessionInbox cursor exists without transcript message {}",
1916 claim.envelope.id
1917 )));
1918 }
1919 let before = session.clone();
1920 if !transcript_has_id {
1921 let message = claim.envelope.to_provider_message().map_err(|error| {
1922 AgentError::LLM(format!(
1923 "translate canonical SessionInbox envelope {}: {error}",
1924 claim.envelope.id
1925 ))
1926 })?;
1927 session.add_message(message);
1928 }
1929 session
1930 .session_inbox_admission_mut()
1931 .record(claim.envelope.id.clone(), claim.generation);
1932 session.updated_at = chrono::Utc::now();
1933
1934 if let Err(error) = binding
1935 .persistence
1936 .checkpoint_runtime_session(session)
1937 .await
1938 {
1939 *session = before;
1940 return Err(AgentError::LLM(format!(
1941 "checkpoint canonical SessionInbox claim {}: {error}",
1942 claim.envelope.id
1943 )));
1944 }
1945 if !session
1946 .messages
1947 .iter()
1948 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
1949 {
1950 *session = before;
1951 return Err(AgentError::LLM(format!(
1952 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
1953 claim.envelope.id
1954 )));
1955 }
1956 binding
1957 .inbox
1958 .ack(&session.id, claim)
1959 .await
1960 .map_err(|error| {
1961 AgentError::LLM(format!(
1962 "ack canonical SessionInbox claim {} after checkpoint: {error}",
1963 claim.envelope.id
1964 ))
1965 })
1966}
1967
1968async fn checkpoint_claim_context_before_dispatch(
1979 binding: &SessionInboxRuntimeBinding,
1980 session: &mut Session,
1981 claims: &[SessionInboxClaim],
1982) -> crate::runtime::runner::Result<()> {
1983 if claims.is_empty() {
1984 return Ok(());
1985 }
1986 let before = session.clone();
1987 for claim in claims {
1988 if claim.envelope.target_session_id != session.id {
1989 return Err(AgentError::LLM(format!(
1990 "canonical SessionInbox claim target {} does not match actor session {}",
1991 claim.envelope.target_session_id, session.id
1992 )));
1993 }
1994 let matching = session
1995 .messages
1996 .iter()
1997 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
1998 if session
1999 .messages
2000 .iter()
2001 .any(|message| message.id == claim.envelope.id.as_str())
2002 && !matching
2003 {
2004 return Err(AgentError::LLM(format!(
2005 "canonical SessionInbox id {} collides before actor dispatch",
2006 claim.envelope.id
2007 )));
2008 }
2009 if session
2010 .session_inbox_admission()
2011 .is_some_and(|state| state.contains(&claim.envelope.id))
2012 && !matching
2013 {
2014 return Err(AgentError::LLM(format!(
2015 "canonical SessionInbox cursor exists without transcript proof for {}",
2016 claim.envelope.id
2017 )));
2018 }
2019 if !matching {
2020 let message = claim.envelope.to_provider_message().map_err(|error| {
2021 AgentError::LLM(format!(
2022 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
2023 claim.envelope.id
2024 ))
2025 })?;
2026 session.add_message(message);
2027 }
2028 }
2029 session.updated_at = chrono::Utc::now();
2030 if let Err(error) = binding
2031 .persistence
2032 .checkpoint_runtime_session(session)
2033 .await
2034 {
2035 *session = before;
2036 return Err(AgentError::LLM(format!(
2037 "checkpoint canonical SessionInbox actor context: {error}"
2038 )));
2039 }
2040 for claim in claims {
2041 if !session
2042 .messages
2043 .iter()
2044 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
2045 {
2046 *session = before;
2047 return Err(AgentError::LLM(format!(
2048 "actor context checkpoint lost typed transcript proof for {}",
2049 claim.envelope.id
2050 )));
2051 }
2052 }
2053 Ok(())
2054}
2055
2056async fn claim_canonical_deliveries(
2057 binding: &SessionInboxRuntimeBinding,
2058 session: &mut Session,
2059 activation_run_id: &str,
2060 limit: usize,
2061) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
2062 let claims = binding
2063 .inbox
2064 .claim_for_turn(&session.id, limit, Some(activation_run_id))
2065 .await
2066 .map_err(|error| {
2067 AgentError::LLM(format!(
2068 "claim canonical SessionInbox for active actor {}: {error}",
2069 session.id
2070 ))
2071 })?;
2072 if claims.is_empty() {
2073 return Ok(Vec::new());
2074 }
2075 let interrupt_generation = binding
2076 .inbox
2077 .inspect(&session.id)
2078 .await
2079 .map_err(|error| {
2080 AgentError::LLM(format!(
2081 "inspect canonical SessionInbox activation policy for {}: {error}",
2082 session.id
2083 ))
2084 })?
2085 .interrupt_generation;
2086 let mut unconfirmed = Vec::with_capacity(claims.len());
2087 for claim in claims {
2088 if binding
2089 .inbox
2090 .was_admitted(&session.id, &claim.envelope.id)
2091 .await
2092 .map_err(|error| {
2093 AgentError::LLM(format!(
2094 "inspect canonical SessionInbox claim {}: {error}",
2095 claim.envelope.id
2096 ))
2097 })?
2098 {
2099 reconcile_already_admitted_claim(binding, session, &claim).await?;
2100 continue;
2101 }
2102 unconfirmed.push(claim);
2103 }
2104 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed).await?;
2105
2106 let mut deliveries = Vec::with_capacity(unconfirmed.len());
2107 for claim in unconfirmed {
2108 if session
2113 .session_inbox_admission()
2114 .is_some_and(|state| state.contains(&claim.envelope.id))
2115 {
2116 binding
2117 .inbox
2118 .ack(&session.id, &claim)
2119 .await
2120 .map_err(|error| {
2121 AgentError::LLM(format!(
2122 "finish confirmed canonical SessionInbox ack {}: {error}",
2123 claim.envelope.id
2124 ))
2125 })?;
2126 continue;
2127 }
2128 let activation_policy = if claim.generation <= interrupt_generation {
2129 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait
2130 } else {
2131 bamboo_domain::SessionActivationPolicy::RespectSpecificWait
2132 };
2133 let delivery = SessionMessageDelivery {
2134 target_session_id: session.id.clone(),
2135 envelope: claim.envelope.clone(),
2136 canonical_claim_generation: claim.generation,
2137 activation_run_id: activation_run_id.to_string(),
2138 activation_policy,
2139 };
2140 deliveries.push((claim, delivery));
2141 }
2142 Ok(deliveries)
2143}
2144
2145async fn forward_next_canonical_claim(
2146 client: &mut dyn bamboo_subagent::ChildLink,
2147 binding: &SessionInboxRuntimeBinding,
2148 session: &mut Session,
2149 activation_run_id: &str,
2150 inflight: &mut VecDeque<SessionInboxClaim>,
2151) -> crate::runtime::runner::Result<()> {
2152 if !inflight.is_empty() {
2153 return Ok(());
2154 }
2155 let Some((claim, delivery)) =
2156 claim_canonical_deliveries(binding, session, activation_run_id, 1)
2157 .await?
2158 .pop()
2159 else {
2160 return Ok(());
2161 };
2162 client
2163 .send(ParentFrame::SessionMessage { delivery })
2164 .await
2165 .map_err(|error| {
2166 AgentError::LLM(format!(
2167 "forward canonical SessionInbox claim {} to active actor: {error}",
2168 claim.envelope.id
2169 ))
2170 })?;
2171 inflight.push_back(claim);
2172 Ok(())
2173}
2174
2175struct ActorDriveContext<'a> {
2177 client: &'a mut dyn bamboo_subagent::ChildLink,
2178 parent_session_id: &'a str,
2179 child_session_id: &'a str,
2180 child_attempt: u32,
2181 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
2182 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
2183 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
2184 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
2185 event_tx: &'a mpsc::Sender<AgentEvent>,
2186 cancel_token: &'a CancellationToken,
2187 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
2188 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
2189 logical_session: &'a mut Session,
2190 expected_permission_posture: Option<ExpectedPermissionPosture>,
2191 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
2192 activation_run_id: Option<&'a str>,
2193 execution_epoch: u64,
2194 expected_source_actor_id: &'a str,
2195 initial_inflight_claims: VecDeque<SessionInboxClaim>,
2196 first_frame_timeout: Option<Duration>,
2197}
2198
2199#[derive(Debug, Clone, PartialEq, Eq)]
2200struct ExpectedPermissionPosture {
2201 policy_revision: u64,
2202 resolution: bamboo_domain::PermissionModeResolution,
2203 expected_audit_revision: Option<u64>,
2204 executor_mapping: String,
2205}
2206
2207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2208enum PermissionPostureHandshake {
2209 NotRequired,
2211 Awaiting,
2214 Confirmed,
2216}
2217
2218impl PermissionPostureHandshake {
2219 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
2220 if expected.is_some() {
2221 Self::Awaiting
2222 } else {
2223 Self::NotRequired
2224 }
2225 }
2226
2227 fn is_awaiting(self) -> bool {
2228 self == Self::Awaiting
2229 }
2230
2231 fn posture_was_confirmed(self) -> bool {
2232 self == Self::Confirmed
2233 }
2234}
2235
2236fn permission_posture_seed_from_event(
2237 session: &Session,
2238 event: &AgentEvent,
2239) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
2240 let AgentEvent::PermissionPostureActivated {
2241 session_id,
2242 policy_revision,
2243 requested_mode,
2244 effective_mode,
2245 executor_mapping,
2246 } = event
2247 else {
2248 return Ok(None);
2249 };
2250 if session_id != &session.id {
2251 return Err("permission posture event targets a different logical session".to_string());
2252 }
2253 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
2254 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
2255 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
2256 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
2257 let resolution = bamboo_domain::PermissionModeResolution {
2258 requested,
2259 effective,
2260 };
2261 if !resolution.is_consistent() {
2262 return Err("permission posture event has an inconsistent mode pair".to_string());
2263 }
2264 let current_requested = session
2265 .agent_runtime_state
2266 .as_ref()
2267 .map(|state| state.effective_permission_mode())
2268 .unwrap_or_default();
2269 if current_requested != requested {
2270 return Err("permission posture event is stale for the host typed mode".to_string());
2271 }
2272 let mapping_chars = executor_mapping.chars().count();
2273 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
2274 return Err("permission posture event has an invalid executor mapping".to_string());
2275 }
2276 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
2277 *policy_revision,
2278 resolution,
2279 executor_mapping,
2280 )))
2281}
2282
2283fn validate_actor_event_batch(
2284 batch: &ActorEventBatch,
2285 logical_session: &Session,
2286 parent_session_id: &str,
2287 activation_run_id: Option<&str>,
2288 execution_epoch: u64,
2289 expected_source_actor_id: &str,
2290) -> Result<(), AgentError> {
2291 batch
2292 .validate()
2293 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
2294 let identity = batch.logical_session.as_ref().ok_or_else(|| {
2295 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
2296 })?;
2297 let expected_identity = LogicalSessionIdentity {
2302 session_id: logical_session.id.clone(),
2303 parent_session_id: logical_session
2304 .parent_session_id
2305 .clone()
2306 .or_else(|| Some(parent_session_id.to_string())),
2307 root_session_id: if logical_session.root_session_id.trim().is_empty() {
2308 parent_session_id.to_string()
2309 } else {
2310 logical_session.root_session_id.clone()
2311 },
2312 };
2313 if identity != &expected_identity {
2314 return Err(AgentError::LLM(
2315 "actor event batch targets a different logical session".to_string(),
2316 ));
2317 }
2318 if batch.activation_id.as_deref() != activation_run_id {
2319 return Err(AgentError::LLM(
2320 "actor event batch belongs to a stale activation".to_string(),
2321 ));
2322 }
2323 if batch.execution_epoch != execution_epoch {
2324 return Err(AgentError::LLM(
2325 "actor event batch belongs to a stale execution epoch".to_string(),
2326 ));
2327 }
2328 if batch
2329 .source_actor_id
2330 .as_deref()
2331 .is_some_and(|source| source != expected_source_actor_id)
2332 {
2333 return Err(AgentError::LLM(
2334 "actor event batch declares a different physical actor".to_string(),
2335 ));
2336 }
2337 Ok(())
2338}
2339
2340#[allow(clippy::too_many_arguments)]
2341async fn process_actor_event(
2342 event: serde_json::Value,
2343 strict_permission_events: bool,
2344 permission_handshake: &mut PermissionPostureHandshake,
2345 expected_permission_posture: Option<&ExpectedPermissionPosture>,
2346 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
2347 logical_session: &mut Session,
2348 event_tx: &mpsc::Sender<AgentEvent>,
2349) -> crate::runtime::runner::Result<()> {
2350 let event = match serde_json::from_value::<AgentEvent>(event) {
2351 Ok(event) => event,
2352 Err(error) if strict_permission_events => {
2353 return Err(AgentError::LLM(format!(
2354 "actor emitted malformed AgentEvent under a typed permission posture contract: {error}"
2355 )));
2356 }
2357 Err(_) => return Ok(()),
2358 };
2359 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
2360 if permission_handshake.posture_was_confirmed() {
2361 return Err(AgentError::LLM(
2362 "actor emitted a duplicate permission posture activation".to_string(),
2363 ));
2364 }
2365 let seed = permission_posture_seed_from_event(logical_session, &event)
2366 .map_err(AgentError::LLM)?
2367 .ok_or_else(|| {
2368 AgentError::LLM(
2369 "actor permission posture event did not decode as a posture".to_string(),
2370 )
2371 })?;
2372 if let Some(expected) = expected_permission_posture {
2373 if seed.policy_revision != expected.policy_revision
2374 || seed.resolution != expected.resolution
2375 {
2376 return Err(AgentError::LLM(
2377 "permission posture event does not match the host-dispatched policy"
2378 .to_string(),
2379 ));
2380 }
2381 if seed.executor_mapping() != expected.executor_mapping {
2382 return Err(AgentError::LLM(
2383 "permission posture event does not match the host-dispatched executor mapping"
2384 .to_string(),
2385 ));
2386 }
2387 }
2388 if let Some(binding) = session_inbox_runtime {
2389 let saved = binding
2390 .persistence
2391 .record_permission_posture_activation(
2392 &logical_session.id,
2393 expected_permission_posture
2394 .and_then(|expected| expected.expected_audit_revision),
2395 &seed,
2396 )
2397 .await
2398 .map_err(|error| {
2399 AgentError::LLM(format!(
2400 "persist child permission posture bootstrap: {error}"
2401 ))
2402 })?
2403 .ok_or_else(|| {
2404 AgentError::LLM(
2405 "persist child permission posture bootstrap: session not found".to_string(),
2406 )
2407 })?;
2408 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
2409 .ok_or_else(|| {
2410 AgentError::LLM(
2411 "persisted child permission posture audit is incomplete".to_string(),
2412 )
2413 })?;
2414 snapshot.write_to(&mut logical_session.metadata);
2415 } else {
2416 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
2419 .map_err(|error| {
2420 AgentError::LLM(format!(
2421 "record in-memory child permission posture: {error}"
2422 ))
2423 })?;
2424 }
2425 *permission_handshake = PermissionPostureHandshake::Confirmed;
2428 } else if permission_handshake.is_awaiting() {
2429 return Err(AgentError::LLM(
2430 "actor emitted an execution event before permission posture confirmation".to_string(),
2431 ));
2432 }
2433 let _ = event_tx.send(event).await;
2434 Ok(())
2435}
2436
2437async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
2448 let ActorDriveContext {
2449 client,
2450 parent_session_id,
2451 child_session_id,
2452 child_attempt,
2453 approval_registry,
2454 approval_decider,
2455 approval_reviewer,
2456 escalation_bridge,
2457 event_tx,
2458 cancel_token,
2459 live_rx,
2460 delivery_rx,
2461 logical_session,
2462 expected_permission_posture,
2463 session_inbox_runtime,
2464 activation_run_id,
2465 execution_epoch,
2466 expected_source_actor_id,
2467 initial_inflight_claims,
2468 first_frame_timeout,
2469 } = context;
2470
2471 let mut got_first_frame = false;
2478 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
2479 let mut inflight_claims = initial_inflight_claims;
2480 let strict_permission_events = expected_permission_posture.is_some();
2481 let mut permission_handshake =
2482 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
2483 let mut next_actor_event_seq = 1u64;
2484 loop {
2485 tokio::select! {
2486 _ = cancel_token.cancelled() => {
2487 break;
2489 }
2490 _ = async {
2491 match first_frame_watch.as_mut() {
2492 Some(s) => s.as_mut().await,
2493 None => std::future::pending::<()>().await,
2494 }
2495 }, if !got_first_frame => {
2496 return Err(AgentError::WorkerUnresponsive(format!(
2497 "child {child_session_id} produced no frame within {:?}",
2498 first_frame_timeout.unwrap_or_default()
2499 )));
2500 }
2501 Some(_generation) = delivery_rx.recv(),
2502 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
2503 {
2504 forward_next_canonical_claim(
2505 client,
2506 session_inbox_runtime.expect("guarded"),
2507 logical_session,
2508 activation_run_id.expect("guarded"),
2509 &mut inflight_claims,
2510 )
2511 .await?;
2512 }
2513 Some(frame) = live_rx.recv() => {
2514 if client.send(frame).await.is_err() {
2516 tracing::warn!("live steering frame could not be sent; connection failing");
2517 }
2518 }
2519 frame = client.next_frame() => {
2520 got_first_frame = true;
2523 first_frame_watch = None;
2524 match frame {
2525 Ok(Some(ChildFrame::Event { event })) => {
2526 process_actor_event(
2530 event,
2531 strict_permission_events,
2532 &mut permission_handshake,
2533 expected_permission_posture.as_ref(),
2534 session_inbox_runtime,
2535 logical_session,
2536 event_tx,
2537 )
2538 .await?;
2539 }
2540 Ok(Some(ChildFrame::EventBatch { batch })) => {
2541 validate_actor_event_batch(
2542 &batch,
2543 logical_session,
2544 parent_session_id,
2545 activation_run_id,
2546 execution_epoch,
2547 expected_source_actor_id,
2548 )?;
2549 if batch.last_seq < next_actor_event_seq {
2550 continue;
2551 }
2552 if batch.first_seq > next_actor_event_seq {
2553 tracing::warn!(
2554 child_session_id,
2555 expected_seq = next_actor_event_seq,
2556 received_seq = batch.first_seq,
2557 "actor event sequence gap; authoritative session snapshot is required"
2558 );
2559 }
2560 let skip = next_actor_event_seq
2561 .saturating_sub(batch.first_seq)
2562 .min(batch.events.len() as u64) as usize;
2563 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
2564 let seq = batch.first_seq + offset as u64;
2565 process_actor_event(
2566 event,
2567 strict_permission_events,
2568 &mut permission_handshake,
2569 expected_permission_posture.as_ref(),
2570 session_inbox_runtime,
2571 logical_session,
2572 event_tx,
2573 )
2574 .await?;
2575 next_actor_event_seq = seq.saturating_add(1);
2576 }
2577 }
2578 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
2579 if permission_handshake.is_awaiting() {
2580 return Err(AgentError::LLM(
2581 "actor requested approval before permission posture confirmation"
2582 .to_string(),
2583 ));
2584 }
2585 if let Some(reviewer) = approval_reviewer
2591 .cloned()
2592 .or_else(child_approval_reviewer)
2593 {
2594 let child = child_session_id.to_string();
2602 let parent = parent_session_id.to_string();
2603 let req_id = id.clone();
2604 let body = body.clone();
2605 let registry = approval_registry.cloned();
2606 tokio::spawn(async move {
2607 let approved = tokio::time::timeout(
2608 CHILD_APPROVAL_TIMEOUT,
2609 reviewer.review(&parent, &child, &body),
2610 )
2611 .await
2612 .unwrap_or(false);
2613 super::live::deliver_approval_scoped(
2614 registry.as_ref(),
2615 &child,
2616 child_attempt,
2617 &req_id,
2618 approved,
2619 );
2620 });
2621 } else if approval_decider.is_some() {
2622 let approved =
2626 decide_child_approval(approval_decider, child_session_id, &body)
2627 .await;
2628 if client
2629 .send(ParentFrame::ApprovalReply { id, approved })
2630 .await
2631 .is_err()
2632 {
2633 tracing::warn!(
2634 "failed to answer approval_request; connection failing"
2635 );
2636 }
2637 } else if let Some(host) = escalation_bridge.clone() {
2638 let child = child_session_id.to_string();
2645 let req_id = id.clone();
2646 let body = body.clone();
2647 let registry = approval_registry.cloned();
2648 tokio::spawn(async move {
2649 let approved = match tokio::time::timeout(
2650 CHILD_APPROVAL_TIMEOUT,
2651 host.approval_call(body),
2652 )
2653 .await
2654 {
2655 Ok(Ok(reply)) => reply
2656 .get("approved")
2657 .and_then(|v| v.as_bool())
2658 .unwrap_or(false),
2659 _ => false,
2661 };
2662 super::live::deliver_approval_scoped(
2663 registry.as_ref(),
2664 &child,
2665 child_attempt,
2666 &req_id,
2667 approved,
2668 );
2669 });
2670 } else {
2671 tracing::warn!(
2675 parent_session_id,
2676 child_session_id,
2677 request_id = %id,
2678 "forced-ask request has no parent-agent reviewer; denying"
2679 );
2680 if client
2681 .send(ParentFrame::ApprovalReply {
2682 id,
2683 approved: false,
2684 })
2685 .await
2686 .is_err()
2687 {
2688 tracing::warn!(
2689 "failed to send fail-closed approval reply; connection failing"
2690 );
2691 }
2692 }
2693 }
2694 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
2695 let Some(binding) = session_inbox_runtime else {
2696 tracing::warn!(
2697 child_session_id,
2698 "ignoring SessionInbox confirmation without a runtime binding"
2699 );
2700 continue;
2701 };
2702 let Some(bound_run_id) = activation_run_id else {
2703 tracing::warn!(
2704 child_session_id,
2705 "ignoring SessionInbox confirmation without an activation owner"
2706 );
2707 continue;
2708 };
2709 let Some(claim) = inflight_claims.front() else {
2710 tracing::warn!(
2711 child_session_id,
2712 envelope_id = %confirmation.envelope_id,
2713 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
2714 );
2715 continue;
2716 };
2717 let exact = confirmation.target_session_id == logical_session.id
2718 && confirmation.envelope_id == claim.envelope.id.as_str()
2719 && confirmation.canonical_claim_generation == claim.generation
2720 && confirmation.activation_run_id == bound_run_id;
2721 if !exact
2722 || !binding
2723 .router
2724 .owns_run(&logical_session.id, bound_run_id)
2725 .await
2726 {
2727 tracing::warn!(
2728 child_session_id,
2729 expected_target = %logical_session.id,
2730 received_target = %confirmation.target_session_id,
2731 expected_envelope_id = %claim.envelope.id,
2732 received_envelope_id = %confirmation.envelope_id,
2733 expected_generation = claim.generation,
2734 received_generation = confirmation.canonical_claim_generation,
2735 expected_run_id = bound_run_id,
2736 received_run_id = %confirmation.activation_run_id,
2737 "rejecting stale or mismatched SessionInbox admission confirmation"
2738 );
2739 continue;
2740 }
2741 let claim = inflight_claims
2742 .pop_front()
2743 .expect("validated in-flight canonical claim");
2744 checkpoint_and_ack_canonical_claim(binding, logical_session, &claim)
2747 .await?;
2748 if inflight_claims.is_empty() {
2752 forward_next_canonical_claim(
2753 client,
2754 binding,
2755 logical_session,
2756 bound_run_id,
2757 &mut inflight_claims,
2758 )
2759 .await?;
2760 }
2761 }
2762 Ok(Some(ChildFrame::Terminal { status, result, error, .. })) => {
2763 if permission_handshake.is_awaiting() {
2764 return Err(AgentError::LLM(
2765 "actor terminated before permission posture confirmation"
2766 .to_string(),
2767 ));
2768 }
2769 if let Some(claim) = inflight_claims.front() {
2770 return Err(AgentError::LLM(format!(
2771 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
2772 claim.envelope.id
2773 )));
2774 }
2775 return match status {
2776 TerminalStatus::Completed => Ok(result),
2777 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
2778 TerminalStatus::Error => Err(AgentError::LLM(
2779 error.unwrap_or_else(|| "actor child errored".to_string()),
2780 )),
2781 TerminalStatus::Suspended => Err(AgentError::LLM(
2786 "nested sub-agent suspend received but resume transport is not wired"
2787 .to_string(),
2788 )),
2789 };
2790 }
2791 Ok(None) => {
2792 return Err(AgentError::LLM(
2793 "actor child closed before terminal".to_string(),
2794 ));
2795 }
2796 Err(e) => {
2797 return Err(AgentError::LLM(format!("actor transport error: {e}")));
2798 }
2799 }
2800 }
2801 }
2802 }
2803
2804 let _ = client.send(ParentFrame::Cancel).await;
2806 Err(AgentError::Cancelled)
2807}
2808
2809fn project_id_for_actor_run(
2811 session: &Session,
2812) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
2813 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
2814 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
2815 Ok(Some(project_id))
2816 }
2817 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
2818 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
2819 Err(AgentError::LLM(format!(
2820 "child session carries an invalid Project identity '{raw}': {message}"
2821 )))
2822 }
2823 }
2824}
2825
2826fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
2827 LogicalSessionIdentity {
2828 session_id: session.id.clone(),
2829 parent_session_id: session
2830 .parent_session_id
2831 .clone()
2832 .or_else(|| Some(job.parent_session_id.clone())),
2833 root_session_id: if session.root_session_id.trim().is_empty() {
2834 job.parent_session_id.clone()
2835 } else {
2836 session.root_session_id.clone()
2837 },
2838 }
2839}
2840
2841fn extract_assignment(session: &Session) -> String {
2842 session
2843 .messages
2844 .iter()
2845 .rev()
2846 .find(|m| matches!(m.role, Role::User))
2847 .map(|m| m.content.clone())
2848 .unwrap_or_else(|| {
2849 session
2850 .metadata
2851 .get("title")
2852 .cloned()
2853 .unwrap_or_else(|| "Execute task".to_string())
2854 })
2855}
2856
2857#[cfg(test)]
2858mod tests {
2859 use super::*;
2860 use crate::SessionActivationRouter;
2861 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
2862
2863 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
2864 PooledWorker {
2865 worker: SpawnedChild::remote(AgentRecord {
2866 agent_id: mailbox_id.to_string(),
2867 role: "test".to_string(),
2868 labels: Vec::new(),
2869 endpoint: "ws://127.0.0.1:1".to_string(),
2870 pid: 0,
2871 version: String::new(),
2872 started_at: chrono::Utc::now(),
2873 lease_expires_at: chrono::Utc::now(),
2874 }),
2875 mailbox_id: mailbox_id.to_string(),
2876 parked_at: None,
2877 }
2878 }
2879
2880 #[test]
2881 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
2882 let now = Instant::now();
2883 let timeout = Duration::from_secs(300);
2884 assert!(!parked_worker_expired(None, now, timeout));
2885 assert!(!parked_worker_expired(
2886 Some(now - timeout + Duration::from_millis(1)),
2887 now,
2888 timeout
2889 ));
2890 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
2891 }
2892
2893 #[test]
2894 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
2895 let mut pool = WorkerPool::new();
2896 pool.insert(
2897 "fingerprint-a".to_string(),
2898 vec![processless_pool_worker("a")],
2899 );
2900 pool.insert(
2901 "fingerprint-b".to_string(),
2902 vec![processless_pool_worker("b")],
2903 );
2904
2905 assert_eq!(
2906 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
2907 Some("pool_global_limit")
2908 );
2909 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
2910 assert_eq!(
2911 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
2912 Some("pool_per_key_limit")
2913 );
2914 }
2915
2916 #[test]
2917 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
2918 let mut pool = WorkerPool::new();
2919 pool.insert(
2920 "fingerprint-a".to_string(),
2921 vec![processless_pool_worker("dead")],
2922 );
2923
2924 let reaped = take_reapable_workers(
2925 &mut pool,
2926 Instant::now(),
2927 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2928 );
2929
2930 assert!(pool.is_empty());
2931 assert_eq!(reaped.len(), 1);
2932 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
2933 }
2934
2935 #[test]
2936 fn actor_preflight_counts_only_current_session_scoped_denies() {
2937 let config = bamboo_tools::permission::PermissionConfig::new();
2938 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
2939 config.deny_scoped_session_permission(
2940 "target-session",
2941 bamboo_tools::permission::PermissionType::ExecuteCommand,
2942 secret_matcher,
2943 );
2944 config.deny_scoped_session_permission(
2945 "other-session",
2946 bamboo_tools::permission::PermissionType::WriteFile,
2947 "/other/**",
2948 );
2949
2950 assert_eq!(
2951 active_scoped_session_deny_count(&config, "target-session"),
2952 1
2953 );
2954 assert_eq!(
2955 active_scoped_session_deny_count(&config, "clean-session"),
2956 0
2957 );
2958 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
2959 .unwrap_err()
2960 .to_string();
2961 assert!(!error.contains(secret_matcher));
2962 ensure_no_active_scoped_session_denies(&config, "clean-session")
2963 .expect("another session's deny must not block this activation");
2964 }
2965
2966 #[test]
2967 fn permission_posture_event_rejects_oversized_executor_mapping() {
2968 let session = Session::new("mapping-bound", "model");
2969 let event = AgentEvent::PermissionPostureActivated {
2970 session_id: session.id.clone(),
2971 policy_revision: 1,
2972 requested_mode: "default".to_string(),
2973 effective_mode: "default".to_string(),
2974 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
2975 };
2976
2977 assert!(permission_posture_seed_from_event(&session, &event)
2978 .unwrap_err()
2979 .contains("executor mapping"));
2980 }
2981
2982 #[test]
2983 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
2984 let mut session = Session::new("host-resigns-audit", "model");
2985 let hostile_timestamp = "9".repeat(1024);
2986 let event: AgentEvent = serde_json::from_value(serde_json::json!({
2987 "type": "permission_posture_activated",
2988 "session_id": session.id.clone(),
2989 "policy_revision": 31,
2990 "requested_mode": "default",
2991 "effective_mode": "default",
2992 "executor_mapping": "codex_exec:approval_policy=never",
2993 "audit_revision": u64::MAX,
2994 "transitioned_at": hostile_timestamp,
2995 }))
2996 .expect("unknown remote audit fields are ignored by the typed event");
2997 let seed = permission_posture_seed_from_event(&session, &event)
2998 .unwrap()
2999 .expect("permission event");
3000
3001 let host_revision =
3002 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
3003 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3004 .expect("host-generated complete audit");
3005 assert_eq!(host_audit.audit_revision, host_revision);
3006 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
3007 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
3008 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
3009 }
3010
3011 struct ActorFaultingPersistence {
3012 inner: Arc<bamboo_storage::LockedSessionStore>,
3013 fail_checkpoint_once: std::sync::atomic::AtomicBool,
3014 }
3015
3016 #[async_trait]
3017 impl RuntimeSessionPersistence for ActorFaultingPersistence {
3018 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
3019 self.inner.merge_save_runtime(session).await
3020 }
3021
3022 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
3023 if self
3024 .fail_checkpoint_once
3025 .swap(false, std::sync::atomic::Ordering::SeqCst)
3026 {
3027 return Err(std::io::Error::other("injected actor checkpoint failure"));
3028 }
3029 self.inner.checkpoint_runtime_session(session).await
3030 }
3031
3032 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
3033 self.inner.storage().load_session(session_id).await
3034 }
3035 }
3036
3037 struct ActorFailBeforeAckInbox {
3038 inner: Arc<dyn SessionInboxPort>,
3039 fail_once: std::sync::atomic::AtomicBool,
3040 }
3041
3042 #[async_trait]
3043 impl SessionInboxPort for ActorFailBeforeAckInbox {
3044 async fn deliver(
3045 &self,
3046 envelope: &bamboo_domain::SessionMessageEnvelope,
3047 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
3048 self.inner.deliver(envelope).await
3049 }
3050
3051 async fn mark_activation_eligible(
3052 &self,
3053 target_session_id: &str,
3054 generation: u64,
3055 policy: bamboo_domain::SessionActivationPolicy,
3056 ) -> Result<(), bamboo_domain::SessionInboxError> {
3057 self.inner
3058 .mark_activation_eligible(target_session_id, generation, policy)
3059 .await
3060 }
3061
3062 async fn claim(
3063 &self,
3064 target_session_id: &str,
3065 limit: usize,
3066 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
3067 self.inner.claim(target_session_id, limit).await
3068 }
3069
3070 async fn was_admitted(
3071 &self,
3072 target_session_id: &str,
3073 id: &bamboo_domain::SessionMessageId,
3074 ) -> Result<bool, bamboo_domain::SessionInboxError> {
3075 self.inner.was_admitted(target_session_id, id).await
3076 }
3077
3078 async fn ack(
3079 &self,
3080 target_session_id: &str,
3081 claim: &SessionInboxClaim,
3082 ) -> Result<(), bamboo_domain::SessionInboxError> {
3083 if self
3084 .fail_once
3085 .swap(false, std::sync::atomic::Ordering::SeqCst)
3086 {
3087 return Err(bamboo_domain::SessionInboxError::Storage(
3088 "injected actor pre-ack failure".to_string(),
3089 ));
3090 }
3091 self.inner.ack(target_session_id, claim).await
3092 }
3093
3094 async fn inspect(
3095 &self,
3096 target_session_id: &str,
3097 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
3098 self.inner.inspect(target_session_id).await
3099 }
3100 }
3101
3102 async fn actor_inbox_fixture(
3103 session_id: &str,
3104 ) -> (
3105 tempfile::TempDir,
3106 Arc<bamboo_storage::SessionStoreV2>,
3107 Arc<bamboo_storage::LockedSessionStore>,
3108 Arc<dyn SessionInboxPort>,
3109 Session,
3110 SessionInboxClaim,
3111 ) {
3112 let temp = tempfile::tempdir().unwrap();
3113 let store = Arc::new(
3114 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3115 .await
3116 .unwrap(),
3117 );
3118 let storage: Arc<dyn Storage> = store.clone();
3119 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
3120 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3121 store.clone(),
3122 bamboo_domain::SessionInboxLimits::default(),
3123 ));
3124 let session = Session::new(session_id, "model");
3125 store.save_session(&session).await.unwrap();
3126 let mut envelope =
3127 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
3128 envelope.id =
3129 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
3130 let receipt = inbox.deliver(&envelope).await.unwrap();
3131 inbox
3132 .mark_activation_eligible(
3133 session_id,
3134 receipt.generation,
3135 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3136 )
3137 .await
3138 .unwrap();
3139 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
3140 (temp, store, locked, inbox, session, claim)
3141 }
3142
3143 fn actor_binding(
3144 store: Arc<bamboo_storage::SessionStoreV2>,
3145 inbox: Arc<dyn SessionInboxPort>,
3146 persistence: Arc<dyn RuntimeSessionPersistence>,
3147 ) -> SessionInboxRuntimeBinding {
3148 let storage: Arc<dyn Storage> = store;
3149 SessionInboxRuntimeBinding {
3150 router: SessionActivationRouter::new(),
3151 inbox,
3152 storage,
3153 persistence,
3154 }
3155 }
3156
3157 #[tokio::test]
3158 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
3159 let (_temp, store, locked, inbox, mut session, claim) =
3160 actor_inbox_fixture("actor-live-id-collision").await;
3161 let mut forged = claim.envelope.to_provider_message().unwrap();
3162 forged.metadata = Some(serde_json::json!({
3163 "session_message": {
3164 "id": claim.envelope.id,
3165 "target_session_id": "different-session"
3166 }
3167 }));
3168 session.add_message(forged);
3169 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3170 let binding = actor_binding(store, inbox.clone(), persistence);
3171
3172 assert!(
3173 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3174 .await
3175 .is_err()
3176 );
3177 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3178 assert!(!inbox
3179 .was_admitted(&session.id, &claim.envelope.id)
3180 .await
3181 .unwrap());
3182 }
3183
3184 #[tokio::test]
3185 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
3186 let (_temp, store, locked, inbox, mut session, claim) =
3187 actor_inbox_fixture("actor-durable-id-collision").await;
3188 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3189 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
3190 forged.id = claim.envelope.id.to_string();
3191 concurrent.add_message(forged);
3192 store.save_session(&concurrent).await.unwrap();
3193 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3194 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3195
3196 assert!(
3197 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3198 .await
3199 .is_err()
3200 );
3201 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3202 assert!(!inbox
3203 .was_admitted(&session.id, &claim.envelope.id)
3204 .await
3205 .unwrap());
3206 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3207 assert!(!durable.messages.iter().any(|message| {
3208 bamboo_domain::is_matching_session_message(message, &claim.envelope)
3209 }));
3210 }
3211
3212 #[tokio::test]
3213 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
3214 let (_temp, store, locked, inbox, mut session, claim) =
3215 actor_inbox_fixture("actor-durable-typed-body-collision").await;
3216 let mut different = claim.envelope.clone();
3217 different.body = bamboo_domain::SessionMessageBody::Content(
3218 bamboo_domain::SessionMessageContent::text("forged actor body"),
3219 );
3220 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
3221 concurrent.add_message(different.to_provider_message().unwrap());
3222 store.save_session(&concurrent).await.unwrap();
3223 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3224 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
3225
3226 assert!(
3227 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3228 .await
3229 .is_err()
3230 );
3231 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3232 assert!(!inbox
3233 .was_admitted(&session.id, &claim.envelope.id)
3234 .await
3235 .unwrap());
3236 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3237 assert!(!durable
3238 .messages
3239 .iter()
3240 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
3241 }
3242
3243 #[tokio::test]
3244 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
3245 let (_temp, store, locked, inbox, mut session, claim) =
3246 actor_inbox_fixture("actor-checkpoint-failure").await;
3247 let envelope_id = claim.envelope.id.clone();
3248 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
3249 inner: locked.clone(),
3250 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
3251 });
3252 let binding = actor_binding(store.clone(), inbox.clone(), fault);
3253
3254 assert!(
3255 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3256 .await
3257 .is_err()
3258 );
3259 assert!(!session
3260 .messages
3261 .iter()
3262 .any(|message| message.id == envelope_id.as_str()));
3263 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
3264 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
3265
3266 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3267 store.clone(),
3268 bamboo_domain::SessionInboxLimits::default(),
3269 ));
3270 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3271 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3272 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3273 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
3274 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3275 .await
3276 .unwrap();
3277 assert_eq!(
3278 restarted
3279 .messages
3280 .iter()
3281 .filter(|message| message.id == envelope_id.as_str())
3282 .count(),
3283 1
3284 );
3285 assert!(reopened
3286 .was_admitted(&session.id, &envelope_id)
3287 .await
3288 .unwrap());
3289 let backlog = reopened.inspect(&session.id).await.unwrap();
3290 assert_eq!(backlog.pending + backlog.claimed, 0);
3291 }
3292
3293 #[tokio::test]
3294 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
3295 let (_temp, store, locked, real_inbox, mut session, claim) =
3296 actor_inbox_fixture("actor-pre-ack-failure").await;
3297 let envelope_id = claim.envelope.id.clone();
3298 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
3299 inner: real_inbox.clone(),
3300 fail_once: std::sync::atomic::AtomicBool::new(true),
3301 });
3302 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
3303 let binding = actor_binding(store.clone(), faulted, persistence);
3304
3305 assert!(
3306 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim)
3307 .await
3308 .is_err()
3309 );
3310 let durable = store.load_session(&session.id).await.unwrap().unwrap();
3311 assert_eq!(
3312 durable
3313 .messages
3314 .iter()
3315 .filter(|message| message.id == envelope_id.as_str())
3316 .count(),
3317 1
3318 );
3319 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
3320 assert!(!real_inbox
3321 .was_admitted(&session.id, &envelope_id)
3322 .await
3323 .unwrap());
3324
3325 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3326 store.clone(),
3327 bamboo_domain::SessionInboxLimits::default(),
3328 ));
3329 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
3330 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
3331 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
3332 let mut restarted = durable;
3333 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered)
3334 .await
3335 .unwrap();
3336 assert_eq!(
3337 restarted
3338 .messages
3339 .iter()
3340 .filter(|message| message.id == envelope_id.as_str())
3341 .count(),
3342 1
3343 );
3344 assert!(reopened
3345 .was_admitted(&session.id, &envelope_id)
3346 .await
3347 .unwrap());
3348 let backlog = reopened.inspect(&session.id).await.unwrap();
3349 assert_eq!(backlog.pending + backlog.claimed, 0);
3350 }
3351
3352 struct ConfirmationSequenceLink {
3353 frames: VecDeque<ChildFrame>,
3354 sent: Vec<ParentFrame>,
3355 }
3356
3357 #[async_trait]
3358 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
3359 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
3360 self.sent.push(frame);
3361 Ok(())
3362 }
3363
3364 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
3365 match self.frames.pop_front() {
3366 Some(frame) => Ok(Some(frame)),
3367 None => std::future::pending().await,
3368 }
3369 }
3370 }
3371
3372 fn admission_confirmation(
3373 session_id: &str,
3374 claim: &SessionInboxClaim,
3375 run_id: &str,
3376 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3377 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
3378 target_session_id: session_id.to_string(),
3379 envelope_id: claim.envelope.id.to_string(),
3380 canonical_claim_generation: claim.generation,
3381 activation_run_id: run_id.to_string(),
3382 }
3383 }
3384
3385 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
3386 ExpectedPermissionPosture {
3387 policy_revision,
3388 resolution: bamboo_domain::PermissionModeResolution {
3389 requested: bamboo_domain::SessionPermissionMode::Default,
3390 effective: bamboo_domain::PermissionMode::Default,
3391 },
3392 expected_audit_revision: None,
3393 executor_mapping: "test_actor:permission_mode=default".to_string(),
3394 }
3395 }
3396
3397 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
3398 ChildFrame::Event {
3399 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3400 session_id: session_id.to_string(),
3401 policy_revision,
3402 requested_mode: "default".to_string(),
3403 effective_mode: "default".to_string(),
3404 executor_mapping: "test_actor:permission_mode=default".to_string(),
3405 })
3406 .expect("serialize permission posture event"),
3407 }
3408 }
3409
3410 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
3411 ChildFrame::Event {
3412 event: serde_json::to_value(event).expect("serialize actor event"),
3413 }
3414 }
3415
3416 #[test]
3417 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
3418 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
3419 let mut batch = ActorEventBatch {
3420 logical_session: Some(LogicalSessionIdentity {
3421 session_id: session.id.clone(),
3422 parent_session_id: session.parent_session_id.clone(),
3423 root_session_id: session.root_session_id.clone(),
3424 }),
3425 activation_id: Some("activation-7".into()),
3426 execution_epoch: 11,
3427 source_node_id: Some("node-a".into()),
3428 source_actor_id: Some("worker-a".into()),
3429 first_seq: 1,
3430 last_seq: 1,
3431 qos: bamboo_subagent::ActorEventQos::Ephemeral,
3432 events: vec![serde_json::json!({"type":"token","content":"ok"})],
3433 };
3434 validate_actor_event_batch(
3435 &batch,
3436 &session,
3437 "logical-parent",
3438 Some("activation-7"),
3439 11,
3440 "worker-a",
3441 )
3442 .unwrap();
3443
3444 batch.execution_epoch = 10;
3445 assert!(validate_actor_event_batch(
3446 &batch,
3447 &session,
3448 "logical-parent",
3449 Some("activation-7"),
3450 11,
3451 "worker-a",
3452 )
3453 .unwrap_err()
3454 .to_string()
3455 .contains("execution epoch"));
3456 batch.execution_epoch = 11;
3457 batch.activation_id = Some("replaced-activation".into());
3458 assert!(validate_actor_event_batch(
3459 &batch,
3460 &session,
3461 "logical-parent",
3462 Some("activation-7"),
3463 11,
3464 "worker-a",
3465 )
3466 .unwrap_err()
3467 .to_string()
3468 .contains("stale activation"));
3469 batch.activation_id = Some("activation-7".into());
3470 batch
3471 .logical_session
3472 .as_mut()
3473 .expect("logical identity")
3474 .session_id = "foreign-child".into();
3475 assert!(validate_actor_event_batch(
3476 &batch,
3477 &session,
3478 "logical-parent",
3479 Some("activation-7"),
3480 11,
3481 "worker-a",
3482 )
3483 .unwrap_err()
3484 .to_string()
3485 .contains("different logical session"));
3486 batch
3487 .logical_session
3488 .as_mut()
3489 .expect("logical identity")
3490 .session_id = session.id.clone();
3491 batch.source_actor_id = Some("worker-b".into());
3492 assert!(validate_actor_event_batch(
3493 &batch,
3494 &session,
3495 "logical-parent",
3496 Some("activation-7"),
3497 11,
3498 "worker-a",
3499 )
3500 .unwrap_err()
3501 .to_string()
3502 .contains("different physical actor"));
3503 }
3504
3505 fn completed_actor_frame() -> ChildFrame {
3506 ChildFrame::Terminal {
3507 status: TerminalStatus::Completed,
3508 result: Some("done".to_string()),
3509 error: None,
3510 transcript: Vec::new(),
3511 }
3512 }
3513
3514 async fn drive_permission_handshake_frames(
3515 session_id: &str,
3516 frames: impl IntoIterator<Item = ChildFrame>,
3517 expected: ExpectedPermissionPosture,
3518 ) -> (
3519 crate::runtime::runner::Result<Option<String>>,
3520 Session,
3521 Vec<AgentEvent>,
3522 Vec<ParentFrame>,
3523 ) {
3524 let mut link = ConfirmationSequenceLink {
3525 frames: frames.into_iter().collect(),
3526 sent: Vec::new(),
3527 };
3528 let (event_tx, mut event_rx) = mpsc::channel(16);
3529 let cancel = CancellationToken::new();
3530 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3531 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3532 let mut session = Session::new(session_id, "model");
3533 let result = drive(ActorDriveContext {
3534 client: &mut link,
3535 parent_session_id: "permission-parent",
3536 child_session_id: session_id,
3537 child_attempt: 0,
3538 approval_registry: None,
3539 approval_decider: None,
3540 approval_reviewer: None,
3541 escalation_bridge: None,
3542 event_tx: &event_tx,
3543 cancel_token: &cancel,
3544 live_rx: &mut live_rx,
3545 delivery_rx: &mut delivery_rx,
3546 logical_session: &mut session,
3547 expected_permission_posture: Some(expected),
3548 session_inbox_runtime: None,
3549 activation_run_id: None,
3550 execution_epoch: 0,
3551 expected_source_actor_id: session_id,
3552 initial_inflight_claims: VecDeque::new(),
3553 first_frame_timeout: Some(Duration::from_secs(1)),
3554 })
3555 .await;
3556 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
3557 (result, session, events, link.sent)
3558 }
3559
3560 #[tokio::test]
3561 async fn actor_permission_handshake_rejects_terminal_without_posture() {
3562 let session_id = "permission-missing";
3563 let (result, session, events, _) = drive_permission_handshake_frames(
3564 session_id,
3565 [completed_actor_frame()],
3566 expected_default_permission_posture(7),
3567 )
3568 .await;
3569
3570 assert!(result
3571 .unwrap_err()
3572 .to_string()
3573 .contains("terminated before permission posture confirmation"));
3574 assert!(events.is_empty());
3575 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3576 }
3577
3578 #[tokio::test]
3579 async fn actor_permission_handshake_rejects_malformed_agent_event() {
3580 let session_id = "permission-malformed";
3581 let (result, session, events, _) = drive_permission_handshake_frames(
3582 session_id,
3583 [ChildFrame::Event {
3584 event: serde_json::json!({"type": "token", "content": 42}),
3585 }],
3586 expected_default_permission_posture(7),
3587 )
3588 .await;
3589
3590 assert!(result
3591 .unwrap_err()
3592 .to_string()
3593 .contains("malformed AgentEvent"));
3594 assert!(events.is_empty());
3595 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3596 }
3597
3598 #[tokio::test]
3599 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
3600 let session_id = "permission-early-event";
3601 let early_events = [
3602 (
3603 "progress",
3604 AgentEvent::RunnerProgress {
3605 session_id: session_id.to_string(),
3606 round_count: 1,
3607 },
3608 ),
3609 (
3610 "token",
3611 AgentEvent::Token {
3612 content: "must-not-forward".to_string(),
3613 },
3614 ),
3615 (
3616 "tool",
3617 AgentEvent::ToolStart {
3618 tool_call_id: "early-tool".to_string(),
3619 tool_name: "Read".to_string(),
3620 arguments: serde_json::json!({"file_path": "README.md"}),
3621 },
3622 ),
3623 ];
3624 for (kind, event) in early_events {
3625 let (result, session, events, _) = drive_permission_handshake_frames(
3626 session_id,
3627 [
3628 actor_event_frame(event),
3629 permission_posture_frame(session_id, 7),
3630 completed_actor_frame(),
3631 ],
3632 expected_default_permission_posture(7),
3633 )
3634 .await;
3635
3636 assert!(
3637 result
3638 .unwrap_err()
3639 .to_string()
3640 .contains("execution event before permission posture confirmation"),
3641 "{kind} must fail closed before posture"
3642 );
3643 assert!(events.is_empty(), "{kind} must not be forwarded");
3644 assert!(
3645 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3646 "{kind} must not advance the permission audit"
3647 );
3648 }
3649 }
3650
3651 #[tokio::test]
3652 async fn actor_permission_handshake_rejects_approval_before_posture() {
3653 let session_id = "permission-early-approval";
3654 let (result, session, events, sent) = drive_permission_handshake_frames(
3655 session_id,
3656 [ChildFrame::ApprovalRequest {
3657 id: "approval-before-posture".to_string(),
3658 body: serde_json::json!({
3659 "tool_name": "Bash",
3660 "permission": "execute",
3661 "resource": "echo must-not-run"
3662 }),
3663 }],
3664 expected_default_permission_posture(7),
3665 )
3666 .await;
3667
3668 assert!(result
3669 .unwrap_err()
3670 .to_string()
3671 .contains("requested approval before permission posture confirmation"));
3672 assert!(events.is_empty());
3673 assert!(
3674 sent.is_empty(),
3675 "an unconfirmed actor must receive no approval reply"
3676 );
3677 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3678 }
3679
3680 #[tokio::test]
3681 async fn actor_permission_handshake_rejects_mismatched_posture() {
3682 let session_id = "permission-mismatch";
3683 let (result, session, events, _) = drive_permission_handshake_frames(
3684 session_id,
3685 [permission_posture_frame(session_id, 8)],
3686 expected_default_permission_posture(7),
3687 )
3688 .await;
3689
3690 assert!(result
3691 .unwrap_err()
3692 .to_string()
3693 .contains("does not match the host-dispatched policy"));
3694 assert!(events.is_empty());
3695 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
3696 }
3697
3698 #[tokio::test]
3699 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
3700 let session_id = "permission-hostile-mapping";
3701 for hostile_mapping in [
3702 "wrong_executor:permission_mode=default",
3703 "test_actor:permission_mode=default;credential=must-not-persist",
3704 ] {
3705 let frame = ChildFrame::Event {
3706 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
3707 session_id: session_id.to_string(),
3708 policy_revision: 7,
3709 requested_mode: "default".to_string(),
3710 effective_mode: "default".to_string(),
3711 executor_mapping: hostile_mapping.to_string(),
3712 })
3713 .expect("serialize hostile posture fixture"),
3714 };
3715 let (result, session, events, _) = drive_permission_handshake_frames(
3716 session_id,
3717 [frame],
3718 expected_default_permission_posture(7),
3719 )
3720 .await;
3721
3722 let error = result.unwrap_err().to_string();
3723 assert!(error.contains("host-dispatched executor mapping"));
3724 assert!(
3725 !error.contains(hostile_mapping),
3726 "untrusted mapping must not be reflected in host errors"
3727 );
3728 assert!(events.is_empty());
3729 assert!(
3730 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
3731 "untrusted mapping must not reach durable or in-memory audit state"
3732 );
3733 }
3734 }
3735
3736 #[tokio::test]
3737 async fn actor_permission_handshake_rejects_duplicate_posture() {
3738 let session_id = "permission-duplicate";
3739 let (result, session, events, _) = drive_permission_handshake_frames(
3740 session_id,
3741 [
3742 permission_posture_frame(session_id, 7),
3743 permission_posture_frame(session_id, 7),
3744 completed_actor_frame(),
3745 ],
3746 expected_default_permission_posture(7),
3747 )
3748 .await;
3749
3750 assert!(result
3751 .unwrap_err()
3752 .to_string()
3753 .contains("duplicate permission posture activation"));
3754 assert_eq!(
3755 events.len(),
3756 1,
3757 "only the confirmed posture may be forwarded"
3758 );
3759 assert!(matches!(
3760 events[0],
3761 AgentEvent::PermissionPostureActivated { .. }
3762 ));
3763 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3764 .expect("the first matching posture must be recorded");
3765 assert_eq!(audit.policy_revision, 7);
3766 }
3767
3768 #[tokio::test]
3769 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
3770 let session_id = "permission-happy";
3771 let (result, session, events, _) = drive_permission_handshake_frames(
3772 session_id,
3773 [
3774 permission_posture_frame(session_id, 7),
3775 actor_event_frame(AgentEvent::RunnerProgress {
3776 session_id: session_id.to_string(),
3777 round_count: 1,
3778 }),
3779 actor_event_frame(AgentEvent::Token {
3780 content: "working".to_string(),
3781 }),
3782 actor_event_frame(AgentEvent::ToolStart {
3783 tool_call_id: "tool-1".to_string(),
3784 tool_name: "Read".to_string(),
3785 arguments: serde_json::json!({"file_path": "README.md"}),
3786 }),
3787 completed_actor_frame(),
3788 ],
3789 expected_default_permission_posture(7),
3790 )
3791 .await;
3792
3793 assert_eq!(result.unwrap().as_deref(), Some("done"));
3794 assert!(matches!(
3795 events.as_slice(),
3796 [
3797 AgentEvent::PermissionPostureActivated { .. },
3798 AgentEvent::RunnerProgress { .. },
3799 AgentEvent::Token { .. },
3800 AgentEvent::ToolStart { .. }
3801 ]
3802 ));
3803 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
3804 .expect("matching posture must be recorded before execution events are accepted");
3805 assert_eq!(audit.policy_revision, 7);
3806 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
3807 }
3808
3809 #[tokio::test]
3810 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
3811 let temp = tempfile::tempdir().unwrap();
3812 let store = Arc::new(
3813 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
3814 .await
3815 .unwrap(),
3816 );
3817 let storage: Arc<dyn Storage> = store.clone();
3818 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
3819 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
3820 store.clone(),
3821 bamboo_domain::SessionInboxLimits::default(),
3822 ));
3823 let session_id = "actor-confirmation-order";
3824 let run_id = "actor-run-current";
3825 let mut session = Session::new(session_id, "model");
3826 store.save_session(&session).await.unwrap();
3827 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
3828 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
3829 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
3830 inbox.deliver(&envelope).await.unwrap();
3831 }
3832 inbox
3833 .mark_activation_eligible(
3834 session_id,
3835 2,
3836 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
3837 )
3838 .await
3839 .unwrap();
3840 let router = SessionActivationRouter::new();
3841 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
3842 let binding = SessionInboxRuntimeBinding {
3843 router,
3844 inbox: inbox.clone(),
3845 storage,
3846 persistence: locked,
3847 };
3848 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX)
3849 .await
3850 .unwrap();
3851 assert_eq!(
3852 pairs
3853 .iter()
3854 .map(|(claim, _)| claim.envelope.id.as_str())
3855 .collect::<Vec<_>>(),
3856 vec!["actor-first", "actor-second"]
3857 );
3858 let seeded = store.load_session(session_id).await.unwrap().unwrap();
3859 assert_eq!(
3860 seeded
3861 .messages
3862 .iter()
3863 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3864 .map(|message| message.id.as_str())
3865 .collect::<Vec<_>>(),
3866 vec!["actor-first", "actor-second"],
3867 "host context must be durable before actor dispatch"
3868 );
3869 for (claim, _) in &pairs {
3870 assert_eq!(
3871 seeded
3872 .messages
3873 .iter()
3874 .filter(|message| bamboo_domain::is_matching_session_message(
3875 message,
3876 &claim.envelope
3877 ))
3878 .count(),
3879 1,
3880 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
3881 claim.envelope.id
3882 );
3883 }
3884 assert!(
3885 seeded.session_inbox_admission().is_none_or(|cursor| {
3886 !cursor.contains(&pairs[0].0.envelope.id)
3887 && !cursor.contains(&pairs[1].0.envelope.id)
3888 }),
3889 "pre-dispatch transcript seeding must not forge worker confirmation"
3890 );
3891 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
3892 let claims = pairs
3893 .into_iter()
3894 .map(|(claim, _)| claim)
3895 .collect::<VecDeque<_>>();
3896 let first = claims[0].clone();
3897 let second = claims[1].clone();
3898 let mut stale = admission_confirmation(session_id, &first, "stale-run");
3899 stale.canonical_claim_generation = second.generation;
3900 let mut link = ConfirmationSequenceLink {
3901 frames: VecDeque::from([
3902 ChildFrame::SessionMessageAdmitted {
3903 confirmation: stale,
3904 },
3905 ChildFrame::SessionMessageAdmitted {
3906 confirmation: admission_confirmation(session_id, &first, run_id),
3907 },
3908 ChildFrame::SessionMessageAdmitted {
3909 confirmation: admission_confirmation(session_id, &second, run_id),
3910 },
3911 ChildFrame::Terminal {
3912 status: TerminalStatus::Completed,
3913 result: Some("done".to_string()),
3914 error: None,
3915 transcript: Vec::new(),
3916 },
3917 ]),
3918 sent: Vec::new(),
3919 };
3920 let (event_tx, _event_rx) = mpsc::channel(8);
3921 let cancel = CancellationToken::new();
3922 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
3923 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
3924 let result = drive(ActorDriveContext {
3925 client: &mut link,
3926 parent_session_id: "parent",
3927 child_session_id: session_id,
3928 child_attempt: 0,
3929 approval_registry: None,
3930 approval_decider: None,
3931 approval_reviewer: None,
3932 escalation_bridge: None,
3933 event_tx: &event_tx,
3934 cancel_token: &cancel,
3935 live_rx: &mut live_rx,
3936 delivery_rx: &mut delivery_rx,
3937 logical_session: &mut session,
3938 expected_permission_posture: None,
3939 session_inbox_runtime: Some(&binding),
3940 activation_run_id: Some(run_id),
3941 execution_epoch: 0,
3942 expected_source_actor_id: session_id,
3943 initial_inflight_claims: claims,
3944 first_frame_timeout: Some(Duration::from_secs(1)),
3945 })
3946 .await
3947 .unwrap();
3948 assert_eq!(result.as_deref(), Some("done"));
3949 assert_eq!(
3950 session
3951 .messages
3952 .iter()
3953 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
3954 .map(|message| message.id.as_str())
3955 .collect::<Vec<_>>(),
3956 vec!["actor-first", "actor-second"]
3957 );
3958 let backlog = inbox.inspect(session_id).await.unwrap();
3959 assert_eq!(backlog.pending + backlog.claimed, 0);
3960 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
3961 let cursor = confirmed
3962 .session_inbox_admission()
3963 .expect("exact worker confirmation must checkpoint the admission cursor");
3964 assert!(cursor.contains(&first.envelope.id));
3965 assert!(cursor.contains(&second.envelope.id));
3966 for claim in [&first, &second] {
3967 assert_eq!(
3968 confirmed
3969 .messages
3970 .iter()
3971 .filter(|message| bamboo_domain::is_matching_session_message(
3972 message,
3973 &claim.envelope
3974 ))
3975 .count(),
3976 1,
3977 "confirmation must retain one exact canonical marker for {}",
3978 claim.envelope.id
3979 );
3980 }
3981 assert!(inbox
3982 .was_admitted(session_id, &first.envelope.id)
3983 .await
3984 .unwrap());
3985 assert!(inbox
3986 .was_admitted(session_id, &second.envelope.id)
3987 .await
3988 .unwrap());
3989 owner_registration.begin_finalization().await;
3990 owner_registration.finish(2).await.unwrap();
3991 }
3992
3993 #[derive(Default)]
3994 struct RecordingCodexTokenAuthority {
3995 issued_for: std::sync::Mutex<Vec<String>>,
3996 revoked: std::sync::Mutex<Vec<String>>,
3997 }
3998
3999 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
4000 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
4001 self.issued_for
4002 .lock()
4003 .expect("issued fixture lock")
4004 .push(session_id.to_string());
4005 Ok(IssuedCodexRunToken {
4006 token_id: format!("id-{session_id}"),
4007 token: format!("bcx1_secret-{session_id}"),
4008 })
4009 }
4010
4011 fn revoke(&self, token_id: &str) {
4012 self.revoked
4013 .lock()
4014 .expect("revoked fixture lock")
4015 .push(token_id.to_string());
4016 }
4017 }
4018
4019 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
4020 let bamboo_mode = auth_mode == Some("bamboo")
4021 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
4022 ExecutorSpec::Codex {
4023 binary: None,
4024 model: None,
4025 mode: None,
4026 sandbox: None,
4027 inherit_user_config,
4028 auth_mode: auth_mode.map(str::to_string),
4029 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
4030 wire_api: Some("responses".to_string()),
4031 provider_key_ref: None,
4032 forward_env: None,
4033 approval_policy: None,
4034 network_access: None,
4035 allow_danger_bypass: None,
4036 permission_profile: None,
4037 workspace_owned: None,
4038 }
4039 }
4040
4041 fn permission_resolution(
4042 requested: bamboo_domain::SessionPermissionMode,
4043 effective: bamboo_domain::PermissionMode,
4044 ) -> bamboo_domain::PermissionModeResolution {
4045 bamboo_domain::PermissionModeResolution {
4046 requested,
4047 effective,
4048 }
4049 }
4050
4051 #[test]
4052 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
4053 use bamboo_domain::{PermissionMode, SessionPermissionMode};
4054
4055 let default =
4056 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
4057 assert_eq!(
4058 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
4059 .unwrap()
4060 .as_deref(),
4061 Some("bamboo_runtime:default")
4062 );
4063 assert_eq!(
4064 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
4065 None,
4066 "transport-only Echo must not claim the typed permission contract"
4067 );
4068 assert_eq!(
4069 expected_permission_executor_mapping(
4070 &ExecutorSpec::CliAdapter {
4071 command: "must-not-appear-in-contract".to_string(),
4072 args: vec!["credential-like-argument".to_string()],
4073 },
4074 default,
4075 false,
4076 )
4077 .unwrap(),
4078 None,
4079 "unimplemented CliAdapter must not leak command data into a contract"
4080 );
4081
4082 let claude = ExecutorSpec::ClaudeCode {
4083 binary: None,
4084 model: None,
4085 permission_mode: Some("default".to_string()),
4086 inherit_user_config: None,
4087 forward_env: None,
4088 };
4089 for (resolution, mapping) in [
4090 (
4091 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
4092 "claude_code:permission_mode=plan",
4093 ),
4094 (
4095 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4096 "claude_code:permission_mode=bypassPermissions",
4097 ),
4098 (
4099 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
4100 "claude_code:permission_mode=acceptEdits",
4101 ),
4102 (
4103 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
4104 "claude_code:permission_mode=dontAsk",
4105 ),
4106 (
4107 permission_resolution(
4108 SessionPermissionMode::Bypass,
4109 PermissionMode::BypassPermissions,
4110 ),
4111 "claude_code:permission_mode=default",
4112 ),
4113 ] {
4114 assert_eq!(
4115 expected_permission_executor_mapping(&claude, resolution, false)
4116 .unwrap()
4117 .as_deref(),
4118 Some(mapping)
4119 );
4120 }
4121 assert_eq!(
4122 expected_permission_executor_mapping(&claude, default, true)
4123 .unwrap()
4124 .as_deref(),
4125 Some("claude_code:blocked_explicit_deny")
4126 );
4127
4128 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
4129 if let ExecutorSpec::Codex {
4130 approval_policy, ..
4131 } = &mut codex_exec
4132 {
4133 *approval_policy = Some("on-failure".to_string());
4134 }
4135 assert_eq!(
4136 expected_permission_executor_mapping(&codex_exec, default, false)
4137 .unwrap()
4138 .as_deref(),
4139 Some("codex_exec:approval_policy=on-failure")
4140 );
4141 assert_eq!(
4142 expected_permission_executor_mapping(
4143 &codex_exec,
4144 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4145 false,
4146 )
4147 .unwrap()
4148 .as_deref(),
4149 Some("codex_exec:approval_policy=never")
4150 );
4151 assert_eq!(
4152 expected_permission_executor_mapping(&codex_exec, default, true)
4153 .unwrap()
4154 .as_deref(),
4155 Some("codex_exec:blocked_explicit_deny")
4156 );
4157
4158 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
4159 if let ExecutorSpec::Codex {
4160 mode,
4161 approval_policy,
4162 ..
4163 } = &mut codex_app_server
4164 {
4165 *mode = Some("app_server".to_string());
4166 *approval_policy = Some("on-request".to_string());
4167 }
4168 assert_eq!(
4169 expected_permission_executor_mapping(&codex_app_server, default, false)
4170 .unwrap()
4171 .as_deref(),
4172 Some("codex_app_server:approvalPolicy=on-request")
4173 );
4174 assert_eq!(
4175 expected_permission_executor_mapping(
4176 &codex_app_server,
4177 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
4178 false,
4179 )
4180 .unwrap()
4181 .as_deref(),
4182 Some("codex_app_server:approvalPolicy=never")
4183 );
4184 assert_eq!(
4185 expected_permission_executor_mapping(&codex_app_server, default, true)
4186 .unwrap()
4187 .as_deref(),
4188 Some("codex_app_server:blocked_explicit_deny")
4189 );
4190 }
4191
4192 #[test]
4193 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
4194 let project = tempfile::tempdir().unwrap();
4195 let managed = project.path().join(".bamboo/worktree/child-571");
4196 std::fs::create_dir_all(&managed).unwrap();
4197 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
4198 let marker = project
4199 .path()
4200 .join(".bamboo/worktree/.bamboo-owned/child-571");
4201 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
4202 std::fs::write(&marker, "bamboo/child-571").unwrap();
4203 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
4204 let nested = managed.join("nested/path");
4205 std::fs::create_dir_all(&nested).unwrap();
4206 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
4207
4208 let arbitrary = tempfile::tempdir().unwrap();
4209 assert!(!workspace_is_bamboo_owned(
4210 arbitrary.path().to_str().unwrap()
4211 ));
4212 }
4213
4214 #[test]
4215 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
4216 let authority = Arc::new(RecordingCodexTokenAuthority::default());
4217 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4218
4219 let (secrets, guard) = build_codex_run_secrets(
4220 &codex_executor(Some("bamboo"), None),
4221 Some(authority_dyn),
4222 "child-570",
4223 )
4224 .unwrap();
4225
4226 let token = secrets
4227 .codex_provider_token
4228 .as_ref()
4229 .expect("bamboo mode mints a token");
4230 assert_eq!(token.expose(), "bcx1_secret-child-570");
4231 assert!(!format!("{token:?}").contains("secret-child-570"));
4232 assert_eq!(
4233 authority.issued_for.lock().unwrap().as_slice(),
4234 ["child-570"]
4235 );
4236 assert!(authority.revoked.lock().unwrap().is_empty());
4237
4238 drop(guard);
4239 assert_eq!(
4240 authority.revoked.lock().unwrap().as_slice(),
4241 ["id-child-570"]
4242 );
4243 }
4244
4245 #[test]
4246 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
4247 let authority = Arc::new(RecordingCodexTokenAuthority::default());
4248 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
4249 let (secrets, guard) = build_codex_run_secrets(
4250 &codex_executor(Some("custom"), None),
4251 Some(authority_dyn),
4252 "child-custom",
4253 )
4254 .unwrap();
4255 assert!(secrets.codex_provider_token.is_none());
4256 assert!(guard.is_none());
4257 assert!(authority.issued_for.lock().unwrap().is_empty());
4258
4259 let error = build_codex_run_secrets(
4260 &codex_executor(Some("bamboo"), None),
4261 None,
4262 "child-no-authority",
4263 )
4264 .err()
4265 .expect("bamboo mode without an authority must fail closed");
4266 assert!(error.to_string().contains("per-run token authority"));
4267 }
4268
4269 #[test]
4270 fn codex_provisioning_never_leaks_the_session_provider_credential() {
4271 let credentials = vec![ScopedCredential {
4272 provider: "openai".to_string(),
4273 api_key: "upstream-secret-must-not-cross".to_string(),
4274 base_url: None,
4275 provider_type: Some("openai".to_string()),
4276 credential_ref: Some("provider.openai.api_key".to_string()),
4277 }];
4278
4279 for (mode, label) in [
4280 (Some("inherit"), "inherit"),
4281 (Some("api_key"), "api_key"),
4282 (Some("bamboo"), "bamboo"),
4283 (None, "default-bamboo"),
4284 ] {
4285 let runner = ActorChildRunner::new(
4286 format!("codex-{label}-test"),
4287 PathBuf::from("/bin/false"),
4288 Vec::new(),
4289 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
4290 codex_executor(mode, None),
4291 credentials.clone(),
4292 "openai".to_string(),
4293 1,
4294 );
4295 let mut session = Session::new(format!("child-{label}"), "model");
4296 session.add_message(bamboo_agent_core::Message::user("test"));
4297 let spec = runner.build_spec(
4298 &session,
4299 &crate::runtime::execution::SpawnJob {
4300 parent_session_id: "parent".to_string(),
4301 child_session_id: format!("child-{label}"),
4302 model: "gpt-5.4".to_string(),
4303 disabled_tools: None,
4304 },
4305 );
4306 assert!(
4307 spec.secrets.provider_credentials.is_empty(),
4308 "{label} Codex must not receive the session provider key"
4309 );
4310 }
4311 }
4312
4313 #[test]
4314 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
4315 let credentials = vec![
4316 ScopedCredential {
4317 provider: "openai".to_string(),
4318 api_key: "selected-openai-secret".to_string(),
4319 base_url: None,
4320 provider_type: Some("openai".to_string()),
4321 credential_ref: Some("provider.openai.api_key".to_string()),
4322 },
4323 ScopedCredential {
4324 provider: "other".to_string(),
4325 api_key: "unrelated-secret".to_string(),
4326 base_url: None,
4327 provider_type: Some("openai".to_string()),
4328 credential_ref: Some("provider.other.api_key".to_string()),
4329 },
4330 ];
4331 let runner = ActorChildRunner::new(
4332 "echo-test".to_string(),
4333 PathBuf::from("/bin/false"),
4334 Vec::new(),
4335 std::env::temp_dir().join("bamboo-echo-provider-570"),
4336 ExecutorSpec::Echo,
4337 credentials,
4338 "openai".to_string(),
4339 1,
4340 );
4341 let spec = runner.build_spec(
4342 &Session::new("child-echo", "model"),
4343 &crate::runtime::execution::SpawnJob {
4344 parent_session_id: "parent".to_string(),
4345 child_session_id: "child-echo".to_string(),
4346 model: "gpt-5.4".to_string(),
4347 disabled_tools: None,
4348 },
4349 );
4350
4351 assert_eq!(spec.secrets.provider_credentials.len(), 1);
4352 assert_eq!(
4353 spec.secrets.provider_credentials[0].api_key,
4354 "selected-openai-secret"
4355 );
4356 }
4357
4358 #[test]
4359 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
4360 let mut executor = codex_executor(Some("custom"), None);
4361 if let ExecutorSpec::Codex {
4362 base_url,
4363 provider_key_ref,
4364 ..
4365 } = &mut executor
4366 {
4367 *base_url = Some("https://provider.example/v1".to_string());
4368 *provider_key_ref = Some("provider.custom.api_key".to_string());
4369 }
4370 let credentials = vec![
4371 ScopedCredential {
4372 provider: "openai".to_string(),
4373 api_key: "session-provider-secret".to_string(),
4374 base_url: None,
4375 provider_type: Some("openai".to_string()),
4376 credential_ref: Some("provider.openai.api_key".to_string()),
4377 },
4378 ScopedCredential {
4379 provider: "custom".to_string(),
4380 api_key: "selected-secret".to_string(),
4381 base_url: None,
4382 provider_type: Some("openai".to_string()),
4383 credential_ref: Some("provider.custom.api_key".to_string()),
4384 },
4385 ScopedCredential {
4386 provider: "other".to_string(),
4387 api_key: "unrelated-secret".to_string(),
4388 base_url: None,
4389 provider_type: Some("openai".to_string()),
4390 credential_ref: Some("provider.other.api_key".to_string()),
4391 },
4392 ];
4393 let runner = ActorChildRunner::new(
4394 "codex-test".to_string(),
4395 PathBuf::from("/bin/false"),
4396 Vec::new(),
4397 std::env::temp_dir().join("bamboo-codex-570"),
4398 executor,
4399 credentials,
4400 "openai".to_string(),
4401 1,
4402 );
4403 let mut session = Session::new("child-custom", "model");
4404 session.add_message(bamboo_agent_core::Message::user("test"));
4405 let spec = runner.build_spec(
4406 &session,
4407 &crate::runtime::execution::SpawnJob {
4408 parent_session_id: "parent".to_string(),
4409 child_session_id: "child-custom".to_string(),
4410 model: "gpt-5.4".to_string(),
4411 disabled_tools: None,
4412 },
4413 );
4414
4415 assert_eq!(spec.secrets.provider_credentials.len(), 1);
4416 assert_eq!(
4417 spec.secrets.provider_credentials[0]
4418 .credential_ref
4419 .as_deref(),
4420 Some("provider.custom.api_key")
4421 );
4422 assert_eq!(
4423 spec.secrets.provider_credentials[0].api_key,
4424 "selected-secret"
4425 );
4426 }
4427
4428 fn spec_with(
4429 role: &str,
4430 provider: &str,
4431 model: &str,
4432 workspace: Option<&str>,
4433 disabled: Option<Vec<&str>>,
4434 ) -> ProvisionSpec {
4435 let mut spec = ProvisionSpec::new(
4436 ChildIdentity {
4437 child_id: "c".into(),
4438 parent_id: None,
4439 project_key: None,
4440 role: role.into(),
4441 depth: 0,
4442 },
4443 ExecutorSpec::Echo,
4444 "/tmp/fab".into(),
4445 );
4446 spec.workspace = workspace.map(|w| w.to_string());
4447 spec.model = Some(ModelRefSpec {
4448 provider: provider.into(),
4449 model: model.into(),
4450 });
4451 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
4452 spec
4453 }
4454
4455 #[test]
4456 fn fingerprint_matches_interchangeable_children() {
4457 let a = spec_with(
4460 "explorer",
4461 "p",
4462 "m",
4463 Some("/ws"),
4464 Some(vec!["Bash", "Edit"]),
4465 );
4466 let mut b = spec_with(
4467 "explorer",
4468 "p",
4469 "m",
4470 Some("/ws"),
4471 Some(vec!["Edit", "Bash"]),
4472 );
4473 b.identity.child_id = "other".into();
4474 assert_eq!(
4475 ActorChildRunner::fingerprint(&a),
4476 ActorChildRunner::fingerprint(&b)
4477 );
4478 }
4479
4480 #[test]
4481 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
4482 let mut session =
4483 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
4484 session.root_session_id = "logical-root-681".to_string();
4485 let job = SpawnJob {
4486 parent_session_id: "logical-parent-681".to_string(),
4487 child_session_id: "logical-child-681".to_string(),
4488 model: "model".to_string(),
4489 disabled_tools: None,
4490 };
4491 let expected = LogicalSessionIdentity {
4492 session_id: "logical-child-681".to_string(),
4493 parent_session_id: Some("logical-parent-681".to_string()),
4494 root_session_id: "logical-root-681".to_string(),
4495 };
4496
4497 let placements_and_transport_ids = [
4498 (Placement::Local, "local-mailbox-first"),
4499 (
4500 Placement::Remote {
4501 endpoint: "wss://remote.example/actor".to_string(),
4502 },
4503 "remote-process-44",
4504 ),
4505 (
4506 Placement::Schedulable {
4507 pool: "gpu-pool".to_string(),
4508 },
4509 "scheduled-mailbox-9",
4510 ),
4511 (Placement::Local, "warm-mailbox-reused-77"),
4513 ];
4514 for (placement, transport_id) in placements_and_transport_ids {
4515 let mut provision = spec_with("worker", "provider", "model", None, None);
4516 provision.placement = placement;
4517 provision.identity.child_id = transport_id.to_string();
4518 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
4519 assert_ne!(
4520 provision.identity.child_id, expected.session_id,
4521 "test fixture must prove transport identity is independent"
4522 );
4523 }
4524 }
4525
4526 #[test]
4527 fn fingerprint_separates_distinct_runtimes() {
4528 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
4529 let base_fp = ActorChildRunner::fingerprint(&base);
4530 assert_ne!(
4532 base_fp,
4533 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
4534 );
4535 assert_ne!(
4536 base_fp,
4537 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
4538 );
4539 assert_ne!(
4540 base_fp,
4541 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
4542 );
4543 assert_ne!(
4544 base_fp,
4545 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
4546 );
4547 assert_ne!(
4548 base_fp,
4549 ActorChildRunner::fingerprint(&spec_with(
4550 "explorer",
4551 "p",
4552 "m",
4553 Some("/ws"),
4554 Some(vec!["Bash"])
4555 ))
4556 );
4557 }
4558
4559 #[test]
4560 fn fingerprint_splits_on_baked_capabilities() {
4561 let base_fp =
4566 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
4567
4568 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
4569 depth.identity.depth = 2;
4570 assert_ne!(
4571 base_fp,
4572 ActorChildRunner::fingerprint(&depth),
4573 "depth must split"
4574 );
4575
4576 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
4577 nested.capabilities.nested_spawn = true;
4578 assert_ne!(
4579 base_fp,
4580 ActorChildRunner::fingerprint(&nested),
4581 "nested_spawn must split"
4582 );
4583
4584 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
4585 bypass.capabilities.bypass = true;
4586 assert_ne!(
4587 base_fp,
4588 ActorChildRunner::fingerprint(&bypass),
4589 "bypass must split"
4590 );
4591
4592 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4593 auto.capabilities.auto_approve_permissions = true;
4594 assert_ne!(
4595 base_fp,
4596 ActorChildRunner::fingerprint(&auto),
4597 "auto_approve_permissions must split"
4598 );
4599
4600 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
4601 global_auto.capabilities.permission_requested_mode = "default".to_string();
4602 global_auto.capabilities.permission_effective_mode = "auto".to_string();
4603 global_auto.capabilities.auto_approve_permissions = true;
4604 let mut explicit_auto = global_auto.clone();
4605 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
4606 assert_ne!(
4607 ActorChildRunner::fingerprint(&global_auto),
4608 ActorChildRunner::fingerprint(&explicit_auto),
4609 "permission_requested_mode must split global and explicit Auto"
4610 );
4611
4612 let mut plan_overlay = explicit_auto.clone();
4613 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
4614 assert_ne!(
4615 ActorChildRunner::fingerprint(&explicit_auto),
4616 ActorChildRunner::fingerprint(&plan_overlay),
4617 "permission_effective_mode must split Plan overlay from Auto"
4618 );
4619
4620 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
4621 enforce.capabilities.enforce_permissions = true;
4622 assert_ne!(
4623 base_fp,
4624 ActorChildRunner::fingerprint(&enforce),
4625 "enforce_permissions must split"
4626 );
4627
4628 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
4629 cap.capabilities.max_spawn_depth = Some(8);
4630 assert_ne!(
4631 base_fp,
4632 ActorChildRunner::fingerprint(&cap),
4633 "max_spawn_depth must split"
4634 );
4635
4636 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
4640 nha.capabilities.no_human_approver = true;
4641 assert_ne!(
4642 base_fp,
4643 ActorChildRunner::fingerprint(&nha),
4644 "no_human_approver must split"
4645 );
4646
4647 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
4650 gro.capabilities.guardian_read_only = true;
4651 assert_ne!(
4652 base_fp,
4653 ActorChildRunner::fingerprint(&gro),
4654 "guardian_read_only must split"
4655 );
4656 }
4657
4658 #[test]
4659 fn fingerprint_splits_codex_exec_and_app_server_workers() {
4660 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
4661 exec.executor = codex_executor(Some("inherit"), None);
4662 let mut app_server = exec.clone();
4663 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
4664 *mode = Some("app_server".to_string());
4665 }
4666 assert_ne!(
4667 ActorChildRunner::fingerprint(&exec),
4668 ActorChildRunner::fingerprint(&app_server)
4669 );
4670 }
4671
4672 struct StaticDecider(bool);
4673
4674 #[async_trait]
4675 impl ChildApprovalDecider for StaticDecider {
4676 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
4677 self.0
4678 }
4679 }
4680
4681 struct RecordingReviewer {
4682 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
4683 }
4684
4685 #[async_trait]
4686 impl ChildApprovalReviewer for RecordingReviewer {
4687 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
4688 let _ = self
4689 .reviewed
4690 .send((parent.to_string(), child.to_string(), request.clone()));
4691 true
4692 }
4693 }
4694
4695 struct SilentLink;
4700 #[async_trait]
4701 impl bamboo_subagent::ChildLink for SilentLink {
4702 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4703 Ok(())
4704 }
4705 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4706 std::future::pending().await
4707 }
4708 }
4709
4710 struct InstantTerminalLink {
4712 done: bool,
4713 }
4714
4715 struct ApprovalRoundTripLink {
4716 step: u8,
4717 approval_reply: Option<(String, bool)>,
4718 }
4719
4720 #[async_trait]
4721 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
4722 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4723 if let ParentFrame::ApprovalReply { id, approved } = frame {
4724 self.approval_reply = Some((id, approved));
4725 self.step = 2;
4726 }
4727 Ok(())
4728 }
4729
4730 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4731 match self.step {
4732 0 => {
4733 self.step = 1;
4734 Ok(Some(ChildFrame::ApprovalRequest {
4735 id: "approval-1".into(),
4736 body: serde_json::json!({
4737 "tool_name": "Bash",
4738 "permission": "execute",
4739 "resource": "rm -rf target",
4740 "permission_request": {"reason_code": "hard_dangerous"}
4741 }),
4742 }))
4743 }
4744 1 => std::future::pending().await,
4745 2 => {
4746 self.step = 3;
4747 Ok(Some(ChildFrame::Terminal {
4748 status: TerminalStatus::Completed,
4749 result: Some("done".into()),
4750 error: None,
4751 transcript: vec![],
4752 }))
4753 }
4754 _ => std::future::pending().await,
4755 }
4756 }
4757 }
4758
4759 #[tokio::test]
4760 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
4761 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4762 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
4763 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
4764 reviewed: review_tx,
4765 });
4766 let cancel = CancellationToken::new();
4767 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4768 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4769 let mut logical_session = Session::new("child-reviewer", "model");
4770 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
4771 let mut link = ApprovalRoundTripLink {
4772 step: 0,
4773 approval_reply: None,
4774 };
4775
4776 let result = tokio::time::timeout(
4777 Duration::from_secs(1),
4778 drive(ActorDriveContext {
4779 client: &mut link,
4780 parent_session_id: "parent-reviewer",
4781 child_session_id: "child-reviewer",
4782 child_attempt: 0,
4783 approval_registry: None,
4784 approval_decider: None,
4785 approval_reviewer: Some(&reviewer),
4786 escalation_bridge: None,
4787 event_tx: &event_tx,
4788 cancel_token: &cancel,
4789 live_rx: &mut live_rx,
4790 delivery_rx: &mut delivery_rx,
4791 logical_session: &mut logical_session,
4792 expected_permission_posture: None,
4793 session_inbox_runtime: None,
4794 activation_run_id: None,
4795 execution_epoch: 0,
4796 expected_source_actor_id: "child-reviewer",
4797 initial_inflight_claims: VecDeque::new(),
4798 first_frame_timeout: None,
4799 }),
4800 )
4801 .await
4802 .expect("worker must receive the reviewer verdict before terminating");
4803
4804 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4805 assert_eq!(
4806 link.approval_reply,
4807 Some(("approval-1".to_string(), true)),
4808 "reviewer verdict must traverse the live route back to the worker"
4809 );
4810 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
4811 .await
4812 .expect("reviewer should be invoked off-loop")
4813 .expect("review channel should remain open");
4814 assert_eq!(parent, "parent-reviewer");
4815 assert_eq!(child, "child-reviewer");
4816 assert_eq!(
4817 body.pointer("/permission_request/reason_code")
4818 .and_then(serde_json::Value::as_str),
4819 Some("hard_dangerous")
4820 );
4821 assert!(
4822 event_rx.try_recv().is_err(),
4823 "must not emit a human-review event"
4824 );
4825 drop(live_guard);
4826 }
4827
4828 #[tokio::test]
4829 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
4830 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
4831 let cancel = CancellationToken::new();
4832 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4833 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4834 let mut logical_session = Session::new("child-no-reviewer", "model");
4835 let mut link = ApprovalRoundTripLink {
4836 step: 0,
4837 approval_reply: None,
4838 };
4839
4840 let result = tokio::time::timeout(
4841 Duration::from_secs(1),
4842 drive(ActorDriveContext {
4843 client: &mut link,
4844 parent_session_id: "parent-no-reviewer",
4845 child_session_id: "child-no-reviewer",
4846 child_attempt: 0,
4847 approval_registry: None,
4848 approval_decider: None,
4849 approval_reviewer: None,
4850 escalation_bridge: None,
4851 event_tx: &event_tx,
4852 cancel_token: &cancel,
4853 live_rx: &mut live_rx,
4854 delivery_rx: &mut delivery_rx,
4855 logical_session: &mut logical_session,
4856 expected_permission_posture: None,
4857 session_inbox_runtime: None,
4858 activation_run_id: None,
4859 execution_epoch: 0,
4860 expected_source_actor_id: "child-no-reviewer",
4861 initial_inflight_claims: VecDeque::new(),
4862 first_frame_timeout: None,
4863 }),
4864 )
4865 .await
4866 .expect("fail-closed reply must unblock the child immediately");
4867
4868 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
4869 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
4870 assert!(
4871 event_rx.try_recv().is_err(),
4872 "missing parent review must not surface a manual approval event"
4873 );
4874 }
4875 #[async_trait]
4876 impl bamboo_subagent::ChildLink for InstantTerminalLink {
4877 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
4878 Ok(())
4879 }
4880 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
4881 if self.done {
4882 std::future::pending().await
4883 } else {
4884 self.done = true;
4885 Ok(Some(ChildFrame::Terminal {
4886 status: TerminalStatus::Completed,
4887 result: Some("done".into()),
4888 error: None,
4889 transcript: vec![],
4890 }))
4891 }
4892 }
4893 }
4894
4895 #[tokio::test]
4896 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
4897 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4898 let cancel = CancellationToken::new();
4899 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4900 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4901 let mut logical_session = Session::new("child-x", "model");
4902 let mut link = SilentLink;
4903 let r = drive(ActorDriveContext {
4904 client: &mut link,
4905 parent_session_id: "parent-x",
4906 child_session_id: "child-x",
4907 child_attempt: 0,
4908 approval_registry: None,
4909 approval_decider: None,
4910 approval_reviewer: None,
4911 escalation_bridge: None,
4912 event_tx: &event_tx,
4913 cancel_token: &cancel,
4914 live_rx: &mut live_rx,
4915 delivery_rx: &mut delivery_rx,
4916 logical_session: &mut logical_session,
4917 expected_permission_posture: None,
4918 session_inbox_runtime: None,
4919 activation_run_id: None,
4920 execution_epoch: 0,
4921 expected_source_actor_id: "child-x",
4922 initial_inflight_claims: VecDeque::new(),
4923 first_frame_timeout: Some(Duration::from_millis(100)),
4924 })
4925 .await;
4926 assert!(
4927 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
4928 "a silent worker must trip the first-frame watchdog, got {r:?}"
4929 );
4930 }
4931
4932 #[tokio::test]
4933 async fn drive_does_not_trip_when_a_frame_arrives() {
4934 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
4935 let cancel = CancellationToken::new();
4936 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4937 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
4938 let mut logical_session = Session::new("child-y", "model");
4939 let mut link = InstantTerminalLink { done: false };
4940 let r = drive(ActorDriveContext {
4943 client: &mut link,
4944 parent_session_id: "parent-y",
4945 child_session_id: "child-y",
4946 child_attempt: 0,
4947 approval_registry: None,
4948 approval_decider: None,
4949 approval_reviewer: None,
4950 escalation_bridge: None,
4951 event_tx: &event_tx,
4952 cancel_token: &cancel,
4953 live_rx: &mut live_rx,
4954 delivery_rx: &mut delivery_rx,
4955 logical_session: &mut logical_session,
4956 expected_permission_posture: None,
4957 session_inbox_runtime: None,
4958 activation_run_id: None,
4959 execution_epoch: 0,
4960 expected_source_actor_id: "child-y",
4961 initial_inflight_claims: VecDeque::new(),
4962 first_frame_timeout: Some(Duration::from_millis(50)),
4963 })
4964 .await;
4965 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
4966 }
4967
4968 #[tokio::test]
4969 async fn child_approval_fails_closed_without_decider() {
4970 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
4972 assert!(!decide_child_approval(None, "child-1", &body).await);
4973 }
4974
4975 #[tokio::test]
4976 async fn child_approval_honors_wired_decider() {
4977 let body =
4978 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
4979 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
4980 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
4981 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
4982 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
4983 }
4984
4985 use crate::runtime::execution::SpawnJob;
4988 use bamboo_agent_core::Session;
4989
4990 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
4993 ActorChildRunner::new(
4994 "test-actor".into(),
4995 PathBuf::from("/bin/false"),
4996 vec![],
4997 std::env::temp_dir().join("bamboo-test-fab-193"),
4998 ExecutorSpec::Echo,
4999 vec![],
5000 "anthropic".into(),
5001 4,
5002 )
5003 .with_remote_placements(placements)
5004 }
5005
5006 fn session_of_role(role: &str, assignment: &str) -> Session {
5009 let mut s = Session::new("child-1", "test-model");
5010 s.metadata
5011 .insert("subagent_type".to_string(), role.to_string());
5012 s.add_message(bamboo_agent_core::Message::user(assignment));
5013 s
5014 }
5015
5016 fn job_for(child: &str) -> SpawnJob {
5017 SpawnJob {
5018 parent_session_id: "parent-1".into(),
5019 child_session_id: child.into(),
5020 model: String::new(),
5021 disabled_tools: None,
5022 }
5023 }
5024
5025 #[derive(Default)]
5026 struct RecordingChildSessionPort {
5027 saved: std::sync::Mutex<Option<Session>>,
5028 }
5029
5030 impl RecordingChildSessionPort {
5031 fn saved_child(&self) -> Session {
5032 self.saved
5033 .lock()
5034 .expect("saved-child fixture lock")
5035 .clone()
5036 .expect("create_child_action must save the child")
5037 }
5038 }
5039
5040 #[async_trait]
5041 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
5042 async fn load_root_session(
5043 &self,
5044 _root_id: &str,
5045 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
5046 unreachable!("create_child_action does not load the root")
5047 }
5048
5049 async fn load_child_for_parent(
5050 &self,
5051 _parent_id: &str,
5052 _child_id: &str,
5053 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
5054 unreachable!("create_child_action does not reload the child")
5055 }
5056
5057 async fn save_child_session(
5058 &self,
5059 child: &mut Session,
5060 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5061 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
5062 Ok(())
5063 }
5064
5065 async fn save_child_session_authoritative_flags(
5066 &self,
5067 _child: &mut Session,
5068 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5069 unreachable!("new-child creation uses the ordinary save")
5070 }
5071
5072 async fn is_child_running(&self, _child_id: &str) -> bool {
5073 false
5074 }
5075
5076 async fn list_children(
5077 &self,
5078 _parent_id: &str,
5079 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
5080 Vec::new()
5081 }
5082
5083 async fn enqueue_child_run(
5084 &self,
5085 _parent: &Session,
5086 _child: &Session,
5087 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5088 unreachable!("fixture creates the child with auto_run=false")
5089 }
5090
5091 async fn cancel_child_run_and_wait(
5092 &self,
5093 _child_id: &str,
5094 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5095 unreachable!("create_child_action does not cancel")
5096 }
5097
5098 async fn delete_child_session(
5099 &self,
5100 _parent_id: &str,
5101 _child_id: &str,
5102 ) -> Result<
5103 crate::session_app::child_session::DeleteChildResult,
5104 crate::session_app::child_session::ChildSessionError,
5105 > {
5106 unreachable!("create_child_action does not delete")
5107 }
5108
5109 async fn get_child_runner_info(
5110 &self,
5111 _child_id: &str,
5112 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
5113 None
5114 }
5115
5116 async fn register_parent_wait_for_child(
5117 &self,
5118 _parent_session_id: &str,
5119 _child_session_id: &str,
5120 _tool_call_id: Option<&str>,
5121 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
5122 unreachable!("create_child_action does not register a wait")
5123 }
5124
5125 async fn register_parent_wait_for_children(
5126 &self,
5127 _parent_session_id: &str,
5128 _child_session_ids: &[String],
5129 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
5130 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
5131 unreachable!("create_child_action does not register a wait")
5132 }
5133
5134 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
5135 Vec::new()
5136 }
5137
5138 async fn find_resident_child(
5139 &self,
5140 _root_session_id: &str,
5141 _resident_name: &str,
5142 ) -> Option<String> {
5143 None
5144 }
5145
5146 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
5147 }
5148
5149 #[test]
5150 fn build_spec_sets_remote_placement_for_matching_role() {
5151 let mut placements = HashMap::new();
5152 placements.insert(
5153 "explorer".to_string(),
5154 ResolvedRemotePlacement {
5155 endpoint: "wss://gpu-host:8443".into(),
5156 token: Some("T-secret".into()),
5157 ca_cert_file: None,
5158 host_label: None,
5159 },
5160 );
5161 let runner = bogus_runner(placements);
5162
5163 let s = session_of_role("explorer", "do the thing");
5165 let spec = runner.build_spec(&s, &job_for("child-1"));
5166 match &spec.placement {
5167 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
5168 other => panic!("expected Remote, got {other:?}"),
5169 }
5170 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-secret"));
5171 }
5172
5173 #[test]
5174 fn build_spec_leaves_local_for_unmatched_role() {
5175 let mut placements = HashMap::new();
5176 placements.insert(
5177 "explorer".to_string(),
5178 ResolvedRemotePlacement {
5179 endpoint: "wss://gpu-host:8443".into(),
5180 token: Some("T".into()),
5181 ca_cert_file: None,
5182 host_label: None,
5183 },
5184 );
5185 let runner = bogus_runner(placements);
5186
5187 let s = session_of_role("writer", "do the thing");
5189 let spec = runner.build_spec(&s, &job_for("child-1"));
5190 assert_eq!(spec.placement, Placement::Local);
5191 assert!(spec.secrets.worker_auth_token.is_none());
5192 }
5193
5194 #[test]
5195 fn build_spec_local_when_no_placements() {
5196 let runner = bogus_runner(HashMap::new());
5197 let s = session_of_role("explorer", "do the thing");
5198 let spec = runner.build_spec(&s, &job_for("child-1"));
5199 assert_eq!(spec.placement, Placement::Local);
5200 assert!(spec.secrets.worker_auth_token.is_none());
5201 }
5202
5203 #[tokio::test]
5204 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
5205 for (label, mode) in [
5209 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
5210 ("auto", bamboo_domain::SessionPermissionMode::Auto),
5211 ] {
5212 let runner = bogus_runner(HashMap::new());
5213 let mut parent = Session::new(format!("parent-{label}"), "test-model");
5214 parent
5215 .agent_runtime_state
5216 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
5217 .set_permission_mode(mode);
5218 let workspace = tempfile::tempdir().expect("workspace fixture");
5219 let port = RecordingChildSessionPort::default();
5220 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
5221 crate::session_app::child_session::create_child_action(
5222 &port,
5223 crate::session_app::child_session::CreateChildInput {
5224 parent_session: parent,
5225 child_id: child_id.clone(),
5226 title: format!("{label} child"),
5227 responsibility: "Run ordinary commands".to_string(),
5228 assignment_prompt: "run an ordinary command".to_string(),
5229 subagent_type: "explorer".to_string(),
5230 workspace: workspace.path().to_string_lossy().into_owned(),
5231 workspace_source: crate::project_context::WorkspaceSource::Explicit,
5232 model_override: None,
5233 model_ref_override: None,
5234 runtime_metadata: HashMap::new(),
5235 auto_run: false,
5236 reasoning_effort: None,
5237 lifecycle: None,
5238 resident_name: None,
5239 resident_context: None,
5240 disabled_tools: None,
5241 context_fork: None,
5242 },
5243 )
5244 .await
5245 .expect("create inherited-permission child");
5246 let child = port.saved_child();
5247
5248 assert_eq!(
5249 child
5250 .agent_runtime_state
5251 .as_ref()
5252 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
5253 Some(mode),
5254 "create_child_action must inherit {label} from the parent"
5255 );
5256
5257 let spec = runner.build_spec(&child, &job_for(&child_id));
5258
5259 assert_eq!(
5260 spec.capabilities.bypass,
5261 mode == bamboo_domain::SessionPermissionMode::Bypass
5262 );
5263 assert_eq!(
5264 spec.capabilities.auto_approve_permissions,
5265 mode == bamboo_domain::SessionPermissionMode::Auto
5266 );
5267 assert!(
5268 spec.capabilities.enforce_permissions,
5269 "policy evaluation must remain active under {label}"
5270 );
5271 }
5272 }
5273
5274 #[tokio::test]
5275 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
5276 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
5277 let workspace = tempfile::tempdir().expect("workspace fixture");
5278
5279 for (role, lifecycle, resident_name) in [
5280 ("explorer", None, None),
5281 ("resident", Some("resident"), Some("stable-reviewer")),
5282 ("guardian", None, None),
5283 ] {
5284 let mut parent = Session::new(format!("parent-{role}"), "test-model");
5285 parent.set_project_id_meta(project_id.to_string());
5286 let port = RecordingChildSessionPort::default();
5287 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
5288 crate::session_app::child_session::create_child_action(
5289 &port,
5290 crate::session_app::child_session::CreateChildInput {
5291 parent_session: parent,
5292 child_id: child_id.clone(),
5293 title: format!("{role} child"),
5294 responsibility: "Review the assigned work".to_string(),
5295 assignment_prompt: "inspect the change".to_string(),
5296 subagent_type: role.to_string(),
5297 workspace: workspace.path().to_string_lossy().into_owned(),
5298 workspace_source: crate::project_context::WorkspaceSource::Explicit,
5299 model_override: None,
5300 model_ref_override: None,
5301 runtime_metadata: HashMap::new(),
5302 auto_run: false,
5303 reasoning_effort: None,
5304 lifecycle: lifecycle.map(str::to_string),
5305 resident_name: resident_name.map(str::to_string),
5306 resident_context: None,
5307 disabled_tools: None,
5308 context_fork: None,
5309 },
5310 )
5311 .await
5312 .expect("create Project-inheriting child");
5313 let child = port.saved_child();
5314 assert_eq!(
5315 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
5316 Some(project_id.clone()),
5317 "{role} child must inherit its parent's Project"
5318 );
5319
5320 assert_eq!(
5321 project_id_for_actor_run(&child).expect("valid actor Project identity"),
5322 Some(project_id.clone()),
5323 "{role} actor RunSpec must preserve inherited Project identity"
5324 );
5325 }
5326 }
5327
5328 #[test]
5329 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
5330 assert_eq!(placement_metadata(&Placement::Local, None), None);
5332
5333 let r = placement_metadata(
5335 &Placement::Remote {
5336 endpoint: "wss://10.0.0.5:8443/stream".into(),
5337 },
5338 None,
5339 )
5340 .unwrap();
5341 assert!(r.contains(r#""kind":"remote""#), "{r}");
5342 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
5343
5344 let labeled = placement_metadata(
5346 &Placement::Remote {
5347 endpoint: "ws://169.254.230.101:8899".into(),
5348 },
5349 Some("mini"),
5350 )
5351 .unwrap();
5352 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
5353
5354 let s = placement_metadata(
5356 &Placement::Schedulable {
5357 pool: "explorers".into(),
5358 },
5359 Some("mini"),
5360 )
5361 .unwrap();
5362 assert!(s.contains(r#""kind":"remote""#), "{s}");
5363 assert!(s.contains(r#""host":"mini""#), "{s}");
5364
5365 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
5367 assert_eq!(p.kind, "remote");
5368 assert_eq!(p.host, "mini");
5369 }
5370
5371 #[tokio::test]
5378 async fn execute_external_child_routes_role_to_remote_worker_without_spawning() {
5379 let token = "remote-test-token";
5381 let server = bamboo_subagent::transport::WsServer::bind_with_token(
5382 (std::net::Ipv4Addr::LOCALHOST, 0).into(),
5383 Some(token.to_string()),
5384 )
5385 .await
5386 .expect("bind resident worker");
5387 let endpoint = server.ws_endpoint(); let srv = tokio::spawn(async move {
5389 let _ = server
5391 .serve(Arc::new(bamboo_subagent::executor::EchoExecutor))
5392 .await;
5393 });
5394
5395 let mut placements = HashMap::new();
5397 placements.insert(
5398 "explorer".to_string(),
5399 ResolvedRemotePlacement {
5400 endpoint: endpoint.clone(),
5401 token: Some(token.to_string()),
5402 ca_cert_file: None,
5403 host_label: Some("mini-e2e".into()), },
5405 );
5406 let runner = bogus_runner(placements);
5407
5408 let mut session = session_of_role("explorer", "hello remote");
5410 let job = job_for("child-1");
5411 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(64);
5412 let cancel = CancellationToken::new();
5413
5414 let result = tokio::time::timeout(
5415 Duration::from_secs(10),
5416 runner.execute_external_child(&mut session, &job, event_tx, cancel),
5417 )
5418 .await
5419 .expect("run did not hang")
5420 .expect("remote run succeeded (connected to resident worker, did not spawn)");
5421
5422 let _ = result;
5423 let last = session
5426 .messages
5427 .iter()
5428 .rev()
5429 .find(|m| matches!(m.role, Role::Assistant))
5430 .expect("an assistant reply was written back");
5431 assert!(
5432 last.content.contains("echo:"),
5433 "expected echo reply, got {:?}",
5434 last.content
5435 );
5436
5437 let placement = session
5440 .metadata
5441 .get("placement")
5442 .expect("remote child session stamped with a placement");
5443 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
5444 assert!(placement.contains(r#""host":"mini-e2e""#), "{placement}");
5445
5446 let mut saw_event = false;
5449 while let Ok(Some(_ev)) =
5450 tokio::time::timeout(Duration::from_millis(50), event_rx.recv()).await
5451 {
5452 saw_event = true;
5453 }
5454 let _ = saw_event;
5455
5456 srv.abort();
5457 }
5458
5459 fn bogus_sched_runner(
5465 remote: HashMap<String, ResolvedRemotePlacement>,
5466 sched: HashMap<String, ResolvedSchedulablePlacement>,
5467 ) -> ActorChildRunner {
5468 ActorChildRunner::new(
5469 "test-actor".into(),
5470 PathBuf::from("/bin/false"),
5471 vec![],
5472 std::env::temp_dir().join("bamboo-test-fab-181"),
5473 ExecutorSpec::Echo,
5474 vec![],
5475 "anthropic".into(),
5476 4,
5477 )
5478 .with_remote_placements(remote)
5479 .with_schedulable_placements(sched)
5480 }
5481
5482 fn sched_placement(
5483 pool: &str,
5484 _registry_url: impl Into<String>,
5485 ) -> ResolvedSchedulablePlacement {
5486 ResolvedSchedulablePlacement {
5487 pool: pool.into(),
5488 host_label: None,
5489 }
5490 }
5491
5492 #[test]
5493 fn build_spec_sets_schedulable_placement_for_matching_role() {
5494 let mut sched = HashMap::new();
5495 sched.insert(
5496 "explorer".to_string(),
5497 sched_placement("gpu-pool", "unused"),
5498 );
5499 let runner = bogus_sched_runner(HashMap::new(), sched);
5500
5501 let s = session_of_role("explorer", "do the thing");
5502 let spec = runner.build_spec(&s, &job_for("child-1"));
5503 match &spec.placement {
5504 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
5505 other => panic!("expected Schedulable, got {other:?}"),
5506 }
5507 assert!(spec.secrets.worker_auth_token.is_none());
5509 }
5510
5511 #[test]
5512 fn build_spec_remote_wins_when_role_in_both_maps() {
5513 let mut remote = HashMap::new();
5516 remote.insert(
5517 "explorer".to_string(),
5518 ResolvedRemotePlacement {
5519 endpoint: "wss://fixed-host:8443".into(),
5520 token: Some("T-remote".into()),
5521 ca_cert_file: None,
5522 host_label: None,
5523 },
5524 );
5525 let mut sched = HashMap::new();
5526 sched.insert(
5527 "explorer".to_string(),
5528 sched_placement("gpu-pool", "https://control-plane:9562"),
5529 );
5530 let runner = bogus_sched_runner(remote, sched);
5531
5532 let s = session_of_role("explorer", "do the thing");
5533 let spec = runner.build_spec(&s, &job_for("child-1"));
5534 match &spec.placement {
5535 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
5536 other => panic!("expected Remote (precedence), got {other:?}"),
5537 }
5538 assert_eq!(spec.secrets.worker_auth_token.as_deref(), Some("T-remote"));
5539 }
5540
5541 #[test]
5542 fn build_spec_local_for_unmatched_schedulable_role() {
5543 let mut sched = HashMap::new();
5544 sched.insert(
5545 "explorer".to_string(),
5546 sched_placement("gpu-pool", "https://control-plane:9562"),
5547 );
5548 let runner = bogus_sched_runner(HashMap::new(), sched);
5549 let s = session_of_role("writer", "do the thing");
5550 let spec = runner.build_spec(&s, &job_for("child-1"));
5551 assert_eq!(spec.placement, Placement::Local);
5552 assert!(spec.secrets.worker_auth_token.is_none());
5553 }
5554
5555 #[test]
5560 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
5561 let mut remote = HashMap::new();
5563 remote.insert(
5564 "explorer".to_string(),
5565 ResolvedRemotePlacement {
5566 endpoint: "ws://169.254.230.101:8899".into(),
5567 token: None,
5568 ca_cert_file: None,
5569 host_label: Some("mini".into()),
5570 },
5571 );
5572 let runner = bogus_runner(remote);
5573 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
5574 let stamp = runner
5575 .placement_stamp_for(&spec)
5576 .expect("remote child is stamped");
5577 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
5578 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
5579
5580 let mut remote_nolabel = HashMap::new();
5582 remote_nolabel.insert(
5583 "explorer".to_string(),
5584 ResolvedRemotePlacement {
5585 endpoint: "ws://169.254.230.101:8899".into(),
5586 token: None,
5587 ca_cert_file: None,
5588 host_label: None,
5589 },
5590 );
5591 let r2 = bogus_runner(remote_nolabel);
5592 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
5593 assert!(r2
5594 .placement_stamp_for(&spec2)
5595 .unwrap()
5596 .contains(r#""host":"169.254.230.101""#));
5597
5598 let mut sched = HashMap::new();
5600 sched.insert(
5601 "mac-mini-monitor".to_string(),
5602 ResolvedSchedulablePlacement {
5603 pool: "mac-mini-monitor".into(),
5604 host_label: Some("mini".into()),
5605 },
5606 );
5607 let sr = bogus_sched_runner(HashMap::new(), sched);
5608 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
5609 let stamp3 = sr
5610 .placement_stamp_for(&spec3)
5611 .expect("scheduled child is stamped");
5612 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
5613 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
5614
5615 let local = bogus_runner(HashMap::new());
5617 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
5618 assert_eq!(local.placement_stamp_for(&spec4), None);
5619 }
5620
5621 async fn start_bus() -> (String, tempfile::TempDir) {
5624 let dir = tempfile::tempdir().unwrap();
5625 let core = std::sync::Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
5626 let server = std::sync::Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
5627 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5628 let addr = listener.local_addr().unwrap();
5629 tokio::spawn(async move {
5630 let _ = server.serve(listener).await;
5631 });
5632 (format!("ws://{addr}"), dir)
5633 }
5634
5635 async fn join_pool(endpoint: &str, id: &str, pool: &str) -> bamboo_broker::BrokerClient {
5636 let mut c = bamboo_broker::BrokerClient::connect(
5637 endpoint,
5638 bamboo_subagent::AgentRef {
5639 session_id: id.into(),
5640 role: Some(pool.into()),
5641 },
5642 "t",
5643 )
5644 .await
5645 .unwrap();
5646 c.subscribe().await.unwrap();
5647 c
5648 }
5649
5650 fn sched_runner_on_bus(endpoint: &str, child_role: &str, pool: &str) -> ActorChildRunner {
5651 let mut sched = HashMap::new();
5652 sched.insert(child_role.to_string(), sched_placement(pool, "unused"));
5653 bogus_sched_runner(HashMap::new(), sched).with_bus(Some(bamboo_subagent::BusEndpoint {
5654 endpoint: endpoint.into(),
5655 token: "t".into(),
5656 }))
5657 }
5658
5659 #[tokio::test]
5660 async fn resolve_schedulable_picks_a_live_bus_worker() {
5661 let (endpoint, _dir) = start_bus().await;
5662 let _w = join_pool(&endpoint, "w-gpu", "gpu-pool").await;
5663 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5664
5665 let mailbox = runner
5666 .resolve_schedulable_worker("explorer")
5667 .await
5668 .expect("a live pool worker is found on the bus");
5669 assert_eq!(mailbox, "w-gpu");
5670 }
5671
5672 #[tokio::test]
5673 async fn resolve_schedulable_round_robins_over_pool_workers() {
5674 let (endpoint, _dir) = start_bus().await;
5675 let _a = join_pool(&endpoint, "w-a", "gpu-pool").await;
5676 let _b = join_pool(&endpoint, "w-b", "gpu-pool").await;
5677 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5678
5679 let mut picked = std::collections::HashSet::new();
5681 for _ in 0..6 {
5682 picked.insert(runner.resolve_schedulable_worker("explorer").await.unwrap());
5683 }
5684 assert_eq!(
5685 picked,
5686 ["w-a".to_string(), "w-b".to_string()].into_iter().collect(),
5687 "round-robin must cover every connected pool worker"
5688 );
5689 }
5690
5691 #[tokio::test]
5692 async fn resolve_schedulable_errors_on_empty_pool() {
5693 let (endpoint, _dir) = start_bus().await;
5694 let runner = sched_runner_on_bus(&endpoint, "explorer", "gpu-pool");
5696
5697 let err = runner
5698 .resolve_schedulable_worker("explorer")
5699 .await
5700 .expect_err("an empty pool is terminal — no local fallback")
5701 .to_string();
5702 assert!(err.contains("no live worker in pool"), "got: {err}");
5703 assert!(err.contains("NOT spawning"), "got: {err}");
5704 }
5705
5706 #[tokio::test]
5713 async fn execute_external_child_runs_schedulable_over_bus_and_stamps_node_label() {
5714 let (endpoint, _dir) = start_bus().await;
5715
5716 let ep = endpoint.clone();
5718 let worker = tokio::spawn(async move {
5719 let _ = bamboo_broker::serve_executor(
5720 &ep,
5721 bamboo_subagent::AgentRef {
5722 session_id: "mmm-worker".into(),
5723 role: Some("mac-mini-monitor".into()),
5724 },
5725 "t",
5726 std::sync::Arc::new(bamboo_subagent::executor::EchoExecutor),
5727 )
5728 .await;
5729 });
5730
5731 let mut probe = bamboo_broker::BrokerClient::connect(
5734 &endpoint,
5735 bamboo_subagent::AgentRef {
5736 session_id: "probe".into(),
5737 role: None,
5738 },
5739 "t",
5740 )
5741 .await
5742 .unwrap();
5743 let mut ready = false;
5744 for _ in 0..100 {
5745 if probe
5746 .list_connected("mac-mini-monitor")
5747 .await
5748 .unwrap()
5749 .iter()
5750 .any(|id| id == "mmm-worker")
5751 {
5752 ready = true;
5753 break;
5754 }
5755 tokio::time::sleep(Duration::from_millis(30)).await;
5756 }
5757 assert!(ready, "worker never joined the pool");
5758
5759 let mut sched = HashMap::new();
5762 sched.insert(
5763 "mac-mini-monitor".to_string(),
5764 ResolvedSchedulablePlacement {
5765 pool: "mac-mini-monitor".into(),
5766 host_label: Some("mini".into()),
5767 },
5768 );
5769 let runner = bogus_sched_runner(HashMap::new(), sched).with_bus(Some(
5770 bamboo_subagent::BusEndpoint {
5771 endpoint: endpoint.clone(),
5772 token: "t".into(),
5773 },
5774 ));
5775
5776 let mut session = session_of_role("mac-mini-monitor", "hello scheduled");
5777 let job = job_for("child-1");
5778 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(64);
5779 let cancel = CancellationToken::new();
5780
5781 tokio::time::timeout(
5782 Duration::from_secs(10),
5783 runner.execute_external_child(&mut session, &job, event_tx, cancel),
5784 )
5785 .await
5786 .expect("run did not hang")
5787 .expect("schedulable run succeeded over the bus (no local spawn)");
5788
5789 let last = session
5791 .messages
5792 .iter()
5793 .rev()
5794 .find(|m| matches!(m.role, Role::Assistant))
5795 .expect("an assistant reply was written back");
5796 assert!(last.content.contains("echo:"), "got {:?}", last.content);
5797
5798 let placement = session
5800 .metadata
5801 .get("placement")
5802 .expect("scheduled child session stamped with a placement");
5803 assert!(placement.contains(r#""kind":"remote""#), "{placement}");
5804 assert!(placement.contains(r#""host":"mini""#), "{placement}");
5805
5806 worker.abort();
5807 }
5808}