1use std::collections::{BTreeSet, HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use crate::placement_scheduler::PlacementScheduler;
20use async_trait::async_trait;
21use bamboo_agent_core::storage::Storage;
22use bamboo_agent_core::tools::tool_start_arguments_for_display;
23use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
24use bamboo_domain::poison::PoisonRecover;
25use bamboo_domain::{
26 ActorActivationClaim, ActorActivationFence, ActorActivationFinish, ActorDirectoryPort,
27 ActorLogicalState, ActorSnapshotLimits, ActorSnapshotPort, ActorSnapshotPrincipal, HookResult,
28 ParentQuestion, ParentQuestionCheckpointV1, SessionInboxClaim, SessionInboxConsumerId,
29 SessionInboxFailureReport, SessionInboxLeaseRequest, SessionInboxOwnedClaim, SessionInboxPort,
30 PARENT_QUESTION_CHECKPOINT_ACTION,
31};
32use bamboo_domain::{HostPlacementIntent, HostPlacementRequest, WorkerSlotLease};
33use sha2::{Digest, Sha256};
34use tokio::sync::mpsc;
35use tokio_util::sync::CancellationToken;
36
37use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
38use bamboo_subagent::proto::{
39 ActorEventBatch, AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame,
40 PermissionPolicyContext, RunSpec, SessionMessageDelivery, TerminalStatus,
41};
42use bamboo_subagent::provision::{
43 ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
44};
45
46use super::actor_event_router::{
47 ActorEventClass, ActorEventRoute, ActorEventRouteError, ActorEventRouter,
48};
49use super::actor_event_stream::{ActorEventObserver, PublicActorEvent};
50use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
51
52pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 200;
56
57pub use bamboo_config::DEFAULT_MAX_SPAWN_DEPTH as MAX_SPAWN_DEPTH;
59
60const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
62
63const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
67
68const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
71
72const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
76
77const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
84
85fn active_scoped_session_deny_count(
86 config: &bamboo_tools::permission::PermissionConfig,
87 session_id: &str,
88) -> usize {
89 config
90 .temporary_grants()
91 .into_iter()
92 .filter(|grant| {
93 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
94 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
95 && grant.session_id.as_deref() == Some(session_id)
96 })
97 .count()
98}
99
100fn ensure_no_active_scoped_session_denies(
101 config: &bamboo_tools::permission::PermissionConfig,
102 session_id: &str,
103) -> Result<(), AgentError> {
104 let count = active_scoped_session_deny_count(config, session_id);
105 if count == 0 {
106 Ok(())
107 } else {
108 Err(AgentError::LLM(format!(
109 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
110 )))
111 }
112}
113
114pub struct IssuedCodexRunToken {
117 pub token_id: String,
118 pub token: String,
119}
120
121pub trait CodexRunTokenAuthority: Send + Sync + 'static {
124 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
125 fn revoke(&self, token_id: &str);
126}
127
128struct CodexRunTokenGuard {
129 authority: Arc<dyn CodexRunTokenAuthority>,
130 token_id: String,
131}
132
133impl Drop for CodexRunTokenGuard {
134 fn drop(&mut self) {
135 self.authority.revoke(&self.token_id);
136 }
137}
138
139fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
140 matches!(
141 executor,
142 ExecutorSpec::Codex {
143 auth_mode: Some(mode),
144 ..
145 } if mode == "bamboo"
146 ) || matches!(
147 executor,
148 ExecutorSpec::Codex {
149 auth_mode: None,
150 inherit_user_config,
151 ..
152 } if !inherit_user_config.unwrap_or(false)
153 )
154}
155
156fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
157 match executor {
158 ExecutorSpec::ClaudeCode {
159 permission_mode, ..
160 } => permission_mode
161 .as_deref()
162 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
163 ExecutorSpec::Codex {
164 permission_profile,
165 sandbox,
166 ..
167 } => {
168 permission_profile
169 .as_deref()
170 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
171 || sandbox
172 .as_deref()
173 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
174 }
175 _ => false,
176 }
177}
178
179fn expected_permission_executor_mapping(
187 executor: &ExecutorSpec,
188 resolution: bamboo_domain::PermissionModeResolution,
189 has_explicit_deny: bool,
190) -> Result<Option<String>, AgentError> {
191 let mapping = match executor {
192 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
193 ExecutorSpec::BambooRuntime => {
194 format!("bamboo_runtime:{}", resolution.effective.as_str())
195 }
196 ExecutorSpec::ClaudeCode {
197 permission_mode, ..
198 } => {
199 if has_explicit_deny {
200 "claude_code:blocked_explicit_deny".to_string()
201 } else {
202 let mode = match resolution.effective {
203 bamboo_domain::PermissionMode::Plan => "plan",
204 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
205 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
206 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
207 bamboo_domain::PermissionMode::Default
208 | bamboo_domain::PermissionMode::BypassPermissions => {
209 permission_mode.as_deref().unwrap_or("default")
210 }
211 };
212 format!("claude_code:permission_mode={mode}")
213 }
214 }
215 ExecutorSpec::Codex {
216 mode,
217 sandbox,
218 approval_policy,
219 allow_danger_bypass,
220 ..
221 } => match mode.as_deref().unwrap_or("exec") {
222 "exec" => {
223 let approval_policy = expected_codex_exec_approval_policy(
224 sandbox.as_deref(),
225 approval_policy.as_deref(),
226 allow_danger_bypass.unwrap_or(false),
227 resolution,
228 )?;
229 if has_explicit_deny {
230 "codex_exec:blocked_explicit_deny".to_string()
231 } else {
232 format!("codex_exec:approval_policy={approval_policy}")
233 }
234 }
235 "app_server" => {
236 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
237 return Err(AgentError::LLM(
238 "invalid Codex app-server permission posture configuration".to_string(),
239 ));
240 }
241 if has_explicit_deny {
242 "codex_app_server:blocked_explicit_deny".to_string()
243 } else {
244 let approval_policy = if resolution.suppress_approval_prompts()
245 || resolution.effective == bamboo_domain::PermissionMode::Plan
246 {
247 "never"
248 } else {
249 "on-request"
250 };
251 format!("codex_app_server:approvalPolicy={approval_policy}")
252 }
253 }
254 _ => {
255 return Err(AgentError::LLM(
256 "unsupported Codex executor mode for permission posture contract".to_string(),
257 ));
258 }
259 },
260 };
261 Ok(Some(mapping))
262}
263
264fn expected_codex_exec_approval_policy(
265 sandbox: Option<&str>,
266 approval_policy: Option<&str>,
267 allow_danger_bypass: bool,
268 resolution: bamboo_domain::PermissionModeResolution,
269) -> Result<&'static str, AgentError> {
270 let configured = match approval_policy {
271 None | Some("never") => "never",
272 Some("on-failure") => "on-failure",
273 Some(_) => {
274 return Err(AgentError::LLM(
275 "invalid Codex exec permission posture configuration".to_string(),
276 ));
277 }
278 };
279 if resolution.suppress_approval_prompts()
280 || resolution.effective == bamboo_domain::PermissionMode::Plan
281 {
282 return Ok("never");
283 }
284 match sandbox {
285 Some("danger-full-access") => Ok("never"),
286 Some("read-only") | Some("workspace-write") => Ok(configured),
287 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
288 None => Ok(configured),
289 Some(_) => Err(AgentError::LLM(
290 "invalid Codex exec permission posture configuration".to_string(),
291 )),
292 }
293}
294
295fn workspace_is_bamboo_owned(raw: &str) -> bool {
296 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
297 let configured_root = bamboo_config::paths::resolve_workspace_root();
298 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
299 if workspace.starts_with(&configured_root) {
300 return true;
301 }
302
303 workspace.ancestors().any(|candidate| {
308 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
309 return false;
310 };
311 let Some(worktree_root) = candidate.parent() else {
312 return false;
313 };
314 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
315 || worktree_root.parent().and_then(Path::file_name)
316 != Some(std::ffi::OsStr::new(".bamboo"))
317 {
318 return false;
319 }
320 let marker = worktree_root.join(".bamboo-owned").join(name);
321 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
322 })
323}
324
325fn build_codex_run_secrets(
326 executor: &ExecutorSpec,
327 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
328 child_session_id: &str,
329) -> Result<
330 (
331 bamboo_subagent::proto::RunSecrets,
332 Option<CodexRunTokenGuard>,
333 ),
334 AgentError,
335> {
336 if !executor_uses_bamboo_codex(executor) {
337 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
338 }
339
340 let authority = authority.ok_or_else(|| {
341 AgentError::LLM(
342 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
343 )
344 })?;
345 let issued = authority
346 .issue(child_session_id)
347 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
348 let guard = CodexRunTokenGuard {
349 authority,
350 token_id: issued.token_id,
351 };
352 Ok((
353 bamboo_subagent::proto::RunSecrets {
354 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
355 },
356 Some(guard),
357 ))
358}
359
360struct PooledWorker {
366 worker: SpawnedChild,
367 mailbox_id: String,
369 parked_at: Option<Instant>,
372}
373
374type WorkerPool = HashMap<String, Vec<PooledWorker>>;
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377enum PoolReapReason {
378 ProcessExited,
379 IdleTimeout,
380}
381
382impl PoolReapReason {
383 fn as_str(self) -> &'static str {
384 match self {
385 Self::ProcessExited => "process_exited_while_parked",
386 Self::IdleTimeout => "pool_idle_timeout",
387 }
388 }
389}
390
391fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
392 parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
393}
394
395fn idle_pool_limit_reason(
396 pool: &WorkerPool,
397 key: &str,
398 max_per_key: usize,
399 max_total: usize,
400) -> Option<&'static str> {
401 if pool.get(key).map_or(0, Vec::len) >= max_per_key {
402 Some("pool_per_key_limit")
403 } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
404 Some("pool_global_limit")
405 } else {
406 None
407 }
408}
409
410fn take_reapable_workers(
411 pool: &mut WorkerPool,
412 now: Instant,
413 timeout: Duration,
414) -> Vec<(String, PoolReapReason, PooledWorker)> {
415 let mut reaped = Vec::new();
416 for (key, bucket) in pool.iter_mut() {
417 let mut retained = Vec::with_capacity(bucket.len());
418 for mut worker in std::mem::take(bucket) {
419 let reason = if !worker.worker.is_alive() {
420 Some(PoolReapReason::ProcessExited)
421 } else if parked_worker_expired(worker.parked_at, now, timeout) {
422 Some(PoolReapReason::IdleTimeout)
423 } else {
424 None
425 };
426 if let Some(reason) = reason {
427 reaped.push((key.clone(), reason, worker));
428 } else {
429 retained.push(worker);
430 }
431 }
432 *bucket = retained;
433 }
434 pool.retain(|_, bucket| !bucket.is_empty());
435 reaped
436}
437
438async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
439 let now = Instant::now();
440 let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
441 let reaped = {
442 let mut pool = pool.lock().await;
443 take_reapable_workers(&mut pool, now, timeout)
444 };
445 let count = reaped.len();
446 for (pool_key, reason, worker) in reaped {
447 let idle_ms = worker
448 .parked_at
449 .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
450 tracing::info!(
451 pool_key,
452 mailbox_id = %worker.mailbox_id,
453 worker_pid = ?worker.worker.pid(),
454 worker_idle_ms = ?idle_ms,
455 shutdown_reason = reason.as_str(),
456 "reaping parked sub-agent worker"
457 );
458 worker.worker.kill().await;
459 }
460 count
461}
462
463#[derive(Clone, Default)]
468pub struct ResolvedRemotePlacement {
469 pub endpoint: String,
470 pub token: Option<String>,
471 pub ca_cert_file: Option<PathBuf>,
472 pub host_label: Option<String>,
476 pub broker_peer: Option<Result<bamboo_config::RemoteBrokerPeer, ()>>,
478 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
479}
480impl std::fmt::Debug for ResolvedRemotePlacement {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 f.debug_struct("ResolvedRemotePlacement")
483 .field("broker_peer", &self.broker_peer.is_some())
484 .finish_non_exhaustive()
485 }
486}
487
488#[derive(Debug, Clone, Default)]
495pub struct ResolvedSchedulablePlacement {
496 pub pool: String,
497 pub host_label: Option<String>,
501 pub endpoint: String,
502 pub token: Option<String>,
503 pub ca_cert_file: Option<PathBuf>,
504 pub broker_parent: Option<bamboo_config::SchedulableBrokerParent>,
505 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
506}
507
508#[derive(Clone)]
509struct ScopedPlacementRoute {
510 endpoint: String,
511 token: String,
512 ca_cert_file: PathBuf,
513 parent: bamboo_subagent::AgentRef,
514 worker_role: String,
515 pinned_mailbox: Option<String>,
516 requirements: bamboo_config::OperatorPlacementRequirements,
517}
518
519impl ScopedPlacementRoute {
520 async fn query(&self) -> Result<bamboo_broker::BrokerClient, AgentError> {
521 let tls = bamboo_broker::client_config_trusting_cert(&self.ca_cert_file)
522 .map_err(|_| remote_broker_unavailable())?;
523 bamboo_broker::BrokerClient::connect_with_tls(
524 &self.endpoint,
525 self.parent.clone(),
526 &self.token,
527 Some(tls),
528 )
529 .await
530 .map_err(|_| remote_broker_unavailable())
531 }
532}
533
534struct ActivePlacementLease {
539 registry: bamboo_storage::v2::FileHostRegistry,
540 actor_store: Arc<bamboo_storage::SessionStoreV2>,
541 route: ScopedPlacementRoute,
542 mailbox: String,
543 host_ref: String,
544 fence: ActorActivationFence,
545 placement_ref: bamboo_domain::ActorPlacementRef,
546 lease: Arc<tokio::sync::Mutex<WorkerSlotLease>>,
547 heartbeat: tokio::task::JoinHandle<()>,
548 run_cancel: CancellationToken,
549 lost: Arc<AtomicBool>,
550 release_on_drop: AtomicBool,
551}
552
553async fn fail_pre_dispatch_activation(
557 actor_store: &bamboo_storage::SessionStoreV2,
558 fence: &ActorActivationFence,
559) -> Result<(), bamboo_domain::ActorDirectoryError> {
560 actor_store
561 .finish_activation(fence, chrono::Utc::now(), ActorActivationFinish::Failed)
562 .await
563 .map(|_| ())
564}
565
566async fn send_actor_run(
570 client: &mut dyn bamboo_subagent::ChildLink,
571 spec: RunSpec,
572 send_invoked: &mut bool,
573) -> Result<(), AgentError> {
574 *send_invoked = true;
575 client
576 .send(ParentFrame::Run(spec))
577 .await
578 .map_err(|error| AgentError::LLM(format!("actor run dispatch failed: {error}")))
579}
580
581fn remote_activation_finish(
582 result: &Result<Option<String>, AgentError>,
583 accepted_terminal: Option<TerminalStatus>,
584) -> ActorActivationFinish {
585 if result.is_ok() {
586 ActorActivationFinish::Succeeded
587 } else if matches!(result, Err(AgentError::Cancelled))
588 && accepted_terminal == Some(TerminalStatus::Cancelled)
589 {
590 ActorActivationFinish::Cancelled
591 } else {
592 ActorActivationFinish::Failed
593 }
594}
595
596async fn append_fenced_remote_reply(
600 actor_store: &bamboo_storage::SessionStoreV2,
601 fence: &ActorActivationFence,
602 session: &mut Session,
603 text: &str,
604) -> Result<(), AgentError> {
605 let message = bamboo_agent_core::Message::assistant(text, None);
606 let committed = actor_store
607 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
608 fence: fence.clone(),
609 expected_created_at: session.created_at,
610 expected_messages: session.messages.clone(),
611 expected_provider_transcript: session.provider_transcript.clone(),
612 messages: vec![message],
613 native_groups: Vec::new(),
614 })
615 .await
616 .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
617 session.messages = committed.messages;
618 session.provider_transcript = committed.provider_transcript;
619 session.updated_at = committed.updated_at;
620 Ok(())
621}
622
623impl ActivePlacementLease {
624 async fn reserve(
625 registry: bamboo_storage::v2::FileHostRegistry,
626 actor_store: Arc<bamboo_storage::SessionStoreV2>,
627 route: ScopedPlacementRoute,
628 mailbox: String,
629 actor_id: String,
630 run_id: String,
631 project_id: Option<bamboo_domain::ProjectId>,
632 required_tools: BTreeSet<String>,
633 parent_cancel: &CancellationToken,
634 ) -> Result<Self, AgentError> {
635 let scheduler = PlacementScheduler::new(registry.clone());
636 let mut query = route.query().await?;
637 let observed = query
638 .observe_host_capacity(&mailbox, &route.worker_role)
639 .await
640 .map_err(|_| remote_broker_unavailable())?
641 .ok_or_else(remote_broker_unavailable)?;
642 let host = scheduler
643 .observe_scoped_host(
644 &mut query,
645 &observed.host_ref,
646 &mailbox,
647 &route.worker_role,
648 chrono::Duration::seconds(110),
649 )
650 .await
651 .map_err(|_| remote_broker_unavailable())?;
652 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
653 || host.connection_generation != observed.connection_generation
654 || route
655 .pinned_mailbox
656 .as_deref()
657 .is_some_and(|pinned| pinned != mailbox)
658 {
659 return Err(remote_broker_unavailable());
660 }
661 let now = chrono::Utc::now();
662 let request = HostPlacementRequest {
663 intent: HostPlacementIntent::PinnedHost {
664 host_ref: host.host_ref.clone(),
665 },
666 actor_id,
667 run_id,
668 project_id: project_id.clone(),
669 trust_zone: route.requirements.trust_zone.clone(),
670 workspace_label: route.requirements.workspace_label.clone(),
671 executor: "bamboo-runtime".into(),
672 required_tools,
673 network_zone: route.requirements.network_zone.clone(),
674 require_network_isolation: route.requirements.require_network_isolation,
675 preferred_host_ref: None,
676 now,
677 lease_expires_at: now + chrono::Duration::seconds(80),
678 };
679 if !request.eligible(&host) {
680 return Err(remote_broker_unavailable());
681 }
682 let lease = scheduler
683 .reserve(request)
684 .await
685 .map_err(|_| remote_broker_unavailable())?;
686 let placement_ref = bamboo_domain::ActorPlacementRef {
687 class: bamboo_domain::ActorPlacementClass::Remote,
688 lease_id: lease.lease_id.clone(),
689 slot_epoch: Some(lease.epoch),
690 };
691 let mut claimed_fence = None;
692 let actor_binding = async {
693 let actor = actor_store
694 .ensure_actor(&lease.actor_id)
695 .await
696 .map_err(|_| remote_broker_unavailable())?;
697 if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
698 return Err(remote_broker_unavailable());
699 }
700 let placement_intent = if route.pinned_mailbox.is_some() {
701 bamboo_domain::ActorPlacementIntent::PinnedHost {
702 host_ref: host.host_ref.clone(),
703 }
704 } else {
705 bamboo_domain::ActorPlacementIntent::Pool {
706 pool_id: route.worker_role.clone(),
707 }
708 };
709 actor_store
710 .bind_control_plane(
711 &lease.actor_id,
712 bamboo_domain::ActorControlPlaneBinding {
713 policy_revision: None,
714 placement_intent: Some(placement_intent),
715 },
716 )
717 .await
718 .map_err(|_| {
719 tracing::warn!(stage = "actor_bind", "remote placement admission failed");
720 remote_broker_unavailable()
721 })?;
722 let activation = actor_store
723 .claim_activation(&ActorActivationClaim {
724 actor_id: lease.actor_id.clone(),
725 run_id: lease.run_id.clone(),
726 lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
727 lease_expires_at: lease.expires_at,
728 inbox_generation: 0,
729 placement_ref: Some(placement_ref.clone()),
730 now: chrono::Utc::now(),
731 })
732 .await
733 .map_err(|_| {
734 tracing::warn!(stage = "actor_claim", "remote placement admission failed");
735 remote_broker_unavailable()
736 })?;
737 let fence = activation.fence();
738 claimed_fence = Some(fence.clone());
739 actor_store
740 .start_activation(&fence, chrono::Utc::now())
741 .await
742 .map_err(|_| {
743 tracing::warn!(stage = "actor_start", "remote placement admission failed");
744 remote_broker_unavailable()
745 })?;
746 let reservation = registry
747 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
748 placement_ref: placement_ref.clone(),
749 actor_id: lease.actor_id.clone(),
750 run_id: lease.run_id.clone(),
751 observed_host_ref: host.host_ref.clone(),
752 observed_connection_generation: host.connection_generation.clone(),
753 now: chrono::Utc::now(),
754 })
755 .await
756 .map_err(|error| {
757 tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
758 remote_broker_unavailable()
759 })?;
760 if reservation.mailbox != mailbox || reservation.lease != lease {
761 tracing::warn!(stage = "slot_binding", "remote placement admission failed");
762 return Err(remote_broker_unavailable());
763 }
764 Ok::<_, AgentError>(fence)
765 }
766 .await;
767 let fence = match actor_binding {
768 Ok(fence) => fence,
769 Err(error) => {
770 if let Some(fence) = claimed_fence.as_ref() {
771 if let Err(rollback_error) =
772 fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
773 {
774 tracing::warn!(
775 stage = "actor_pre_dispatch_rollback",
776 reason = %rollback_error,
777 "remote placement activation rollback failed"
778 );
779 }
780 }
781 let _ = registry.release_slot(&lease).await;
782 return Err(error);
783 }
784 };
785 let lease = Arc::new(tokio::sync::Mutex::new(lease));
786 let host_ref = host.host_ref.clone();
787 let lost = Arc::new(AtomicBool::new(false));
788 let run_cancel = parent_cancel.child_token();
789 let heartbeat = {
790 let registry = registry.clone();
791 let actor_store = actor_store.clone();
792 let route = route.clone();
793 let host_ref = host_ref.clone();
794 let mailbox = mailbox.clone();
795 let fence = fence.clone();
796 let placement_ref = placement_ref.clone();
797 let lease = lease.clone();
798 let lost = lost.clone();
799 let run_cancel = run_cancel.clone();
800 tokio::spawn(async move {
801 let mut interval = tokio::time::interval(Duration::from_secs(30));
802 interval.tick().await;
803 loop {
804 tokio::select! {
805 _ = run_cancel.cancelled() => break,
806 _ = interval.tick() => {}
807 }
808 let refreshed = async {
809 let mut query = route.query().await?;
810 let host = PlacementScheduler::new(registry.clone())
811 .observe_scoped_host(
812 &mut query,
813 &host_ref,
814 &mailbox,
815 &route.worker_role,
816 chrono::Duration::seconds(110),
817 )
818 .await
819 .map_err(|_| remote_broker_unavailable())?;
820 let current = lease.lock().await.clone();
821 if host.connection_generation != current.connection_generation {
822 return Err(remote_broker_unavailable());
823 }
824 let entry = actor_store
825 .inspect_actor(¤t.actor_id)
826 .await
827 .map_err(|_| remote_broker_unavailable())?;
828 if entry.activation.as_ref().is_none_or(|activation| {
829 activation.fence() != fence
830 || activation.placement_ref.as_ref() != Some(&placement_ref)
831 }) {
832 return Err(remote_broker_unavailable());
833 }
834 let now = chrono::Utc::now();
835 registry
836 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
837 placement_ref: placement_ref.clone(),
838 actor_id: current.actor_id.clone(),
839 run_id: current.run_id.clone(),
840 observed_host_ref: host.host_ref.clone(),
841 observed_connection_generation: host.connection_generation.clone(),
842 now,
843 })
844 .await
845 .map_err(|_| remote_broker_unavailable())?;
846 let renewed = registry
847 .renew_slot(¤t, now, now + chrono::Duration::seconds(80))
848 .await
849 .map_err(|_| remote_broker_unavailable())?;
850 *lease.lock().await = renewed;
851 let expiry = lease.lock().await.expires_at;
852 actor_store
853 .renew_activation(&fence, now, expiry)
854 .await
855 .map_err(|_| remote_broker_unavailable())?;
856 Ok::<(), AgentError>(())
857 }
858 .await;
859 if refreshed.is_err() {
860 lost.store(true, Ordering::SeqCst);
861 run_cancel.cancel();
862 break;
863 }
864 }
865 })
866 };
867 Ok(Self {
868 registry,
869 actor_store,
870 route,
871 mailbox,
872 host_ref,
873 fence,
874 placement_ref,
875 lease,
876 heartbeat,
877 run_cancel,
878 lost,
879 release_on_drop: AtomicBool::new(false),
880 })
881 }
882
883 fn lost(&self) -> bool {
884 self.lost.load(Ordering::SeqCst)
885 }
886
887 async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
888 if send_invoked {
889 return Ok(false);
890 }
891 self.abort_before_dispatch().await?;
892 Ok(true)
893 }
894
895 async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
896 self.heartbeat.abort();
897 let _ = (&mut self.heartbeat).await;
898 let current = self.lease.lock().await.clone();
899 let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
900 let released = self.registry.release_slot(¤t).await;
901 if let Err(error) = failed {
902 tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
903 return Err(remote_broker_unavailable());
904 }
905 if let Err(error) = released {
906 if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
907 tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
908 return Err(remote_broker_unavailable());
909 }
910 }
911 Ok(())
912 }
913
914 async fn complete(
915 &mut self,
916 outcome: ActorActivationFinish,
917 terminal_proven: bool,
918 ) -> Result<(), AgentError> {
919 if !terminal_proven {
920 return if outcome == ActorActivationFinish::Succeeded {
923 Err(remote_broker_unavailable())
924 } else {
925 Ok(())
926 };
927 }
928 self.heartbeat.abort();
931 let _ = (&mut self.heartbeat).await;
932 let current = self.lease.lock().await.clone();
933 let mut query = self.route.query().await?;
937 let observed = query
938 .observe_host_capacity(&self.mailbox, &self.route.worker_role)
939 .await
940 .map_err(|_| remote_broker_unavailable())?
941 .ok_or_else(remote_broker_unavailable)?;
942 if observed.host_ref != self.host_ref
943 || observed.connection_generation != current.connection_generation
944 || observed.mailbox != self.mailbox
945 || observed.role.as_deref() != Some(self.route.worker_role.as_str())
946 {
947 return Err(remote_broker_unavailable());
948 }
949 let entry = self
950 .actor_store
951 .inspect_actor(¤t.actor_id)
952 .await
953 .map_err(|_| remote_broker_unavailable())?;
954 if entry.activation.as_ref().is_none_or(|activation| {
955 activation.fence() != self.fence
956 || activation.placement_ref.as_ref() != Some(&self.placement_ref)
957 }) {
958 return Err(remote_broker_unavailable());
959 }
960 self.registry
961 .validate_slot(¤t, chrono::Utc::now())
962 .await
963 .map_err(|_| remote_broker_unavailable())?;
964 self.actor_store
965 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
966 .await
967 .map_err(|_| remote_broker_unavailable())?;
968 self.release_on_drop.store(true, Ordering::SeqCst);
971 match self.registry.release_slot(¤t).await {
972 Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
973 self.release_on_drop.store(false, Ordering::SeqCst);
974 Ok(())
975 }
976 Err(error) => {
977 tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
978 Err(remote_broker_unavailable())
979 }
980 }
981 }
982}
983
984impl Drop for ActivePlacementLease {
985 fn drop(&mut self) {
986 self.run_cancel.cancel();
987 self.heartbeat.abort();
988 if !self.release_on_drop.load(Ordering::SeqCst) {
989 return;
990 }
991 let registry = self.registry.clone();
992 let actor_store = self.actor_store.clone();
993 let fence = self.fence.clone();
994 let placement_ref = self.placement_ref.clone();
995 let lease = self.lease.clone();
996 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
997 runtime.spawn(async move {
998 let current = lease.lock().await.clone();
999 if actor_store
1000 .inspect_actor(¤t.actor_id)
1001 .await
1002 .ok()
1003 .and_then(|entry| entry.activation)
1004 .is_some_and(|activation| {
1005 activation.fence() == fence
1006 && activation.placement_ref.as_ref() == Some(&placement_ref)
1007 })
1008 {
1009 let _ = registry.release_slot(¤t).await;
1010 }
1011 });
1012 }
1013 }
1014}
1015
1016pub struct BrokerTerminalReceiptReconciler {
1021 store: Arc<bamboo_storage::SessionStoreV2>,
1022 bus: Option<bamboo_subagent::BusEndpoint>,
1023 remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1024 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1025}
1026
1027struct ReceiptRemoteRoute {
1028 endpoint: String,
1029 token: String,
1030 ca_cert_file: PathBuf,
1031 parent: bamboo_subagent::AgentRef,
1032}
1033
1034#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1035pub struct BrokerReceiptRepairReport {
1036 pub candidates: usize,
1037 pub repaired: usize,
1038 pub blocked: usize,
1039}
1040
1041impl BrokerTerminalReceiptReconciler {
1042 pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1043 let subagents = config.subagents();
1044 let mut counts = HashMap::<&str, usize>::new();
1045 for placement in &subagents.remote_placements {
1046 *counts.entry(placement.role.as_str()).or_default() += 1;
1047 }
1048 let mut remote = HashMap::new();
1049 for placement in &subagents.remote_placements {
1050 let route = (counts.get(placement.role.as_str()) == Some(&1))
1051 .then(|| receipt_remote_route(placement))
1052 .flatten();
1053 remote.insert(placement.role.clone(), route);
1054 }
1055 Self {
1056 store,
1057 ticket_service: None,
1058 bus: subagents.broker.clone().and_then(|broker| {
1059 (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1060 bamboo_subagent::BusEndpoint {
1061 endpoint: broker.endpoint,
1062 token: broker.token,
1063 },
1064 )
1065 }),
1066 remote,
1067 }
1068 }
1069
1070 pub fn with_ticket_service(
1071 mut self,
1072 service: Option<Arc<bamboo_tickets::TicketService>>,
1073 ) -> Self {
1074 self.ticket_service = service;
1075 self
1076 }
1077
1078 pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1081 let candidates = match self
1082 .store
1083 .discover_unconfirmed_broker_terminal_children()
1084 .await
1085 {
1086 Ok(candidates) => candidates,
1087 Err(error) => {
1088 tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1089 return BrokerReceiptRepairReport {
1090 blocked: 1,
1091 ..BrokerReceiptRepairReport::default()
1092 };
1093 }
1094 };
1095 let mut report = BrokerReceiptRepairReport {
1096 candidates: candidates.len(),
1097 ..BrokerReceiptRepairReport::default()
1098 };
1099 for child in candidates {
1100 let result =
1101 tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1102 match result {
1103 Ok(Ok(())) => report.repaired += 1,
1104 Ok(Err(reason)) => {
1105 report.blocked += 1;
1106 tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1107 }
1108 Err(_) => {
1109 report.blocked += 1;
1110 tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1111 }
1112 }
1113 }
1114 report
1115 }
1116
1117 async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1118 if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1119 || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1120 || child.metadata.get("external.agent_id").map(String::as_str)
1121 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1122 {
1123 return Err("Child has no trusted built-in actor recovery route");
1124 }
1125 let current = self
1126 .store
1127 .load_session(&child.id)
1128 .await
1129 .map_err(|_| "canonical Child reload failed")?
1130 .ok_or("canonical Child disappeared")?;
1131 if current.kind != bamboo_domain::SessionKind::Child
1132 || current.id != child.id
1133 || current.created_at != child.created_at
1134 || current.parent_session_id != child.parent_session_id
1135 || current.root_session_id != child.root_session_id
1136 || current.project_id_meta() != child.project_id_meta()
1137 || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1138 || current
1139 .metadata
1140 .get("external.protocol")
1141 .map(String::as_str)
1142 != Some("actor")
1143 || current
1144 .metadata
1145 .get("external.agent_id")
1146 .map(String::as_str)
1147 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1148 || bamboo_domain::ActorSession::from_session(¤t).is_err()
1149 {
1150 return Err("canonical Child birth, Root, or Project changed");
1151 }
1152 let root = self
1153 .store
1154 .load_session(&child.root_session_id)
1155 .await
1156 .map_err(|_| "canonical Root reload failed")?
1157 .ok_or("canonical Root disappeared")?;
1158 if root.kind != bamboo_domain::SessionKind::Root
1159 || root.id != child.root_session_id
1160 || root.root_session_id != child.root_session_id
1161 || root.project_id_meta() != child.project_id_meta()
1162 {
1163 return Err("canonical Root identity changed");
1164 }
1165 let parent_id = child
1166 .parent_session_id
1167 .as_deref()
1168 .ok_or("Child parent identity missing")?;
1169 let parent = self
1170 .store
1171 .load_session(parent_id)
1172 .await
1173 .map_err(|_| "canonical parent reload failed")?
1174 .ok_or("canonical parent disappeared")?;
1175 if parent.id != parent_id
1176 || !matches!(
1177 parent.kind,
1178 bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1179 )
1180 || parent.root_session_id != child.root_session_id
1181 || parent.project_id_meta() != child.project_id_meta()
1182 || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1183 {
1184 return Err("canonical parent lineage changed");
1185 }
1186
1187 let receipts = self
1191 .store
1192 .recover_broker_terminal_receipts(¤t)
1193 .await
1194 .map_err(|_| "Host broker terminal checkpoint unverified")?;
1195 if receipts.is_empty() {
1196 return Ok(());
1197 }
1198 let role = current
1199 .metadata
1200 .get("subagent_type")
1201 .map(String::as_str)
1202 .unwrap_or("worker");
1203 for receipt in receipts {
1204 publish_recovered_completion_source(&self.store, &receipt)
1205 .await
1206 .map_err(|_| "committed Child completion source remains pending")?;
1207 if current
1208 .metadata
1209 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1210 {
1211 let service = self
1212 .ticket_service
1213 .as_ref()
1214 .ok_or("Ticket authority unavailable; no result ACK")?;
1215 crate::ticket_runtime::checkpoint_ticket_result(
1216 service,
1217 self.store.as_ref(),
1218 ¤t,
1219 &receipt.activation_run_id,
1220 )
1221 .await
1222 .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1223 }
1224 let local_mailbox = format!("p-{}", child.id);
1225 let mut link = if receipt.parent_mailbox == local_mailbox {
1228 let bus = self
1229 .bus
1230 .as_ref()
1231 .ok_or("local broker receipt bus unavailable")?;
1232 bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1233 &bus.endpoint,
1234 bamboo_subagent::AgentRef {
1235 session_id: local_mailbox,
1236 role: None,
1237 },
1238 &bus.token,
1239 )
1240 .await
1241 .map_err(|_| "local broker receipt connection unavailable")?
1242 } else if let Some(remote) = self.remote.get(role) {
1243 let route = remote
1244 .as_ref()
1245 .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1246 if receipt.parent_mailbox != route.parent.session_id {
1247 return Err("fixed remote parent mailbox changed since Host checkpoint");
1248 }
1249 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1250 .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1251 bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1252 &route.endpoint,
1253 route.parent.clone(),
1254 &route.token,
1255 tls,
1256 )
1257 .await
1258 .map_err(|_| "fixed remote broker receipt connection unavailable")?
1259 } else {
1260 return Err("committed broker receipt route is unavailable");
1261 };
1262 if link.parent_mailbox() != receipt.parent_mailbox {
1263 return Err("broker receipt link mailbox differs from Host checkpoint");
1264 }
1265 bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1266 &mut link,
1267 &receipt.broker_identity,
1268 &receipt.message_ids,
1269 )
1270 .await
1271 .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1272 self.store
1273 .clear_acknowledged_broker_terminal_receipt(
1274 &receipt.session_id,
1275 receipt.created_at,
1276 &receipt.activation_run_id,
1277 &receipt.parent_mailbox,
1278 )
1279 .await
1280 .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1281 }
1282 Ok(())
1283 }
1284}
1285
1286async fn publish_recovered_completion_source(
1290 store: &Arc<bamboo_storage::SessionStoreV2>,
1291 receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1292) -> Result<(), String> {
1293 let expected = store
1294 .load_session(&receipt.session_id)
1295 .await
1296 .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1297 .ok_or("Child completion recovery snapshot missing")?;
1298 if expected.id != receipt.session_id
1299 || expected.created_at != receipt.created_at
1300 || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1301 || expected.root_session_id != receipt.root_session_id
1302 || expected.project_id_meta() != receipt.project_id
1303 {
1304 return Err("Child completion recovery identity changed".into());
1305 }
1306 let mut candidate = expected.clone();
1307 if candidate.last_run_status().as_deref() == Some("running") {
1308 candidate.set_last_run_status(receipt.terminal_status.clone());
1309 if let Some(error) = &receipt.terminal_error {
1310 candidate.set_last_run_error(error.clone());
1311 } else {
1312 candidate.clear_last_run_error();
1313 }
1314 }
1315 if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1316 &mut candidate,
1317 &receipt.activation_run_id,
1318 )? {
1319 let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1320 .ok_or("Child completion recovery source invalid")?;
1321 let published = candidate
1322 .metadata
1323 .get("runtime.child_completion_source_v1")
1324 .ok_or("Child completion recovery source missing")?;
1325 let saved = store
1329 .publish_broker_terminal_source(receipt, &expected, published)
1330 .await
1331 .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1332 if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1333 != Some(&source)
1334 {
1335 return Err("Child completion recovery source changed".into());
1336 }
1337 }
1338 Ok(())
1339}
1340
1341fn receipt_remote_route(
1342 placement: &bamboo_config::RemoteActorPlacement,
1343) -> Option<ReceiptRemoteRoute> {
1344 let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1345 let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1346 if placement.endpoint.len() > 2048
1347 || endpoint.scheme() != "wss"
1348 || endpoint.host_str().is_none()
1349 || !endpoint.username().is_empty()
1350 || endpoint.password().is_some()
1351 || endpoint.query().is_some()
1352 || endpoint.fragment().is_some()
1353 {
1354 return None;
1355 }
1356 let token_env = placement
1357 .token_env
1358 .as_deref()
1359 .filter(|name| !name.is_empty() && name.len() <= 256)?;
1360 let token = std::env::var(token_env).ok()?;
1361 if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1362 return None;
1363 }
1364 let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1365 bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1366 Some(ReceiptRemoteRoute {
1367 endpoint: placement.endpoint.clone(),
1368 token,
1369 ca_cert_file,
1370 parent: bamboo_subagent::AgentRef {
1371 session_id: peer.parent_mailbox.clone(),
1372 role: peer.parent_role.clone(),
1373 },
1374 })
1375}
1376
1377enum PlacementKind {
1382 Local,
1383 Remote,
1384 Schedulable,
1385}
1386
1387fn ensure_ticket_placement_is_enforceable(
1389 session: &Session,
1390 spec: &ProvisionSpec,
1391) -> Result<(), AgentError> {
1392 let ticket = session
1393 .metadata
1394 .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1395 || session
1396 .metadata
1397 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1398 if ticket && !matches!(spec.placement, Placement::Local) {
1399 return Err(AgentError::LLM(
1400 "Ticket execution requires local placement and owned stop proof".into(),
1401 ));
1402 }
1403 Ok(())
1404}
1405
1406fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1415 if !spec.capabilities.read_only_enforced() {
1416 return Ok(());
1417 }
1418
1419 if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1420 return Err(AgentError::LLM(format!(
1421 "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1422 spec.identity.role
1423 )));
1424 }
1425
1426 if !matches!(spec.placement, Placement::Local) {
1427 return Err(AgentError::LLM(format!(
1428 "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1429 spec.identity.role
1430 )));
1431 }
1432
1433 Ok(())
1434}
1435
1436pub struct ActorChildRunner {
1438 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1439 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1440 agent_id: String,
1441 worker_bin: PathBuf,
1442 worker_args: Vec<String>,
1443 builtin_required_context_route: bool,
1444 native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1445 fabric_dir: PathBuf,
1446 executor: ExecutorSpec,
1447 credentials: Vec<ScopedCredential>,
1452 default_provider: String,
1454 max_spawn_depth: u32,
1456 live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1460 bus: Option<bamboo_subagent::BusEndpoint>,
1463 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1467 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1473 max_idle_per_key: usize,
1474 max_idle_total: usize,
1475 pool_reaper_started: AtomicBool,
1476 next_execution_epoch: AtomicU64,
1480 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1484 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1488 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1499 remote_placements: HashMap<String, ResolvedRemotePlacement>,
1505 strict_remote_subscription: tokio::sync::Mutex<()>,
1507 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1515 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1521 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1524 actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1525 canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1526 ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1527 actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1528 pending_durable_links: tokio::sync::Mutex<
1531 HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1532 >,
1533}
1534
1535struct PendingDurableChildLink {
1536 link: Box<dyn bamboo_subagent::ChildLink>,
1537 host_nested_wait_handoff: bool,
1540 strict_history: Option<StrictTerminalHistory>,
1542 strict_history_required: bool,
1543 strict_history_committed: bool,
1544}
1545
1546#[derive(Clone)]
1547struct StrictTerminalHistory {
1548 proof: bamboo_storage::v2::HostTerminalCompleteness,
1549 watermark: bamboo_subagent::ActorEventWatermark,
1550}
1551
1552fn effective_broker_terminal_status(
1553 raw: TerminalStatus,
1554 host_nested_wait_handoff: bool,
1555) -> Result<&'static str, String> {
1556 if host_nested_wait_handoff {
1557 if raw != TerminalStatus::Completed {
1558 return Err("nested wait cannot project a failed broker terminal".into());
1559 }
1560 Ok("suspended")
1561 } else {
1562 Ok(broker_terminal_status_label(raw))
1563 }
1564}
1565
1566fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1567 match status {
1568 TerminalStatus::Completed => "completed",
1569 TerminalStatus::Suspended => "suspended",
1570 TerminalStatus::Error => "error",
1571 TerminalStatus::Cancelled => "cancelled",
1572 }
1573}
1574
1575#[async_trait]
1585pub trait ChildApprovalDecider: Send + Sync {
1586 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1589}
1590
1591async fn decide_child_approval(
1594 decider: Option<&Arc<dyn ChildApprovalDecider>>,
1595 child_session_id: &str,
1596 request: &serde_json::Value,
1597) -> bool {
1598 match decider {
1599 Some(decider) => decider.decide(child_session_id, request).await,
1600 None => false,
1601 }
1602}
1603
1604const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1608
1609#[derive(Clone)]
1620pub struct ChildApprovalScope {
1621 stamp: serde_json::Value,
1622 deadline: chrono::DateTime<chrono::Utc>,
1623 parent: String,
1624 child: String,
1625 run: String,
1626 epoch: u64,
1627 current_epoch: Arc<AtomicU64>,
1628 cancel: CancellationToken,
1629 router: Arc<crate::SessionActivationRouter>,
1630 admission: Arc<AtomicUsize>,
1631}
1632
1633pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1634impl Drop for ChildApprovalAdmission {
1635 fn drop(&mut self) {
1636 self.0.store(2, Ordering::Release);
1637 }
1638}
1639
1640impl ChildApprovalScope {
1641 pub fn new(
1643 parent: &str,
1644 child: &str,
1645 transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1646 router: Arc<crate::SessionActivationRouter>,
1647 cancel: CancellationToken,
1648 current_epoch: Arc<AtomicU64>,
1649 ) -> Self {
1650 let (attempt, run, epoch, reply, deadline) = transport;
1651 let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1652 Self {
1653 stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1654 "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1655 "deadline": deadline}),
1656 deadline,
1657 parent: parent.into(),
1658 child: child.into(),
1659 run: run.into(),
1660 epoch,
1661 current_epoch,
1662 cancel,
1663 router,
1664 admission: Arc::new(AtomicUsize::new(0)),
1665 }
1666 }
1667 pub fn stamp(&self) -> &serde_json::Value {
1668 &self.stamp
1669 }
1670 pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1671 self.deadline
1672 }
1673 pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1674 self.admission
1675 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1676 .ok()
1677 .map(|_| ChildApprovalAdmission(self.admission.clone()))
1678 }
1679 pub fn is_admitting(&self) -> bool {
1680 self.admission.load(Ordering::Acquire) == 1
1681 }
1682 pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1683 self.parent == parent
1684 && self.child == child
1685 && !self.cancel.is_cancelled()
1686 && self.current_epoch.load(Ordering::Acquire) == self.epoch
1687 && self.router.owns_run(child, &self.run).await
1688 }
1689}
1690
1691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1692pub enum ChildApprovalReview {
1693 Reply(bool),
1694 NoReply,
1695}
1696
1697#[async_trait]
1698pub trait ChildApprovalReviewer: Send + Sync {
1699 async fn review(
1702 &self,
1703 parent_session_id: &str,
1704 child_session_id: &str,
1705 request: &serde_json::Value,
1706 ) -> bool;
1707
1708 async fn review_scoped(
1709 &self,
1710 parent: &str,
1711 child: &str,
1712 request: &serde_json::Value,
1713 _scope: &ChildApprovalScope,
1714 ) -> ChildApprovalReview {
1715 ChildApprovalReview::Reply(self.review(parent, child, request).await)
1716 }
1717}
1718
1719fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1720 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1721 &SLOT
1722}
1723
1724pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1726 let _ = child_approval_reviewer_slot().set(reviewer);
1727}
1728
1729pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1731 child_approval_reviewer_slot().get().cloned()
1732}
1733
1734impl ActorChildRunner {
1735 #[allow(clippy::too_many_arguments)]
1736 pub fn new(
1737 agent_id: String,
1738 worker_bin: PathBuf,
1739 worker_args: Vec<String>,
1740 fabric_dir: PathBuf,
1741 executor: ExecutorSpec,
1742 credentials: Vec<ScopedCredential>,
1743 default_provider: String,
1744 max_concurrent: usize,
1745 ) -> Self {
1746 Self {
1747 approval_registry: None,
1748 permission_config: None,
1749 agent_id,
1750 worker_bin,
1751 worker_args,
1752 builtin_required_context_route: false,
1753 fabric_dir,
1754 executor,
1755 credentials,
1756 default_provider,
1757 max_spawn_depth: MAX_SPAWN_DEPTH,
1758 live_provider_config: None,
1759 native_tool_ceiling: None,
1760 bus: None,
1761 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1762 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1763 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1764 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1765 pool_reaper_started: AtomicBool::new(false),
1766 next_execution_epoch: AtomicU64::new(0),
1767 approval_decider: None,
1768 approval_reviewer: None,
1769 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1770 remote_placements: HashMap::new(),
1771 strict_remote_subscription: tokio::sync::Mutex::new(()),
1772 schedulable_placements: HashMap::new(),
1773 codex_run_tokens: None,
1774 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1775 actor_directory_store: std::sync::Mutex::new(None),
1776 canonical_subagent_tool: std::sync::Mutex::new(None),
1777 ticket_service: std::sync::Mutex::new(None),
1778 actor_event_observer: std::sync::Mutex::new(None),
1779 pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1780 }
1781 }
1782
1783 pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1788 self.builtin_required_context_route = supported;
1789 self
1790 }
1791
1792 pub(crate) fn with_native_tool_ceiling_source(
1793 mut self,
1794 source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1795 ) -> Self {
1796 self.native_tool_ceiling = source;
1797 self
1798 }
1799
1800 async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1801 bamboo_subagent::fleet::require_worker_capability(
1802 &self.worker_bin,
1803 &self.worker_args,
1804 bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1805 )
1806 .await
1807 .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1808 }
1809
1810 pub fn with_live_provider_config(
1811 mut self,
1812 config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1813 ) -> Self {
1814 self.live_provider_config = Some(config);
1815 self
1816 }
1817
1818 pub fn with_max_spawn_depth(mut self, max_spawn_depth: u32) -> Self {
1819 self.max_spawn_depth = max_spawn_depth;
1820 self
1821 }
1822
1823 pub fn with_approval_registry(
1824 mut self,
1825 registry: super::approval_registry::SharedApprovalRegistry,
1826 ) -> Self {
1827 self.approval_registry = Some(registry);
1828 self
1829 }
1830
1831 pub fn with_permission_config(
1832 mut self,
1833 config: Arc<bamboo_tools::permission::PermissionConfig>,
1834 ) -> Self {
1835 self.permission_config = Some(config);
1836 self
1837 }
1838
1839 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1844 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1845 self
1846 }
1847
1848 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1851 self.approval_decider = Some(decider);
1852 self
1853 }
1854
1855 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1856 self.approval_reviewer = Some(reviewer);
1857 self
1858 }
1859
1860 pub fn with_codex_run_tokens(
1861 mut self,
1862 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1863 ) -> Self {
1864 self.codex_run_tokens = authority;
1865 self
1866 }
1867
1868 pub fn with_remote_placements(
1874 mut self,
1875 placements: HashMap<String, ResolvedRemotePlacement>,
1876 ) -> Self {
1877 self.remote_placements = placements;
1878 self
1879 }
1880
1881 pub fn with_schedulable_placements(
1887 mut self,
1888 placements: HashMap<String, ResolvedSchedulablePlacement>,
1889 ) -> Self {
1890 self.schedulable_placements = placements;
1891 self
1892 }
1893
1894 fn fingerprint(spec: &ProvisionSpec) -> String {
1913 let role = spec.identity.role.as_str();
1914 let (provider, model) = spec
1915 .model
1916 .as_ref()
1917 .map(|m| (m.provider.as_str(), m.model.as_str()))
1918 .unwrap_or(("", ""));
1919 let workspace = spec.workspace.as_deref().unwrap_or("");
1920 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1921 tools.sort();
1922 let caps = &spec.capabilities;
1923 let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1928 "none".to_string()
1929 } else {
1930 let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1931 .expect("scoped provider credentials are serializable");
1932 hex::encode(Sha256::digest(serialized))
1933 };
1934 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1937 format!(
1938 "{role}\u{1}{provider}\u{1}{model}\u{1}cred={credential_revision}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}pr={}\u{1}pe={}\u{1}by={}\u{1}auto={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}ro={}\u{1}gro={}\u{1}ci={}\u{1}executor={executor}",
1939 tools.join(","),
1940 spec.identity.depth,
1941 caps.nested_spawn,
1942 caps.permission_requested_mode,
1943 caps.permission_effective_mode,
1944 caps.bypass,
1945 caps.auto_approve_permissions,
1946 caps.enforce_permissions,
1947 caps.max_spawn_depth.unwrap_or(0),
1948 caps.no_human_approver,
1955 caps.read_only,
1958 caps.guardian_read_only,
1961 caps.child_creation_identity,
1962 )
1963 }
1964
1965 fn ensure_pool_reaper(&self) {
1968 if self
1969 .pool_reaper_started
1970 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1971 .is_err()
1972 {
1973 return;
1974 }
1975 let pool = Arc::downgrade(&self.pool);
1976 tokio::spawn(async move {
1977 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1978 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1979 interval.tick().await;
1982 loop {
1983 interval.tick().await;
1984 let Some(pool) = pool.upgrade() else {
1985 break;
1986 };
1987 let _ = reap_worker_pool_once(&pool).await;
1988 }
1989 });
1990 }
1991
1992 fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1993 let mut physical = spec.clone();
1994 physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1998 physical
1999 }
2000
2001 async fn acquire_bus_worker(
2007 &self,
2008 key: &str,
2009 spec: &ProvisionSpec,
2010 ) -> crate::runtime::runner::Result<PooledWorker> {
2011 if spec.capabilities.required_child_context {
2012 let physical = Self::fresh_required_worker_spec(spec);
2013 let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2014 .await
2015 .map_err(|error| {
2016 AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2017 })?;
2018 let mailbox_id = worker.record.agent_id.clone();
2019 return Ok(PooledWorker {
2020 worker,
2021 mailbox_id,
2022 parked_at: None,
2023 });
2024 }
2025 self.ensure_pool_reaper();
2026 loop {
2030 let candidate = {
2031 let mut pool = self.pool.lock().await;
2032 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2033 if pool.get(key).is_some_and(Vec::is_empty) {
2034 pool.remove(key);
2035 }
2036 candidate
2037 };
2038 let Some(mut candidate) = candidate else {
2039 break;
2040 };
2041 let now = Instant::now();
2042 let expired = parked_worker_expired(
2043 candidate.parked_at,
2044 now,
2045 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2046 );
2047 if candidate.worker.is_alive() && !expired {
2048 candidate.parked_at = None;
2049 return Ok(candidate);
2050 }
2051 tracing::info!(
2052 pool_key = key,
2053 mailbox_id = %candidate.mailbox_id,
2054 worker_pid = ?candidate.worker.pid(),
2055 shutdown_reason = if expired {
2056 PoolReapReason::IdleTimeout.as_str()
2057 } else {
2058 PoolReapReason::ProcessExited.as_str()
2059 },
2060 "discarding unusable parked sub-agent worker during checkout"
2061 );
2062 candidate.worker.kill().await;
2063 }
2064
2065 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2066 .await
2067 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2068 let mailbox_id = spawned.record.agent_id.clone();
2069 Ok(PooledWorker {
2070 worker: spawned,
2071 mailbox_id,
2072 parked_at: None,
2073 })
2074 }
2075
2076 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2080 self.ensure_pool_reaper();
2081 if !worker.worker.is_alive() {
2082 tracing::info!(
2083 pool_key = key,
2084 mailbox_id = %worker.mailbox_id,
2085 worker_pid = ?worker.worker.pid(),
2086 shutdown_reason = "process_exited_before_park",
2087 "discarding sub-agent worker instead of parking"
2088 );
2089 worker.worker.kill().await;
2090 return;
2091 }
2092 worker.parked_at = Some(Instant::now());
2093 let mut pool = self.pool.lock().await;
2094 let key_count = pool.get(key).map_or(0, Vec::len);
2095 let total_count = pool.values().map(Vec::len).sum::<usize>();
2096 let limit_reason =
2097 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2098 if let Some(shutdown_reason) = limit_reason {
2099 drop(pool);
2100 tracing::info!(
2101 pool_key = key,
2102 mailbox_id = %worker.mailbox_id,
2103 worker_pid = ?worker.worker.pid(),
2104 idle_workers_for_key = key_count,
2105 idle_workers_total = total_count,
2106 max_idle_per_key = self.max_idle_per_key,
2107 max_idle_total = self.max_idle_total,
2108 shutdown_reason,
2109 "discarding sub-agent worker because the warm pool is full"
2110 );
2111 worker.worker.kill().await;
2112 return;
2113 }
2114 pool.entry(key.to_string()).or_default().push(worker);
2115 }
2116
2117 fn build_spec_with_provider_config(
2120 &self,
2121 session: &Session,
2122 job: &SpawnJob,
2123 credentials: &[ScopedCredential],
2124 default_provider: &str,
2125 max_spawn_depth: u32,
2126 ) -> ProvisionSpec {
2127 let mut spec = ProvisionSpec::new(
2128 ChildIdentity {
2129 child_id: job.child_session_id.clone(),
2130 parent_id: Some(job.parent_session_id.clone()),
2131 project_key: None,
2132 role: session
2133 .metadata
2134 .get("subagent_type")
2135 .cloned()
2136 .unwrap_or_else(|| "worker".to_string()),
2137 depth: session.spawn_depth,
2142 },
2143 self.executor.clone(),
2144 self.fabric_dir.to_string_lossy().into_owned(),
2145 );
2146 spec.workspace = session.workspace.clone();
2147 if let ExecutorSpec::Codex {
2148 workspace_owned, ..
2149 } = &mut spec.executor
2150 {
2151 *workspace_owned = Some(
2152 spec.workspace
2153 .as_deref()
2154 .is_some_and(workspace_is_bamboo_owned),
2155 );
2156 }
2157 spec.bus = self.bus.clone();
2160 spec.model = session
2163 .model_ref
2164 .as_ref()
2165 .map(|r| ModelRefSpec {
2166 provider: r.provider.clone(),
2167 model: r.model.clone(),
2168 })
2169 .or_else(|| {
2170 let m = job.model.trim();
2171 (!m.is_empty()).then(|| ModelRefSpec {
2172 provider: default_provider.to_string(),
2173 model: m.to_string(),
2174 })
2175 });
2176 spec.disabled_tools = job.disabled_tools.clone();
2177 match &spec.executor {
2178 ExecutorSpec::Codex {
2184 auth_mode,
2185 provider_key_ref,
2186 ..
2187 } => {
2188 if auth_mode.as_deref() == Some("custom") {
2189 if let Some(reference) = provider_key_ref {
2190 if let Some(credential) = credentials.iter().find(|credential| {
2191 credential.credential_ref.as_deref() == Some(reference)
2192 }) {
2193 spec.secrets.provider_credentials.push(credential.clone());
2194 } else {
2195 tracing::warn!(
2196 "actor child {}: custom Codex credential reference '{}' did not resolve",
2197 job.child_session_id,
2198 reference
2199 );
2200 }
2201 } else {
2202 tracing::warn!(
2203 "actor child {}: custom Codex executor has no credential reference",
2204 job.child_session_id
2205 );
2206 }
2207 }
2208 }
2209 _ => {
2212 let provider = spec
2213 .model
2214 .as_ref()
2215 .map(|model| model.provider.as_str())
2216 .filter(|provider| !provider.trim().is_empty())
2217 .unwrap_or(default_provider);
2218 if let Some(credential) = credentials
2219 .iter()
2220 .find(|credential| credential.provider == provider)
2221 {
2222 spec.secrets.provider_credentials.push(credential.clone());
2223 } else {
2224 tracing::warn!(
2225 "actor child {}: no credential found for provider '{}'",
2226 job.child_session_id,
2227 provider
2228 );
2229 }
2230 }
2231 }
2232 spec.capabilities.nested_spawn = session.spawn_depth < max_spawn_depth;
2235 spec.capabilities.max_spawn_depth = Some(max_spawn_depth);
2236 spec.capabilities.enforce_permissions = true;
2242 let requested_permission_mode = session
2247 .agent_runtime_state
2248 .as_ref()
2249 .map(|state| state.effective_permission_mode())
2250 .unwrap_or_default();
2251 let configured_permission_mode = self
2252 .permission_config
2253 .as_ref()
2254 .map(|config| config.mode())
2255 .unwrap_or_default();
2256 spec.capabilities.no_human_approver = session
2261 .agent_runtime_state
2262 .as_ref()
2263 .is_some_and(|s| s.no_human_approver);
2264 let typed_read_only = session
2270 .agent_runtime_state
2271 .as_ref()
2272 .is_some_and(|state| state.read_only);
2273 let legacy_guardian_read_only = spec.identity.role == "guardian"
2274 && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2275 ["Edit", "Write", "SubAgent", "WebFetch"]
2276 .iter()
2277 .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2278 });
2279 let read_only = typed_read_only || legacy_guardian_read_only;
2280 spec.capabilities.read_only = read_only;
2281 spec.capabilities.guardian_read_only = read_only;
2285 if spec.capabilities.read_only_enforced() {
2286 spec.capabilities.nested_spawn = false;
2289 spec.disabled_tools
2294 .get_or_insert_with(Default::default)
2295 .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2296 match &mut spec.executor {
2297 ExecutorSpec::ClaudeCode {
2298 permission_mode,
2299 inherit_user_config,
2300 ..
2301 } => {
2302 *permission_mode = Some("plan".to_string());
2303 *inherit_user_config = Some(false);
2306 }
2307 ExecutorSpec::Codex {
2308 mode,
2309 sandbox,
2310 inherit_user_config,
2311 approval_policy,
2312 network_access,
2313 allow_danger_bypass,
2314 permission_profile,
2315 ..
2316 } => {
2317 *sandbox = Some("read-only".to_string());
2318 *inherit_user_config = Some(false);
2319 *approval_policy = Some(
2322 if mode.as_deref() == Some("app_server") {
2323 "on-request"
2324 } else {
2325 "never"
2326 }
2327 .to_string(),
2328 );
2329 *network_access = Some(false);
2330 *allow_danger_bypass = Some(false);
2331 *permission_profile = Some("read-only".to_string());
2332 }
2333 _ => {}
2334 }
2335 }
2336 let read_only_overlay = session
2337 .agent_runtime_state
2338 .as_ref()
2339 .is_some_and(|state| state.plan_mode.is_some())
2340 || spec.capabilities.read_only_enforced()
2341 || executor_has_read_only_permission_profile(&spec.executor);
2342 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2343 requested_permission_mode,
2344 configured_permission_mode,
2345 read_only_overlay,
2346 );
2347 spec.capabilities.bypass = permission_resolution.bypass_permissions();
2348 spec.capabilities.auto_approve_permissions =
2349 permission_resolution.suppress_approval_prompts();
2350 spec.capabilities.permission_requested_mode =
2351 permission_resolution.requested.as_str().to_string();
2352 spec.capabilities.permission_effective_mode =
2353 permission_resolution.effective.as_str().to_string();
2354 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2361 spec.placement = Placement::Remote {
2362 endpoint: placement.endpoint.clone(),
2363 };
2364 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2365 {
2366 spec.placement = Placement::Schedulable {
2374 pool: placement.pool.clone(),
2375 };
2376 }
2377 spec
2378 }
2379
2380 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2383 self.build_spec_with_provider_config(
2384 session,
2385 job,
2386 &self.credentials,
2387 &self.default_provider,
2388 self.max_spawn_depth,
2389 )
2390 }
2391
2392 async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2397 let Some(config) = self.live_provider_config.as_ref() else {
2398 return self.build_spec(session, job);
2399 };
2400
2401 let config = config.read().await;
2402 let credentials = super::runtime::extract_provider_credentials(&config);
2403 let default_provider = config.effective_default_provider().to_string();
2404 self.build_spec_with_provider_config(
2405 session,
2406 job,
2407 &credentials,
2408 &default_provider,
2409 config.subagents().effective_max_spawn_depth(),
2410 )
2411 }
2412
2413 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2419 let host_label = match &spec.placement {
2420 Placement::Remote { .. } => self
2421 .remote_placements
2422 .get(spec.identity.role.as_str())
2423 .and_then(|p| p.host_label.as_deref()),
2424 Placement::Schedulable { .. } => self
2425 .schedulable_placements
2426 .get(spec.identity.role.as_str())
2427 .and_then(|p| p.host_label.as_deref()),
2428 Placement::Local => None,
2429 };
2430 placement_metadata(&spec.placement, host_label)
2431 }
2432
2433 fn scoped_placement_route(
2434 &self,
2435 spec: &ProvisionSpec,
2436 ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2437 match &spec.placement {
2438 Placement::Local => Ok(None),
2439 Placement::Remote { .. } => {
2440 let placement = self
2441 .remote_placements
2442 .get(&spec.identity.role)
2443 .ok_or_else(remote_broker_unavailable)?;
2444 let peer = placement
2445 .broker_peer
2446 .as_ref()
2447 .and_then(|peer| peer.as_ref().ok())
2448 .ok_or_else(remote_broker_unavailable)?;
2449 let worker_role = peer
2450 .worker_role
2451 .clone()
2452 .ok_or_else(remote_broker_unavailable)?;
2453 Ok(Some(ScopedPlacementRoute {
2454 endpoint: placement.endpoint.clone(),
2455 token: placement
2456 .token
2457 .clone()
2458 .ok_or_else(remote_broker_unavailable)?,
2459 ca_cert_file: placement
2460 .ca_cert_file
2461 .clone()
2462 .ok_or_else(remote_broker_unavailable)?,
2463 parent: bamboo_subagent::AgentRef {
2464 session_id: peer.parent_mailbox.clone(),
2465 role: peer.parent_role.clone(),
2466 },
2467 worker_role,
2468 pinned_mailbox: Some(peer.worker_mailbox.clone()),
2469 requirements: placement
2470 .requirements
2471 .clone()
2472 .ok_or_else(remote_broker_unavailable)?,
2473 }))
2474 }
2475 Placement::Schedulable { .. } => {
2476 let placement = self
2477 .schedulable_placements
2478 .get(&spec.identity.role)
2479 .ok_or_else(remote_broker_unavailable)?;
2480 let parent = placement
2481 .broker_parent
2482 .as_ref()
2483 .ok_or_else(remote_broker_unavailable)?;
2484 Ok(Some(ScopedPlacementRoute {
2485 endpoint: placement.endpoint.clone(),
2486 token: placement
2487 .token
2488 .clone()
2489 .ok_or_else(remote_broker_unavailable)?,
2490 ca_cert_file: placement
2491 .ca_cert_file
2492 .clone()
2493 .ok_or_else(remote_broker_unavailable)?,
2494 parent: bamboo_subagent::AgentRef {
2495 session_id: parent.parent_mailbox.clone(),
2496 role: Some(parent.parent_role.clone()),
2497 },
2498 worker_role: placement.pool.clone(),
2499 pinned_mailbox: None,
2500 requirements: placement
2501 .requirements
2502 .clone()
2503 .ok_or_else(remote_broker_unavailable)?,
2504 }))
2505 }
2506 }
2507 }
2508
2509 async fn select_scoped_worker(
2510 &self,
2511 route: &ScopedPlacementRoute,
2512 registry: &bamboo_storage::v2::FileHostRegistry,
2513 actor_id: &str,
2514 project_id: Option<bamboo_domain::ProjectId>,
2515 required_tools: &BTreeSet<String>,
2516 ) -> Result<String, AgentError> {
2517 if let Some(mailbox) = &route.pinned_mailbox {
2518 return Ok(mailbox.clone());
2519 }
2520 let mut query = route.query().await?;
2521 let candidates = query
2522 .list_connected(&route.worker_role)
2523 .await
2524 .map_err(|_| remote_broker_unavailable())?;
2525 let scheduler = PlacementScheduler::new(registry.clone());
2526 let mut selected: Option<(String, usize, u16)> = None;
2527 for mailbox in candidates {
2528 let Some(observed) = query
2529 .observe_host_capacity(&mailbox, &route.worker_role)
2530 .await
2531 .map_err(|_| remote_broker_unavailable())?
2532 else {
2533 continue;
2534 };
2535 let Ok(host) = scheduler
2536 .observe_scoped_host(
2537 &mut query,
2538 &observed.host_ref,
2539 &mailbox,
2540 &route.worker_role,
2541 chrono::Duration::seconds(110),
2542 )
2543 .await
2544 else {
2545 continue;
2546 };
2547 let now = chrono::Utc::now();
2548 let request = HostPlacementRequest {
2549 intent: HostPlacementIntent::Pool {
2550 pool_id: route.worker_role.clone(),
2551 },
2552 actor_id: actor_id.to_owned(),
2553 run_id: "selection".into(),
2554 project_id: project_id.clone(),
2555 trust_zone: route.requirements.trust_zone.clone(),
2556 workspace_label: route.requirements.workspace_label.clone(),
2557 executor: "bamboo-runtime".into(),
2558 required_tools: required_tools.clone(),
2559 network_zone: route.requirements.network_zone.clone(),
2560 require_network_isolation: route.requirements.require_network_isolation,
2561 preferred_host_ref: None,
2562 now,
2563 lease_expires_at: now + chrono::Duration::seconds(80),
2564 };
2565 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2566 || !request.eligible(&host)
2567 {
2568 continue;
2569 }
2570 let load = host.active_slots(now);
2571 if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2572 load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2573 }) {
2574 selected = Some((mailbox, load, host.max_slots));
2575 }
2576 }
2577 selected
2578 .map(|(mailbox, _, _)| mailbox)
2579 .ok_or_else(remote_broker_unavailable)
2580 }
2581}
2582
2583#[async_trait]
2584impl ExternalChildRunner for ActorChildRunner {
2585 async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2586 let role = session
2587 .metadata
2588 .get("subagent_type")
2589 .map(String::as_str)
2590 .unwrap_or("worker");
2591 let current = std::env::current_exe()
2592 .ok()
2593 .and_then(|path| path.canonicalize().ok());
2594 if self.native_tool_ceiling.is_none()
2595 || !self.builtin_required_context_route
2596 || !self.should_handle(session).await
2597 || current.is_none()
2598 || self.worker_bin.canonicalize().ok() != current
2599 || self.worker_args != ["subagent-worker".to_string()]
2600 || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2601 || self.bus.is_none()
2602 || self.remote_placements.contains_key(role)
2603 || self.schedulable_placements.contains_key(role)
2604 {
2605 return Err(
2606 "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2607 .into(),
2608 );
2609 }
2610 bamboo_subagent::fleet::require_worker_capability(
2611 &self.worker_bin,
2612 &self.worker_args,
2613 bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2614 )
2615 .await
2616 .map_err(|_| {
2617 "required_child_context_unsupported: worker capability unconfirmed".to_string()
2618 })?;
2619 bamboo_subagent::fleet::require_worker_capability(
2620 &self.worker_bin,
2621 &self.worker_args,
2622 bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2623 )
2624 .await
2625 .map_err(|_| {
2626 "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2627 })?;
2628 bamboo_subagent::fleet::require_worker_capability(
2629 &self.worker_bin,
2630 &self.worker_args,
2631 bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2632 )
2633 .await
2634 .map_err(|_| {
2635 "required_child_context_unsupported: worker birth capability unconfirmed".into()
2636 })
2637 }
2638 async fn should_handle(&self, session: &Session) -> bool {
2639 session.metadata.get("runtime.kind") == Some(&"external".to_string())
2640 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2641 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2642 }
2643
2644 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2645 *self.escalation_bridge.lock().recover_poison() = bridge;
2646 }
2647
2648 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2649 *self.session_inbox_runtime.lock().recover_poison() = binding;
2650 }
2651
2652 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2653 *self.actor_directory_store.lock().recover_poison() = store;
2654 }
2655
2656 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2657 *self.canonical_subagent_tool.lock().recover_poison() = tool;
2658 }
2659
2660 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2661 *self.ticket_service.lock().recover_poison() = service;
2662 }
2663
2664 fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2665 *self.actor_event_observer.lock().recover_poison() = observer;
2666 }
2667
2668 async fn prepare_durable_child_delivery(
2669 &self,
2670 session: &Session,
2671 activation_run_id: &str,
2672 ) -> Result<bool, String> {
2673 let key = (
2674 session.id.clone(),
2675 session.created_at,
2676 activation_run_id.to_owned(),
2677 );
2678 let (delivery, host_nested_wait_handoff, strict_history) = {
2679 let pending = self.pending_durable_links.lock().await;
2680 let Some(pending) = pending.get(&key) else {
2681 return Ok(false);
2682 };
2683 if pending.strict_history_required && pending.strict_history.is_none() {
2684 return Err("Host strict terminal completeness proof missing".into());
2685 }
2686 (
2687 pending
2688 .link
2689 .durable_delivery_receipt()
2690 .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2691 pending.host_nested_wait_handoff,
2692 pending.strict_history.clone(),
2693 )
2694 };
2695 let expected_status =
2696 effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2697 if session.last_run_status().as_deref() != Some(expected_status) {
2698 tracing::warn!(
2699 child_session_id = %session.id,
2700 activation_run_id,
2701 expected_status,
2702 actual_status = ?session.last_run_status(),
2703 "Host final status differs from accepted broker terminal"
2704 );
2705 return Err("Host final status differs from accepted broker terminal".into());
2706 }
2707 let store = self
2708 .actor_directory_store
2709 .lock()
2710 .recover_poison()
2711 .clone()
2712 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2713 let binding = self
2714 .session_inbox_runtime
2715 .lock()
2716 .recover_poison()
2717 .clone()
2718 .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2719 if !binding
2720 .router
2721 .owns_run(&session.id, activation_run_id)
2722 .await
2723 {
2724 return Err("stale Child activation cannot prepare broker receipt".into());
2725 }
2726 let ticket_service = self.ticket_service.lock().recover_poison().clone();
2727 let yielded_call = if expected_status == "completed"
2728 && session
2729 .metadata
2730 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2731 {
2732 let service = ticket_service
2733 .as_deref()
2734 .ok_or("Ticket authority unavailable for broker question")?;
2735 crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2736 .map_err(|e| e.to_string())?
2737 } else {
2738 None
2739 };
2740 let route = bamboo_storage::v2::BrokerTerminalRoute {
2741 activation_run_id,
2742 broker_identity: &delivery.broker_identity,
2743 parent_mailbox: &delivery.parent_mailbox,
2744 broker_correlation_id: &delivery.correlation_id,
2745 message_ids: &delivery.message_ids,
2746 };
2747 if let Some(call_id) = yielded_call {
2748 let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2749 session,
2750 activation_run_id,
2751 &call_id,
2752 )
2753 .map_err(|error| error.to_string())?;
2754 if let Some(history) = strict_history.as_ref() {
2755 store
2756 .prepare_broker_tool_yield_receipt_with_completeness(
2757 session,
2758 route,
2759 &proof,
2760 &history.proof,
2761 )
2762 .await
2763 } else {
2764 store
2765 .prepare_broker_tool_yield_receipt(session, route, &proof)
2766 .await
2767 }
2768 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2769 } else {
2770 if let Some(history) = strict_history.as_ref() {
2771 store
2772 .prepare_broker_terminal_receipt_with_completeness(
2773 session,
2774 route,
2775 &history.proof,
2776 )
2777 .await
2778 } else {
2779 store
2780 .prepare_broker_terminal_receipt(
2781 session,
2782 activation_run_id,
2783 &delivery.broker_identity,
2784 &delivery.parent_mailbox,
2785 &delivery.correlation_id,
2786 &delivery.message_ids,
2787 )
2788 .await
2789 }
2790 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2791 }
2792 Ok(true)
2793 }
2794
2795 async fn commit_durable_child_delivery(
2796 &self,
2797 session: &Session,
2798 activation_run_id: &str,
2799 ) -> Result<(), String> {
2800 let key = (
2801 session.id.clone(),
2802 session.created_at,
2803 activation_run_id.to_owned(),
2804 );
2805 let mut pending_links = self.pending_durable_links.lock().await;
2806 let Some(pending) = pending_links.get_mut(&key) else {
2807 return Ok(());
2808 };
2809 let delivery = pending
2810 .link
2811 .durable_delivery_receipt()
2812 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2813 let expected_status = effective_broker_terminal_status(
2814 delivery.terminal_status,
2815 pending.host_nested_wait_handoff,
2816 )?;
2817 if session.last_run_status().as_deref() != Some(expected_status) {
2818 return Err("Host final status differs from accepted broker terminal".into());
2819 }
2820 if pending.strict_history_required && pending.strict_history.is_none() {
2821 return Err("Host strict terminal completeness proof missing".into());
2822 }
2823 let store = self
2824 .actor_directory_store
2825 .lock()
2826 .recover_poison()
2827 .clone()
2828 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2829 let committed = if let Some(history) = pending.strict_history.as_ref() {
2830 store
2831 .commit_broker_terminal_receipt_with_completeness(
2832 session,
2833 activation_run_id,
2834 &history.proof,
2835 )
2836 .await
2837 } else {
2838 store
2839 .commit_broker_terminal_receipt(session, activation_run_id)
2840 .await
2841 }
2842 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2843 if committed.broker_identity != delivery.broker_identity
2844 || committed.parent_mailbox != delivery.parent_mailbox
2845 || committed.broker_correlation_id != delivery.correlation_id
2846 || committed.message_ids != delivery.message_ids
2847 || committed.terminal_status != expected_status
2848 {
2849 return Err("Host broker receipt changed before completion".into());
2850 }
2851 if let Some(history) = pending.strict_history.as_ref() {
2852 let proof = committed
2853 .terminal_completeness
2854 .as_ref()
2855 .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2856 let creation = bamboo_subagent::proto::ChildCreationIdentity {
2857 created_at: session.created_at,
2858 spawn_depth: session.spawn_depth,
2859 };
2860 validate_strict_history_terminal(
2861 Some(&history.watermark),
2862 session,
2863 session
2864 .parent_session_id
2865 .as_deref()
2866 .ok_or("Child parent missing")?,
2867 Some(activation_run_id),
2868 proof.execution_epoch,
2869 Some(&creation),
2870 proof.contiguous_applied_seq,
2871 )
2872 .map_err(|error| error.to_string())?;
2873 }
2874 publish_recovered_completion_source(&store, &committed).await?;
2878 pending.strict_history_committed = true;
2879 Ok(())
2880 }
2881
2882 async fn confirm_durable_child_delivery(
2883 &self,
2884 session: &Session,
2885 activation_run_id: &str,
2886 save_succeeded: bool,
2887 ) -> Result<(), String> {
2888 if save_succeeded
2889 && session
2890 .metadata
2891 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2892 {
2893 let service = self
2894 .ticket_service
2895 .lock()
2896 .recover_poison()
2897 .clone()
2898 .ok_or_else(|| {
2899 "Ticket authority unavailable for terminal checkpoint".to_string()
2900 })?;
2901 let runtime = self
2902 .session_inbox_runtime
2903 .lock()
2904 .recover_poison()
2905 .clone()
2906 .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2907 crate::ticket_runtime::checkpoint_ticket_result(
2908 &service,
2909 runtime.storage.as_ref(),
2910 session,
2911 activation_run_id,
2912 )
2913 .await
2914 .map_err(|e| e.to_string())?;
2915 }
2916 let key = (
2917 session.id.clone(),
2918 session.created_at,
2919 activation_run_id.to_owned(),
2920 );
2921 let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2922 return Ok(());
2923 };
2924 let delivery = pending
2927 .link
2928 .durable_delivery_receipt()
2929 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2930 let expected_status = effective_broker_terminal_status(
2931 delivery.terminal_status,
2932 pending.host_nested_wait_handoff,
2933 )?;
2934 if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2935 return Ok(());
2936 }
2937 let store = self
2938 .actor_directory_store
2939 .lock()
2940 .recover_poison()
2941 .clone()
2942 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2943 let committed = if pending.strict_history_required {
2944 if !pending.strict_history_committed {
2945 return Err("Host strict completeness was not committed before ACK".into());
2946 }
2947 let history = pending
2948 .strict_history
2949 .as_ref()
2950 .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2951 store
2952 .commit_broker_terminal_receipt_with_completeness(
2953 session,
2954 activation_run_id,
2955 &history.proof,
2956 )
2957 .await
2958 } else {
2959 store
2960 .commit_broker_terminal_receipt(session, activation_run_id)
2961 .await
2962 }
2963 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2964 if committed.broker_identity != delivery.broker_identity
2965 || committed.parent_mailbox != delivery.parent_mailbox
2966 || committed.broker_correlation_id != delivery.correlation_id
2967 || committed.message_ids != delivery.message_ids
2968 || committed.terminal_status != expected_status
2969 {
2970 return Err("Host broker receipt changed before ACK".into());
2971 }
2972 pending
2973 .link
2974 .acknowledge_durable_frames()
2975 .await
2976 .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2977 store
2978 .clear_acknowledged_broker_terminal_receipt(
2979 &session.id,
2980 session.created_at,
2981 activation_run_id,
2982 &delivery.parent_mailbox,
2983 )
2984 .await
2985 .map_err(|error| {
2986 format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2987 })
2988 }
2989
2990 #[tracing::instrument(
2991 name = "actor_child_run",
2992 level = "warn",
2993 skip_all,
2994 fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2995 )]
2996 async fn execute_external_child(
2997 &self,
2998 session: &mut Session,
2999 job: &SpawnJob,
3000 event_tx: mpsc::Sender<AgentEvent>,
3001 cancel_token: CancellationToken,
3002 ) -> crate::runtime::runner::Result<()> {
3003 crate::session_app::child_session::named_profile::validate_named_profile(session)
3004 .map_err(|error| AgentError::LLM(error.to_string()))?;
3005 let escalation = self.escalation_bridge.lock().recover_poison().clone();
3014 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3015 let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3016 let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3017 let ticket_service = self.ticket_service.lock().recover_poison().clone();
3018 let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3019 let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3020 .map_err(|error| AgentError::Budget(error.to_string()))?;
3021 if crate::session_app::child_session::named_profile::has_named_profile(session)
3022 && required_context.is_none()
3023 {
3024 return Err(AgentError::LLM(
3025 "named_profile_requires_strict_context_route".into(),
3026 ));
3027 }
3028 let assignment = required_context
3029 .as_ref()
3030 .map(|binding| binding.payload.required_assignment.clone())
3031 .unwrap_or_else(|| extract_assignment(session));
3032 let mut spec = self.build_live_spec(session, job).await;
3033 ensure_ticket_placement_is_enforceable(session, &spec)?;
3036 let scoped_route = self.scoped_placement_route(&spec)?;
3037 let strict_remote = scoped_route.as_ref();
3038 if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3039 return Err(remote_broker_unavailable());
3040 }
3041 let lineage = if strict_remote.is_some() {
3042 Some(
3043 remote_canonical_lineage(
3044 session_inbox_runtime
3045 .as_ref()
3046 .ok_or_else(remote_broker_unavailable)?,
3047 session,
3048 )
3049 .await?,
3050 )
3051 } else {
3052 None
3053 };
3054 let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3055 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3056 {
3057 let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3058 AgentError::LLM(
3059 "child creation identity unavailable: canonical host Storage required".into(),
3060 )
3061 })?;
3062 spec.capabilities.child_creation_identity = true;
3063 Some(canonical_child_creation(binding, session, job).await?)
3064 } else {
3065 None
3066 };
3067 spec.reusable = required_context.is_none();
3070 spec.capabilities.required_child_context = required_context.is_some();
3071 if required_context.is_some() {
3072 self.validate_required_child_context_route(session)
3073 .await
3074 .map_err(AgentError::LLM)?;
3075 }
3076 if required_context.is_some() {
3077 let names = self
3078 .native_tool_ceiling
3079 .as_ref()
3080 .unwrap()
3081 .observe(session)
3082 .await
3083 .map_err(AgentError::LLM)?;
3084 let birth = creation
3085 .as_ref()
3086 .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3087 spec.capabilities.native_tool_ceiling_required = true;
3088 spec.capabilities.native_tool_ceiling =
3089 Some(bamboo_subagent::proto::NativeToolCeiling {
3090 version: 1,
3091 child_session_id: session.id.clone(),
3092 parent_session_id: job.parent_session_id.clone(),
3093 root_session_id: session.root_session_id.clone(),
3094 created_at: birth.created_at,
3095 spawn_depth: birth.spawn_depth,
3096 project_id: project_id_for_actor_run(session)?,
3097 tools: names,
3098 });
3099 spec.capabilities.mcp = None;
3101 spec.capabilities.mcp_proxy = None;
3102 spec.capabilities.skills_dir = None;
3103 spec.capabilities.nested_spawn = false;
3104 spec.validate()
3105 .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3106 }
3107 let first_reply_store = actor_directory_store.clone().filter(|_| {
3110 required_context.is_some()
3111 && crate::session_app::child_session::named_profile::has_named_profile(session)
3112 && matches!(spec.placement, Placement::Local)
3113 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3114 && spec.capabilities.native_tool_ceiling_required
3115 && spec
3116 .capabilities
3117 .native_tool_ceiling
3118 .as_ref()
3119 .is_some_and(|ceiling| {
3120 ceiling.tools.is_empty()
3121 || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3122 })
3123 });
3124 let plain_actor_store = if let Some(store) = first_reply_store {
3125 let root = bamboo_agent_core::storage::Storage::load_session(
3126 store.as_ref(),
3127 &session.root_session_id,
3128 )
3129 .await
3130 .map_err(|_| plain_actor_unsupported())?
3131 .ok_or_else(|| plain_actor_unsupported())?;
3132 if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3133 return Err(plain_actor_unsupported());
3134 }
3135 if root.root_orchestration_only_enabled() {
3136 if !plain_initial_history(session)
3137 && (spec
3138 .capabilities
3139 .native_tool_ceiling
3140 .as_ref()
3141 .is_none_or(|ceiling| !ceiling.tools.is_empty())
3142 || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3143 {
3144 return Err(plain_actor_unsupported());
3145 }
3146 Some(store)
3147 } else if spec
3148 .capabilities
3149 .native_tool_ceiling
3150 .as_ref()
3151 .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3152 {
3153 require_unowned_glob(store.as_ref(), session).await?;
3156 None
3157 } else {
3158 let observed = store
3161 .actor_subtree_snapshot(
3162 ActorSnapshotPrincipal::host_owner(),
3163 &session.root_session_id,
3164 &session.id,
3165 ActorSnapshotLimits::default(),
3166 )
3167 .await
3168 .map_err(|_| plain_actor_unsupported())?;
3169 if observed
3170 .nodes
3171 .iter()
3172 .any(|node| node.actor_id == session.id && node.activation.is_some())
3173 {
3174 return Err(plain_actor_unsupported());
3175 }
3176 None
3177 }
3178 } else {
3179 None
3180 };
3181 let readonly_actor = plain_actor_store.is_some()
3182 && spec
3183 .capabilities
3184 .native_tool_ceiling
3185 .as_ref()
3186 .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3187 let local_history_tools = (plain_actor_store.is_none()
3188 && required_context.is_some()
3189 && (crate::session_app::child_session::named_profile::has_named_profile(session)
3190 || session
3191 .metadata
3192 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3193 && matches!(spec.placement, Placement::Local)
3194 && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3195 .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3196 .flatten()
3197 .filter(|ceiling| {
3198 bamboo_subagent::proto::LocalToolMessages::supports_tools(
3199 &ceiling.tools,
3200 spec.capabilities.read_only_enforced(),
3201 )
3202 })
3203 .map(|ceiling| ceiling.tools.clone());
3204 if plain_actor_store.is_some() && !readonly_actor {
3205 self.require_initial_input_release_worker().await?;
3206 spec.capabilities.initial_input_release_required = true;
3207 spec.validate().map_err(|_| plain_actor_unsupported())?;
3208 }
3209 let mut readonly_messages = Vec::new();
3210 let mut plain_activation = None;
3211 if spec.limits.idle_timeout_secs.is_none() {
3212 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3213 }
3214 ensure_read_only_activation_is_enforceable(&spec)?;
3215 let pool_key = Self::fingerprint(&spec);
3216
3217 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3222 {
3223 return Err(AgentError::LLM(
3224 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3225 .to_string(),
3226 ));
3227 }
3228 let project_id = project_id_for_actor_run(session)?;
3229 let requested_permission_mode = session
3230 .agent_runtime_state
3231 .as_ref()
3232 .map(|state| state.effective_permission_mode())
3233 .unwrap_or_default();
3234 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3239 ensure_no_active_scoped_session_denies(config, &session.id)?;
3240 let read_only_overlay = session
3241 .agent_runtime_state
3242 .as_ref()
3243 .is_some_and(|state| state.plan_mode.is_some())
3244 || spec.capabilities.read_only_enforced()
3245 || executor_has_read_only_permission_profile(&spec.executor);
3246 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3247 requested_permission_mode,
3248 config.mode(),
3249 read_only_overlay,
3250 );
3251 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3252 AgentError::LLM(format!(
3253 "failed to serialize permission policy for external executor: {error}"
3254 ))
3255 })?;
3256 Some(PermissionPolicyContext {
3257 revision: config.policy_revision(),
3258 requested_mode: resolution.requested.as_str().to_string(),
3259 effective_mode: resolution.effective.as_str().to_string(),
3260 bypass_permissions: resolution.bypass_permissions(),
3261 auto_approve_permissions: resolution.suppress_approval_prompts(),
3262 session_id: session.id.clone(),
3263 workspace_path: session.workspace.clone(),
3264 environment_lease: None,
3265 inherit_session_grants: false,
3266 policy,
3267 })
3268 } else {
3269 None
3270 };
3271 let provisioned_permission =
3272 spec.capabilities.permission_resolution().map_err(|error| {
3273 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3274 })?;
3275 let policy_resolution = permission_policy
3276 .as_ref()
3277 .map(|context| {
3278 context.resolved_modes().map(|(requested, effective)| {
3279 bamboo_domain::PermissionModeResolution {
3280 requested,
3281 effective,
3282 }
3283 })
3284 })
3285 .transpose()
3286 .map_err(|error| {
3287 AgentError::LLM(format!("invalid host permission posture: {error}"))
3288 })?;
3289 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3290 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3291 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3292 .is_some()
3293 });
3294 let expected_executor_mapping = expected_permission_executor_mapping(
3295 &spec.executor,
3296 policy_resolution.unwrap_or(provisioned_permission),
3297 has_explicit_deny,
3298 )?;
3299 let mut expected_permission_posture =
3300 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3301 policy_revision: permission_policy
3302 .as_ref()
3303 .map(|context| context.revision)
3304 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3305 .unwrap_or_default(),
3306 resolution: policy_resolution.unwrap_or(provisioned_permission),
3307 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3308 executor_mapping,
3309 });
3310
3311 let _slot = if strict_remote.is_some() {
3316 tokio::select! { biased;
3317 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3318 permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3319 }
3320 } else {
3321 self.concurrency
3322 .acquire()
3323 .await
3324 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3325 };
3326 let _remote_subscription = if strict_remote.is_some() {
3327 Some(tokio::select! { biased;
3328 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3329 guard = self.strict_remote_subscription.lock() => guard,
3330 })
3331 } else {
3332 None
3333 };
3334
3335 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3342 &spec.executor,
3343 self.codex_run_tokens.clone(),
3344 &job.child_session_id,
3345 )?;
3346
3347 let kind = match spec.placement {
3355 Placement::Remote { .. } => PlacementKind::Remote,
3356 Placement::Schedulable { .. } => PlacementKind::Schedulable,
3357 Placement::Local => PlacementKind::Local,
3358 };
3359 let remote = !matches!(kind, PlacementKind::Local);
3360 let scoped_registry = if remote {
3361 let store = actor_directory_store
3362 .as_ref()
3363 .ok_or_else(remote_broker_unavailable)?;
3364 Some(
3365 bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3366 .await
3367 .map_err(|_| remote_broker_unavailable())?,
3368 )
3369 } else {
3370 None
3371 };
3372 let scoped_tools = scoped_route.as_ref().map(|route| {
3373 let mut tools = route.requirements.required_tools.clone();
3374 if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3375 tools.extend(ceiling.tools.iter().cloned());
3376 }
3377 tools
3378 });
3379
3380 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3386 session
3387 .metadata
3388 .insert("placement".to_string(), placement_meta);
3389 }
3390
3391 let mut attempt = 0u8;
3398 let (result, actor) = loop {
3399 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3400 let (actor, mut client) = match kind {
3401 PlacementKind::Remote => {
3402 let route = scoped_route
3403 .as_ref()
3404 .ok_or_else(remote_broker_unavailable)?;
3405 let mailbox_id = route
3406 .pinned_mailbox
3407 .clone()
3408 .ok_or_else(remote_broker_unavailable)?;
3409 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3410 .map_err(|_| remote_broker_unavailable())?;
3411 let connect =
3412 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3413 &route.endpoint,
3414 route.parent.clone(),
3415 &route.token,
3416 bamboo_subagent::AgentRef {
3417 session_id: mailbox_id.clone(),
3418 role: Some(route.worker_role.clone()),
3419 },
3420 tls,
3421 );
3422 let link = tokio::select! { biased;
3423 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3424 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3425 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3426 };
3427 let record = AgentRecord {
3428 agent_id: job.child_session_id.clone(),
3429 role: spec.identity.role.clone(),
3430 labels: Vec::new(),
3431 endpoint: String::new(),
3432 pid: 0,
3433 version: String::new(),
3434 started_at: chrono::Utc::now(),
3435 lease_expires_at: chrono::Utc::now(),
3436 };
3437 (
3438 PooledWorker {
3439 worker: SpawnedChild::remote(record),
3440 mailbox_id,
3441 parked_at: None,
3442 },
3443 Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3444 )
3445 }
3446 PlacementKind::Schedulable => {
3447 let route = scoped_route
3448 .as_ref()
3449 .ok_or_else(remote_broker_unavailable)?;
3450 let registry = scoped_registry
3451 .as_ref()
3452 .ok_or_else(remote_broker_unavailable)?;
3453 let mailbox_id = self
3454 .select_scoped_worker(
3455 route,
3456 registry,
3457 &session.id,
3458 project_id.clone(),
3459 scoped_tools
3460 .as_ref()
3461 .ok_or_else(remote_broker_unavailable)?,
3462 )
3463 .await?;
3464 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3465 .map_err(|_| remote_broker_unavailable())?;
3466 let connect =
3467 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3468 &route.endpoint,
3469 route.parent.clone(),
3470 &route.token,
3471 bamboo_subagent::AgentRef {
3472 session_id: mailbox_id.clone(),
3473 role: Some(route.worker_role.clone()),
3474 },
3475 tls,
3476 );
3477 let link = tokio::select! { biased;
3478 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3479 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3480 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3481 };
3482 let actor = PooledWorker {
3483 worker: SpawnedChild::remote(AgentRecord {
3484 agent_id: mailbox_id.clone(),
3485 role: spec.identity.role.clone(),
3486 labels: Vec::new(),
3487 endpoint: route.endpoint.clone(),
3488 pid: 0,
3489 version: String::new(),
3490 started_at: chrono::Utc::now(),
3491 lease_expires_at: chrono::Utc::now(),
3492 }),
3493 mailbox_id,
3494 parked_at: None,
3495 };
3496 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3497 (actor, client)
3498 }
3499 PlacementKind::Local => {
3500 let bus = self.bus.as_ref().ok_or_else(|| {
3507 AgentError::LLM(
3508 "local sub-agents require a mailbox bus (subagents.broker); none is \
3509 configured and the bus could not be embedded"
3510 .to_string(),
3511 )
3512 })?;
3513 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3514 let parent = bamboo_subagent::AgentRef {
3515 session_id: format!("p-{}", job.child_session_id),
3516 role: None,
3517 };
3518 let link = bamboo_broker::BrokerChildLink::connect(
3519 &bus.endpoint,
3520 parent,
3521 &bus.token,
3522 actor.mailbox_id.clone(),
3523 )
3524 .await
3525 .map_err(|e| {
3526 AgentError::LLM(format!("broker child link connect failed: {e}"))
3527 })?;
3528 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3529 (actor, client)
3530 }
3531 };
3532
3533 if let Some(store) = actor_directory_store.as_ref() {
3539 let recovery = async {
3540 let receipts = store
3541 .recover_broker_terminal_receipts(session)
3542 .await
3543 .map_err(|error| {
3544 format!("Host broker receipt recovery blocked: {error}")
3545 })?;
3546 for receipt in receipts {
3547 if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3551 return Err(
3552 "old Child broker parent mailbox changed before ACK".to_string()
3553 );
3554 }
3555 client
3556 .acknowledge_recovered_durable_frames(
3557 &receipt.broker_identity,
3558 &receipt.message_ids,
3559 )
3560 .await
3561 .map_err(|error| {
3562 format!("old Child broker ACK unconfirmed: {error}")
3563 })?;
3564 store
3565 .clear_acknowledged_broker_terminal_receipt(
3566 &receipt.session_id,
3567 receipt.created_at,
3568 &receipt.activation_run_id,
3569 &receipt.parent_mailbox,
3570 )
3571 .await
3572 .map_err(|error| {
3573 format!("old Child broker ACK cleanup failed: {error}")
3574 })?;
3575 }
3576 Ok::<(), String>(())
3577 }
3578 .await;
3579 if let Err(error) = recovery {
3580 if !remote {
3581 actor.worker.kill().await;
3582 }
3583 return Err(AgentError::LLM(error));
3584 }
3585 }
3586
3587 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3592 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3593 Some(binding) => {
3594 let run_id = binding
3595 .router
3596 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3597 .await;
3598 if run_id.is_none() {
3599 tracing::debug!(
3600 session_id = %job.child_session_id,
3601 "actor driver had no current SessionInbox activation owner to bind"
3602 );
3603 }
3604 run_id
3605 }
3606 None => None,
3607 };
3608 tracing::Span::current().record(
3609 "activation_run_id",
3610 bound_activation_run_id.as_deref().unwrap_or("unbound"),
3611 );
3612 drop(delivery_tx);
3613 let initial_pairs = match (
3614 session_inbox_runtime.as_ref(),
3615 bound_activation_run_id.as_deref(),
3616 ) {
3617 (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3618 match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3619 .await
3620 {
3621 Ok(deliveries) => deliveries,
3622 Err(error) => {
3623 binding
3624 .router
3625 .detach_delivery_sink(&job.child_session_id, run_id)
3626 .await;
3627 if !remote {
3628 actor.worker.kill().await;
3629 }
3630 return Err(error);
3631 }
3632 }
3633 }
3634 _ => Vec::new(),
3635 };
3636 let mut initial_session_messages = initial_pairs
3637 .iter()
3638 .map(|(_, delivery)| delivery.clone())
3639 .collect::<Vec<_>>();
3640 let mut initial_inflight_claims = initial_pairs
3641 .into_iter()
3642 .map(|(claim, _)| claim)
3643 .collect::<VecDeque<_>>();
3644 let mut messages = if let Some(binding) = &required_context {
3647 binding
3648 .validate_messages(&session.id, &session.messages)
3649 .map_err(|error| AgentError::Budget(error.to_string()))?;
3650 session
3651 .messages
3652 .iter()
3653 .map(serde_json::to_value)
3654 .collect::<std::result::Result<Vec<_>, _>>()
3655 .map_err(|_| {
3656 AgentError::Budget(
3657 bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3658 )
3659 })?
3660 } else {
3661 session
3662 .messages
3663 .iter()
3664 .filter_map(|message| serde_json::to_value(message).ok())
3665 .collect()
3666 };
3667
3668 if strict_remote.is_some() {
3669 let current =
3670 remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3671 .await;
3672 if current.as_ref().ok() != lineage.as_ref()
3673 || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3674 || cancel_token.is_cancelled()
3675 {
3676 if let (Some(binding), Some(run_id)) = (
3677 session_inbox_runtime.as_ref(),
3678 bound_activation_run_id.as_deref(),
3679 ) {
3680 binding
3681 .router
3682 .detach_delivery_sink(&job.child_session_id, run_id)
3683 .await;
3684 }
3685 return Err(if cancel_token.is_cancelled() {
3686 AgentError::Cancelled
3687 } else {
3688 remote_broker_unavailable()
3689 });
3690 }
3691 }
3692 if let Some(expected) = creation.as_ref() {
3693 let checked =
3694 canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3695 .await;
3696 if checked.as_ref().ok() != Some(expected) {
3697 if let (Some(binding), Some(run_id)) = (
3698 session_inbox_runtime.as_ref(),
3699 bound_activation_run_id.as_deref(),
3700 ) {
3701 binding
3702 .router
3703 .detach_delivery_sink(&job.child_session_id, run_id)
3704 .await;
3705 }
3706 actor.worker.kill().await;
3707 return Err(AgentError::LLM(
3708 "child creation identity changed before dispatch".into(),
3709 ));
3710 }
3711 }
3712 if let Some(store) = &plain_actor_store {
3713 let started = async {
3714 let policy = permission_policy
3719 .as_ref()
3720 .ok_or_else(|| plain_actor_unsupported())?;
3721 let config = self
3722 .permission_config
3723 .as_ref()
3724 .ok_or_else(|| plain_actor_unsupported())?;
3725 if config.policy_revision() != policy.revision
3726 || !matches!(spec.placement, Placement::Local)
3727 {
3728 return Err(plain_actor_unsupported());
3729 }
3730 store
3731 .bind_control_plane(
3732 &session.id,
3733 bamboo_domain::ActorControlPlaneBinding {
3734 policy_revision: Some(policy.revision),
3735 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3736 },
3737 )
3738 .await
3739 .map_err(|error| AgentError::LLM(error.to_string()))?;
3740 PlainActorActivation::start_and_prepare(
3741 store.clone(),
3742 session,
3743 session_inbox_runtime.as_ref().unwrap(),
3744 bound_activation_run_id.as_deref(),
3745 self.permission_config.clone(),
3746 !readonly_actor,
3747 spec.capabilities
3748 .initial_input_release_required
3749 .then_some(actor.mailbox_id.as_str()),
3750 )
3751 .await
3752 }
3753 .await;
3754 match started {
3755 Ok((activation, prepared)) => {
3756 if let Some((prefix, delivery)) = prepared {
3757 messages = prefix;
3758 initial_session_messages = vec![delivery];
3759 }
3760 plain_activation = Some(activation);
3761 }
3762 Err(error) => {
3763 if let Some(run_id) = bound_activation_run_id.as_deref() {
3764 session_inbox_runtime
3765 .as_ref()
3766 .unwrap()
3767 .router
3768 .detach_delivery_sink(&job.child_session_id, run_id)
3769 .await;
3770 }
3771 actor.worker.kill().await;
3772 return Err(error);
3773 }
3774 }
3775 }
3776 let mut logical_identity = logical_identity_for_actor_run(session, job);
3777 logical_identity.creation = creation.clone();
3778 let run_permission_policy = if strict_remote.is_some() {
3779 let prepared = async {
3780 let mut policy = permission_policy.clone().ok_or_else(|| {
3781 AgentError::LLM("remote_environment_permission_policy_missing".into())
3782 })?;
3783 let workspace = session.workspace.as_deref().ok_or_else(|| {
3784 AgentError::LLM("remote_environment_workspace_missing".into())
3785 })?;
3786 let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3787 AgentError::LLM("remote_environment_activation_missing".into())
3788 })?;
3789 let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3790 workspace,
3791 &session.id,
3792 run_id,
3793 execution_epoch,
3794 )
3795 .await
3796 .map_err(|error| AgentError::LLM(error.into()))?;
3797 policy.workspace_path = None;
3798 policy.environment_lease = Some(lease);
3799 Ok::<_, AgentError>(Some(policy))
3800 }
3801 .await;
3802 match prepared {
3803 Ok(policy) => policy,
3804 Err(error) => {
3805 if let (Some(binding), Some(run_id)) = (
3806 session_inbox_runtime.as_ref(),
3807 bound_activation_run_id.as_deref(),
3808 ) {
3809 binding
3810 .router
3811 .detach_delivery_sink(&job.child_session_id, run_id)
3812 .await;
3813 }
3814 return Err(error);
3815 }
3816 }
3817 } else {
3818 permission_policy.clone()
3819 };
3820 if session
3821 .metadata
3822 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3823 {
3824 let admission = async {
3825 let service = ticket_service
3826 .as_ref()
3827 .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3828 let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3829 AgentError::LLM("Ticket activation runtime unavailable".into())
3830 })?;
3831 let run_id = bound_activation_run_id
3832 .as_deref()
3833 .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3834 crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3835 .await
3836 .map_err(|e| AgentError::LLM(e.to_string()))
3837 }
3838 .await;
3839 if let Err(error) = admission {
3840 if let (Some(runtime), Some(run_id)) = (
3841 session_inbox_runtime.as_ref(),
3842 bound_activation_run_id.as_deref(),
3843 ) {
3844 runtime
3845 .router
3846 .detach_delivery_sink(&job.child_session_id, run_id)
3847 .await;
3848 }
3849 actor.worker.kill().await;
3850 return Err(error);
3851 }
3852 }
3853 let mut run_spec = RunSpec {
3854 assignment: assignment.clone(),
3856 logical_session: Some(logical_identity),
3857 project_id: project_id.clone(),
3858 reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3862 {
3863 session
3864 .reasoning_effort
3865 .map(|effort| effort.as_str().to_owned())
3866 } else {
3867 None
3868 },
3869 permission_policy: run_permission_policy,
3870 messages,
3871 activation_run_id: bound_activation_run_id.clone(),
3872 execution_epoch,
3873 initial_session_messages,
3874 secrets: run_secrets.clone(),
3875 };
3876 let mut active_lease = if remote {
3877 let reserve = ActivePlacementLease::reserve(
3878 scoped_registry
3879 .as_ref()
3880 .ok_or_else(remote_broker_unavailable)?
3881 .clone(),
3882 actor_directory_store
3883 .as_ref()
3884 .ok_or_else(remote_broker_unavailable)?
3885 .clone(),
3886 scoped_route
3887 .as_ref()
3888 .ok_or_else(remote_broker_unavailable)?
3889 .clone(),
3890 actor.mailbox_id.clone(),
3891 session.id.clone(),
3892 bound_activation_run_id
3893 .clone()
3894 .ok_or_else(remote_broker_unavailable)?,
3895 project_id.clone(),
3896 scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3897 &cancel_token,
3898 )
3899 .await;
3900 match reserve {
3901 Ok(lease) => Some(lease),
3902 Err(error) => {
3903 if let (Some(binding), Some(run_id)) = (
3904 session_inbox_runtime.as_ref(),
3905 bound_activation_run_id.as_deref(),
3906 ) {
3907 binding
3908 .router
3909 .detach_delivery_sink(&job.child_session_id, run_id)
3910 .await;
3911 }
3912 return Err(error);
3913 }
3914 }
3915 } else {
3916 None
3917 };
3918 if let Some(lease) = active_lease.as_mut() {
3919 let seeded = async {
3920 let binding = session_inbox_runtime
3921 .as_ref()
3922 .ok_or_else(remote_broker_unavailable)?;
3923 let run_id = bound_activation_run_id
3924 .as_deref()
3925 .ok_or_else(remote_broker_unavailable)?;
3926 let pairs = claim_canonical_deliveries(
3927 binding,
3928 session,
3929 run_id,
3930 usize::MAX,
3931 Some((lease.actor_store.as_ref(), &lease.fence)),
3932 )
3933 .await?;
3934 if let Some(binding) = &required_context {
3935 binding
3936 .validate_messages(&session.id, &session.messages)
3937 .map_err(|error| AgentError::Budget(error.to_string()))?;
3938 }
3939 run_spec.messages = session
3940 .messages
3941 .iter()
3942 .map(serde_json::to_value)
3943 .collect::<std::result::Result<Vec<_>, _>>()
3944 .map_err(|_| remote_broker_unavailable())?;
3945 run_spec.initial_session_messages =
3946 pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3947 initial_inflight_claims = pairs
3948 .into_iter()
3949 .map(|(claim, _)| claim)
3950 .collect::<VecDeque<_>>();
3951 Ok::<(), AgentError>(())
3952 }
3953 .await;
3954 if let Err(error) = seeded {
3955 if let Err(rollback_error) = lease.abort_before_dispatch().await {
3956 tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3957 }
3958 if let (Some(binding), Some(run_id)) = (
3959 session_inbox_runtime.as_ref(),
3960 bound_activation_run_id.as_deref(),
3961 ) {
3962 binding
3963 .router
3964 .detach_delivery_sink(&job.child_session_id, run_id)
3965 .await;
3966 }
3967 return Err(error);
3968 }
3969 }
3970 let mut run_send_invoked = false;
3971 let dispatch = async {
3972 if let Some(activation) = plain_activation
3973 .as_ref()
3974 .filter(|activation| activation.initial_input.is_some())
3975 {
3976 activation.refresh_continuation_posture(
3977 session,
3978 &run_spec,
3979 &mut expected_permission_posture,
3980 )?;
3981 }
3982 send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3983 }
3984 .await;
3985 if let Err(e) = dispatch {
3986 if remote {
3987 tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3988 }
3989 if let (Some(binding), Some(run_id)) = (
3990 session_inbox_runtime.as_ref(),
3991 bound_activation_run_id.as_deref(),
3992 ) {
3993 binding
3994 .router
3995 .detach_delivery_sink(&job.child_session_id, run_id)
3996 .await;
3997 }
3998 if !remote {
3999 actor.worker.kill().await;
4000 }
4001 if let Some(activation) = &plain_activation {
4002 activation.finish(ActorActivationFinish::Failed).await?;
4003 }
4004 if let Some(lease) = active_lease.as_mut() {
4005 match lease.abort_if_run_unsent(run_send_invoked).await {
4006 Ok(false) => {
4007 tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4008 }
4009 Ok(true) => {}
4010 Err(rollback_error) => {
4011 tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4012 }
4013 }
4014 }
4015 return Err(e);
4016 }
4017
4018 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4022 let live_guard = super::live::register(
4023 &job.child_session_id,
4024 live_tx,
4025 attempt as u32,
4026 self.approval_registry.clone(),
4027 );
4028
4029 let mut strict_history = None;
4030 let result = drive(ActorDriveContext {
4031 client: &mut *client,
4032 parent_session_id: &job.parent_session_id,
4033 child_session_id: &job.child_session_id,
4034 child_attempt: attempt as u32,
4035 approval_registry: self.approval_registry.as_ref(),
4036 approval_decider: self.approval_decider.as_ref(),
4037 approval_reviewer: self.approval_reviewer.as_ref(),
4038 escalation_bridge: escalation.clone(),
4039 event_tx: &event_tx,
4040 cancel_token: active_lease
4041 .as_ref()
4042 .map_or(&cancel_token, |lease| &lease.run_cancel),
4043 live_rx: &mut live_rx,
4044 delivery_rx: &mut delivery_rx,
4045 logical_session: session,
4046 expected_permission_posture: expected_permission_posture.clone(),
4047 expected_creation: creation.as_ref(),
4048 session_inbox_runtime: session_inbox_runtime.as_ref(),
4049 actor_directory_store: actor_directory_store.as_deref(),
4050 canonical_subagent_tool: canonical_subagent_tool.clone(),
4051 ticket_service: ticket_service.clone(),
4052 activation_run_id: bound_activation_run_id.as_deref(),
4053 execution_epoch,
4054 expected_source_actor_id: &actor.mailbox_id,
4055 initial_inflight_claims,
4056 plain_actor: plain_activation.is_some(),
4063 remote_environment_lease: strict_remote.is_some(),
4064 readonly_output: if readonly_actor {
4065 Some(&mut readonly_messages)
4066 } else {
4067 None
4068 },
4069 local_history_tools: local_history_tools.as_deref(),
4070 local_history_read_only: spec.capabilities.read_only_enforced(),
4071 strict_history_output: Some(&mut strict_history),
4072 plain_input: plain_activation.as_ref(),
4073 canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4074 canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4075 actor_event_observer: actor_event_observer.as_deref(),
4076 plain_run: self
4079 .bus
4080 .as_ref()
4081 .map(|_| (&run_spec, &self.next_execution_epoch)),
4082 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4083 })
4084 .await;
4085 let mut result = if active_lease
4086 .as_ref()
4087 .is_some_and(ActivePlacementLease::lost)
4088 {
4089 Err(remote_broker_unavailable())
4090 } else {
4091 result
4092 };
4093 if local_history_tools.is_none() {
4094 let reply = result
4095 .as_ref()
4096 .ok()
4097 .and_then(|reply| reply.as_deref())
4098 .filter(|reply| !reply.is_empty())
4099 .map(str::to_owned);
4100 if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4101 if let Err(error) = append_fenced_remote_reply(
4102 lease.actor_store.as_ref(),
4103 &lease.fence,
4104 session,
4105 &reply,
4106 )
4107 .await
4108 {
4109 result = Err(error);
4110 }
4111 }
4112 }
4113 if remote {
4114 if let Err(error) = &result {
4115 let code = match error {
4116 AgentError::LLM(message) if message.starts_with("actor event") => {
4117 "actor_event"
4118 }
4119 AgentError::LLM(message) if message.starts_with("remote_actor") => {
4120 "remote_actor"
4121 }
4122 AgentError::LLM(message) if message.starts_with("remote_environment") => {
4123 if message.len() <= 128
4124 && message.bytes().all(|byte| {
4125 byte.is_ascii_lowercase()
4126 || byte.is_ascii_digit()
4127 || byte == b'_'
4128 })
4129 {
4130 message.as_str()
4131 } else {
4132 "environment"
4133 }
4134 }
4135 AgentError::LLM(message) if message.contains("permission posture") => {
4136 "permission_posture"
4137 }
4138 AgentError::LLM(message) if message.contains("broker") => "broker",
4139 AgentError::LLM(_) => "other_llm",
4140 AgentError::WorkerUnresponsive(_) => "unresponsive",
4141 AgentError::Cancelled => "cancelled",
4142 _ => "other",
4143 };
4144 tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4145 }
4146 }
4147 if let Some(lease) = active_lease.as_mut() {
4148 let accepted_terminal = client
4149 .durable_delivery_receipt()
4150 .map(|receipt| receipt.terminal_status);
4151 if let Err(error) = lease
4152 .complete(
4153 remote_activation_finish(&result, accepted_terminal),
4154 client.has_pending_durable_terminal(),
4155 )
4156 .await
4157 {
4158 tracing::warn!(
4159 stage = "actor_complete",
4160 "remote placement admission failed"
4161 );
4162 if let (Some(binding), Some(run_id)) = (
4163 session_inbox_runtime.as_ref(),
4164 bound_activation_run_id.as_deref(),
4165 ) {
4166 binding
4167 .router
4168 .detach_delivery_sink(&job.child_session_id, run_id)
4169 .await;
4170 }
4171 drop(live_guard);
4172 drop(client); drop(active_lease);
4174 return Err(error);
4175 }
4176 }
4177 drop(active_lease);
4178 if let (Some(binding), Some(run_id)) = (
4179 session_inbox_runtime.as_ref(),
4180 bound_activation_run_id.as_deref(),
4181 ) {
4182 binding
4183 .router
4184 .detach_delivery_sink(&job.child_session_id, run_id)
4185 .await;
4186 }
4187 drop(live_guard);
4193 if client.has_pending_durable_terminal() {
4197 if let Some(run_id) = bound_activation_run_id.as_deref() {
4198 let key = (session.id.clone(), session.created_at, run_id.to_owned());
4199 let host_nested_wait_handoff = session
4200 .metadata
4201 .get("runtime.actor_nested_wait_handoff")
4202 .is_some_and(|value| value == "true");
4203 let mut pending = self.pending_durable_links.lock().await;
4204 if pending.contains_key(&key) {
4205 return Err(AgentError::LLM(
4206 "child broker terminal receipt already awaits Host checkpoint".into(),
4207 ));
4208 }
4209 pending.insert(
4210 key,
4211 PendingDurableChildLink {
4212 link: client,
4213 host_nested_wait_handoff,
4214 strict_history,
4215 strict_history_required: local_history_tools.is_some(),
4216 strict_history_committed: false,
4217 },
4218 );
4219 } else {
4220 drop(client);
4222 }
4223 } else {
4224 drop(client);
4225 }
4226
4227 if required_context.is_none()
4231 && attempt == 0
4232 && matches!(kind, PlacementKind::Local)
4233 && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4234 {
4235 tracing::warn!(
4236 "actor child {} got no first frame; reaping the worker and respawning once",
4237 job.child_session_id
4238 );
4239 actor.worker.kill().await;
4240 attempt += 1;
4241 continue;
4242 }
4243 break (result, actor);
4244 };
4245
4246 let result = if strict_remote.is_some() {
4247 result.map_err(|error| match &error {
4248 AgentError::Cancelled => error,
4249 AgentError::LLM(message) => remote_environment_error_code(message)
4250 .map(|code| AgentError::LLM(code.to_owned()))
4251 .unwrap_or_else(remote_broker_unavailable),
4252 _ => remote_broker_unavailable(),
4253 })
4254 } else {
4255 result
4256 };
4257
4258 if remote {
4262 drop(actor);
4263 } else if required_context.is_some() {
4264 if session
4265 .metadata
4266 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4267 {
4268 let service = self.ticket_service.lock().recover_poison().clone();
4269 let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4270 let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4271 .ok()
4272 .flatten()
4273 .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4274 match actor.worker.kill_confirmed().await {
4275 Ok(proof) => {
4276 if let (Some(service), Some(runtime), Some(run_id)) =
4277 (service, runtime, owned_run_id.as_deref())
4278 {
4279 if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4280 &service,
4281 &runtime,
4282 session,
4283 run_id,
4284 &proof,
4285 result.is_ok(),
4286 )
4287 .await
4288 {
4289 tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4292 }
4293 }
4294 }
4295 Err(error) => {
4296 tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4297 }
4298 }
4299 } else {
4300 actor.worker.kill().await;
4301 }
4302 } else {
4303 match &result {
4304 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4305 Err(_) => actor.worker.kill().await,
4306 }
4307 }
4308
4309 if let Some(activation) = plain_activation {
4310 return match result {
4311 Ok(Some(text)) if !text.is_empty() => {
4312 if readonly_actor {
4313 let messages = std::mem::take(&mut readonly_messages);
4314 let committed = activation
4315 .store
4316 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4317 fence: activation.fence.clone(),
4318 expected_created_at: activation.created_at,
4319 expected_messages: session.messages.clone(),
4320 expected_provider_transcript: activation
4321 .provider_transcript
4322 .clone(),
4323 messages: messages.clone(),
4324 native_groups: Vec::new(),
4325 })
4326 .await;
4327 return match committed {
4328 Ok(committed) => {
4329 *session = committed;
4330 for message in messages {
4331 let _ = event_tx
4332 .send(AgentEvent::message_appended(&session.id, &message))
4333 .await;
4334 }
4335 activation
4336 .finish_with_observer(
4337 ActorActivationFinish::Succeeded,
4338 actor_event_observer.as_deref(),
4339 )
4340 .await
4341 }
4342 Err(error) => {
4343 let _ = activation.finish(ActorActivationFinish::Failed).await;
4344 Err(AgentError::LLM(format!(
4345 "actor reply commit failed: {error}"
4346 )))
4347 }
4348 };
4349 }
4350 match activation.append_reply(session, text, &event_tx).await {
4351 Ok(()) => {
4352 activation
4353 .finish_with_observer(
4354 ActorActivationFinish::Succeeded,
4355 actor_event_observer.as_deref(),
4356 )
4357 .await
4358 }
4359 Err(error) => {
4360 let _ = activation.finish(ActorActivationFinish::Failed).await;
4362 Err(AgentError::LLM(format!(
4363 "actor reply commit failed: {error}"
4364 )))
4365 }
4366 }
4367 }
4368 other => {
4369 let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4370 ActorActivationFinish::Cancelled
4371 } else {
4372 ActorActivationFinish::Failed
4373 };
4374 activation.finish(outcome).await?;
4375 match other {
4376 Err(error) => Err(error),
4377 _ => Err(plain_actor_unsupported()),
4378 }
4379 }
4380 };
4381 }
4382
4383 match result {
4387 Ok(Some(text)) => {
4388 if !remote && local_history_tools.is_none() && !text.is_empty() {
4389 session.add_message(bamboo_agent_core::Message::assistant(text, None));
4390 }
4391 Ok(())
4392 }
4393 Ok(None) => Ok(()),
4394 Err(e) => Err(e),
4395 }
4396 }
4397}
4398
4399async fn require_unowned_glob(
4402 store: &bamboo_storage::SessionStoreV2,
4403 session: &Session,
4404) -> Result<(), AgentError> {
4405 let rel = store
4406 .resolve_rel_path(&session.id)
4407 .await
4408 .ok_or_else(|| plain_actor_unsupported())?;
4409 if !store.bamboo_home_dir().is_absolute()
4410 || rel
4411 != format!(
4412 "sessions/{}/children/{}",
4413 session.root_session_id, session.id
4414 )
4415 {
4416 return Err(plain_actor_unsupported());
4417 }
4418 let directory = store.bamboo_home_dir().join(rel);
4419 let mut checked = PathBuf::new();
4420 for component in directory.components() {
4421 if !matches!(
4422 component,
4423 std::path::Component::RootDir | std::path::Component::Normal(_)
4424 ) {
4425 return Err(plain_actor_unsupported());
4426 }
4427 checked.push(component.as_os_str());
4428 if !tokio::fs::symlink_metadata(&checked)
4429 .await
4430 .map_err(|_| plain_actor_unsupported())?
4431 .is_dir()
4432 {
4433 return Err(plain_actor_unsupported());
4434 }
4435 }
4436 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4437 match tokio::fs::symlink_metadata(directory.join(name)).await {
4438 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4439 _ => return Err(plain_actor_unsupported()),
4440 }
4441 }
4442 Ok(())
4443}
4444
4445struct PlainActorActivation {
4451 store: Arc<bamboo_storage::SessionStoreV2>,
4452 fence: ActorActivationFence,
4453 inbox_generation: u64,
4454 created_at: chrono::DateTime<chrono::Utc>,
4455 provider_transcript: bamboo_domain::ProviderTranscriptState,
4456 input_consumer: SessionInboxConsumerId,
4457 input_deadline: chrono::DateTime<chrono::Utc>,
4458 actor_deadline: chrono::DateTime<chrono::Utc>,
4459 initial_release_required: bool,
4460 input_inbox: bamboo_storage::FileSessionInbox,
4461 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4462 initial_input: Option<SessionInboxOwnedClaim>,
4463 recovering_pre_ack: bool,
4464 completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4465}
4466type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4467#[track_caller]
4468fn plain_actor_unsupported() -> AgentError {
4469 let source = std::panic::Location::caller();
4470 tracing::warn!(
4471 process_id = std::process::id(),
4472 source_file = source.file(),
4473 source_line = source.line(),
4474 source_column = source.column(),
4475 "plain Actor continuation unsupported"
4476 );
4477 AgentError::LLM("This Actor Child supports a fresh plain activation, a completed plain Child with one new input, two bounded corrections while Running, a Failed retry with one new input, an expired initial Local owner with one new input, or verified expired pre-ACK input recovery through run(reset_to_last_user=false). Other continuation is unsupported; durable history is preserved.".into())
4478}
4479fn plain_initial_history(session: &Session) -> bool {
4480 !session.messages.iter().any(|message| {
4481 matches!(message.role, Role::Assistant | Role::Tool)
4482 || message
4483 .metadata
4484 .as_ref()
4485 .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4486 }) && session.provider_transcript.is_empty()
4487 && session.session_inbox_admission().is_none()
4488 && session.pending_injected_messages().is_none()
4489}
4490fn plain_completed_history(session: &Session) -> bool {
4493 session.provider_transcript.is_empty()
4494 && session.pending_injected_messages().is_none()
4495 && session
4496 .messages
4497 .last()
4498 .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4499 && !session
4500 .messages
4501 .iter()
4502 .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4503}
4504fn pre_ack_input(
4507 session: &Session,
4508) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4509 let Some(message) = session.messages.last() else {
4510 return Ok(None);
4511 };
4512 let Some(proof) = message
4513 .metadata
4514 .as_ref()
4515 .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4516 else {
4517 return Ok(None);
4518 };
4519 let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4520 .map_err(|_| plain_actor_unsupported())?;
4521 let generation = proof
4522 .get("generation")
4523 .and_then(serde_json::Value::as_u64)
4524 .filter(|g| *g > 0)
4525 .ok_or_else(|| plain_actor_unsupported())?;
4526 if message.role != Role::User
4527 || session
4528 .messages
4529 .iter()
4530 .filter(|m| m.id == message.id)
4531 .count()
4532 != 1
4533 || !session
4534 .session_inbox_admission()
4535 .is_some_and(|cursor| cursor.contains(&id))
4536 || !session.provider_transcript.is_empty()
4537 || session.pending_injected_messages().is_some()
4538 || session
4539 .messages
4540 .iter()
4541 .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4542 {
4543 return Err(plain_actor_unsupported());
4544 }
4545 Ok(Some((id, generation)))
4546}
4547fn qualified_pre_ack_owner(
4548 entry: &bamboo_domain::ActorDirectoryEntry,
4549 now: chrono::DateTime<chrono::Utc>,
4550) -> bool {
4551 let Some(old) = entry.activation.as_ref() else {
4552 return false;
4553 };
4554 let recoverable = (entry.actor.state == ActorLogicalState::Failed
4555 && old.status == bamboo_domain::ActorActivationStatus::Failed)
4556 || (entry.actor.state == ActorLogicalState::Active
4557 && old.status.is_live()
4558 && old.lease_expires_at <= now);
4559 let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4560 placement.class == bamboo_domain::ActorPlacementClass::Local
4561 && placement
4562 .lease_id
4563 .strip_prefix("owned-initial-release-v1:")
4564 .is_some_and(|worker| {
4565 !worker.is_empty()
4566 && worker.len() <= 256
4567 && !worker.contains("..")
4568 && worker.bytes().all(|b| {
4569 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4570 })
4571 })
4572 });
4573 recoverable && trusted
4574}
4575async fn expired_pre_ack_input(
4576 inbox: &bamboo_storage::FileSessionInbox,
4577 session: &Session,
4578) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4579 let Some((id, generation)) = pre_ack_input(session)? else {
4580 return Ok(None);
4581 };
4582 let backlog = inbox
4583 .inspect(&session.id)
4584 .await
4585 .map_err(|_| plain_actor_unsupported())?;
4586 let leases = inbox
4587 .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4588 .await
4589 .map_err(|_| plain_actor_unsupported())?;
4590 if backlog.pending != 0
4591 || backlog.claimed != 1
4592 || backlog.generation != generation
4593 || backlog.oldest_generation != Some(generation)
4594 || !backlog.activation_pending()
4595 || leases.len() != 1
4596 || !leases[0].expired
4597 || leases[0].generation != generation
4598 || inbox
4599 .was_admitted(&session.id, &id)
4600 .await
4601 .map_err(|_| plain_actor_unsupported())?
4602 {
4603 return Err(plain_actor_unsupported());
4604 }
4605 Ok(Some((id, generation)))
4606}
4607impl PlainActorActivation {
4608 fn start<'a>(
4611 store: Arc<bamboo_storage::SessionStoreV2>,
4612 session: &'a Session,
4613 binding: &'a SessionInboxRuntimeBinding,
4614 run_id: Option<&'a str>,
4615 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4616 allow_failed_retry: bool,
4617 release_worker: Option<&'a str>,
4618 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4619 {
4620 Box::pin(async move {
4621 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4622 let backlog = binding
4623 .inbox
4624 .inspect(&session.id)
4625 .await
4626 .map_err(|_| plain_actor_unsupported())?;
4627 let entry = store
4628 .ensure_actor(&session.id)
4629 .await
4630 .map_err(|error| AgentError::LLM(error.to_string()))?;
4631 let config = permission_config
4632 .as_ref()
4633 .ok_or_else(|| plain_actor_unsupported())?;
4634 if entry.actor.policy_revision != Some(config.policy_revision())
4635 || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4636 {
4637 return Err(plain_actor_unsupported());
4638 }
4639 let fresh = entry.actor.current_attempt == 0
4640 && entry.actor.state == ActorLogicalState::Cold
4641 && entry.activation.is_none()
4642 && backlog.pending == 0
4643 && backlog.claimed == 0
4644 && backlog.generation == 0;
4645 let new_input = backlog.pending == 1
4646 && backlog.claimed == 0
4647 && backlog.oldest_generation == Some(backlog.generation)
4648 && backlog.activation_pending();
4649 let retry = allow_failed_retry
4650 && entry.actor.state == ActorLogicalState::Failed
4651 && entry.actor.current_attempt > 0
4652 && entry.activation.as_ref().is_some_and(|old| {
4653 old.status == bamboo_domain::ActorActivationStatus::Failed
4654 && backlog.generation > old.inbox_generation
4655 })
4656 && new_input;
4657 let continuation = allow_failed_retry
4658 && release_worker.is_some()
4659 && entry.actor.state == ActorLogicalState::Cold
4660 && entry.actor.current_attempt > 0
4661 && entry.activation.as_ref().is_some_and(|old| {
4662 old.status == bamboo_domain::ActorActivationStatus::Succeeded
4663 && backlog.generation > old.inbox_generation
4664 })
4665 && new_input
4666 && plain_completed_history(session);
4667 let input_inbox = bamboo_storage::FileSessionInbox::new(
4668 store.clone(),
4669 bamboo_domain::SessionInboxLimits::default(),
4670 );
4671 let now = chrono::Utc::now();
4672 let replacement = allow_failed_retry
4676 && release_worker.is_some()
4677 && entry.actor.state == ActorLogicalState::Active
4678 && qualified_pre_ack_owner(&entry, now)
4679 && entry.activation.as_ref().is_some_and(|old| {
4680 old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4681 })
4682 && new_input
4683 && plain_initial_history(session);
4684 let recovery = if allow_failed_retry
4685 && release_worker.is_some()
4686 && qualified_pre_ack_owner(&entry, now)
4687 {
4688 expired_pre_ack_input(&input_inbox, session).await?
4689 } else {
4690 None
4691 };
4692 if (recovery.is_some() || continuation || replacement)
4693 && (!entry.actor.matches_session(session)
4694 || entry.actor.project_id
4695 != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4696 {
4697 return Err(plain_actor_unsupported());
4698 }
4699 if recovery.is_none()
4700 && !continuation
4701 && !replacement
4702 && ((!fresh && !retry) || !plain_initial_history(session))
4703 {
4704 return Err(plain_actor_unsupported());
4705 }
4706 let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4707 .max_total_secs
4708 .checked_add(60)
4709 .and_then(chrono::Duration::try_seconds)
4710 .ok_or_else(|| plain_actor_unsupported())?;
4711 let expires = now
4712 .checked_add_signed(duration)
4713 .ok_or_else(|| plain_actor_unsupported())?;
4714 let input_consumer = SessionInboxConsumerId::new();
4715 let input_deadline = expires.min(now + chrono::Duration::hours(1));
4716 let recovered = if let Some((id, generation)) = &recovery {
4717 if !binding.router.owns_run(&session.id, run_id).await {
4718 return Err(plain_actor_unsupported());
4719 }
4720 let lease = SessionInboxLeaseRequest {
4721 consumer: input_consumer.clone(),
4722 now,
4723 duration: input_deadline - now,
4724 };
4725 let mut claims = input_inbox
4726 .claim_owned(&session.id, 1, Some(run_id), &lease)
4727 .await
4728 .map_err(|_| plain_actor_unsupported())?;
4729 let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4730 if claim.claim.envelope.id != *id
4731 || claim.claim.generation != *generation
4732 || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4733 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4734 {
4735 return Err(plain_actor_unsupported());
4736 }
4737 Some(claim)
4738 } else {
4739 None
4740 };
4741 let claimed = store
4742 .claim_activation(&ActorActivationClaim {
4743 actor_id: session.id.clone(),
4744 run_id: run_id.into(),
4745 lease_owner: input_consumer.as_str().into(),
4746 lease_expires_at: expires,
4747 inbox_generation: recovered
4748 .as_ref()
4749 .map_or(backlog.generation, |claim| claim.claim.generation),
4750 placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4751 class: bamboo_domain::ActorPlacementClass::Local,
4752 lease_id: format!("owned-initial-release-v1:{worker}"),
4755 slot_epoch: None,
4756 }),
4757 now,
4758 })
4759 .await
4760 .map_err(|error| AgentError::LLM(error.to_string()))?;
4761 let activation = Self {
4762 store,
4763 fence: claimed.fence(),
4764 inbox_generation: claimed.inbox_generation,
4765 created_at: session.created_at,
4766 provider_transcript: session.provider_transcript.clone(),
4767 input_consumer,
4768 input_deadline,
4769 actor_deadline: claimed.lease_expires_at,
4770 initial_release_required: release_worker.is_some(),
4771 input_inbox,
4772 permission_config,
4773 initial_input: recovered,
4774 recovering_pre_ack: recovery.is_some(),
4775 completion_router: std::sync::Mutex::new(None),
4776 };
4777 if let Err(error) = activation
4778 .store
4779 .start_activation(&activation.fence, chrono::Utc::now())
4780 .await
4781 {
4782 let _ = activation.finish(ActorActivationFinish::Failed).await;
4783 return Err(AgentError::LLM(error.to_string()));
4784 }
4785 Ok(activation)
4786 })
4787 }
4788 fn start_and_prepare<'a>(
4789 store: Arc<bamboo_storage::SessionStoreV2>,
4790 session: &'a mut Session,
4791 binding: &'a SessionInboxRuntimeBinding,
4792 run_id: Option<&'a str>,
4793 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4794 allow_failed_retry: bool,
4795 release_worker: Option<&'a str>,
4796 ) -> std::pin::Pin<
4797 Box<
4798 impl std::future::Future<
4799 Output = Result<(Self, Option<PlainInitialDelivery>), AgentError>,
4800 > + Send
4801 + 'a,
4802 >,
4803 > {
4804 Box::pin(async move {
4805 let mut activation = Self::start(
4806 store,
4807 session,
4808 binding,
4809 run_id,
4810 permission_config,
4811 allow_failed_retry,
4812 release_worker,
4813 )
4814 .await?;
4815 if activation.inbox_generation == 0 {
4816 return Ok((activation, None));
4817 }
4818 let prepared = async {
4819 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4820 let backlog = activation
4821 .input_inbox
4822 .inspect(&session.id)
4823 .await
4824 .map_err(|_| plain_actor_unsupported())?;
4825 let expected = if activation.recovering_pre_ack {
4826 (0, 1)
4827 } else {
4828 (1, 0)
4829 };
4830 if (backlog.pending, backlog.claimed) != expected
4831 || backlog.generation != activation.inbox_generation
4832 {
4833 return Err(plain_actor_unsupported());
4834 }
4835 let claim = match activation.initial_input.clone() {
4836 Some(claim) => claim,
4837 None => activation
4838 .claim_input(binding, session, run_id)
4839 .await?
4840 .ok_or_else(|| plain_actor_unsupported())?,
4841 };
4842 if claim.claim.generation != activation.inbox_generation {
4843 return Err(plain_actor_unsupported());
4844 }
4845 let delivery = activation.checkpoint_input(session, run_id, &claim).await?;
4846 activation.initial_input = Some(claim);
4847 Ok(delivery)
4848 }
4849 .await;
4850 match prepared {
4851 Ok(delivery) => Ok((activation, Some(delivery))),
4852 Err(error) => {
4853 activation.finish(ActorActivationFinish::Failed).await?;
4854 Err(error)
4855 }
4856 }
4857 })
4858 }
4859 fn claim_input<'a>(
4860 &'a self,
4861 binding: &'a SessionInboxRuntimeBinding,
4862 session: &'a Session,
4863 run_id: &'a str,
4864 ) -> std::pin::Pin<
4865 Box<
4866 impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4867 + Send
4868 + 'a,
4869 >,
4870 > {
4871 Box::pin(async move {
4872 if !self
4873 .input_inbox
4874 .inspect(&session.id)
4875 .await
4876 .map_err(|_| plain_actor_unsupported())?
4877 .activation_pending()
4878 {
4879 return Ok(None);
4880 }
4881 if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4882 return Err(plain_actor_unsupported());
4883 }
4884 let now = chrono::Utc::now();
4885 if now >= self.input_deadline {
4886 return Err(plain_actor_unsupported());
4887 }
4888 let lease = SessionInboxLeaseRequest {
4889 consumer: self.input_consumer.clone(),
4890 now,
4891 duration: self.input_deadline - now,
4892 };
4893 let claim = self
4894 .input_inbox
4895 .claim_owned(&session.id, 1, Some(run_id), &lease)
4896 .await
4897 .map_err(|error| {
4898 tracing::warn!(%error, "Actor correction claim unconfirmed");
4899 AgentError::LLM(
4900 "Actor correction claim unconfirmed; durable input is preserved".into(),
4901 )
4902 })?
4903 .into_iter()
4904 .next();
4905 if let Some(claim) = &claim {
4906 if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4907 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4908 {
4909 self.input_inbox
4910 .fail_owned(
4911 &session.id,
4912 claim,
4913 &SessionInboxFailureReport {
4914 now: chrono::Utc::now(),
4915 error_code: "unsupported_actor_input".into(),
4916 },
4917 )
4918 .await
4919 .map_err(|error| {
4920 tracing::warn!(%error, "Actor correction failure report unconfirmed");
4921 AgentError::LLM(
4922 "Actor correction failure report unconfirmed; durable input is preserved".into(),
4923 )
4924 })?;
4925 return Err(plain_actor_unsupported());
4926 }
4927 }
4928 Ok(claim)
4929 })
4930 }
4931 fn checkpoint_input<'a>(
4932 &'a self,
4933 session: &'a mut Session,
4934 run_id: &'a str,
4935 claim: &'a SessionInboxOwnedClaim,
4936 ) -> std::pin::Pin<
4937 Box<
4938 impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4939 >,
4940 > {
4941 Box::pin(async move {
4942 let request = bamboo_storage::ActorInputCheckpoint {
4945 fence: self.fence.clone(),
4946 expected_created_at: self.created_at,
4947 claim: claim.clone(),
4948 expected_messages: session.messages.clone(),
4949 expected_provider_transcript: session.provider_transcript.clone(),
4950 expected_admission: session.session_inbox_admission().cloned(),
4951 };
4952 let mut retries = 0;
4953 let committed = loop {
4954 let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
4955 if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
4956 && retries < 3
4957 {
4958 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
4964 retries += 1;
4965 continue;
4966 }
4967 break result;
4968 }.map_err(|error| {
4969 tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
4970 AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
4971 })?;
4972 if self.recovering_pre_ack
4973 && committed.status
4974 != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4975 {
4976 return Err(plain_actor_unsupported());
4977 }
4978 let messages = session
4979 .messages
4980 .iter()
4981 .map(|message| {
4982 let mut worker = message.clone();
4983 if committed.status
4984 == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4985 && worker.id == committed.envelope.id.as_str()
4986 {
4987 worker
4988 .metadata
4989 .as_mut()
4990 .and_then(serde_json::Value::as_object_mut)
4991 .ok_or_else(|| plain_actor_unsupported())?
4992 .remove("_bamboo_owned_input_checkpoint");
4993 if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
4994 {
4995 return Err(plain_actor_unsupported());
4996 }
4997 }
4998 serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
4999 })
5000 .collect::<Result<Vec<_>, _>>()?;
5001 *session = committed.session;
5002 Ok((
5003 messages,
5004 SessionMessageDelivery {
5005 target_session_id: session.id.clone(),
5006 envelope: committed.envelope,
5007 canonical_claim_generation: committed.generation,
5008 activation_run_id: run_id.into(),
5009 activation_policy: claim.claim.activation_policy,
5010 },
5011 ))
5012 })
5013 }
5014 fn append_reply<'a>(
5015 &'a self,
5016 session: &'a mut Session,
5017 text: String,
5018 event_tx: &'a mpsc::Sender<AgentEvent>,
5019 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5020 {
5021 Box::pin(async move {
5022 let message = bamboo_agent_core::Message::assistant(text, None);
5023 let request = bamboo_storage::ActorTranscriptAppend {
5024 fence: self.fence.clone(),
5025 expected_created_at: self.created_at,
5026 expected_messages: session.messages.clone(),
5027 expected_provider_transcript: self.provider_transcript.clone(),
5028 messages: vec![message.clone()],
5029 native_groups: Vec::new(),
5030 };
5031 let mut retries = 0;
5032 *session = loop {
5033 let result = self.store.append_actor_transcript(request.clone()).await;
5034 if matches!(
5035 &result,
5036 Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5037 ) && retries < 3
5038 {
5039 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5044 retries += 1;
5045 continue;
5046 }
5047 break result;
5048 }
5049 .map_err(|error| {
5050 tracing::warn!(
5051 %error,
5052 "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5053 );
5054 AgentError::LLM(format!("actor reply commit failed: {error}"))
5055 })?;
5056 let _ = event_tx
5057 .send(AgentEvent::message_appended(&session.id, &message))
5058 .await;
5059 Ok(())
5060 })
5061 }
5062 fn continue_input<'a>(
5066 &'a self,
5067 client: &'a mut dyn bamboo_subagent::ChildLink,
5068 binding: &'a SessionInboxRuntimeBinding,
5069 session: &'a mut Session,
5070 run_id: &'a str,
5071 run: Option<(&'a RunSpec, &'a AtomicU64)>,
5072 expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5073 text: String,
5074 event_tx: &'a mpsc::Sender<AgentEvent>,
5075 ) -> std::pin::Pin<
5076 Box<
5077 impl std::future::Future<
5078 Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5079 > + Send
5080 + 'a,
5081 >,
5082 > {
5083 Box::pin(async move {
5084 let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5085 return Ok(None);
5086 };
5087 let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5088 self.append_reply(session, text, event_tx).await?;
5089 let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5090 self.refresh_continuation_posture(session, run, expected_posture)?;
5092 let epoch = epochs
5093 .fetch_add(1, Ordering::Relaxed)
5094 .checked_add(1)
5095 .ok_or_else(|| plain_actor_unsupported())?;
5096 let mut next = run.clone();
5097 next.messages = messages;
5098 next.initial_session_messages = vec![delivery];
5099 next.execution_epoch = epoch;
5100 client.send(ParentFrame::Run(next)).await.map_err(|_| {
5101 AgentError::LLM(
5102 "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5103 )
5104 })?;
5105 Ok(Some((claim, epoch)))
5106 })
5107 }
5108 fn refresh_continuation_posture(
5109 &self,
5110 session: &Session,
5111 run: &RunSpec,
5112 expected: &mut Option<ExpectedPermissionPosture>,
5113 ) -> Result<(), AgentError> {
5114 let Some(expected) = expected.as_mut() else {
5115 return Err(plain_actor_unsupported());
5116 };
5117 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5118 .ok_or_else(|| plain_actor_unsupported())?;
5119 if audit.policy_revision != expected.policy_revision
5120 || audit.resolution != expected.resolution
5121 || audit.executor_mapping != expected.executor_mapping
5122 {
5123 return Err(plain_actor_unsupported());
5124 }
5125 match (&self.permission_config, &run.permission_policy) {
5126 (Some(config), Some(policy)) => {
5127 ensure_no_active_scoped_session_denies(config, &session.id)?;
5128 if config.policy_revision() != policy.revision
5129 || policy.revision != audit.policy_revision
5130 || policy.session_id != session.id
5131 || policy.workspace_path != session.workspace
5132 || policy.inherit_session_grants
5133 || serde_json::to_value(config.to_serializable())
5134 .map_err(|_| plain_actor_unsupported())?
5135 != policy.policy
5136 {
5137 return Err(plain_actor_unsupported());
5138 }
5139 }
5140 (None, None) => {}
5141 _ => return Err(plain_actor_unsupported()),
5142 }
5143 expected.expected_audit_revision = Some(audit.audit_revision);
5144 Ok(())
5145 }
5146 fn release_initial_input<'a>(
5147 &'a self,
5148 binding: &'a SessionInboxRuntimeBinding,
5149 session: &'a Session,
5150 run: &'a RunSpec,
5151 epoch: u64,
5152 claim: &'a SessionInboxOwnedClaim,
5153 request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5154 expected: &'a mut Option<ExpectedPermissionPosture>,
5155 previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5156 ) -> std::pin::Pin<
5157 Box<
5158 impl std::future::Future<
5159 Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5160 > + Send
5161 + 'a,
5162 >,
5163 > {
5164 Box::pin(async move {
5165 if !self.initial_release_required {
5166 return Err(plain_actor_unsupported());
5167 }
5168 let delivery = SessionMessageDelivery {
5169 target_session_id: session.id.clone(),
5170 envelope: claim.claim.envelope.clone(),
5171 canonical_claim_generation: claim.claim.generation,
5172 activation_run_id: self.fence.run_id.clone(),
5173 activation_policy: claim.claim.activation_policy,
5174 };
5175 let mut current_run = run.clone();
5176 current_run.execution_epoch = epoch;
5177 let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5178 ¤t_run,
5179 &delivery,
5180 request.nonce.clone(),
5181 )
5182 .map_err(|_| plain_actor_unsupported())?;
5183 if &exact != request
5184 || request.child_id != session.id
5185 || request.created_at != session.created_at
5186 || request.spawn_depth != session.spawn_depth
5187 || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5188 || request.root_id != session.root_session_id
5189 || request.project_id != project_id_for_actor_run(session)?
5190 || !binding
5191 .router
5192 .owns_run(&session.id, &self.fence.run_id)
5193 .await
5194 {
5195 return Err(plain_actor_unsupported());
5196 }
5197 let deadline = self
5198 .actor_deadline
5199 .min(self.input_deadline)
5200 .min(claim.lease.expires_at);
5201 if chrono::Utc::now() >= deadline {
5202 return Err(plain_actor_unsupported());
5203 }
5204 self.store
5205 .validate_fence(&self.fence, chrono::Utc::now())
5206 .await
5207 .map_err(|_| plain_actor_unsupported())?;
5208 let actual =
5209 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5210 .await
5211 .map_err(|_| plain_actor_unsupported())?
5212 .ok_or_else(|| plain_actor_unsupported())?;
5213 if actual.created_at != session.created_at
5214 || actual.root_session_id != session.root_session_id
5215 || actual.parent_session_id != session.parent_session_id
5216 || actual.spawn_depth != session.spawn_depth
5217 || project_id_for_actor_run(&actual)? != request.project_id
5218 || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5219 != serde_json::to_value(&session.messages)
5220 .map_err(|_| plain_actor_unsupported())?
5221 || serde_json::to_value(&actual.provider_transcript)
5222 .map_err(|_| plain_actor_unsupported())?
5223 != serde_json::to_value(&session.provider_transcript)
5224 .map_err(|_| plain_actor_unsupported())?
5225 || serde_json::to_value(actual.session_inbox_admission())
5226 .map_err(|_| plain_actor_unsupported())?
5227 != serde_json::to_value(session.session_inbox_admission())
5228 .map_err(|_| plain_actor_unsupported())?
5229 {
5230 return Err(plain_actor_unsupported());
5231 }
5232 self.refresh_continuation_posture(&actual, ¤t_run, expected)?;
5233 if let Some(previous) = previous {
5234 if !previous.permits(request, chrono::Utc::now())
5235 || previous.expires_at != deadline
5236 || !self
5237 .input_inbox
5238 .was_admitted(&session.id, &claim.claim.envelope.id)
5239 .await
5240 .map_err(|_| plain_actor_unsupported())?
5241 {
5242 return Err(plain_actor_unsupported());
5243 }
5244 } else {
5245 let checkpoint = bamboo_storage::ActorInputCheckpoint {
5248 fence: self.fence.clone(),
5249 expected_created_at: self.created_at,
5250 claim: claim.clone(),
5251 expected_messages: actual.messages.clone(),
5252 expected_provider_transcript: actual.provider_transcript.clone(),
5253 expected_admission: actual.session_inbox_admission().cloned(),
5254 };
5255 let mut retries = 0;
5256 loop {
5257 let result = self
5258 .input_inbox
5259 .checkpoint_actor_input(checkpoint.clone())
5260 .await;
5261 if matches!(
5262 &result,
5263 Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5264 ) && retries < 3
5265 {
5266 tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5270 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5271 retries += 1;
5272 continue;
5273 }
5274 result.map_err(|error| {
5275 tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5276 plain_actor_unsupported()
5277 })?;
5278 break;
5279 }
5280 self.confirm_input(
5281 binding,
5282 &actual,
5283 &self.fence.run_id,
5284 claim,
5285 &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5286 target_session_id: request.child_id.clone(),
5287 envelope_id: request.envelope_id.clone(),
5288 canonical_claim_generation: request.generation,
5289 activation_run_id: request.activation_run_id.clone(),
5290 },
5291 )
5292 .await?;
5293 }
5294 if chrono::Utc::now() >= deadline
5297 || !binding
5298 .router
5299 .owns_run(&session.id, &self.fence.run_id)
5300 .await
5301 {
5302 return Err(plain_actor_unsupported());
5303 }
5304 self.store
5305 .validate_fence(&self.fence, chrono::Utc::now())
5306 .await
5307 .map_err(|_| plain_actor_unsupported())?;
5308 let final_session =
5309 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5310 .await
5311 .map_err(|_| plain_actor_unsupported())?
5312 .ok_or_else(|| plain_actor_unsupported())?;
5313 self.store
5314 .validate_fence(&self.fence, chrono::Utc::now())
5315 .await
5316 .map_err(|_| plain_actor_unsupported())?;
5317 self.refresh_continuation_posture(&final_session, ¤t_run, expected)?;
5318 if chrono::Utc::now() >= deadline {
5319 return Err(plain_actor_unsupported());
5320 }
5321 Ok(bamboo_subagent::proto::InitialInputRelease {
5322 request: request.clone(),
5323 expires_at: deadline,
5324 })
5325 })
5326 }
5327 async fn confirm_input(
5328 &self,
5329 binding: &SessionInboxRuntimeBinding,
5330 session: &Session,
5331 run_id: &str,
5332 claim: &SessionInboxOwnedClaim,
5333 confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5334 ) -> Result<(), AgentError> {
5335 if confirmation.target_session_id != session.id
5336 || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5337 || confirmation.canonical_claim_generation != claim.claim.generation
5338 || confirmation.activation_run_id != run_id
5339 || run_id != self.fence.run_id
5340 || !binding.router.owns_run(&session.id, run_id).await
5341 {
5342 return Err(plain_actor_unsupported());
5343 }
5344 self.input_inbox
5345 .ack_owned(&session.id, claim, chrono::Utc::now())
5346 .await
5347 .map_err(|error| {
5348 tracing::warn!(%error, "Actor correction ACK unresolved");
5349 AgentError::LLM(
5350 "Actor correction ACK unresolved; durable input is preserved".into(),
5351 )
5352 })
5353 }
5354 async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5355 self.finish_with_observer(outcome, None).await
5356 }
5357
5358 async fn finish_with_observer(
5359 &self,
5360 outcome: ActorActivationFinish,
5361 observer: Option<&dyn ActorEventObserver>,
5362 ) -> Result<(), AgentError> {
5363 let committed = self
5364 .store
5365 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5366 .await
5367 .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5368 let router = self.completion_router.lock().recover_poison().take();
5371 if let (Some(observer), Some(router)) = (observer, router) {
5372 match self.store.inspect_actor(&self.fence.actor_id).await {
5376 Ok(entry) => match router.into_host_completion(&entry, &committed) {
5377 Ok(event) => observer.publish(event),
5378 Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5379 "Actor completion notification superseded"),
5380 },
5381 Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5382 "Actor completion committed; notification snapshot unavailable"),
5383 }
5384 }
5385 Ok(())
5386 }
5387}
5388fn local_tool_history_unsupported() -> AgentError {
5389 AgentError::LLM("local_tool_history_unsupported".into())
5390}
5391
5392#[derive(Clone, Copy)]
5393enum LocalToolTerminal<'a> {
5394 Completed(Option<&'a str>),
5395 Cancelled,
5398}
5399
5400impl LocalToolTerminal<'_> {
5401 fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5402 match self {
5403 Self::Completed(_) => !cancel.is_cancelled(),
5404 Self::Cancelled => cancel.is_cancelled(),
5405 }
5406 }
5407}
5408
5409#[derive(Default)]
5410struct LocalToolCollector {
5411 messages: Option<Vec<bamboo_agent_core::Message>>,
5412 starts: HashMap<String, (String, serde_json::Value)>,
5413 outcomes: HashMap<String, (bool, String)>,
5414 ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5417}
5418
5419impl LocalToolCollector {
5420 fn ticket_event(
5421 &self,
5422 value: &serde_json::Value,
5423 committed: &HashMap<String, serde_json::Value>,
5424 ) -> Result<bool, AgentError> {
5425 if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5426 return Ok(true);
5427 }
5428 match serde_json::from_value::<AgentEvent>(value.clone())
5429 .map_err(|_| local_tool_history_unsupported())?
5430 {
5431 AgentEvent::ToolStart { tool_name, .. }
5432 if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5433 {
5434 Err(local_tool_history_unsupported())
5435 }
5436 AgentEvent::ToolComplete {
5437 tool_call_id,
5438 result,
5439 } if result.success => {
5440 let Some((name, args)) = self.starts.get(&tool_call_id) else {
5441 return Err(local_tool_history_unsupported());
5442 };
5443 let valid = if name == "Task" {
5444 committed.get(&tool_call_id) == Some(args)
5445 } else {
5446 committed.get(&tool_call_id).is_some_and(|proof| {
5447 proof["file_tool"] == *name
5448 && proof["arguments"] == *args
5449 && proof["result"] == result.result
5450 })
5451 };
5452 if !valid {
5453 return Err(AgentError::LLM(
5454 "LocalPlan success has no exact Host receipt".into(),
5455 ));
5456 }
5457 Ok(true)
5458 }
5459 AgentEvent::TaskListUpdated { .. } => Ok(false),
5461 _ => Ok(true),
5462 }
5463 }
5464
5465 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5466 use bamboo_subagent::proto::LocalToolMessages;
5467 if self.messages.is_some() {
5468 return Err(local_tool_history_unsupported());
5469 }
5470 if value["type"] == LocalToolMessages::TYPE {
5471 let observation: LocalToolMessages = serde_json::from_value(value.clone())
5472 .map_err(|_| local_tool_history_unsupported())?;
5473 self.messages = Some(
5474 observation
5475 .validate()
5476 .map_err(|_| local_tool_history_unsupported())?,
5477 );
5478 return Ok(false);
5479 }
5480 let event: AgentEvent =
5481 serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5482 match event {
5483 AgentEvent::ToolStart {
5484 tool_call_id,
5485 tool_name,
5486 arguments,
5487 } => {
5488 if !arguments.is_object()
5489 || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5490 || tool_call_id.is_empty()
5491 || tool_call_id.len() > 128
5492 || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5493 || self.outcomes.contains_key(&tool_call_id)
5494 || self
5495 .starts
5496 .insert(tool_call_id, (tool_name, arguments))
5497 .is_some()
5498 {
5499 return Err(local_tool_history_unsupported());
5500 }
5501 }
5502 AgentEvent::ToolComplete {
5503 tool_call_id,
5504 result,
5505 } => {
5506 if !result.images.is_empty() {
5507 return Err(local_tool_history_unsupported());
5508 }
5509 self.outcome(tool_call_id, result.success, result.result)?;
5510 }
5511 AgentEvent::ToolError {
5512 tool_call_id,
5513 error,
5514 } => {
5515 self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5516 }
5517 AgentEvent::ToolToken { tool_call_id, .. } => {
5518 if !self.starts.contains_key(&tool_call_id)
5519 || self.outcomes.contains_key(&tool_call_id)
5520 {
5521 return Err(local_tool_history_unsupported());
5522 }
5523 }
5524 AgentEvent::ToolLifecycle {
5525 tool_call_id,
5526 tool_name,
5527 is_mutating,
5528 auto_approved,
5529 phase,
5530 ..
5531 } => {
5532 if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5533 || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5534 || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5535 || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5536 {
5537 return Err(local_tool_history_unsupported());
5538 }
5539 }
5540 AgentEvent::ReasoningToken { .. } => {
5541 }
5545 AgentEvent::ContextSummarized { .. }
5546 | AgentEvent::ContextArchived { .. }
5547 | AgentEvent::ContextCompressionStatus { .. }
5548 | AgentEvent::NeedClarification { .. }
5549 | AgentEvent::SubAgentStarted { .. }
5550 | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5551 AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5553 return Ok(false)
5554 }
5555 _ => {}
5556 }
5557 Ok(true)
5558 }
5559
5560 fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5561 if id.is_empty()
5562 || id.len() > 128
5563 || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5564 || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5565 || self.outcomes.insert(id, (success, text)).is_some()
5566 {
5567 return Err(local_tool_history_unsupported());
5568 }
5569 Ok(())
5570 }
5571
5572 fn suffix(
5573 self,
5574 host: &Session,
5575 tools: &[String],
5576 read_only: bool,
5577 terminal: Option<&str>,
5578 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5579 self.suffix_for_terminal(
5580 host,
5581 tools,
5582 read_only,
5583 LocalToolTerminal::Completed(terminal),
5584 )
5585 }
5586
5587 fn suffix_for_terminal(
5588 self,
5589 host: &Session,
5590 tools: &[String],
5591 read_only: bool,
5592 terminal: LocalToolTerminal<'_>,
5593 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5594 use bamboo_subagent::proto::LocalToolMessages;
5595 let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5596 if cancelled
5597 && (!read_only
5598 || tools.is_empty()
5599 || tools
5600 .iter()
5601 .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5602 || self.ticket_yield.is_some())
5603 {
5604 return Err(local_tool_history_unsupported());
5605 }
5606 let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5607 let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5608 let prefix: Vec<_> = host
5609 .messages
5610 .iter()
5611 .filter(|m| m.role != Role::System)
5612 .collect();
5613 if !host.provider_transcript.groups().is_empty()
5614 || candidate.len() < prefix.len()
5615 || (!cancelled && candidate.len() == prefix.len())
5616 || serde_json::to_value(&candidate[..prefix.len()])
5617 .map_err(|_| local_tool_history_unsupported())?
5618 != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5619 {
5620 return Err(local_tool_history_unsupported());
5621 }
5622 let mut ids = std::collections::HashSet::new();
5623 let mut calls = std::collections::HashSet::new();
5624 for message in &host.messages {
5625 if !ids.insert(message.id.clone()) {
5626 return Err(local_tool_history_unsupported());
5627 }
5628 for call in message.tool_calls.iter().flatten() {
5629 calls.insert(call.id.clone());
5630 }
5631 if let Some(id) = &message.tool_call_id {
5632 calls.insert(id.clone());
5633 }
5634 }
5635 let suffix: Vec<_> = candidate[prefix.len()..]
5636 .iter()
5637 .map(|message| (**message).clone())
5638 .collect();
5639 let mut pending = HashMap::new();
5640 let mut used = std::collections::HashSet::new();
5641 for message in &suffix {
5642 if !ids.insert(message.id.clone()) || message.never_compress {
5643 return Err(local_tool_history_unsupported());
5644 }
5645 match message.role {
5646 Role::Assistant => {
5647 if !pending.is_empty()
5648 || message.metadata.is_some()
5649 || message.tool_call_id.is_some()
5650 || message.tool_success.is_some()
5651 {
5652 return Err(local_tool_history_unsupported());
5653 }
5654 if let Some(batch) = &message.tool_calls {
5655 if batch.is_empty() {
5656 return Err(local_tool_history_unsupported());
5657 }
5658 for call in batch {
5659 let arguments =
5660 serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5661 .ok()
5662 .filter(|arguments| arguments.is_object())
5663 .ok_or_else(local_tool_history_unsupported)?;
5664 if call.id.is_empty()
5665 || call.id.len() > 128
5666 || call.tool_type != "function"
5667 || !calls.insert(call.id.clone())
5668 || calls.len() > LocalToolMessages::MAX_MESSAGES
5669 || !matches!(
5670 call.function.name.as_str(),
5671 "Read" | "Glob" | "Write" | "Task"
5672 )
5673 || (cancelled
5674 && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5675 || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5676 name != &call.function.name || actual != &arguments
5677 })
5678 {
5679 return Err(local_tool_history_unsupported());
5680 }
5681 pending.insert(call.id.clone(), call.function.name.clone());
5682 }
5683 } else if message.content.trim().is_empty() {
5684 return Err(local_tool_history_unsupported());
5685 }
5686 }
5687 Role::Tool => {
5688 let id = message
5689 .tool_call_id
5690 .as_ref()
5691 .ok_or_else(local_tool_history_unsupported)?;
5692 let name = pending
5693 .remove(id)
5694 .ok_or_else(local_tool_history_unsupported)?;
5695 let success = message
5696 .tool_success
5697 .ok_or_else(local_tool_history_unsupported)?;
5698 if message.tool_calls.is_some()
5699 || message.phase.is_some()
5700 || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5701 || !used.insert(id.clone())
5702 || used.len() > LocalToolMessages::MAX_PAIRS
5703 {
5704 return Err(local_tool_history_unsupported());
5705 }
5706 let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5707 if denied_write {
5708 if success
5709 || !(message.content.contains("native_tool_ceiling_denied")
5710 || message
5711 .content
5712 .contains("not callable at the current conversation position"))
5713 {
5714 return Err(local_tool_history_unsupported());
5715 }
5716 } else if !tools.contains(&name)
5717 || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5718 {
5719 return Err(local_tool_history_unsupported());
5720 }
5721 if let Some(metadata) = &message.metadata {
5722 let object = metadata
5723 .as_object()
5724 .ok_or_else(local_tool_history_unsupported)?;
5725 if object.len() != 5
5726 || metadata["elapsed_ms"].as_u64().is_none()
5727 || metadata["tool_name"].as_str() != Some(name.as_str())
5728 || metadata["is_mutating"].as_bool()
5729 != Some(matches!(name.as_str(), "Write" | "Task"))
5730 || metadata["auto_approved"].as_bool()
5731 != Some(!matches!(name.as_str(), "Write" | "Task"))
5732 || metadata["success"].as_bool() != Some(success)
5733 {
5734 return Err(local_tool_history_unsupported());
5735 }
5736 }
5737 }
5738 _ => return Err(local_tool_history_unsupported()),
5739 }
5740 }
5741 let last = suffix.last();
5742 let completed_text = match terminal {
5743 LocalToolTerminal::Completed(text) => text,
5744 LocalToolTerminal::Cancelled => None,
5745 };
5746 let question_terminal =
5747 self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5748 last.is_some_and(|last| last.role == Role::Tool
5749 && last.tool_call_id.as_ref() == Some(id)
5750 && last.tool_success == Some(true)
5751 && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5752 && completed_text.is_none_or(str::is_empty)
5753 && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5754 == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5755 });
5756 let report_terminal = last.is_some_and(|last| {
5757 last.role == Role::Assistant
5758 && last.tool_calls.is_none()
5759 && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5760 && completed_text == Some(last.content.as_str())
5761 });
5762 if !pending.is_empty()
5763 || used.len() != self.outcomes.len()
5764 || self.starts.keys().any(|id| !used.contains(id))
5765 || !(cancelled || report_terminal || question_terminal)
5766 {
5767 return Err(local_tool_history_unsupported());
5768 }
5769 Ok(suffix)
5770 }
5771}
5772
5773async fn validate_local_tool_checkpoint(
5774 session: &Session,
5775 binding: &SessionInboxRuntimeBinding,
5776 run_id: &str,
5777 terminal: LocalToolTerminal<'_>,
5778 cancel: &CancellationToken,
5779) -> Result<(), AgentError> {
5780 if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5781 return Err(local_tool_history_unsupported());
5782 }
5783 if matches!(terminal, LocalToolTerminal::Cancelled) {
5784 let backlog = binding
5785 .inbox
5786 .inspect(&session.id)
5787 .await
5788 .map_err(|_| local_tool_history_unsupported())?;
5789 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5790 return Err(local_tool_history_unsupported());
5793 }
5794 }
5795 Ok(())
5796}
5797
5798async fn commit_local_tool_history(
5799 collector: LocalToolCollector,
5800 session: &mut Session,
5801 binding: &SessionInboxRuntimeBinding,
5802 run_id: &str,
5803 selection: (&[String], bool),
5804 terminal: LocalToolTerminal<'_>,
5805 cancel: &CancellationToken,
5806) -> Result<(), AgentError> {
5807 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5808 let latest = binding
5809 .storage
5810 .load_session(&session.id)
5811 .await
5812 .map_err(|_| local_tool_history_unsupported())?
5813 .ok_or_else(local_tool_history_unsupported)?;
5814 if latest.id != session.id
5815 || latest.kind != session.kind
5816 || latest.created_at != session.created_at
5817 || latest.root_session_id != session.root_session_id
5818 || latest.parent_session_id != session.parent_session_id
5819 || latest.spawn_depth != session.spawn_depth
5820 || latest.project_id_meta() != session.project_id_meta()
5821 || serde_json::to_value(&latest.provider_transcript)
5822 .map_err(|_| local_tool_history_unsupported())?
5823 != serde_json::to_value(&session.provider_transcript)
5824 .map_err(|_| local_tool_history_unsupported())?
5825 {
5826 return Err(local_tool_history_unsupported());
5827 }
5828 let suffix = match terminal {
5829 LocalToolTerminal::Completed(text) => {
5830 collector.suffix(&latest, selection.0, selection.1, text)?
5831 }
5832 LocalToolTerminal::Cancelled => {
5833 collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5834 }
5835 };
5836 let before = session.clone();
5837 let mut staged = session.clone();
5838 staged.messages = latest.messages.clone();
5839 staged.messages.extend(suffix.iter().cloned());
5840 staged.updated_at = chrono::Utc::now();
5841 let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5842 Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5843 } else {
5844 None
5845 };
5846 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5847 binding
5848 .persistence
5849 .checkpoint_runtime_session(&mut staged)
5850 .await
5851 .map_err(|_| local_tool_history_unsupported())?;
5852 let saved = binding
5853 .storage
5854 .load_session(&session.id)
5855 .await
5856 .map_err(|_| local_tool_history_unsupported())?
5857 .ok_or_else(local_tool_history_unsupported)?;
5858 if serde_json::to_value(&saved.provider_transcript)
5859 .map_err(|_| local_tool_history_unsupported())?
5860 != serde_json::to_value(&latest.provider_transcript)
5861 .map_err(|_| local_tool_history_unsupported())?
5862 {
5863 return Err(local_tool_history_unsupported());
5864 }
5865 let start = match suffix.first() {
5866 Some(first) => saved
5867 .messages
5868 .iter()
5869 .position(|message| message.id == first.id)
5870 .ok_or_else(local_tool_history_unsupported)?,
5871 None => latest.messages.len(),
5872 };
5873 if cancelled_messages.as_ref().is_some_and(|expected| {
5874 serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5875 }) {
5876 return Err(local_tool_history_unsupported());
5879 }
5880 if saved.messages.len() < latest.messages.len()
5881 || serde_json::to_value(&saved.messages[..latest.messages.len()])
5882 .map_err(|_| local_tool_history_unsupported())?
5883 != serde_json::to_value(&latest.messages)
5884 .map_err(|_| local_tool_history_unsupported())?
5885 || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5886 .map_err(|_| local_tool_history_unsupported())?
5887 != serde_json::to_value(Some(&suffix[..]))
5888 .map_err(|_| local_tool_history_unsupported())?
5889 {
5890 return Err(local_tool_history_unsupported());
5891 }
5892 let expected = latest.messages.iter().chain(&suffix);
5895 for message in expected {
5896 let matches: Vec<_> = saved
5897 .messages
5898 .iter()
5899 .filter(|current| current.id == message.id)
5900 .collect();
5901 if matches.len() != 1
5902 || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5903 != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5904 {
5905 *session = before;
5906 return Err(local_tool_history_unsupported());
5907 }
5908 }
5909 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5910 staged.messages = saved.messages;
5911 *session = staged;
5912 Ok(())
5913}
5914
5915#[derive(Default)]
5916struct ReadOnlyActorCollector {
5917 start: Option<(String, serde_json::Value)>,
5918 result: Option<String>,
5919 messages: Option<Vec<bamboo_agent_core::Message>>,
5920}
5921impl ReadOnlyActorCollector {
5922 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5923 if self.messages.is_some() {
5924 return Err(plain_actor_unsupported());
5925 }
5926 if value["type"] == "owned_readonly_transcript" {
5927 let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5928 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5929 tail.validate().map_err(|_| plain_actor_unsupported())?;
5930 let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5931 let typed: Vec<bamboo_agent_core::Message> = messages
5932 .iter()
5933 .map(|raw| {
5934 let message: bamboo_agent_core::Message =
5935 serde_json::from_value(raw.clone())
5936 .map_err(|_| plain_actor_unsupported())?;
5937 if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
5938 != *raw
5939 {
5940 return Err(plain_actor_unsupported());
5941 }
5942 Ok(message)
5943 })
5944 .collect::<Result<_, _>>()?;
5945 let (id, args) = self
5946 .start
5947 .as_ref()
5948 .ok_or_else(|| plain_actor_unsupported())?;
5949 let call = typed[0]
5950 .tool_calls
5951 .as_ref()
5952 .and_then(|calls| {
5953 if calls.len() == 1 {
5954 calls.first()
5955 } else {
5956 None
5957 }
5958 })
5959 .ok_or_else(|| plain_actor_unsupported())?;
5960 if call.id != *id
5961 || call.function.name != "Glob"
5962 || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5963 .map_err(|_| plain_actor_unsupported())?
5964 != *args
5965 || typed[1].tool_call_id.as_ref() != Some(id)
5966 || typed[1].tool_success != Some(true)
5967 || self.result.as_ref() != Some(&typed[1].content)
5968 {
5969 return Err(plain_actor_unsupported());
5970 }
5971 self.messages = Some(typed);
5972 return Ok(false);
5973 }
5974 let event: AgentEvent =
5975 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5976 match event {
5977 AgentEvent::ToolStart {
5978 tool_call_id,
5979 tool_name,
5980 arguments,
5981 } => {
5982 if tool_name != "Glob"
5983 || tool_call_id.trim().is_empty()
5984 || self.start.is_some()
5985 || !arguments.is_object()
5986 {
5987 return Err(plain_actor_unsupported());
5988 }
5989 self.start = Some((tool_call_id, arguments));
5990 Ok(true)
5991 }
5992 AgentEvent::ToolComplete {
5993 tool_call_id,
5994 result,
5995 } => {
5996 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
5997 || self.result.is_some()
5998 || !result.success
5999 || !result.images.is_empty()
6000 {
6001 return Err(plain_actor_unsupported());
6002 }
6003 self.result = Some(result.result);
6004 Ok(true)
6005 }
6006 AgentEvent::ToolToken { tool_call_id, .. } => {
6007 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6008 {
6009 return Err(plain_actor_unsupported());
6010 }
6011 Ok(true)
6012 }
6013 AgentEvent::ToolLifecycle {
6014 tool_call_id,
6015 tool_name,
6016 is_mutating,
6017 phase,
6018 error,
6019 ..
6020 } => {
6021 if tool_name != "Glob"
6022 || is_mutating
6023 || !matches!(phase.as_str(), "begin" | "finished")
6024 || error.is_some()
6025 || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6026 {
6027 return Err(plain_actor_unsupported());
6028 }
6029 Ok(true)
6030 }
6031 _ => plain_actor_event(value),
6032 }
6033 }
6034 fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6035 let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6036 if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6037 return Err(plain_actor_unsupported());
6038 }
6039 Ok(messages)
6040 }
6041}
6042
6043fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6044 let event: AgentEvent =
6045 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6046 match event {
6047 AgentEvent::ReasoningToken { .. }
6048 | AgentEvent::ToolToken { .. }
6049 | AgentEvent::ToolStart { .. }
6050 | AgentEvent::ToolComplete { .. }
6051 | AgentEvent::ToolError { .. }
6052 | AgentEvent::ToolLifecycle { .. }
6053 | AgentEvent::ContextSummarized { .. }
6054 | AgentEvent::ContextArchived { .. }
6055 | AgentEvent::ContextCompressionStatus { .. }
6056 | AgentEvent::NeedClarification { .. }
6057 | AgentEvent::SubAgentStarted { .. }
6058 | AgentEvent::SubAgentEvent { .. }
6059 | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6060 AgentEvent::MessageAppended { .. }
6063 | AgentEvent::SessionHistoryCommitted { .. }
6064 | AgentEvent::VisibleMessageStart { .. }
6065 | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6066 event if event.is_durable_change() => Ok(false),
6067 _ => Ok(true),
6068 }
6069}
6070
6071fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6077 let value = match placement {
6080 Placement::Local => return None,
6081 Placement::Remote { endpoint } => serde_json::json!({
6082 "kind": "remote",
6083 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6084 }),
6085 Placement::Schedulable { pool } => serde_json::json!({
6086 "kind": "remote",
6087 "host": host_label.unwrap_or(pool),
6088 }),
6089 };
6090 serde_json::to_string(&value).ok()
6091}
6092
6093fn host_of_endpoint(endpoint: &str) -> String {
6095 endpoint
6096 .trim()
6097 .trim_start_matches("wss://")
6098 .trim_start_matches("ws://")
6099 .split(['/', ':'])
6100 .next()
6101 .unwrap_or(endpoint)
6102 .to_string()
6103}
6104
6105async fn reconcile_already_admitted_claim(
6106 binding: &SessionInboxRuntimeBinding,
6107 session: &mut Session,
6108 claim: &SessionInboxClaim,
6109) -> crate::runtime::runner::Result<()> {
6110 let latest = binding
6111 .storage
6112 .load_session(&session.id)
6113 .await
6114 .map_err(|error| {
6115 AgentError::LLM(format!(
6116 "load canonical SessionInbox checkpoint for {}: {error}",
6117 session.id
6118 ))
6119 })?
6120 .ok_or_else(|| {
6121 AgentError::LLM(format!(
6122 "canonical SessionInbox target disappeared: {}",
6123 session.id
6124 ))
6125 })?;
6126 let Some(message) = latest
6127 .messages
6128 .iter()
6129 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6130 .cloned()
6131 else {
6132 return Err(AgentError::LLM(format!(
6133 "canonical admitted receipt for {} exists without transcript message {}",
6134 session.id, claim.envelope.id
6135 )));
6136 };
6137 if let Some(existing) = session
6138 .messages
6139 .iter_mut()
6140 .find(|existing| existing.id == message.id)
6141 {
6142 *existing = message;
6143 } else {
6144 session.add_message(message);
6145 }
6146 bamboo_domain::merge_session_inbox_admission(session, &latest);
6147 binding
6148 .inbox
6149 .ack(&session.id, claim)
6150 .await
6151 .map_err(|error| {
6152 AgentError::LLM(format!(
6153 "ack recovered canonical SessionInbox claim {}: {error}",
6154 claim.envelope.id
6155 ))
6156 })
6157}
6158
6159async fn checkpoint_and_ack_canonical_claim(
6163 binding: &SessionInboxRuntimeBinding,
6164 session: &mut Session,
6165 claim: &SessionInboxClaim,
6166 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6167) -> crate::runtime::runner::Result<()> {
6168 if claim.envelope.target_session_id != session.id {
6169 return Err(AgentError::LLM(format!(
6170 "canonical SessionInbox claim target {} does not match active logical session {}",
6171 claim.envelope.target_session_id, session.id
6172 )));
6173 }
6174 if binding
6175 .inbox
6176 .was_admitted(&session.id, &claim.envelope.id)
6177 .await
6178 .map_err(|error| {
6179 AgentError::LLM(format!(
6180 "inspect canonical admitted receipt {}: {error}",
6181 claim.envelope.id
6182 ))
6183 })?
6184 {
6185 return reconcile_already_admitted_claim(binding, session, claim).await;
6186 }
6187
6188 if let Some((store, fence)) = fenced {
6189 let committed = store
6190 .confirm_actor_claim_context(
6191 concrete_file_inbox(binding)?,
6192 bamboo_storage::ActorClaimContextSeed {
6193 fence: fence.clone(),
6194 expected_created_at: session.created_at,
6195 expected_messages: session.messages.clone(),
6196 expected_provider_transcript: session.provider_transcript.clone(),
6197 expected_admission: session.session_inbox_admission().cloned(),
6198 claims: vec![claim.clone()],
6199 },
6200 )
6201 .await;
6202 let committed = match committed {
6203 Ok(committed) => committed,
6204 Err(error) => {
6205 adopt_latest_actor_context(store, session).await;
6209 return Err(AgentError::LLM(format!(
6210 "confirm fenced Actor claim context unconfirmed: {error}"
6211 )));
6212 }
6213 };
6214 bamboo_domain::merge_session_inbox_admission(session, &committed);
6215 session.messages = committed.messages;
6216 session.provider_transcript = committed.provider_transcript;
6217 session.updated_at = committed.updated_at;
6218 if !session
6219 .messages
6220 .iter()
6221 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6222 || !session
6223 .session_inbox_admission()
6224 .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6225 {
6226 return Err(AgentError::LLM(
6227 "fenced Actor claim confirmation lost durable proof".into(),
6228 ));
6229 }
6230 return binding
6231 .inbox
6232 .ack(&session.id, claim)
6233 .await
6234 .map_err(|error| {
6235 AgentError::LLM(format!(
6236 "ack fenced Actor claim {} after confirmation: {error}",
6237 claim.envelope.id
6238 ))
6239 });
6240 }
6241
6242 let transcript_has_id = session
6243 .messages
6244 .iter()
6245 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6246 if session
6247 .messages
6248 .iter()
6249 .any(|message| message.id == claim.envelope.id.as_str())
6250 && !transcript_has_id
6251 {
6252 return Err(AgentError::LLM(format!(
6253 "canonical SessionInbox id {} collides with a non-matching transcript message",
6254 claim.envelope.id
6255 )));
6256 }
6257 let cursor_has_id = session
6258 .session_inbox_admission()
6259 .is_some_and(|state| state.contains(&claim.envelope.id));
6260 if cursor_has_id && !transcript_has_id {
6261 return Err(AgentError::LLM(format!(
6262 "canonical SessionInbox cursor exists without transcript message {}",
6263 claim.envelope.id
6264 )));
6265 }
6266 let before = session.clone();
6267 if !transcript_has_id {
6268 let message = claim.envelope.to_provider_message().map_err(|error| {
6269 AgentError::LLM(format!(
6270 "translate canonical SessionInbox envelope {}: {error}",
6271 claim.envelope.id
6272 ))
6273 })?;
6274 session.add_message(message);
6275 }
6276 session
6277 .session_inbox_admission_mut()
6278 .record(claim.envelope.id.clone(), claim.generation);
6279 session.updated_at = chrono::Utc::now();
6280
6281 if let Err(error) = binding
6282 .persistence
6283 .checkpoint_runtime_session(session)
6284 .await
6285 {
6286 *session = before;
6287 return Err(AgentError::LLM(format!(
6288 "checkpoint canonical SessionInbox claim {}: {error}",
6289 claim.envelope.id
6290 )));
6291 }
6292 if !session
6293 .messages
6294 .iter()
6295 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6296 {
6297 *session = before;
6298 return Err(AgentError::LLM(format!(
6299 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6300 claim.envelope.id
6301 )));
6302 }
6303 binding
6304 .inbox
6305 .ack(&session.id, claim)
6306 .await
6307 .map_err(|error| {
6308 AgentError::LLM(format!(
6309 "ack canonical SessionInbox claim {} after checkpoint: {error}",
6310 claim.envelope.id
6311 ))
6312 })
6313}
6314
6315fn concrete_file_inbox(
6316 binding: &SessionInboxRuntimeBinding,
6317) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6318 binding
6319 .inbox
6320 .as_any()
6321 .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6322 .cloned()
6323 .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6324}
6325
6326async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6327 if let Ok(Some(latest)) = store.load_session(&session.id).await {
6328 if latest.created_at == session.created_at {
6329 bamboo_domain::merge_session_inbox_admission(session, &latest);
6330 session.messages = latest.messages;
6331 session.provider_transcript = latest.provider_transcript;
6332 session.updated_at = latest.updated_at;
6333 }
6334 }
6335}
6336
6337async fn checkpoint_claim_context_before_dispatch(
6348 binding: &SessionInboxRuntimeBinding,
6349 session: &mut Session,
6350 claims: &[SessionInboxClaim],
6351 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6352) -> crate::runtime::runner::Result<()> {
6353 if claims.is_empty() {
6354 return Ok(());
6355 }
6356 let before = session.clone();
6357 let mut additions = Vec::new();
6358 for claim in claims {
6359 if claim.envelope.target_session_id != session.id {
6360 return Err(AgentError::LLM(format!(
6361 "canonical SessionInbox claim target {} does not match actor session {}",
6362 claim.envelope.target_session_id, session.id
6363 )));
6364 }
6365 let matching = session
6366 .messages
6367 .iter()
6368 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6369 if session
6370 .messages
6371 .iter()
6372 .any(|message| message.id == claim.envelope.id.as_str())
6373 && !matching
6374 {
6375 return Err(AgentError::LLM(format!(
6376 "canonical SessionInbox id {} collides before actor dispatch",
6377 claim.envelope.id
6378 )));
6379 }
6380 if session
6381 .session_inbox_admission()
6382 .is_some_and(|state| state.contains(&claim.envelope.id))
6383 && !matching
6384 {
6385 return Err(AgentError::LLM(format!(
6386 "canonical SessionInbox cursor exists without transcript proof for {}",
6387 claim.envelope.id
6388 )));
6389 }
6390 if !matching {
6391 let message = claim.envelope.to_provider_message().map_err(|error| {
6392 AgentError::LLM(format!(
6393 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6394 claim.envelope.id
6395 ))
6396 })?;
6397 additions.push(message);
6398 }
6399 }
6400 let fenced_seed = fenced.is_some();
6401 if let Some((store, fence)) = fenced {
6402 let committed = store
6403 .seed_actor_claim_context(
6404 concrete_file_inbox(binding)?,
6405 bamboo_storage::ActorClaimContextSeed {
6406 fence: fence.clone(),
6407 expected_created_at: session.created_at,
6408 expected_messages: session.messages.clone(),
6409 expected_provider_transcript: session.provider_transcript.clone(),
6410 expected_admission: session.session_inbox_admission().cloned(),
6411 claims: claims.to_vec(),
6412 },
6413 )
6414 .await;
6415 let committed = match committed {
6416 Ok(committed) => committed,
6417 Err(error) => {
6418 adopt_latest_actor_context(store, session).await;
6422 return Err(AgentError::LLM(format!(
6423 "seed fenced Actor claim context unconfirmed: {error}"
6424 )));
6425 }
6426 };
6427 bamboo_domain::merge_session_inbox_admission(session, &committed);
6428 session.messages = committed.messages;
6429 session.provider_transcript = committed.provider_transcript;
6430 session.updated_at = committed.updated_at;
6431 } else {
6432 for message in additions {
6433 session.add_message(message);
6434 }
6435 session.updated_at = chrono::Utc::now();
6436 if let Err(error) = binding
6437 .persistence
6438 .checkpoint_runtime_session(session)
6439 .await
6440 {
6441 *session = before;
6442 return Err(AgentError::LLM(format!(
6443 "checkpoint canonical SessionInbox actor context: {error}"
6444 )));
6445 }
6446 }
6447 for claim in claims {
6448 if !session
6449 .messages
6450 .iter()
6451 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6452 {
6453 if !fenced_seed {
6454 *session = before;
6455 }
6456 return Err(AgentError::LLM(format!(
6457 "actor context checkpoint lost typed transcript proof for {}",
6458 claim.envelope.id
6459 )));
6460 }
6461 }
6462 Ok(())
6463}
6464
6465async fn claim_canonical_deliveries(
6466 binding: &SessionInboxRuntimeBinding,
6467 session: &mut Session,
6468 activation_run_id: &str,
6469 limit: usize,
6470 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6471) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6472 let claims = binding
6473 .inbox
6474 .claim_for_turn(&session.id, limit, Some(activation_run_id))
6475 .await
6476 .map_err(|error| {
6477 AgentError::LLM(format!(
6478 "claim canonical SessionInbox for active actor {}: {error}",
6479 session.id
6480 ))
6481 })?;
6482 if claims.is_empty() {
6483 return Ok(Vec::new());
6484 }
6485 let mut unconfirmed = Vec::with_capacity(claims.len());
6486 for claim in claims {
6487 if binding
6488 .inbox
6489 .was_admitted(&session.id, &claim.envelope.id)
6490 .await
6491 .map_err(|error| {
6492 AgentError::LLM(format!(
6493 "inspect canonical SessionInbox claim {}: {error}",
6494 claim.envelope.id
6495 ))
6496 })?
6497 {
6498 reconcile_already_admitted_claim(binding, session, &claim).await?;
6499 continue;
6500 }
6501 unconfirmed.push(claim);
6502 }
6503 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6504
6505 let mut deliveries = Vec::with_capacity(unconfirmed.len());
6506 for claim in unconfirmed {
6507 if session
6512 .session_inbox_admission()
6513 .is_some_and(|state| state.contains(&claim.envelope.id))
6514 {
6515 binding
6516 .inbox
6517 .ack(&session.id, &claim)
6518 .await
6519 .map_err(|error| {
6520 AgentError::LLM(format!(
6521 "finish confirmed canonical SessionInbox ack {}: {error}",
6522 claim.envelope.id
6523 ))
6524 })?;
6525 continue;
6526 }
6527 let delivery = SessionMessageDelivery {
6528 target_session_id: session.id.clone(),
6529 envelope: claim.envelope.clone(),
6530 canonical_claim_generation: claim.generation,
6531 activation_run_id: activation_run_id.to_string(),
6532 activation_policy: claim.activation_policy,
6533 };
6534 deliveries.push((claim, delivery));
6535 }
6536 Ok(deliveries)
6537}
6538
6539async fn forward_next_canonical_claim(
6540 client: &mut dyn bamboo_subagent::ChildLink,
6541 binding: &SessionInboxRuntimeBinding,
6542 session: &mut Session,
6543 activation_run_id: &str,
6544 inflight: &mut VecDeque<SessionInboxClaim>,
6545 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6546) -> crate::runtime::runner::Result<()> {
6547 if !inflight.is_empty() {
6548 return Ok(());
6549 }
6550 let Some((claim, delivery)) =
6551 claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6552 .await?
6553 .pop()
6554 else {
6555 return Ok(());
6556 };
6557 client
6558 .send(ParentFrame::SessionMessage { delivery })
6559 .await
6560 .map_err(|error| {
6561 AgentError::LLM(format!(
6562 "forward canonical SessionInbox claim {} to active actor: {error}",
6563 claim.envelope.id
6564 ))
6565 })?;
6566 inflight.push_back(claim);
6567 Ok(())
6568}
6569
6570struct ActorDriveContext<'a> {
6572 client: &'a mut dyn bamboo_subagent::ChildLink,
6573 parent_session_id: &'a str,
6574 child_session_id: &'a str,
6575 child_attempt: u32,
6576 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6577 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6578 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6579 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6580 event_tx: &'a mpsc::Sender<AgentEvent>,
6581 cancel_token: &'a CancellationToken,
6582 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6583 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6584 logical_session: &'a mut Session,
6585 expected_permission_posture: Option<ExpectedPermissionPosture>,
6586 expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6587 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6588 actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6589 canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6590 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6591 activation_run_id: Option<&'a str>,
6592 execution_epoch: u64,
6593 expected_source_actor_id: &'a str,
6594 initial_inflight_claims: VecDeque<SessionInboxClaim>,
6595 plain_actor: bool,
6596 remote_environment_lease: bool,
6597 readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6598 local_history_tools: Option<&'a [String]>,
6599 local_history_read_only: bool,
6600 strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6601 plain_input: Option<&'a PlainActorActivation>,
6602 canonical_activation: Option<&'a ActorActivationFence>,
6603 canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6604 actor_event_observer: Option<&'a dyn ActorEventObserver>,
6605 plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6606 first_frame_timeout: Option<Duration>,
6607}
6608
6609#[derive(Debug, Clone, PartialEq, Eq)]
6610struct ExpectedPermissionPosture {
6611 policy_revision: u64,
6612 resolution: bamboo_domain::PermissionModeResolution,
6613 expected_audit_revision: Option<u64>,
6614 executor_mapping: String,
6615}
6616
6617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6618enum PermissionPostureHandshake {
6619 NotRequired,
6621 Awaiting,
6624 Confirmed,
6626}
6627
6628impl PermissionPostureHandshake {
6629 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6630 if expected.is_some() {
6631 Self::Awaiting
6632 } else {
6633 Self::NotRequired
6634 }
6635 }
6636
6637 fn is_awaiting(self) -> bool {
6638 self == Self::Awaiting
6639 }
6640
6641 fn posture_was_confirmed(self) -> bool {
6642 self == Self::Confirmed
6643 }
6644}
6645
6646fn permission_posture_seed_from_event(
6647 session: &Session,
6648 event: &AgentEvent,
6649) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6650 let AgentEvent::PermissionPostureActivated {
6651 session_id,
6652 policy_revision,
6653 requested_mode,
6654 effective_mode,
6655 executor_mapping,
6656 } = event
6657 else {
6658 return Ok(None);
6659 };
6660 if session_id != &session.id {
6661 return Err("permission posture event targets a different logical session".to_string());
6662 }
6663 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6664 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6665 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6666 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6667 let resolution = bamboo_domain::PermissionModeResolution {
6668 requested,
6669 effective,
6670 };
6671 if !resolution.is_consistent() {
6672 return Err("permission posture event has an inconsistent mode pair".to_string());
6673 }
6674 let current_requested = session
6675 .agent_runtime_state
6676 .as_ref()
6677 .map(|state| state.effective_permission_mode())
6678 .unwrap_or_default();
6679 if current_requested != requested {
6680 return Err("permission posture event is stale for the host typed mode".to_string());
6681 }
6682 let mapping_chars = executor_mapping.chars().count();
6683 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6684 return Err("permission posture event has an invalid executor mapping".to_string());
6685 }
6686 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6687 *policy_revision,
6688 resolution,
6689 executor_mapping,
6690 )))
6691}
6692
6693fn actor_event_logical_identity(
6694 session: &Session,
6695 parent_session_id: &str,
6696 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6697) -> LogicalSessionIdentity {
6698 LogicalSessionIdentity {
6699 creation: creation.cloned(),
6700 session_id: session.id.clone(),
6701 parent_session_id: session
6702 .parent_session_id
6703 .clone()
6704 .or_else(|| Some(parent_session_id.to_string())),
6705 root_session_id: if session.root_session_id.trim().is_empty() {
6706 parent_session_id.to_string()
6707 } else {
6708 session.root_session_id.clone()
6709 },
6710 }
6711}
6712
6713fn validate_strict_history_terminal(
6714 watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6715 session: &Session,
6716 parent_session_id: &str,
6717 activation_run_id: Option<&str>,
6718 execution_epoch: u64,
6719 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6720 consumed_seq: u64,
6721) -> Result<(), AgentError> {
6722 let identity = actor_event_logical_identity(session, parent_session_id, creation);
6723 if watermark
6724 .zip(activation_run_id)
6725 .is_some_and(|(watermark, run_id)| {
6726 watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6727 })
6728 {
6729 Ok(())
6730 } else {
6731 Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6732 }
6733}
6734
6735fn validate_actor_event_batch(
6736 batch: &ActorEventBatch,
6737 logical_session: &Session,
6738 parent_session_id: &str,
6739 activation_run_id: Option<&str>,
6740 execution_epoch: u64,
6741 expected_source_actor_id: &str,
6742 expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6743) -> Result<(), AgentError> {
6744 batch
6745 .validate()
6746 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6747 let identity = batch.logical_session.as_ref().ok_or_else(|| {
6748 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6749 })?;
6750 let expected_identity =
6755 actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6756 if identity != &expected_identity {
6757 return Err(AgentError::LLM(
6758 "actor event batch targets a different logical session".to_string(),
6759 ));
6760 }
6761 if batch.activation_id.as_deref() != activation_run_id {
6762 return Err(AgentError::LLM(
6763 "actor event batch belongs to a stale activation".to_string(),
6764 ));
6765 }
6766 if batch.execution_epoch != execution_epoch {
6767 return Err(AgentError::LLM(
6768 "actor event batch belongs to a stale execution epoch".to_string(),
6769 ));
6770 }
6771 if batch
6772 .source_actor_id
6773 .as_deref()
6774 .is_some_and(|source| source != expected_source_actor_id)
6775 {
6776 return Err(AgentError::LLM(
6777 "actor event batch declares a different physical actor".to_string(),
6778 ));
6779 }
6780 Ok(())
6781}
6782
6783async fn validate_directory_actor_event(
6787 store: &bamboo_storage::SessionStoreV2,
6788 fence: &ActorActivationFence,
6789 session: &Session,
6790 run_id: Option<&str>,
6791) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6792 store
6793 .validate_fence(fence, chrono::Utc::now())
6794 .await
6795 .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6796 let entry = store
6797 .inspect_actor(&fence.actor_id)
6798 .await
6799 .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6800 if !entry.actor.matches_session(session)
6801 || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6802 || !entry.activation.as_ref().is_some_and(|current| {
6803 current.matches_fence(fence)
6804 && current.status == bamboo_domain::ActorActivationStatus::Running
6805 })
6806 || run_id.is_some_and(|id| id != fence.run_id)
6807 {
6808 return Err(AgentError::LLM("actor event authority changed".into()));
6809 }
6810 Ok(entry)
6811}
6812
6813async fn inspect_active_owned_tree(
6816 store: &bamboo_storage::SessionStoreV2,
6817 binding: &SessionInboxRuntimeBinding,
6818 run_id: &str,
6819 child_id: &str,
6820 parent_id: &str,
6821 logical_session: &Session,
6822 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6823 plain_input: Option<&PlainActorActivation>,
6824 cursor: Option<&str>,
6825) -> Option<serde_json::Value> {
6826 use bamboo_agent_core::storage::Storage;
6827 let current = match store.load_session(child_id).await {
6828 Ok(Some(current)) => current,
6829 _ => {
6830 tracing::warn!("owned tree denied: canonical caller unavailable");
6831 return None;
6832 }
6833 };
6834 let current_project = match project_id_for_actor_run(¤t) {
6835 Ok(project) => project,
6836 Err(_) => {
6837 tracing::warn!("owned tree denied: invalid canonical Project");
6838 return None;
6839 }
6840 };
6841 let logical_project = match project_id_for_actor_run(logical_session) {
6842 Ok(project) => project,
6843 Err(_) => {
6844 tracing::warn!("owned tree denied: invalid logical Project");
6845 return None;
6846 }
6847 };
6848 if current.id != child_id
6849 || current.kind != bamboo_domain::SessionKind::Child
6850 || current.parent_session_id.as_deref() != Some(parent_id)
6851 || current.parent_session_id != logical_session.parent_session_id
6852 || current.root_session_id != logical_session.root_session_id
6853 || current.created_at != creation.created_at
6854 || current.created_at != logical_session.created_at
6855 || current.spawn_depth != creation.spawn_depth
6856 || current.spawn_depth != logical_session.spawn_depth
6857 || current_project != logical_project
6858 {
6859 tracing::warn!("owned tree denied: caller lifetime or Project changed");
6860 return None;
6861 }
6862 if let Some(activation) = plain_input {
6863 validate_directory_actor_event(
6864 activation.store.as_ref(),
6865 &activation.fence,
6866 ¤t,
6867 Some(run_id),
6868 )
6869 .await
6870 .ok()?;
6871 } else if !binding.router.owns_run(child_id, run_id).await {
6872 tracing::warn!("owned tree denied: activation owner changed");
6873 return None;
6874 }
6875 let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6876 store, child_id, cursor,
6877 )
6878 .await
6879 {
6880 Ok(page) => page,
6881 Err(error) => {
6882 tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6883 return None;
6884 }
6885 };
6886 let again = match store.load_session(child_id).await {
6887 Ok(Some(again)) => again,
6888 _ => {
6889 tracing::warn!("owned tree denied: canonical caller disappeared");
6890 return None;
6891 }
6892 };
6893 if again.created_at != current.created_at
6894 || again.parent_session_id != current.parent_session_id
6895 || again.root_session_id != current.root_session_id
6896 || again.spawn_depth != current.spawn_depth
6897 || project_id_for_actor_run(&again).ok()? != current_project
6898 {
6899 tracing::warn!("owned tree denied: caller changed during inspection");
6900 return None;
6901 }
6902 if let Some(activation) = plain_input {
6903 validate_directory_actor_event(
6904 activation.store.as_ref(),
6905 &activation.fence,
6906 &again,
6907 Some(run_id),
6908 )
6909 .await
6910 .ok()?;
6911 } else if !binding.router.owns_run(child_id, run_id).await {
6912 tracing::warn!("owned tree denied: activation owner changed after inspection");
6913 return None;
6914 }
6915 serde_json::to_vec(&page)
6916 .ok()
6917 .filter(|bytes| bytes.len() <= 8192)?;
6918 Some(page)
6919}
6920
6921async fn load_active_subagent_caller(
6925 store: &bamboo_storage::SessionStoreV2,
6926 binding: &SessionInboxRuntimeBinding,
6927 run_id: &str,
6928 child_id: &str,
6929 parent_id: &str,
6930 logical_session: &Session,
6931 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6932 plain_input: Option<&PlainActorActivation>,
6933) -> Option<Session> {
6934 use bamboo_agent_core::storage::Storage;
6935 let current = store.load_session(child_id).await.ok().flatten()?;
6936 let current_project = project_id_for_actor_run(¤t).ok()?;
6937 let logical_project = project_id_for_actor_run(logical_session).ok()?;
6938 if current.id != child_id
6939 || current.kind != bamboo_domain::SessionKind::Child
6940 || current.parent_session_id.as_deref() != Some(parent_id)
6941 || current.parent_session_id != logical_session.parent_session_id
6942 || current.root_session_id != logical_session.root_session_id
6943 || current.created_at != creation.created_at
6944 || current.created_at != logical_session.created_at
6945 || current.spawn_depth != creation.spawn_depth
6946 || current.spawn_depth != logical_session.spawn_depth
6947 || current_project != logical_project
6948 || bamboo_domain::ActorSession::from_session(¤t).is_err()
6949 {
6950 return None;
6951 }
6952 if let Some(activation) = plain_input {
6953 validate_directory_actor_event(
6954 activation.store.as_ref(),
6955 &activation.fence,
6956 ¤t,
6957 Some(run_id),
6958 )
6959 .await
6960 .ok()?;
6961 } else if !binding.router.owns_run(child_id, run_id).await {
6962 return None;
6963 }
6964 Some(current)
6965}
6966
6967async fn invoke_active_subagent(
6968 tool: &dyn bamboo_agent_core::tools::Tool,
6969 caller: &Session,
6970 args: serde_json::Value,
6971 tool_call_id: &str,
6972) -> serde_json::Value {
6973 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
6974 let mut ctx = ToolCtx::none(tool_call_id.to_owned());
6975 ctx.session_id = Some(Arc::from(caller.id.as_str()));
6976 match tool.invoke(args, ctx).await {
6977 Ok(ToolOutcome::Completed(result))
6978 if result.images.is_empty()
6979 && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
6980 {
6981 serde_json::json!({"result":result})
6982 }
6983 _ => serde_json::json!({
6984 "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
6985 }),
6986 }
6987}
6988
6989#[allow(clippy::too_many_arguments)]
6992async fn checkpoint_active_parent_question(
6993 store: &bamboo_storage::SessionStoreV2,
6994 binding: &SessionInboxRuntimeBinding,
6995 run_id: &str,
6996 child_id: &str,
6997 parent_id: &str,
6998 logical_session: &mut Session,
6999 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7000 plain_input: Option<&PlainActorActivation>,
7001 observation: ParentQuestionCheckpointV1,
7002) -> Option<String> {
7003 use bamboo_agent_core::storage::Storage;
7004 if plain_input.is_some() || observation.validate_shape().is_err() {
7005 return None;
7006 }
7007 let _session_guard = binding
7011 .parent_question_lock
7012 .as_ref()?
7013 .acquire_lock(child_id)
7014 .await;
7015 let caller = load_active_subagent_caller(
7016 store,
7017 binding,
7018 run_id,
7019 child_id,
7020 parent_id,
7021 logical_session,
7022 creation,
7023 plain_input,
7024 )
7025 .await?;
7026 let parent = store.load_session(parent_id).await.ok().flatten()?;
7027 if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7028 return None;
7029 }
7030 let (saved, question) = store
7031 .checkpoint_parent_question(&caller, &parent, &observation)
7032 .await
7033 .ok()
7034 .flatten()?;
7035 logical_session.messages = saved.messages;
7038 logical_session.pending_question = saved.pending_question;
7039 if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7040 logical_session
7041 .metadata
7042 .insert("runtime.suspend_reason".into(), reason.clone());
7043 }
7044 if let Some(request) = saved
7045 .metadata
7046 .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7047 {
7048 logical_session.metadata.insert(
7049 bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7050 request.clone(),
7051 );
7052 }
7053 logical_session.metadata.insert(
7054 "runtime.actor_parent_question_handoff".into(),
7055 "true".into(),
7056 );
7057 Some(question.id.as_str().to_owned())
7058}
7059
7060async fn ensure_active_nested_wait(
7065 tool: &dyn bamboo_agent_core::tools::Tool,
7066 store: &bamboo_storage::SessionStoreV2,
7067 binding: &SessionInboxRuntimeBinding,
7068 run_id: &str,
7069 child_id: &str,
7070 parent_id: &str,
7071 logical_session: &Session,
7072 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7073 plain_input: Option<&PlainActorActivation>,
7074) -> Result<bool, AgentError> {
7075 use bamboo_agent_core::storage::Storage;
7076 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7077
7078 let caller = load_active_subagent_caller(
7079 store,
7080 binding,
7081 run_id,
7082 child_id,
7083 parent_id,
7084 logical_session,
7085 creation,
7086 plain_input,
7087 )
7088 .await
7089 .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7090 if caller
7091 .agent_runtime_state
7092 .as_ref()
7093 .and_then(|state| state.waiting_for_children.as_ref())
7094 .is_some()
7095 {
7096 return Ok(true);
7097 }
7098
7099 let active = store
7100 .list_child_run_statuses(child_id)
7101 .await
7102 .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?
7103 .into_iter()
7104 .filter(|(_, status)| {
7105 !status.as_deref().is_some_and(|status| {
7106 matches!(
7107 status,
7108 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7109 )
7110 })
7111 })
7112 .map(|(id, _)| id)
7113 .collect::<Vec<_>>();
7114 if active.is_empty() {
7115 return Ok(false);
7116 }
7117
7118 let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7119 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7120 let outcome = tokio::time::timeout(
7121 Duration::from_secs(30),
7122 tool.invoke(
7123 serde_json::json!({"action":"wait", "child_session_ids":active}),
7124 ctx,
7125 ),
7126 )
7127 .await
7128 .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7129 .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7130 let wait_registered = match outcome {
7131 ToolOutcome::Completed(result) if result.success => {
7132 let payload: serde_json::Value = serde_json::from_str(&result.result)
7133 .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7134 payload["status"] == "waiting"
7135 }
7136 _ => {
7137 return Err(AgentError::LLM(
7138 "nested wait registration was not completed".into(),
7139 ))
7140 }
7141 };
7142
7143 let latest = load_active_subagent_caller(
7146 store,
7147 binding,
7148 run_id,
7149 child_id,
7150 parent_id,
7151 logical_session,
7152 creation,
7153 plain_input,
7154 )
7155 .await
7156 .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7157 if latest
7158 .agent_runtime_state
7159 .as_ref()
7160 .and_then(|state| state.waiting_for_children.as_ref())
7161 .is_some()
7162 {
7163 return Ok(true);
7164 }
7165 let still_active = store
7166 .list_child_run_statuses(child_id)
7167 .await
7168 .map_err(|error| {
7169 AgentError::LLM(format!("nested child wait verification failed: {error}"))
7170 })?
7171 .into_iter()
7172 .any(|(_, status)| {
7173 !status.as_deref().is_some_and(|status| {
7174 matches!(
7175 status,
7176 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7177 )
7178 })
7179 });
7180 if still_active {
7181 return Err(AgentError::LLM(
7182 "nested child remains active without a durable parent wait".into(),
7183 ));
7184 }
7185 Ok(wait_registered)
7190}
7191
7192fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7193 let message = match error {
7194 ActorEventRouteError::SequenceGap { .. } => {
7195 "actor event sequence gap; authoritative session snapshot is required"
7196 }
7197 ActorEventRouteError::LifecycleMissing => {
7198 "actor emitted live content before its activation lifecycle event"
7199 }
7200 ActorEventRouteError::ForgedSession => {
7201 "actor event payload targets a different canonical session"
7202 }
7203 ActorEventRouteError::ConflictingReplay => {
7204 "actor replay reused an event sequence with different content"
7205 }
7206 ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7207 ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7208 };
7209 AgentError::LLM(message.into())
7210}
7211
7212const MAX_DISPLAY_CALLS: usize = 4_096;
7213const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7214const MAX_DISPLAY_ID_BYTES: usize = 256;
7215const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7216
7217fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7218 if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7219 return None;
7220 }
7221 Some(match result {
7222 HookResult::Continue => HookResult::Continue,
7223 HookResult::Mutated => HookResult::Mutated,
7224 HookResult::Allow => HookResult::Allow,
7225 HookResult::Ask => HookResult::Ask,
7226 HookResult::Deny { .. } => HookResult::Deny {
7227 reason: "Hook denied".into(),
7228 },
7229 HookResult::InjectContext { .. } => HookResult::InjectContext {
7230 text: "Hook context hidden".into(),
7231 },
7232 HookResult::WithContext { result, .. } => HookResult::WithContext {
7233 result: Box::new(hook_result_for_display(*result, depth + 1)?),
7234 text: "Hook context hidden".into(),
7235 },
7236 HookResult::Suspend { .. } => HookResult::Suspend {
7237 reason: "Hook suspended".into(),
7238 },
7239 HookResult::Abort { .. } => HookResult::Abort {
7240 reason: "Hook aborted".into(),
7241 },
7242 })
7243}
7244
7245#[derive(Clone, Copy, PartialEq, Eq)]
7246enum DisplayCallKind {
7247 Ordinary,
7248 PrivateBrowser,
7249 Reused,
7250}
7251
7252#[derive(Clone, Copy)]
7253struct DisplayCall {
7254 kind: DisplayCallKind,
7255 started: bool,
7256}
7257
7258#[derive(Default)]
7259struct ActorEventDisplay {
7260 calls: HashMap<String, DisplayCall>,
7261 hook_names: HashMap<String, usize>,
7262 children: HashMap<(String, String), Box<ActorEventDisplay>>,
7263 identities: Arc<AtomicUsize>,
7266 overflowed: bool,
7267 nested_overflowed: bool,
7268}
7269
7270fn is_browser_tool_name(tool_name: &str) -> bool {
7271 tool_name
7272 .trim()
7273 .rsplit("::")
7274 .next()
7275 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7276}
7277
7278fn known_browser_action(args: &serde_json::Value) -> bool {
7279 matches!(
7280 args.get("action").and_then(serde_json::Value::as_str),
7281 Some(
7282 "tabs"
7283 | "new_tab"
7284 | "activate_tab"
7285 | "close_tab"
7286 | "navigate"
7287 | "history"
7288 | "viewport"
7289 | "snapshot"
7290 | "click"
7291 | "click_at"
7292 | "hover"
7293 | "drag"
7294 | "fill"
7295 | "select_option"
7296 | "set_file_input"
7297 | "type"
7298 | "press"
7299 | "key"
7300 | "scroll"
7301 | "download"
7302 | "screenshot"
7303 | "dialog_respond"
7304 )
7305 )
7306}
7307
7308fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7309 is_browser_tool_name(tool_name)
7310 && (!known_browser_action(args)
7311 || args
7312 .get("action")
7313 .and_then(serde_json::Value::as_str)
7314 .is_some_and(|action| {
7315 action.eq_ignore_ascii_case("download") || action == "set_file_input"
7316 })
7317 || args.get("data_base64").is_some())
7318}
7319
7320fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7321 if is_browser_tool_name(tool_name) {
7322 if !known_browser_action(args) {
7325 return serde_json::json!({"details":"[redacted]"});
7326 }
7327 }
7328 tool_start_arguments_for_display(tool_name, args)
7329}
7330
7331impl ActorEventDisplay {
7332 fn fail_closed(&mut self) {
7333 self.identities
7334 .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7335 self.overflowed = true;
7336 self.calls.clear();
7337 self.hook_names.clear();
7338 self.nested_overflowed = true;
7339 self.children.clear();
7340 }
7341
7342 fn reserve_identity(&mut self) -> bool {
7343 if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7344 true
7345 } else {
7346 self.fail_closed();
7347 false
7348 }
7349 }
7350
7351 fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7352 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7353 return DisplayCallKind::Reused;
7354 }
7355 let kind = if private {
7356 DisplayCallKind::PrivateBrowser
7357 } else {
7358 DisplayCallKind::Ordinary
7359 };
7360 if let Some(call) = self.calls.get_mut(id) {
7361 if call.started || call.kind != kind {
7362 call.kind = DisplayCallKind::Reused;
7363 }
7364 call.started = true;
7365 return call.kind;
7366 }
7367 if !self.reserve_identity() {
7368 return DisplayCallKind::Reused;
7369 }
7370 self.calls.insert(
7371 id.to_string(),
7372 DisplayCall {
7373 kind,
7374 started: true,
7375 },
7376 );
7377 kind
7378 }
7379
7380 fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7381 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7382 return DisplayCallKind::Reused;
7383 }
7384 let kind = if private {
7385 DisplayCallKind::PrivateBrowser
7386 } else {
7387 DisplayCallKind::Ordinary
7388 };
7389 if let Some(call) = self.calls.get_mut(id) {
7390 if call.kind != kind {
7391 call.kind = DisplayCallKind::Reused;
7392 }
7393 return call.kind;
7394 }
7395 if !self.reserve_identity() {
7396 return DisplayCallKind::Reused;
7397 }
7398 self.calls.insert(
7399 id.to_string(),
7400 DisplayCall {
7401 kind,
7402 started: false,
7403 },
7404 );
7405 kind
7406 }
7407
7408 fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7409 if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7410 return None;
7411 }
7412 self.calls
7413 .get(id)
7414 .filter(|call| call.started)
7415 .map(|call| call.kind)
7416 }
7417
7418 fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7419 if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7420 self.fail_closed();
7421 return None;
7422 }
7423 if let Some(index) = self.hook_names.get(hook_name) {
7424 return Some(format!("actor-hook-{index}"));
7425 }
7426 if !self.reserve_identity() {
7427 return None;
7428 }
7429 let index = self.hook_names.len() + 1;
7430 self.hook_names.insert(hook_name.to_string(), index);
7431 Some(format!("actor-hook-{index}"))
7432 }
7433
7434 fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7435 let call_id = match &event {
7436 AgentEvent::ToolStart { tool_call_id, .. }
7437 | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7438 | AgentEvent::ToolToken { tool_call_id, .. }
7439 | AgentEvent::ToolComplete { tool_call_id, .. }
7440 | AgentEvent::ToolError { tool_call_id, .. }
7441 | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7442 _ => None,
7443 };
7444 if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7445 self.fail_closed();
7446 return None;
7447 }
7448 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7449 self.fail_closed();
7450 }
7451 Some(match event {
7452 AgentEvent::ToolStart {
7453 tool_call_id,
7454 tool_name,
7455 arguments,
7456 } => {
7457 let private = is_private_browser_call(&tool_name, &arguments);
7458 let kind = self.remember_start(&tool_call_id, private);
7459 let arguments = if kind == DisplayCallKind::Reused {
7460 serde_json::json!({"details":"[redacted]"})
7461 } else {
7462 actor_arguments_for_display(&tool_name, &arguments)
7463 };
7464 AgentEvent::ToolStart {
7465 tool_call_id,
7466 tool_name: match kind {
7467 DisplayCallKind::Reused => "unknown".into(),
7468 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7469 _ => tool_name,
7470 },
7471 arguments,
7472 }
7473 }
7474 AgentEvent::ToolApprovalRequested {
7475 tool_call_id,
7476 tool_name,
7477 parameters,
7478 } => {
7479 let private = is_private_browser_call(&tool_name, ¶meters);
7480 let kind = self.remember_approval(&tool_call_id, private);
7481 let parameters = if kind == DisplayCallKind::Reused {
7482 serde_json::json!({"details":"[redacted]"})
7483 } else {
7484 actor_arguments_for_display(&tool_name, ¶meters)
7485 };
7486 AgentEvent::ToolApprovalRequested {
7487 tool_call_id,
7488 tool_name: match kind {
7489 DisplayCallKind::Reused => "unknown".into(),
7490 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7491 _ => tool_name,
7492 },
7493 parameters,
7494 }
7495 }
7496 AgentEvent::ToolToken {
7497 tool_call_id,
7498 content,
7499 } => {
7500 let content = match self.known_kind(&tool_call_id) {
7501 Some(DisplayCallKind::Ordinary) => content,
7502 Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7503 _ => "Tool output hidden".into(),
7504 };
7505 AgentEvent::ToolToken {
7506 tool_call_id,
7507 content,
7508 }
7509 }
7510 AgentEvent::ToolComplete {
7511 tool_call_id,
7512 result,
7513 } => {
7514 let result = match self.known_kind(&tool_call_id) {
7515 Some(DisplayCallKind::Ordinary) => result,
7516 Some(DisplayCallKind::PrivateBrowser) => {
7517 bamboo_agent_core::tools::ToolResult::text(
7518 result.success,
7519 "Browser tool result hidden",
7520 )
7521 }
7522 _ => bamboo_agent_core::tools::ToolResult::text(
7523 result.success,
7524 "Tool result hidden",
7525 ),
7526 };
7527 AgentEvent::ToolComplete {
7528 tool_call_id,
7529 result,
7530 }
7531 }
7532 AgentEvent::ToolError {
7533 tool_call_id,
7534 error,
7535 } => {
7536 let error = match self.known_kind(&tool_call_id) {
7537 Some(DisplayCallKind::Ordinary) => error,
7538 Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7539 _ => "Tool failed".into(),
7540 };
7541 AgentEvent::ToolError {
7542 tool_call_id,
7543 error,
7544 }
7545 }
7546 AgentEvent::ToolLifecycle {
7547 tool_call_id,
7548 tool_name,
7549 phase,
7550 elapsed_ms,
7551 is_mutating,
7552 auto_approved,
7553 summary,
7554 error,
7555 } => {
7556 let kind = self.known_kind(&tool_call_id);
7557 let (summary, error) = match kind {
7558 Some(DisplayCallKind::Ordinary) => (summary, error),
7559 Some(DisplayCallKind::PrivateBrowser) => (
7560 summary.map(|_| "Browser tool activity hidden".into()),
7561 error.map(|_| "Browser tool failed".into()),
7562 ),
7563 _ => (
7564 summary.map(|_| "Tool activity hidden".into()),
7565 error.map(|_| "Tool failed".into()),
7566 ),
7567 };
7568 AgentEvent::ToolLifecycle {
7569 tool_call_id,
7570 tool_name: match kind {
7571 Some(DisplayCallKind::Ordinary) => tool_name,
7572 Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7573 _ => "unknown".into(),
7574 },
7575 phase: if kind == Some(DisplayCallKind::Ordinary)
7576 || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7577 {
7578 phase
7579 } else {
7580 "event".into()
7581 },
7582 elapsed_ms,
7583 is_mutating,
7584 auto_approved,
7585 summary,
7586 error,
7587 }
7588 }
7589 AgentEvent::HookLifecycle {
7590 hook_name,
7591 point,
7592 phase,
7593 duration_ms,
7594 decision,
7595 } => {
7596 let Some(decision) = hook_result_for_display(decision, 0) else {
7597 self.fail_closed();
7598 return None;
7599 };
7600 let hook_name = self.display_hook_name(&hook_name)?;
7601 let phase = match phase.as_str() {
7602 "completed" | "started" | "error" => phase,
7603 _ => "event".into(),
7604 };
7605 AgentEvent::HookLifecycle {
7606 hook_name,
7607 point,
7608 phase,
7609 duration_ms,
7610 decision,
7611 }
7612 }
7613 AgentEvent::ChildApprovalRequested {
7614 child_session_id,
7615 request_id,
7616 tool_name,
7617 permission,
7618 resource,
7619 } => {
7620 let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7621 (
7622 "browser".into(),
7623 "Browser interaction approval".into(),
7624 "[redacted]".into(),
7625 )
7626 } else {
7627 (tool_name, permission, resource)
7628 };
7629 AgentEvent::ChildApprovalRequested {
7630 child_session_id,
7631 request_id,
7632 tool_name,
7633 permission,
7634 resource,
7635 }
7636 }
7637 AgentEvent::ChildApprovalChanged { tool_name, .. }
7638 if is_browser_tool_name(&tool_name) =>
7639 {
7640 return None;
7644 }
7645 AgentEvent::SubAgentEvent {
7646 parent_session_id,
7647 child_session_id,
7648 event,
7649 } => {
7650 if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7651 return None;
7652 }
7653 if parent_session_id.is_empty()
7654 || child_session_id.is_empty()
7655 || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7656 || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7657 {
7658 self.fail_closed();
7659 return None;
7660 }
7661 let key = (parent_session_id.clone(), child_session_id.clone());
7662 if !self.children.contains_key(&key) && !self.reserve_identity() {
7663 return None;
7664 }
7665 let identities = self.identities.clone();
7666 let child = self.children.entry(key).or_insert_with(|| {
7667 Box::new(ActorEventDisplay {
7668 identities,
7669 ..Default::default()
7670 })
7671 });
7672 let event = child.project(*event, depth + 1)?;
7673 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7674 self.fail_closed();
7675 return None;
7676 }
7677 AgentEvent::SubAgentEvent {
7678 parent_session_id,
7679 child_session_id,
7680 event: Box::new(event),
7681 }
7682 }
7683 other => other,
7684 })
7685 }
7686}
7687
7688#[allow(clippy::too_many_arguments)]
7689async fn process_actor_event(
7690 event: serde_json::Value,
7691 strict_permission_events: bool,
7692 permission_handshake: &mut PermissionPostureHandshake,
7693 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7694 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7695 logical_session: &mut Session,
7696 event_tx: &mpsc::Sender<AgentEvent>,
7697 display: &mut ActorEventDisplay,
7698) -> crate::runtime::runner::Result<()> {
7699 let event = match serde_json::from_value::<AgentEvent>(event) {
7700 Ok(event) => event,
7701 Err(_) if strict_permission_events => {
7702 return Err(AgentError::LLM(
7706 "actor emitted malformed AgentEvent under a typed permission posture contract"
7707 .to_string(),
7708 ));
7709 }
7710 Err(_) => {
7711 display.fail_closed();
7715 return Ok(());
7716 }
7717 };
7718 process_typed_actor_event(
7719 event,
7720 permission_handshake,
7721 expected_permission_posture,
7722 session_inbox_runtime,
7723 logical_session,
7724 event_tx,
7725 display,
7726 )
7727 .await
7728}
7729
7730#[allow(clippy::too_many_arguments)]
7731async fn process_typed_actor_event(
7732 event: AgentEvent,
7733 permission_handshake: &mut PermissionPostureHandshake,
7734 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7735 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7736 logical_session: &mut Session,
7737 event_tx: &mpsc::Sender<AgentEvent>,
7738 display: &mut ActorEventDisplay,
7739) -> crate::runtime::runner::Result<()> {
7740 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7741 if permission_handshake.posture_was_confirmed() {
7742 return Err(AgentError::LLM(
7743 "actor emitted a duplicate permission posture activation".to_string(),
7744 ));
7745 }
7746 let seed = permission_posture_seed_from_event(logical_session, &event)
7747 .map_err(AgentError::LLM)?
7748 .ok_or_else(|| {
7749 AgentError::LLM(
7750 "actor permission posture event did not decode as a posture".to_string(),
7751 )
7752 })?;
7753 if let Some(expected) = expected_permission_posture {
7754 if seed.policy_revision != expected.policy_revision
7755 || seed.resolution != expected.resolution
7756 {
7757 return Err(AgentError::LLM(
7758 "permission posture event does not match the host-dispatched policy"
7759 .to_string(),
7760 ));
7761 }
7762 if seed.executor_mapping() != expected.executor_mapping {
7763 return Err(AgentError::LLM(
7764 "permission posture event does not match the host-dispatched executor mapping"
7765 .to_string(),
7766 ));
7767 }
7768 }
7769 if let Some(binding) = session_inbox_runtime {
7770 let saved = binding
7771 .persistence
7772 .record_permission_posture_activation(
7773 &logical_session.id,
7774 expected_permission_posture
7775 .and_then(|expected| expected.expected_audit_revision),
7776 &seed,
7777 )
7778 .await
7779 .map_err(|error| {
7780 AgentError::LLM(format!(
7781 "persist child permission posture bootstrap: {error}"
7782 ))
7783 })?
7784 .ok_or_else(|| {
7785 AgentError::LLM(
7786 "persist child permission posture bootstrap: session not found".to_string(),
7787 )
7788 })?;
7789 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7790 .ok_or_else(|| {
7791 AgentError::LLM(
7792 "persisted child permission posture audit is incomplete".to_string(),
7793 )
7794 })?;
7795 snapshot.write_to(&mut logical_session.metadata);
7796 } else {
7797 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7800 .map_err(|error| {
7801 AgentError::LLM(format!(
7802 "record in-memory child permission posture: {error}"
7803 ))
7804 })?;
7805 }
7806 *permission_handshake = PermissionPostureHandshake::Confirmed;
7809 } else if permission_handshake.is_awaiting() {
7810 return Err(AgentError::LLM(
7811 "actor emitted an execution event before permission posture confirmation".to_string(),
7812 ));
7813 }
7814 if let Some(event) = display.project(event, 0) {
7815 let _ = event_tx.send(event).await;
7816 }
7817 Ok(())
7818}
7819
7820async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7831 let ActorDriveContext {
7832 client,
7833 parent_session_id,
7834 child_session_id,
7835 child_attempt,
7836 approval_registry,
7837 approval_decider,
7838 approval_reviewer,
7839 escalation_bridge,
7840 event_tx,
7841 cancel_token,
7842 live_rx,
7843 delivery_rx,
7844 logical_session,
7845 mut expected_permission_posture,
7846 expected_creation,
7847 session_inbox_runtime,
7848 actor_directory_store,
7849 canonical_subagent_tool,
7850 ticket_service,
7851 activation_run_id,
7852 execution_epoch,
7853 expected_source_actor_id,
7854 initial_inflight_claims,
7855 first_frame_timeout,
7856 plain_actor,
7857 remote_environment_lease,
7858 mut readonly_output,
7859 local_history_tools,
7860 local_history_read_only,
7861 mut strict_history_output,
7862 plain_input,
7863 canonical_activation,
7864 canonical_placement_ref,
7865 actor_event_observer,
7866 plain_run,
7867 } = context;
7868
7869 logical_session
7873 .metadata
7874 .remove("runtime.actor_nested_wait_handoff");
7875
7876 let mut got_first_frame = false;
7883 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7884 let mut inflight_claims = initial_inflight_claims;
7885 let mut owned_input = plain_input.and_then(|activation| activation.initial_input.clone());
7886 let continuation_limit = if owned_input.is_some() { 1 } else { 2 };
7889 let mut continuations = usize::from(owned_input.is_some());
7890 let mut released_input: Option<(
7891 SessionInboxOwnedClaim,
7892 bamboo_subagent::proto::InitialInputRelease,
7893 )> = None;
7894 let mut current_epoch = execution_epoch;
7895 let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7896 let approval_cancel = cancel_token.child_token();
7897 let _approval_lifetime = approval_cancel.clone().drop_guard();
7898 let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7899 HashMap::new();
7900 let mut owned_tree_requests = 0usize;
7901 let mut canonical_subagent_requests = 0usize;
7902 let mut committed_plan_calls = HashMap::new();
7903 let mut nested_wait_registered_this_run = false;
7907 let mut parent_question_checkpoint_id: Option<String> = None;
7908 let strict_permission_events = expected_permission_posture.is_some();
7909 let mut permission_handshake =
7910 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
7911 let mut next_actor_event_seq = 1u64;
7912 let mut canonical_router: Option<ActorEventRouter> = None;
7913 let canonical_fence = plain_input
7914 .map(|activation| &activation.fence)
7915 .or(canonical_activation);
7916 let canonical_store = plain_input
7917 .map(|activation| activation.store.as_ref())
7918 .or(actor_directory_store);
7919 let remote_claim_fence = actor_directory_store.zip(canonical_activation);
7920 let workflow_usage_requested = logical_session
7921 .metadata
7922 .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
7923 .is_some_and(|v| v == "true");
7924 if workflow_usage_requested {
7925 logical_session
7926 .metadata
7927 .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
7928 logical_session
7929 .metadata
7930 .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
7931 }
7932 let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
7933 let mut display = ActorEventDisplay::default();
7934 let mut readonly = readonly_output
7935 .as_ref()
7936 .map(|_| ReadOnlyActorCollector::default());
7937 let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
7938 loop {
7939 tokio::select! {
7940 _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
7941 if remote_claim_fence.is_some() || workflow_usage_requested {
7942 let _ = client.send(ParentFrame::Cancel).await;
7948 remote_cancel_deadline = Some(
7949 tokio::time::Instant::now() + Duration::from_secs(10)
7950 );
7951 continue;
7952 }
7953 break;
7955 }
7956 _ = async {
7957 if let Some(deadline) = remote_cancel_deadline {
7958 tokio::time::sleep_until(deadline).await;
7959 } else {
7960 std::future::pending::<()>().await;
7961 }
7962 } => {
7963 return Err(AgentError::Cancelled);
7964 }
7965 _ = async {
7966 match first_frame_watch.as_mut() {
7967 Some(s) => s.as_mut().await,
7968 None => std::future::pending::<()>().await,
7969 }
7970 }, if !got_first_frame => {
7971 return Err(AgentError::WorkerUnresponsive(format!(
7972 "child {child_session_id} produced no frame within {:?}",
7973 first_frame_timeout.unwrap_or_default()
7974 )));
7975 }
7976 Some(_generation) = delivery_rx.recv(),
7977 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
7978 {
7979 if plain_input.is_some() {
7980 continue;
7983 }
7984 forward_next_canonical_claim(
7985 client,
7986 session_inbox_runtime.expect("guarded"),
7987 logical_session,
7988 activation_run_id.expect("guarded"),
7989 &mut inflight_claims,
7990 remote_claim_fence,
7991 )
7992 .await?;
7993 }
7994 Some(frame) = live_rx.recv() => {
7995 if plain_actor { return Err(plain_actor_unsupported()); }
7996 if client.send(frame).await.is_err() {
7998 tracing::warn!("live steering frame could not be sent; connection failing");
7999 }
8000 }
8001 frame = client.next_frame() => {
8002 got_first_frame = true;
8005 first_frame_watch = None;
8006 match frame {
8007 Ok(Some(ChildFrame::Event { event })) => {
8008 if canonical_activation.is_some() {
8009 return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8010 }
8011 if local_history.is_some() {
8012 return Err(local_tool_history_unsupported());
8013 }
8014 if event.get("initial_input_control").is_some() {
8015 let bamboo_subagent::proto::InitialInputControl::Request { request } =
8016 bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8017 else { return Err(plain_actor_unsupported()); };
8018 if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8019 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8020 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8021 let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8022 let claim = owned_input.as_ref().or_else(|| released_input.as_ref().map(|(claim, _)| claim))
8023 .ok_or_else(|| plain_actor_unsupported())?;
8024 let release = activation.release_initial_input(binding, logical_session, run, current_epoch, claim,
8025 &request, &mut expected_permission_posture, released_input.as_ref().map(|(_, release)| release)).await?;
8026 if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8027 client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8028 .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8029 released_input = Some((claim.clone(), release));
8030 owned_input = None;
8031 continue;
8032 }
8033 if expected_creation.is_some() {
8034 return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8035 }
8036 if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8037 if event["type"] == "owned_readonly_transcript" {
8038 if plain_actor { return Err(plain_actor_unsupported()); }
8039 continue;
8040 }
8041 if plain_actor && !plain_actor_event(&event)? { continue; }
8042 if let Some(activation) = plain_input {
8043 validate_directory_actor_event(
8044 activation.store.as_ref(),
8045 &activation.fence,
8046 logical_session,
8047 None,
8048 ).await?;
8049 }
8050 process_actor_event(
8054 event,
8055 strict_permission_events,
8056 &mut permission_handshake,
8057 expected_permission_posture.as_ref(),
8058 session_inbox_runtime,
8059 logical_session,
8060 event_tx,
8061 &mut display,
8062 )
8063 .await?;
8064 }
8065 Ok(Some(ChildFrame::EventBatch { batch })) => {
8066 validate_actor_event_batch(
8067 &batch,
8068 logical_session,
8069 parent_session_id,
8070 activation_run_id,
8071 current_epoch,
8072 expected_source_actor_id,
8073 expected_creation,
8074 )?;
8075 let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8076 let entry = validate_directory_actor_event(
8077 store, fence, logical_session, batch.activation_id.as_deref(),
8078 ).await?;
8079 if canonical_placement_ref.is_some_and(|expected| {
8080 entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8081 }) {
8082 return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8083 }
8084 Some(entry)
8085 } else { None };
8086 if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8087 if canonical_router.is_none() {
8088 canonical_router = Some(
8089 ActorEventRouter::new(
8090 entry,
8091 fence,
8092 current_epoch,
8093 strict_permission_events && permission_handshake.is_awaiting(),
8094 )
8095 .map_err(actor_event_route_error)?,
8096 );
8097 }
8098 }
8099 if local_history_tools.is_some()
8100 && (batch.first_seq != next_actor_event_seq
8101 || batch.qos != bamboo_subagent::ActorEventQos::Durable
8102 || batch.last_seq == u64::MAX) {
8103 return Err(local_tool_history_unsupported());
8104 }
8105 if batch.last_seq < next_actor_event_seq {
8106 continue;
8107 }
8108 if batch.first_seq > next_actor_event_seq {
8109 if plain_actor {
8110 return Err(AgentError::LLM(
8111 "actor event sequence gap; authoritative session snapshot is required".into(),
8112 ));
8113 }
8114 tracing::warn!(
8115 child_session_id,
8116 expected_seq = next_actor_event_seq,
8117 received_seq = batch.first_seq,
8118 "actor event sequence gap; authoritative session snapshot is required"
8119 );
8120 display.fail_closed();
8124 }
8125 let skip = next_actor_event_seq
8126 .saturating_sub(batch.first_seq)
8127 .min(batch.events.len() as u64) as usize;
8128 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8129 let seq = batch.first_seq + offset as u64;
8130 let mut publish = true;
8131 if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8132 if workflow_usage_requested {
8133 if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8134 let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8135 let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8136 if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8137 if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8138 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8139 }
8140 publish = false;
8141 }
8142 if publish { if let Some(collector) = local_history.as_mut() {
8143 if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8144 return Err(local_tool_history_unsupported());
8145 }
8146 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8147 publish = collector.ticket_event(&event, &committed_plan_calls)?;
8148 }
8149 publish &= collector.event(&event)?;
8150 } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8151 publish = false;
8153 }}
8154 if publish { publish = if let Some(collector) = readonly.as_mut() {
8155 if permission_handshake.is_awaiting()
8156 && event["type"] != "permission_posture_activated" {
8157 return Err(plain_actor_unsupported());
8158 }
8159 collector.event(&event)?
8160 } else if event["type"] == "owned_readonly_transcript" {
8161 if plain_actor { return Err(plain_actor_unsupported()); }
8162 false } else if plain_actor { plain_actor_event(&event)? } else { true };
8164 }
8165 if let (Some(router), Some(entry), Some(fence)) =
8166 (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8167 {
8168 let typed = if publish {
8169 Some(serde_json::from_value::<AgentEvent>(event.clone())
8170 .map_err(|_| plain_actor_unsupported())?)
8171 } else {
8172 None
8173 };
8174 match router.route(entry, fence, current_epoch, seq, typed)
8175 .map_err(actor_event_route_error)?
8176 {
8177 ActorEventRoute::Publish(envelope) => {
8178 let public_event = actor_event_observer
8179 .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8180 .map(|_| PublicActorEvent::from(envelope.as_ref()));
8181 tracing::trace!(
8182 actor_id = %envelope.actor_id,
8183 event_id = %envelope.event_id,
8184 class = ?envelope.class,
8185 "publishing canonical Actor event"
8186 );
8187 process_typed_actor_event(
8188 envelope.payload,
8189 &mut permission_handshake,
8190 expected_permission_posture.as_ref(),
8191 session_inbox_runtime,
8192 logical_session,
8193 event_tx,
8194 &mut display,
8195 ).await?;
8196 if let (Some(observer), Some(public_event)) =
8197 (actor_event_observer, public_event)
8198 {
8199 observer.publish(public_event);
8200 }
8201 }
8202 ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8203 }
8204 next_actor_event_seq = seq.saturating_add(1);
8205 continue;
8206 }
8207 if !publish {
8208 next_actor_event_seq = seq.saturating_add(1);
8209 continue;
8210 }
8211 process_actor_event(
8212 event,
8213 strict_permission_events,
8214 &mut permission_handshake,
8215 expected_permission_posture.as_ref(),
8216 session_inbox_runtime,
8217 logical_session,
8218 event_tx,
8219 &mut display,
8220 )
8221 .await?;
8222 next_actor_event_seq = seq.saturating_add(1);
8223 }
8224 }
8225 Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8226 owned_tree_requests += 1;
8227 if owned_tree_requests > 16 {
8228 return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8229 }
8230 let page = if id.len() <= 128
8231 && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8232 && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8233 && !cancel_token.is_cancelled()
8234 {
8235 match (actor_directory_store, session_inbox_runtime,
8236 activation_run_id, expected_creation) {
8237 (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8238 tokio::time::timeout(
8239 Duration::from_secs(8),
8240 inspect_active_owned_tree(
8241 store, binding, run_id, child_session_id,
8242 parent_session_id, logical_session, creation,
8243 plain_input, cursor.as_deref(),
8244 ),
8245 ).await.ok().flatten()
8246 }
8247 _ => {
8248 tracing::warn!("owned tree denied: canonical Host scope unavailable");
8249 None
8250 },
8251 }
8252 } else {
8253 tracing::warn!("owned tree denied: request bounds or cancellation");
8254 None
8255 };
8256 if cancel_token.is_cancelled() {
8257 return Err(AgentError::Cancelled);
8258 }
8259 client.send(ParentFrame::OwnedTreeReply { id, page }).await
8260 .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8261 }
8262 Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8263 canonical_subagent_requests += 1;
8264 if canonical_subagent_requests > 64 {
8265 return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8266 }
8267 let valid_shape = !id.is_empty() && id.len() <= 128
8268 && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8269 && args.is_object()
8270 && serde_json::to_vec(&args)
8271 .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8272 let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8273 Some("wait"))
8274 || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8275 && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8276 let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8277 let ticket_bootstrap_pending = valid_shape
8278 && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8279 && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8280 && permission_handshake.is_awaiting()
8281 && !cancel_token.is_cancelled();
8282 let result = if ticket_bootstrap_pending {
8283 serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8287 } else if valid_shape
8288 && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8289 || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8290 || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8291 && !cancel_token.is_cancelled()
8292 && !permission_handshake.is_awaiting()
8293 {
8294 if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8295 match (ticket_service.as_deref(), actor_directory_store,
8296 session_inbox_runtime, activation_run_id, expected_creation) {
8297 (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8298 if let Some(caller) = load_active_subagent_caller(
8299 store, binding, run_id, child_session_id,
8300 parent_session_id, logical_session, creation, plain_input,
8301 ).await {
8302 match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8303 Ok(projection) => {
8304 logical_session.task_list = projection.task_list.clone();
8305 logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8306 if let Some(question) = &projection.question {
8307 let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8308 logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8309 if let Some(collector) = local_history.as_mut() {
8310 collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8311 }
8312 }
8313 if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8314 committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8315 if let Some(task_list) = projection.task_list.clone() {
8316 let _ = event_tx.send(AgentEvent::TaskListUpdated {
8317 task_list,
8318 version: Some(projection.plan_revision),
8319 }).await;
8320 }
8321 }
8322 serde_json::json!({"result":projection})
8323 }
8324 Err(error) => serde_json::json!({"error":error.to_string()}),
8325 }
8326 } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8327 }
8328 _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8329 }
8330 } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8331 match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8332 activation_run_id, expected_creation, local_history_tools) {
8333 (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8334 if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8335 match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8336 Ok(reply)=>{
8337 let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8338 committed_plan_calls.insert(tool_call_id.clone(),serde_json::json!({"file_tool":body["tool"],"arguments":body["arguments"],"result":serde_json::to_string(&reply).map_err(|_|local_tool_history_unsupported())?}));
8339 serde_json::json!({"result":reply})
8340 },
8341 Err(error)=>serde_json::json!({"error":error.to_string()}),
8342 }
8343 } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8344 },
8345 _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8346 }
8347 } else if let Some(payload) = checkpoint_payload {
8348 let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8349 .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8350 .flatten()
8351 .filter(|observation| observation.tool_call_id == tool_call_id);
8352 match (parsed, actor_directory_store, session_inbox_runtime,
8353 activation_run_id, expected_creation) {
8354 (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8355 let receipt = tokio::time::timeout(Duration::from_secs(25),
8356 checkpoint_active_parent_question(
8357 store, binding, run_id, child_session_id,
8358 parent_session_id, logical_session, creation,
8359 plain_input, observation,
8360 )).await.ok().flatten();
8361 if let Some(request_id) = receipt {
8362 parent_question_checkpoint_id = Some(request_id.clone());
8363 serde_json::json!({"result":{"request_id":request_id}})
8364 } else {
8365 serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8366 }
8367 }
8368 _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8369 }
8370 } else {
8371 match (canonical_subagent_tool.as_deref(), actor_directory_store,
8372 session_inbox_runtime, activation_run_id, expected_creation) {
8373 (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8374 if let Some(caller) = load_active_subagent_caller(
8375 store, binding, run_id, child_session_id,
8376 parent_session_id, logical_session, creation,
8377 plain_input,
8378 ).await {
8379 tokio::time::timeout(
8380 Duration::from_secs(30),
8381 invoke_active_subagent(tool, &caller, args, &tool_call_id),
8382 ).await.unwrap_or_else(|_| serde_json::json!({
8383 "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8384 }))
8385 } else {
8386 serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8387 }
8388 }
8389 _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8390 }
8391 }
8392 } else {
8393 serde_json::json!({"error":"canonical SubAgent request rejected"})
8394 };
8395 if registers_wait
8396 && result["result"]["success"] == true
8397 && result["result"]["result"]
8398 .as_str()
8399 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8400 .is_some_and(|value| value["status"] == "waiting")
8401 {
8402 nested_wait_registered_this_run = true;
8403 }
8404 if cancel_token.is_cancelled() {
8405 return Err(AgentError::Cancelled);
8406 }
8407 client.send(ParentFrame::SubAgentReply { id, result }).await
8408 .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8409 }
8410 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8411 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8412 return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8413 }
8414 if plain_actor { return Err(plain_actor_unsupported()); }
8415 if permission_handshake.is_awaiting() {
8416 return Err(AgentError::LLM(
8417 "actor requested approval before permission posture confirmation"
8418 .to_string(),
8419 ));
8420 }
8421 if let Some(reviewer) = approval_reviewer
8427 .cloned()
8428 .or_else(child_approval_reviewer)
8429 {
8430 let child = child_session_id.to_string();
8438 let parent = parent_session_id.to_string();
8439 let req_id = id.clone();
8440 let body = body.clone();
8441 let registry = approval_registry.cloned();
8442 let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8443 tokio::spawn(async move {
8446 let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8447 reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8448 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8449 child_attempt, &req_id, approved);
8450 });
8451 continue;
8452 };
8453 let generation = body.pointer("/permission_request/request_generation")
8454 .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8455 let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8456 if original != &body || scope.stamp()["reply"] != req_id {
8457 return Err(AgentError::LLM("approval generation changed in live scope".into()));
8458 }
8459 scope.clone()
8460 } else {
8461 if approval_scopes.len() >= 64 {
8462 return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8463 }
8464 let scope = ChildApprovalScope::new(&parent, &child,
8465 (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8466 approval_cancel.clone(), approval_epoch.clone());
8467 approval_scopes.insert(generation, (body.clone(), scope.clone()));
8468 scope
8469 };
8470 tokio::spawn(async move {
8471 let result = tokio::time::timeout(
8472 CHILD_APPROVAL_TIMEOUT,
8473 reviewer.review_scoped(&parent, &child, &body, &scope),
8474 ).await.unwrap_or(ChildApprovalReview::Reply(false));
8475 if let ChildApprovalReview::Reply(approved) = result {
8476 if scope.is_current(&parent, &child).await {
8477 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8478 child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8479 }
8480 }
8481 });
8482 } else if approval_decider.is_some() {
8483 let approved =
8487 decide_child_approval(approval_decider, child_session_id, &body)
8488 .await;
8489 if client
8490 .send(ParentFrame::ApprovalReply { id, approved })
8491 .await
8492 .is_err()
8493 {
8494 tracing::warn!(
8495 "failed to answer approval_request; connection failing"
8496 );
8497 }
8498 } else if let Some(host) = escalation_bridge.clone() {
8499 let child = child_session_id.to_string();
8506 let req_id = id.clone();
8507 let body = body.clone();
8508 let registry = approval_registry.cloned();
8509 tokio::spawn(async move {
8510 let approved = match tokio::time::timeout(
8511 CHILD_APPROVAL_TIMEOUT,
8512 host.approval_call(body),
8513 )
8514 .await
8515 {
8516 Ok(Ok(reply)) => reply
8517 .get("approved")
8518 .and_then(|v| v.as_bool())
8519 .unwrap_or(false),
8520 _ => false,
8522 };
8523 super::live::deliver_approval_scoped(
8524 registry.as_ref(),
8525 &child,
8526 child_attempt,
8527 &req_id,
8528 approved,
8529 );
8530 });
8531 } else {
8532 tracing::warn!(
8536 parent_session_id,
8537 child_session_id,
8538 request_id = %id,
8539 "forced-ask request has no parent-agent reviewer; denying"
8540 );
8541 if client
8542 .send(ParentFrame::ApprovalReply {
8543 id,
8544 approved: false,
8545 })
8546 .await
8547 .is_err()
8548 {
8549 tracing::warn!(
8550 "failed to send fail-closed approval reply; connection failing"
8551 );
8552 }
8553 }
8554 }
8555 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8556 if let Some(activation) = plain_input {
8557 if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8558 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8559 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8560 let claim = owned_input.as_ref().ok_or_else(|| plain_actor_unsupported())?;
8561 activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8562 owned_input = None;
8563 continue;
8564 }
8565 let Some(binding) = session_inbox_runtime else {
8566 tracing::warn!(
8567 child_session_id,
8568 "ignoring SessionInbox confirmation without a runtime binding"
8569 );
8570 continue;
8571 };
8572 let Some(bound_run_id) = activation_run_id else {
8573 tracing::warn!(
8574 child_session_id,
8575 "ignoring SessionInbox confirmation without an activation owner"
8576 );
8577 continue;
8578 };
8579 let Some(claim) = inflight_claims.front() else {
8580 tracing::warn!(
8581 child_session_id,
8582 envelope_id = %confirmation.envelope_id,
8583 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8584 );
8585 continue;
8586 };
8587 let exact = confirmation.target_session_id == logical_session.id
8588 && confirmation.envelope_id == claim.envelope.id.as_str()
8589 && confirmation.canonical_claim_generation == claim.generation
8590 && confirmation.activation_run_id == bound_run_id;
8591 if !exact
8592 || !binding
8593 .router
8594 .owns_run(&logical_session.id, bound_run_id)
8595 .await
8596 {
8597 tracing::warn!(
8598 child_session_id,
8599 expected_target = %logical_session.id,
8600 received_target = %confirmation.target_session_id,
8601 expected_envelope_id = %claim.envelope.id,
8602 received_envelope_id = %confirmation.envelope_id,
8603 expected_generation = claim.generation,
8604 received_generation = confirmation.canonical_claim_generation,
8605 expected_run_id = bound_run_id,
8606 received_run_id = %confirmation.activation_run_id,
8607 "rejecting stale or mismatched SessionInbox admission confirmation"
8608 );
8609 continue;
8610 }
8611 let claim = inflight_claims
8612 .pop_front()
8613 .expect("validated in-flight canonical claim");
8614 checkpoint_and_ack_canonical_claim(
8617 binding,
8618 logical_session,
8619 &claim,
8620 remote_claim_fence,
8621 )
8622 .await?;
8623 if inflight_claims.is_empty() {
8627 forward_next_canonical_claim(
8628 client,
8629 binding,
8630 logical_session,
8631 bound_run_id,
8632 &mut inflight_claims,
8633 remote_claim_fence,
8634 )
8635 .await?;
8636 }
8637 }
8638 Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8639 if local_history_tools.is_some() {
8640 validate_strict_history_terminal(
8643 final_event_watermark.as_ref(), logical_session, parent_session_id,
8644 activation_run_id, current_epoch, expected_creation,
8645 next_actor_event_seq - 1,
8646 )?;
8647 }
8648 if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8649 if let Some(tools) = local_history_tools {
8650 if permission_handshake.is_awaiting()
8654 || owned_input.is_some()
8655 || !inflight_claims.is_empty()
8656 || parent_question_checkpoint_id.is_some()
8657 || nested_wait_registered_this_run
8658 || strict_history_output.is_none()
8659 {
8660 return Err(local_tool_history_unsupported());
8661 }
8662 commit_local_tool_history(
8663 local_history.take().ok_or_else(local_tool_history_unsupported)?,
8664 logical_session,
8665 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8666 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8667 (tools, local_history_read_only),
8668 LocalToolTerminal::Cancelled,
8669 cancel_token,
8670 ).await?;
8671 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8672 logical_session,
8673 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8674 current_epoch,
8675 next_actor_event_seq - 1,
8676 ).map_err(|_| local_tool_history_unsupported())?;
8677 *strict_history_output.as_deref_mut()
8678 .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8679 proof,
8680 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8681 });
8682 }
8683 client.accept_durable_terminal(status);
8689 if workflow_usage_requested {
8690 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8691 }
8692 return Err(AgentError::Cancelled);
8693 }
8694 if parent_question_checkpoint_id.is_some()
8695 && status != TerminalStatus::Suspended
8696 {
8697 return Err(AgentError::LLM(
8698 "Child question checkpoint must end in a suspended Run".into(),
8699 ));
8700 }
8701 if permission_handshake.is_awaiting() {
8702 if remote_environment_lease && status == TerminalStatus::Error {
8703 if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8704 client.accept_durable_terminal(status);
8711 if workflow_usage_requested {
8712 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8713 }
8714 return Err(AgentError::LLM(code.to_owned()));
8715 }
8716 }
8717 return Err(AgentError::LLM(
8718 "actor terminated before permission posture confirmation"
8719 .to_string(),
8720 ));
8721 }
8722 if owned_input.is_some() {
8723 return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8724 }
8725 if let Some(claim) = inflight_claims.front() {
8726 return Err(AgentError::LLM(format!(
8727 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8728 claim.envelope.id
8729 )));
8730 }
8731 if status == TerminalStatus::Completed {
8732 if let Some(collector) = local_history.take() {
8733 commit_local_tool_history(
8734 collector, logical_session,
8735 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8736 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8737 (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8738 LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8739 ).await?;
8740 }
8741 }
8742 if let Some(collector) = readonly.take() {
8743 if status == TerminalStatus::Completed {
8744 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8745 let backlog = activation.input_inbox.inspect(&logical_session.id).await
8746 .map_err(|_| plain_actor_unsupported())?;
8747 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8748 return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8749 }
8750 *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8751 collector.finish(result.as_deref())?;
8752 }
8753 } else if let Some(activation) = plain_input {
8754 if status == TerminalStatus::Completed {
8755 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8756 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8757 if continuations < continuation_limit {
8758 if let Some((claim, epoch)) = activation.continue_input(
8759 client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8760 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8761 ).await? {
8762 owned_input = Some(claim);
8763 released_input = None;
8764 continuations += 1;
8765 current_epoch = epoch;
8766 approval_epoch.store(epoch, Ordering::Release);
8767 next_actor_event_seq = 1;
8770 canonical_router = None;
8771 permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8772 display = ActorEventDisplay::default();
8773 continue;
8774 }
8775 } else if activation.input_inbox.inspect(&logical_session.id).await
8776 .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8777 if continuation_limit == 1 {
8778 return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8779 }
8780 activation.append_reply(logical_session,
8785 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8786 event_tx).await?;
8787 return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8788 }
8789 }
8790 }
8791 if status == TerminalStatus::Completed {
8792 if let (Some(tool), Some(creation)) =
8797 (canonical_subagent_tool.as_deref(), expected_creation)
8798 {
8799 let (Some(store), Some(binding), Some(run_id)) =
8800 (actor_directory_store, session_inbox_runtime,
8801 activation_run_id)
8802 else {
8803 return Err(AgentError::LLM(
8804 "nested SubAgent Host authority unavailable at Child terminal"
8805 .into(),
8806 ));
8807 };
8808 let must_suspend = ensure_active_nested_wait(
8809 tool, store, binding, run_id, child_session_id,
8810 parent_session_id, logical_session, creation, plain_input,
8811 ).await?;
8812 if must_suspend || nested_wait_registered_this_run {
8813 logical_session.metadata.insert(
8820 "runtime.actor_nested_wait_handoff".into(),
8821 "true".into(),
8822 );
8823 }
8824 }
8825 }
8826 let terminal_result = match status {
8827 TerminalStatus::Completed => Ok(result),
8828 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8829 TerminalStatus::Error => Err(AgentError::LLM(
8830 error.unwrap_or_else(|| "actor child errored".to_string()),
8831 )),
8832 TerminalStatus::Suspended => {
8833 use bamboo_agent_core::storage::Storage;
8834 let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8835 (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8836 session_inbox_runtime, activation_run_id, expected_creation)
8837 else {
8838 return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8839 };
8840 if load_active_subagent_caller(
8841 store, binding, run_id, child_session_id,
8842 parent_session_id, logical_session, creation, plain_input,
8843 ).await.is_none() {
8844 return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8845 }
8846 let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8847 .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8848 let child = store.load_session(child_session_id).await
8849 .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8850 .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8851 let parent = store.load_session(parent_session_id).await
8852 .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8853 .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8854 let pending = ParentQuestion::for_pending(&parent, &child)
8855 .is_some_and(|question| question.id == id);
8856 let answered = bamboo_domain::ParentQuestionResolution::from_child(
8857 &parent, &child, &id,
8858 ).is_some();
8859 if !pending && !answered {
8860 return Err(AgentError::LLM("canonical Child question proof changed".into()));
8861 }
8862 logical_session.metadata.insert(
8863 "runtime.actor_parent_question_handoff".into(), "true".into(),
8864 );
8865 Ok(None)
8866 }
8867 };
8868 if local_history_tools.is_some() {
8869 if status != TerminalStatus::Completed {
8873 return terminal_result;
8874 }
8875 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8876 logical_session,
8877 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8878 current_epoch,
8879 next_actor_event_seq - 1,
8880 ).map_err(|_| local_tool_history_unsupported())?;
8881 let history = StrictTerminalHistory {
8882 proof,
8883 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8884 };
8885 if let Some(output) = strict_history_output.as_deref_mut() {
8886 *output = Some(history);
8887 }
8888 }
8889 client.accept_durable_terminal(status);
8896 if workflow_usage_requested {
8897 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8898 }
8899 if status == TerminalStatus::Completed {
8900 if let Some(activation) = plain_input {
8901 *activation.completion_router.lock().recover_poison() =
8904 canonical_router.take();
8905 }
8906 }
8907 return terminal_result;
8908 }
8909 Ok(None) => {
8910 return Err(AgentError::LLM(
8911 "actor child closed before terminal".to_string(),
8912 ));
8913 }
8914 Err(e) => {
8915 return Err(AgentError::LLM(format!("actor transport error: {e}")));
8916 }
8917 }
8918 }
8919 }
8920 }
8921
8922 let _ = client.send(ParentFrame::Cancel).await;
8924 Err(AgentError::Cancelled)
8925}
8926
8927fn project_id_for_actor_run(
8929 session: &Session,
8930) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
8931 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
8932 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
8933 Ok(Some(project_id))
8934 }
8935 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
8936 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
8937 Err(AgentError::LLM(format!(
8938 "child session carries an invalid Project identity '{raw}': {message}"
8939 )))
8940 }
8941 }
8942}
8943
8944fn remote_broker_unavailable() -> AgentError {
8945 AgentError::LLM("remote_broker_activation_unavailable".into())
8946}
8947
8948fn remote_environment_error_code(value: &str) -> Option<&'static str> {
8952 const CODES: &[&str] = &[
8953 "remote_environment_lease_invalid",
8954 "remote_environment_snapshot_mismatch",
8955 "remote_environment_checkout_not_clean",
8956 "remote_environment_checkout_unsupported",
8957 "remote_environment_workspace_unavailable",
8958 "remote_environment_workspace_outside_git_root",
8959 "remote_environment_path_unsupported",
8960 "remote_environment_git_unavailable",
8961 "remote_environment_git_inspection_failed",
8962 "remote_environment_git_inspection_timed_out",
8963 "remote_environment_git_output_too_large",
8964 "remote_environment_snapshot_too_large",
8965 "remote_environment_snapshot_changed",
8966 "remote_environment_index_invalid",
8967 "remote_environment_index_unsupported",
8968 "remote_environment_host_path_or_grants_rejected",
8969 "remote_environment_worker_workspace_missing",
8970 "remote_environment_runspec_invalid",
8971 "remote_environment_lease_missing_or_host_path_present",
8972 ];
8973 CODES.iter().copied().find(|code| *code == value)
8974}
8975
8976async fn remote_canonical_lineage(
8979 binding: &SessionInboxRuntimeBinding,
8980 session: &Session,
8981) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
8982 let mut current = bamboo_domain::ActorSession::from_session(session)
8983 .map_err(|_| remote_broker_unavailable())?;
8984 let mut chain = Vec::new();
8985 loop {
8986 if chain.len() > session.spawn_depth as usize {
8987 return Err(remote_broker_unavailable());
8988 }
8989 let saved = binding
8990 .storage
8991 .load_session(¤t.actor_id)
8992 .await
8993 .map_err(|_| remote_broker_unavailable())?
8994 .ok_or_else(remote_broker_unavailable)?;
8995 let observed = bamboo_domain::ActorSession::from_session(&saved)
8996 .map_err(|_| remote_broker_unavailable())?;
8997 if observed.actor_id != current.actor_id
8998 || observed.session_created_at != current.session_created_at
8999 || observed.parent_actor_id != current.parent_actor_id
9000 || observed.root_actor_id != current.root_actor_id
9001 || observed.spawn_depth != current.spawn_depth
9002 || observed.project_id != current.project_id
9003 {
9004 return Err(remote_broker_unavailable());
9005 }
9006 chain.push(observed.clone());
9007 let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9008 if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9009 return Err(remote_broker_unavailable());
9010 }
9011 return Ok(chain);
9012 };
9013 let parent = binding
9014 .storage
9015 .load_session(parent_id)
9016 .await
9017 .map_err(|_| remote_broker_unavailable())?
9018 .ok_or_else(remote_broker_unavailable)?;
9019 let parent = bamboo_domain::ActorSession::from_session(&parent)
9020 .map_err(|_| remote_broker_unavailable())?;
9021 if parent.root_actor_id != observed.root_actor_id
9022 || parent.project_id != observed.project_id
9023 || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9024 || parent.session_created_at > observed.session_created_at
9025 {
9026 return Err(remote_broker_unavailable());
9027 }
9028 current = parent;
9029 }
9030}
9031
9032async fn canonical_child_creation(
9033 binding: &SessionInboxRuntimeBinding,
9034 session: &Session,
9035 job: &SpawnJob,
9036) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9037 let conflict = || {
9038 AgentError::LLM(
9039 "child creation identity unavailable or changed in canonical host Storage".into(),
9040 )
9041 };
9042 let current = binding
9043 .storage
9044 .load_session(&job.child_session_id)
9045 .await
9046 .map_err(|_| conflict())?
9047 .ok_or_else(conflict)?;
9048 if bamboo_domain::ActorSession::from_session(¤t).is_err()
9049 || bamboo_domain::ActorSession::from_session(session).is_err()
9050 || current.kind != bamboo_domain::SessionKind::Child
9051 || session.kind != current.kind
9052 || current.id != session.id
9053 || current.id != job.child_session_id
9054 || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9055 || current.parent_session_id != session.parent_session_id
9056 || current.root_session_id.trim().is_empty()
9057 || current.root_session_id != session.root_session_id
9058 || current.created_at != session.created_at
9059 || current.spawn_depth != session.spawn_depth
9060 || current.spawn_depth == 0
9061 || project_id_for_actor_run(¤t)? != project_id_for_actor_run(session)?
9062 {
9063 return Err(conflict());
9064 }
9065 Ok(bamboo_subagent::proto::ChildCreationIdentity {
9066 created_at: current.created_at,
9067 spawn_depth: current.spawn_depth,
9068 })
9069}
9070
9071fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9072 LogicalSessionIdentity {
9073 creation: None,
9074 session_id: session.id.clone(),
9075 parent_session_id: session
9076 .parent_session_id
9077 .clone()
9078 .or_else(|| Some(job.parent_session_id.clone())),
9079 root_session_id: if session.root_session_id.trim().is_empty() {
9080 job.parent_session_id.clone()
9081 } else {
9082 session.root_session_id.clone()
9083 },
9084 }
9085}
9086
9087fn extract_assignment(session: &Session) -> String {
9088 session
9089 .messages
9090 .iter()
9091 .rev()
9092 .find(|m| matches!(m.role, Role::User))
9093 .map(|m| m.content.clone())
9094 .unwrap_or_else(|| {
9095 session
9096 .metadata
9097 .get("title")
9098 .cloned()
9099 .unwrap_or_else(|| "Execute task".to_string())
9100 })
9101}
9102
9103#[cfg(test)]
9104mod tests {
9105 use super::*;
9106 use crate::SessionActivationRouter;
9107 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9108
9109 include!("expired_plain_owner_tests.rs");
9110
9111 #[test]
9112 fn plain_rejection_logs_branch_and_run_without_changing_error() {
9113 let capture_interest_guard = tracing::Dispatch::new(
9115 tracing_subscriber::fmt()
9116 .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9117 .with_writer(std::io::sink)
9118 .finish(),
9119 );
9120 let output = tempfile::NamedTempFile::new().unwrap();
9121 let subscriber = tracing_subscriber::fmt()
9122 .with_ansi(false)
9123 .without_time()
9124 .with_writer(output.reopen().unwrap())
9125 .finish();
9126 let branch_line = std::cell::Cell::new(0);
9127 let error = tracing::subscriber::with_default(subscriber, || {
9128 std::thread::spawn(|| drop(plain_actor_unsupported()))
9130 .join()
9131 .expect("unscoped rejection warning thread panicked");
9132 let span = tracing::warn_span!(
9133 "actor_child_run",
9134 child_session_id = %"diagnostic-child",
9135 activation_run_id = tracing::field::Empty
9136 );
9137 let _entered = span.enter();
9138 span.record("activation_run_id", "diagnostic-run");
9139 None::<()>
9140 .ok_or_else(|| {
9141 let caller_line = line!() + 1;
9142 let error = plain_actor_unsupported();
9143 branch_line.set(caller_line);
9144 error
9145 })
9146 .unwrap_err()
9147 });
9148 assert!(matches!(&error, AgentError::LLM(message)
9149 if message.starts_with("This Actor Child supports a fresh plain activation,")
9150 && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9151 let log = std::fs::read_to_string(output.path()).unwrap();
9152 assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9153 assert!(
9154 log.contains("activation_run_id=\"diagnostic-run\""),
9155 "{log}"
9156 );
9157 assert!(
9158 log.contains(&format!("process_id={}", std::process::id())),
9159 "{log}"
9160 );
9161 assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9162 assert!(
9163 log.contains(&format!("source_line={}", branch_line.get())),
9164 "{log}"
9165 );
9166 assert!(!error.to_string().contains("diagnostic-run"));
9167 drop(capture_interest_guard);
9168 }
9169
9170 #[tokio::test]
9171 async fn completed_plain_continuation_requires_one_input_and_current_identity() {
9172 for case in [
9173 "valid",
9174 "two-inputs",
9175 "tool-history",
9176 "stale-lineage",
9177 "no-release",
9178 ] {
9179 let temp = tempfile::tempdir().unwrap();
9180 let store = Arc::new(
9181 bamboo_storage::SessionStoreV2::new(temp.path().into())
9182 .await
9183 .unwrap(),
9184 );
9185 let parent = Session::new("continuation-parent", "model");
9186 store.save_session(&parent).await.unwrap();
9187 let mut child =
9188 Session::new_child_of("continuation-child", &parent, "model", "first task");
9189 child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9190 if case == "tool-history" {
9191 let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9192 tool.role = Role::Tool;
9193 child.add_message(tool);
9194 }
9195 child.set_last_run_status("completed");
9196 store.save_session(&child).await.unwrap();
9197 store.ensure_actor(&child.id).await.unwrap();
9198 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9199 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9200 let now = chrono::Utc::now();
9201 let old = store
9202 .claim_activation(&ActorActivationClaim {
9203 actor_id: child.id.clone(),
9204 run_id: "first-run".into(),
9205 lease_owner: "first-owner".into(),
9206 lease_expires_at: now + chrono::Duration::minutes(1),
9207 inbox_generation: 0,
9208 placement_ref: None,
9209 now,
9210 })
9211 .await
9212 .unwrap();
9213 store.start_activation(&old.fence(), now).await.unwrap();
9214 store
9215 .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9216 .await
9217 .unwrap();
9218 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9219 store.clone(),
9220 Default::default(),
9221 ));
9222 for slot in 0..if case == "two-inputs" { 2 } else { 1 } {
9223 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9224 &child.id,
9225 format!("new task {slot}"),
9226 );
9227 let receipt = inbox.deliver(&envelope).await.unwrap();
9228 inbox
9229 .mark_activation_eligible(
9230 &child.id,
9231 receipt.generation,
9232 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9233 )
9234 .await
9235 .unwrap();
9236 }
9237 let storage: Arc<dyn Storage> = store.clone();
9238 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9239 let binding = actor_binding(store.clone(), inbox, persistence);
9240 let _registration = binding
9241 .router
9242 .register_run(&child.id, "second-run")
9243 .await
9244 .unwrap();
9245 if case == "stale-lineage" {
9246 child.parent_session_id = Some("foreign-parent".into());
9247 }
9248 let result = PlainActorActivation::start(
9249 store.clone(),
9250 &child,
9251 &binding,
9252 Some("second-run"),
9253 Some(policy),
9254 true,
9255 (case != "no-release").then_some("second-worker"),
9256 )
9257 .await;
9258 if case == "valid" {
9259 let activation = result.expect("one new input on a completed plain Child");
9260 assert_eq!(activation.fence.attempt, old.attempt + 1);
9261 assert_eq!(activation.fence.actor_id, child.id);
9262 assert_eq!(activation.inbox_generation, 1);
9263 activation
9264 .finish(ActorActivationFinish::Failed)
9265 .await
9266 .unwrap();
9267 } else {
9268 assert!(
9269 result.is_err(),
9270 "unsupported continuation {case} must fail before claim"
9271 );
9272 assert_eq!(
9273 store
9274 .inspect_actor(&child.id)
9275 .await
9276 .unwrap()
9277 .actor
9278 .current_attempt,
9279 old.attempt
9280 );
9281 }
9282 let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9283 assert_eq!(
9284 canonical.parent_session_id.as_deref(),
9285 Some(parent.id.as_str())
9286 );
9287 assert!(canonical
9288 .messages
9289 .iter()
9290 .any(|message| message.content == "first result"));
9291 }
9292 }
9293
9294 #[tokio::test]
9295 async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9296 let temp = tempfile::tempdir().unwrap();
9297 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9298 .await
9299 .unwrap();
9300 let parent = Session::new("placement-parent", "model");
9301 let child = Session::new_child_of("placement-child", &parent, "model", "task");
9302 store.save_session(&parent).await.unwrap();
9303 store.save_session(&child).await.unwrap();
9304 store.ensure_actor(&child.id).await.unwrap();
9305 store
9306 .bind_control_plane(
9307 &child.id,
9308 bamboo_domain::ActorControlPlaneBinding {
9309 policy_revision: None,
9310 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9311 pool_id: "remote-worker".into(),
9312 }),
9313 },
9314 )
9315 .await
9316 .unwrap();
9317
9318 let claim = |run_id: &str| ActorActivationClaim {
9319 actor_id: child.id.clone(),
9320 run_id: run_id.into(),
9321 lease_owner: format!("host-{run_id}"),
9322 lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9323 inbox_generation: 0,
9324 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9325 class: bamboo_domain::ActorPlacementClass::Remote,
9326 lease_id: format!("slot-{run_id}"),
9327 slot_epoch: Some(1),
9328 }),
9329 now: chrono::Utc::now(),
9330 };
9331 let first = store.claim_activation(&claim("first")).await.unwrap();
9332 store
9333 .start_activation(&first.fence(), chrono::Utc::now())
9334 .await
9335 .unwrap();
9336 assert!(store.claim_activation(&claim("retry")).await.is_err());
9337
9338 fail_pre_dispatch_activation(&store, &first.fence())
9341 .await
9342 .unwrap();
9343 let failed = store.inspect_actor(&child.id).await.unwrap();
9344 assert_eq!(
9345 failed.activation.unwrap().status,
9346 bamboo_domain::ActorActivationStatus::Failed
9347 );
9348 let retry = store.claim_activation(&claim("retry")).await.unwrap();
9349 assert_eq!(retry.attempt, first.attempt + 1);
9350 assert!(fail_pre_dispatch_activation(&store, &first.fence())
9351 .await
9352 .is_err());
9353 assert_eq!(
9354 store.inspect_actor(&child.id).await.unwrap().activation,
9355 Some(retry)
9356 );
9357 }
9358
9359 #[tokio::test]
9360 async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9361 struct AcceptedWithoutReceipt {
9362 published: bool,
9363 }
9364
9365 #[async_trait]
9366 impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9367 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9368 assert!(matches!(frame, ParentFrame::Run(_)));
9369 self.published = true;
9370 Err(bamboo_subagent::TransportError::Protocol(
9371 "broker delivery receipt lost after publication".into(),
9372 ))
9373 }
9374
9375 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9376 std::future::pending().await
9377 }
9378 }
9379
9380 let temp = tempfile::tempdir().unwrap();
9381 let store = Arc::new(
9382 bamboo_storage::SessionStoreV2::new(temp.path().into())
9383 .await
9384 .unwrap(),
9385 );
9386 let parent = Session::new("dispatch-parent", "model");
9387 let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9388 store.save_session(&parent).await.unwrap();
9389 store.save_session(&child).await.unwrap();
9390 store.ensure_actor(&child.id).await.unwrap();
9391 let now = chrono::Utc::now();
9392 let claim = ActorActivationClaim {
9393 actor_id: child.id.clone(),
9394 run_id: "dispatch-run".into(),
9395 lease_owner: "dispatch-host".into(),
9396 lease_expires_at: now + chrono::Duration::seconds(80),
9397 inbox_generation: 0,
9398 placement_ref: None,
9399 now,
9400 };
9401 let activation = store.claim_activation(&claim).await.unwrap();
9402 store
9403 .start_activation(&activation.fence(), chrono::Utc::now())
9404 .await
9405 .unwrap();
9406 let mut link = AcceptedWithoutReceipt { published: false };
9407 let mut send_invoked = false;
9408 let run = RunSpec {
9409 assignment: "task".into(),
9410 logical_session: None,
9411 project_id: None,
9412 reasoning_effort: None,
9413 permission_policy: None,
9414 messages: Vec::new(),
9415 activation_run_id: Some(claim.run_id.clone()),
9416 execution_epoch: 1,
9417 initial_session_messages: Vec::new(),
9418 secrets: Default::default(),
9419 };
9420 assert!(send_actor_run(&mut link, run, &mut send_invoked)
9421 .await
9422 .is_err());
9423 assert!(send_invoked && link.published);
9424 let slot = WorkerSlotLease {
9425 lease_id: "dispatch-slot".into(),
9426 host_ref: "dispatch-worker".into(),
9427 connection_generation: "dispatch-generation".into(),
9428 slot: 0,
9429 epoch: 1,
9430 actor_id: child.id.clone(),
9431 run_id: claim.run_id.clone(),
9432 expires_at: claim.lease_expires_at,
9433 };
9434 let mut lease = ActivePlacementLease {
9435 registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9436 .await
9437 .unwrap(),
9438 actor_store: store.clone(),
9439 route: ScopedPlacementRoute {
9440 endpoint: String::new(),
9441 token: String::new(),
9442 ca_cert_file: PathBuf::new(),
9443 parent: bamboo_subagent::AgentRef {
9444 session_id: "dispatch-parent".into(),
9445 role: Some("host".into()),
9446 },
9447 worker_role: "worker".into(),
9448 pinned_mailbox: None,
9449 requirements: Default::default(),
9450 },
9451 mailbox: "dispatch-worker".into(),
9452 host_ref: "dispatch-worker".into(),
9453 fence: activation.fence(),
9454 placement_ref: bamboo_domain::ActorPlacementRef {
9455 class: bamboo_domain::ActorPlacementClass::Remote,
9456 lease_id: slot.lease_id.clone(),
9457 slot_epoch: Some(slot.epoch),
9458 },
9459 lease: Arc::new(tokio::sync::Mutex::new(slot)),
9460 heartbeat: tokio::spawn(std::future::pending()),
9461 run_cancel: CancellationToken::new(),
9462 lost: Arc::new(AtomicBool::new(false)),
9463 release_on_drop: AtomicBool::new(false),
9464 };
9465 assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9466 assert_eq!(
9467 store
9468 .inspect_actor(&child.id)
9469 .await
9470 .unwrap()
9471 .activation
9472 .unwrap()
9473 .status,
9474 bamboo_domain::ActorActivationStatus::Running
9475 );
9476 let mut retry = claim.clone();
9477 retry.run_id = "dispatch-retry".into();
9478 retry.now = chrono::Utc::now();
9479 retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9480 assert!(matches!(
9481 store.claim_activation(&retry).await,
9482 Err(bamboo_domain::ActorDirectoryError::Busy)
9483 ));
9484 }
9485
9486 #[test]
9487 fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9488 let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9489 assert_eq!(
9490 remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9491 ActorActivationFinish::Cancelled
9492 );
9493 assert_eq!(
9494 remote_activation_finish(&cancelled, None),
9495 ActorActivationFinish::Failed
9496 );
9497 assert_eq!(
9498 remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9499 ActorActivationFinish::Failed
9500 );
9501 }
9502
9503 #[tokio::test]
9504 async fn remote_reply_is_fenced_before_terminal_status_save() {
9505 let temp = tempfile::tempdir().unwrap();
9506 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9507 .await
9508 .unwrap();
9509 let parent = Session::new("remote-reply-parent", "model");
9510 let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9511 child.add_message(bamboo_agent_core::Message::user("task"));
9512 store.save_session(&parent).await.unwrap();
9513 store.save_session(&child).await.unwrap();
9514 store.ensure_actor(&child.id).await.unwrap();
9515 store
9516 .bind_control_plane(
9517 &child.id,
9518 bamboo_domain::ActorControlPlaneBinding {
9519 policy_revision: None,
9520 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9521 pool_id: "remote-worker".into(),
9522 }),
9523 },
9524 )
9525 .await
9526 .unwrap();
9527 let now = chrono::Utc::now();
9528 let activation = store
9529 .claim_activation(&ActorActivationClaim {
9530 actor_id: child.id.clone(),
9531 run_id: "remote-reply-run".into(),
9532 lease_owner: "remote-worker".into(),
9533 lease_expires_at: now + chrono::Duration::seconds(80),
9534 inbox_generation: 0,
9535 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9536 class: bamboo_domain::ActorPlacementClass::Remote,
9537 lease_id: "remote-slot".into(),
9538 slot_epoch: Some(1),
9539 }),
9540 now,
9541 })
9542 .await
9543 .unwrap();
9544 let fence = activation.fence();
9545 store.start_activation(&fence, now).await.unwrap();
9546
9547 append_fenced_remote_reply(&store, &fence, &mut child, "done")
9548 .await
9549 .unwrap();
9550 store
9551 .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9552 .await
9553 .unwrap();
9554 child.set_last_run_status("completed");
9555 store.save_session(&child).await.unwrap();
9556 let saved = store.load_session(&child.id).await.unwrap().unwrap();
9557 assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9558 assert_eq!(saved.messages.len(), 2);
9559 assert_eq!(saved.messages.last().unwrap().content, "done");
9560 assert!(
9561 append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9562 .await
9563 .is_err()
9564 );
9565 assert_eq!(
9566 store
9567 .load_session(&child.id)
9568 .await
9569 .unwrap()
9570 .unwrap()
9571 .messages
9572 .len(),
9573 2
9574 );
9575 }
9576
9577 async fn start_bus() -> (String, tempfile::TempDir) {
9578 let dir = tempfile::tempdir().unwrap();
9579 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9580 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9581 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9582 let addr = listener.local_addr().unwrap();
9583 tokio::spawn(async move {
9584 let _ = server.serve(listener).await;
9585 });
9586 (format!("ws://{addr}"), dir)
9587 }
9588
9589 struct DurableAckProbe {
9590 calls: Arc<AtomicUsize>,
9591 fail: bool,
9592 terminal_status: TerminalStatus,
9593 parent_mailbox: String,
9594 }
9595
9596 #[async_trait]
9597 impl bamboo_subagent::ChildLink for DurableAckProbe {
9598 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9599 Ok(())
9600 }
9601
9602 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9603 Ok(None)
9604 }
9605
9606 fn has_pending_durable_terminal(&self) -> bool {
9607 true
9608 }
9609
9610 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9611 Some(bamboo_subagent::DurableChildDeliveryReceipt {
9612 broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9613 parent_mailbox: self.parent_mailbox.clone(),
9614 correlation_id: "broker-run-1".into(),
9615 message_ids: vec!["broker-outcome-1".into()],
9616 terminal_status: self.terminal_status,
9617 })
9618 }
9619
9620 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9621 self.calls.fetch_add(1, Ordering::SeqCst);
9622 if self.fail {
9623 Err(bamboo_subagent::TransportError::Protocol(
9624 "injected ACK delivery failure".into(),
9625 ))
9626 } else {
9627 Ok(())
9628 }
9629 }
9630 }
9631
9632 async fn insert_ack_probe(
9633 runner: &ActorChildRunner,
9634 session: &Session,
9635 calls: &Arc<AtomicUsize>,
9636 terminal_status: TerminalStatus,
9637 ) {
9638 insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9639 }
9640
9641 async fn insert_ack_probe_with_nested_wait(
9642 runner: &ActorChildRunner,
9643 session: &Session,
9644 calls: &Arc<AtomicUsize>,
9645 terminal_status: TerminalStatus,
9646 host_nested_wait_handoff: bool,
9647 ) {
9648 runner.pending_durable_links.lock().await.insert(
9649 (session.id.clone(), session.created_at, "run-1".into()),
9650 PendingDurableChildLink {
9651 link: Box::new(DurableAckProbe {
9652 calls: calls.clone(),
9653 fail: false,
9654 terminal_status,
9655 parent_mailbox: format!("p-{}", session.id),
9656 }),
9657 host_nested_wait_handoff,
9658 strict_history: None,
9659 strict_history_required: false,
9660 strict_history_committed: false,
9661 },
9662 );
9663 }
9664
9665 #[tokio::test]
9666 async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9667 let (endpoint, broker_dir) = start_bus().await;
9668 let home = tempfile::tempdir().unwrap();
9669 let store = Arc::new(
9670 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9671 .await
9672 .unwrap(),
9673 );
9674 let root = Session::new("receipt-root", "model");
9675 store.save_session(&root).await.unwrap();
9676 let mut sender = bamboo_broker::BrokerClient::connect(
9677 &endpoint,
9678 bamboo_subagent::AgentRef {
9679 session_id: "receipt-worker".into(),
9680 role: None,
9681 },
9682 "t",
9683 )
9684 .await
9685 .unwrap();
9686 let broker_identity = sender.durable_broker_identity().await.unwrap();
9687 let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9688 "00000000-0000-4000-8000-000000000002"
9689 } else {
9690 "00000000-0000-4000-8000-000000000001"
9691 };
9692
9693 for (id, receipt_mailbox, receipt_broker) in [
9694 ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9695 (
9696 "receipt-wrong-mailbox",
9697 "p-another-child",
9698 broker_identity.as_str(),
9699 ),
9700 (
9701 "receipt-wrong-broker",
9702 "p-receipt-wrong-broker",
9703 wrong_broker_identity,
9704 ),
9705 (
9706 "receipt-local-role-changed",
9707 "p-receipt-local-role-changed",
9708 broker_identity.as_str(),
9709 ),
9710 (
9711 "receipt-remote-unproven",
9712 "remote-parent-unproven",
9713 broker_identity.as_str(),
9714 ),
9715 ] {
9716 let mut child = Session::new_child_of(id, &root, "model", "task");
9717 child
9718 .metadata
9719 .insert("runtime.kind".into(), "external".into());
9720 child
9721 .metadata
9722 .insert("external.protocol".into(), "actor".into());
9723 child.metadata.insert(
9724 "external.agent_id".into(),
9725 crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9726 );
9727 if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9728 child
9729 .metadata
9730 .insert("subagent_type".into(), "remote-worker".into());
9731 }
9732 child.add_message(bamboo_agent_core::Message::user("work"));
9733 store.save_session(&child).await.unwrap();
9734 let message_id = bamboo_subagent::MsgId::new();
9735 sender
9736 .deliver(
9737 &format!("p-{id}"),
9738 bamboo_subagent::InboxMessage {
9739 id: message_id.clone(),
9740 from: bamboo_subagent::AgentRef {
9741 session_id: "receipt-worker".into(),
9742 role: None,
9743 },
9744 kind: bamboo_subagent::InboxKind::Outcome,
9745 body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9746 "done",
9747 ))
9748 .unwrap(),
9749 created_at: chrono::Utc::now(),
9750 correlation_id: Some(bamboo_subagent::MsgId::new()),
9751 },
9752 )
9753 .await
9754 .unwrap();
9755 let mailbox =
9758 bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9759 assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9760 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9761 child.set_last_run_status("completed");
9762 if id == "receipt-good" {
9763 let prior_ids = child.messages[..child.messages.len() - 1]
9764 .iter()
9765 .map(|message| message.id.clone())
9766 .collect();
9767 crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9768 assert!(
9769 crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9770 &mut child, "run-1"
9771 )
9772 .unwrap()
9773 );
9774 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9775 &child, "run-1", 7, 1,
9776 )
9777 .unwrap();
9778 store
9779 .prepare_broker_terminal_receipt_with_completeness(
9780 &child,
9781 bamboo_storage::v2::BrokerTerminalRoute {
9782 activation_run_id: "run-1",
9783 broker_identity: receipt_broker,
9784 parent_mailbox: receipt_mailbox,
9785 broker_correlation_id: "correlation-1",
9786 message_ids: &[message_id.as_str().to_owned()],
9787 },
9788 &proof,
9789 )
9790 .await
9791 .unwrap();
9792 let mut staged = child.clone();
9793 staged.set_last_run_status("running");
9794 staged.clear_last_run_error();
9795 store.save_session(&staged).await.unwrap();
9796 store
9797 .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9798 .await
9799 .unwrap();
9800 } else {
9801 store
9802 .prepare_broker_terminal_receipt(
9803 &child,
9804 "run-1",
9805 receipt_broker,
9806 receipt_mailbox,
9807 "correlation-1",
9808 &[message_id.as_str().to_owned()],
9809 )
9810 .await
9811 .unwrap();
9812 store.save_session(&child).await.unwrap();
9813 }
9814 }
9815
9816 let mut config = bamboo_config::Config::default();
9817 config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9818 endpoint,
9819 token: "t".into(),
9820 ..Default::default()
9821 });
9822 config
9823 .subagents_mut()
9824 .remote_placements
9825 .push(bamboo_config::RemoteActorPlacement {
9826 role: "remote-worker".into(),
9827 endpoint: "ws://unscoped.example.invalid".into(),
9828 token_env: None,
9829 ca_cert_file: None,
9830 broker_peer: None,
9831 placement_requirements: None,
9832 });
9833 let cold_store = Arc::new(
9836 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9837 .await
9838 .unwrap(),
9839 );
9840 let before = cold_store
9841 .load_session("receipt-good")
9842 .await
9843 .unwrap()
9844 .unwrap();
9845 assert!(crate::execution::ChildCompletionSource::has_source_record(
9846 &before
9847 ));
9848 assert_eq!(before.last_run_status().as_deref(), Some("running"));
9849 assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9850 let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9851 let report = repair.reconcile_once().await;
9852 assert_eq!(report.candidates, 5);
9853 assert_eq!(report.repaired, 2);
9854 assert_eq!(report.blocked, 3);
9855 let after = cold_store
9856 .load_session("receipt-good")
9857 .await
9858 .unwrap()
9859 .unwrap();
9860 assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9861 assert_eq!(
9862 serde_json::to_value(before.messages).unwrap(),
9863 serde_json::to_value(after.messages).unwrap()
9864 );
9865 let retry = repair.reconcile_once().await;
9866 assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9867
9868 let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9869 let wrong = bamboo_subagent::Mailbox::at(
9870 broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9871 );
9872 let wrong_broker = bamboo_subagent::Mailbox::at(
9873 broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9874 );
9875 let local_role_changed = bamboo_subagent::Mailbox::at(
9876 broker_dir
9877 .path()
9878 .join("mailboxes/p-receipt-local-role-changed"),
9879 );
9880 let remote_unproven = bamboo_subagent::Mailbox::at(
9881 broker_dir
9882 .path()
9883 .join("mailboxes/p-receipt-remote-unproven"),
9884 );
9885 assert_eq!(good.pending_count().await.unwrap(), 0);
9886 assert_eq!(wrong.pending_count().await.unwrap(), 1);
9887 assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9888 assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9889 assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9890 let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9891 let wrong_child = store
9892 .load_session("receipt-wrong-mailbox")
9893 .await
9894 .unwrap()
9895 .unwrap();
9896 let wrong_broker_child = store
9897 .load_session("receipt-wrong-broker")
9898 .await
9899 .unwrap()
9900 .unwrap();
9901 let local_role_changed_child = store
9902 .load_session("receipt-local-role-changed")
9903 .await
9904 .unwrap()
9905 .unwrap();
9906 let remote_unproven_child = store
9907 .load_session("receipt-remote-unproven")
9908 .await
9909 .unwrap()
9910 .unwrap();
9911 assert!(store
9912 .recover_broker_terminal_receipts(&good_child)
9913 .await
9914 .unwrap()
9915 .is_empty());
9916 assert_eq!(
9917 store
9918 .recover_broker_terminal_receipts(&wrong_child)
9919 .await
9920 .unwrap()
9921 .len(),
9922 1
9923 );
9924 assert_eq!(
9925 store
9926 .recover_broker_terminal_receipts(&wrong_broker_child)
9927 .await
9928 .unwrap()
9929 .len(),
9930 1
9931 );
9932 assert!(store
9933 .recover_broker_terminal_receipts(&local_role_changed_child)
9934 .await
9935 .unwrap()
9936 .is_empty());
9937 assert_eq!(
9938 store
9939 .recover_broker_terminal_receipts(&remote_unproven_child)
9940 .await
9941 .unwrap()
9942 .len(),
9943 1
9944 );
9945 }
9946
9947 #[tokio::test]
9948 async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
9949 let runner = ActorChildRunner::new(
9950 "test".into(),
9951 PathBuf::new(),
9952 vec![],
9953 PathBuf::new(),
9954 ExecutorSpec::BambooRuntime,
9955 vec![],
9956 "test".into(),
9957 1,
9958 );
9959 let home = tempfile::tempdir().unwrap();
9960 let store = Arc::new(
9961 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9962 .await
9963 .unwrap(),
9964 );
9965 let root = Session::new("ack-parent", "model");
9966 store.save_session(&root).await.unwrap();
9967 let mut child = Session::new_child_of("ack-child", &root, "model", "task");
9968 child.add_message(bamboo_agent_core::Message::user("work"));
9969 store.save_session(&child).await.unwrap();
9970 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9971 child.set_last_run_status("completed");
9972 store
9973 .prepare_broker_terminal_receipt(
9974 &child,
9975 "run-1",
9976 "00000000-0000-4000-8000-000000000001",
9977 "p-ack-child",
9978 "broker-run-1",
9979 &["broker-outcome-1".into()],
9980 )
9981 .await
9982 .unwrap();
9983 store.save_session(&child).await.unwrap();
9984 runner.set_actor_directory_store(Some(store));
9985 let calls = Arc::new(AtomicUsize::new(0));
9986 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9987 runner
9988 .confirm_durable_child_delivery(&child, "wrong-run", true)
9989 .await
9990 .unwrap();
9991 let mut wrong_birth = child.clone();
9992 wrong_birth.created_at += chrono::Duration::seconds(1);
9993 runner
9994 .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
9995 .await
9996 .unwrap();
9997 assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
9998 child.set_last_run_status("completed");
9999 runner
10000 .confirm_durable_child_delivery(&child, "run-1", false)
10001 .await
10002 .unwrap();
10003 assert_eq!(calls.load(Ordering::SeqCst), 0);
10004
10005 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10006 child.set_last_run_status("error");
10007 runner
10008 .confirm_durable_child_delivery(&child, "run-1", true)
10009 .await
10010 .unwrap();
10011 assert_eq!(calls.load(Ordering::SeqCst), 0);
10012
10013 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10014 child.set_last_run_status("completed");
10015 runner
10016 .confirm_durable_child_delivery(&child, "run-1", true)
10017 .await
10018 .unwrap();
10019 assert_eq!(calls.load(Ordering::SeqCst), 1);
10020 }
10021
10022 #[tokio::test]
10023 async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10024 for commit in [false, true] {
10025 let runner = ActorChildRunner::new(
10026 "test".into(),
10027 PathBuf::new(),
10028 vec![],
10029 PathBuf::new(),
10030 ExecutorSpec::BambooRuntime,
10031 vec![],
10032 "test".into(),
10033 1,
10034 );
10035 let home = tempfile::tempdir().unwrap();
10036 let store = Arc::new(
10037 bamboo_storage::SessionStoreV2::new(home.path().into())
10038 .await
10039 .unwrap(),
10040 );
10041 let root = Session::new("strict-ack-parent", "model");
10042 store.save_session(&root).await.unwrap();
10043 let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10044 child.add_message(bamboo_agent_core::Message::user("work"));
10045 store.save_session(&child).await.unwrap();
10046 child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10047 child.set_last_run_status("completed");
10048 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10049 &child, "run-1", 7, 2,
10050 )
10051 .unwrap();
10052 let creation = bamboo_subagent::proto::ChildCreationIdentity {
10053 created_at: child.created_at,
10054 spawn_depth: child.spawn_depth,
10055 };
10056 let history = StrictTerminalHistory {
10057 proof: proof.clone(),
10058 watermark: bamboo_subagent::ActorEventWatermark {
10059 version: 1,
10060 logical_session: Some(actor_event_logical_identity(
10061 &child,
10062 &root.id,
10063 Some(&creation),
10064 )),
10065 activation_id: Some("run-1".into()),
10066 execution_epoch: 7,
10067 final_seq: 2,
10068 },
10069 };
10070 let parent_mailbox = "p-strict-ack-child";
10071 store
10072 .prepare_broker_terminal_receipt_with_completeness(
10073 &child,
10074 bamboo_storage::v2::BrokerTerminalRoute {
10075 activation_run_id: "run-1",
10076 broker_identity: "00000000-0000-4000-8000-000000000001",
10077 parent_mailbox,
10078 broker_correlation_id: "broker-run-1",
10079 message_ids: &["broker-outcome-1".into()],
10080 },
10081 &proof,
10082 )
10083 .await
10084 .unwrap();
10085 store.save_session(&child).await.unwrap();
10086 runner.set_actor_directory_store(Some(store.clone()));
10087 let calls = Arc::new(AtomicUsize::new(0));
10088 runner.pending_durable_links.lock().await.insert(
10089 (child.id.clone(), child.created_at, "run-1".into()),
10090 PendingDurableChildLink {
10091 link: Box::new(DurableAckProbe {
10092 calls: calls.clone(),
10093 fail: true,
10094 terminal_status: TerminalStatus::Completed,
10095 parent_mailbox: parent_mailbox.into(),
10096 }),
10097 host_nested_wait_handoff: false,
10098 strict_history: Some(history),
10099 strict_history_required: true,
10100 strict_history_committed: false,
10101 },
10102 );
10103 if commit {
10104 runner
10105 .commit_durable_child_delivery(&child, "run-1")
10106 .await
10107 .unwrap();
10108 assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10109 }
10110 assert!(runner
10111 .confirm_durable_child_delivery(&child, "run-1", true)
10112 .await
10113 .is_err());
10114 assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10115 let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10116 .await
10117 .unwrap();
10118 let recovered = cold.recover_broker_terminal_receipts(&child).await;
10119 if commit {
10120 let receipts = recovered.unwrap();
10121 assert_eq!(receipts.len(), 1);
10122 assert_eq!(
10123 receipts[0]
10124 .terminal_completeness
10125 .as_ref()
10126 .unwrap()
10127 .contiguous_applied_seq,
10128 2
10129 );
10130 assert_eq!(
10131 cold.load_session(&child.id)
10132 .await
10133 .unwrap()
10134 .unwrap()
10135 .last_run_status()
10136 .as_deref(),
10137 Some("completed")
10138 );
10139 } else {
10140 assert!(
10141 recovered.is_err(),
10142 "matching canonical history is not proof commitment"
10143 );
10144 }
10145 assert_eq!(
10146 serde_json::to_value(
10147 cold.load_session(&child.id)
10148 .await
10149 .unwrap()
10150 .unwrap()
10151 .messages
10152 )
10153 .unwrap(),
10154 serde_json::to_value(&child.messages).unwrap(),
10155 );
10156 }
10157 }
10158
10159 #[tokio::test]
10160 async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10161 for (terminal_status, status) in [
10162 (TerminalStatus::Error, "error"),
10163 (TerminalStatus::Cancelled, "cancelled"),
10164 ] {
10165 let runner = ActorChildRunner::new(
10166 "test".into(),
10167 PathBuf::new(),
10168 vec![],
10169 PathBuf::new(),
10170 ExecutorSpec::BambooRuntime,
10171 vec![],
10172 "test".into(),
10173 1,
10174 );
10175 let home = tempfile::tempdir().unwrap();
10176 let store = Arc::new(
10177 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10178 .await
10179 .unwrap(),
10180 );
10181 let root = Session::new("failed-ack-parent", "model");
10182 store.save_session(&root).await.unwrap();
10183 let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10184 child.add_message(bamboo_agent_core::Message::user("work"));
10185 store.save_session(&child).await.unwrap();
10186 child.set_last_run_status(status);
10187 child.set_last_run_error(format!("worker {status}"));
10188 store
10189 .prepare_broker_terminal_receipt(
10190 &child,
10191 "run-1",
10192 "00000000-0000-4000-8000-000000000001",
10193 "p-failed-ack-child",
10194 "broker-run-1",
10195 &["broker-outcome-1".into()],
10196 )
10197 .await
10198 .unwrap();
10199 store.save_session(&child).await.unwrap();
10200 runner.set_actor_directory_store(Some(store));
10201 let calls = Arc::new(AtomicUsize::new(0));
10202 insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10203 runner
10204 .confirm_durable_child_delivery(&child, "run-1", true)
10205 .await
10206 .unwrap();
10207 assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10208 }
10209 }
10210
10211 #[tokio::test]
10212 async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10213 assert_eq!(
10214 effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10215 "suspended"
10216 );
10217 for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10218 assert!(effective_broker_terminal_status(raw, true).is_err());
10219 }
10220 let runner = ActorChildRunner::new(
10221 "test".into(),
10222 PathBuf::new(),
10223 vec![],
10224 PathBuf::new(),
10225 ExecutorSpec::BambooRuntime,
10226 vec![],
10227 "test".into(),
10228 1,
10229 );
10230 let home = tempfile::tempdir().unwrap();
10231 let store = Arc::new(
10232 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10233 .await
10234 .unwrap(),
10235 );
10236 let root = Session::new("nested-ack-parent", "model");
10237 store.save_session(&root).await.unwrap();
10238 let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10239 child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10240 store.save_session(&child).await.unwrap();
10241 child.set_last_run_status("suspended");
10242 store
10243 .prepare_broker_terminal_receipt(
10244 &child,
10245 "run-1",
10246 "00000000-0000-4000-8000-000000000001",
10247 "p-nested-ack-child",
10248 "broker-run-1",
10249 &["broker-outcome-1".into()],
10250 )
10251 .await
10252 .unwrap();
10253 store.save_session(&child).await.unwrap();
10254 runner.set_actor_directory_store(Some(store.clone()));
10255 let calls = Arc::new(AtomicUsize::new(0));
10256
10257 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10258 runner
10259 .confirm_durable_child_delivery(&child, "run-1", true)
10260 .await
10261 .unwrap();
10262 assert_eq!(calls.load(Ordering::SeqCst), 0);
10263
10264 insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10265 .await;
10266 runner
10267 .confirm_durable_child_delivery(&child, "run-1", true)
10268 .await
10269 .unwrap();
10270 assert_eq!(calls.load(Ordering::SeqCst), 1);
10271 assert_eq!(
10272 store
10273 .load_session(&child.id)
10274 .await
10275 .unwrap()
10276 .unwrap()
10277 .last_run_status()
10278 .as_deref(),
10279 Some("suspended")
10280 );
10281 assert!(store
10282 .recover_broker_terminal_receipts(&child)
10283 .await
10284 .unwrap()
10285 .is_empty());
10286 }
10287
10288 #[tokio::test]
10289 async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10290 struct CommittedObserver {
10291 authority: PathBuf,
10292 events: std::sync::Mutex<Vec<PublicActorEvent>>,
10293 }
10294 impl ActorEventObserver for CommittedObserver {
10295 fn publish(&self, event: PublicActorEvent) {
10296 let entry: bamboo_domain::ActorDirectoryEntry =
10298 serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10299 let committed = entry.activation.unwrap();
10300 assert_eq!(
10301 committed.status,
10302 bamboo_domain::ActorActivationStatus::Succeeded
10303 );
10304 assert_eq!(committed.activation_id, event.activation_id);
10305 assert!(committed.finished_at.is_some());
10306 self.events.lock().unwrap().push(event);
10307 }
10308 }
10309 struct CompletionLink(VecDeque<ChildFrame>);
10310 #[async_trait]
10311 impl bamboo_subagent::ChildLink for CompletionLink {
10312 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10313 Ok(())
10314 }
10315 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10316 Ok(self.0.pop_front())
10317 }
10318 }
10319 for cut in [
10320 "success",
10321 "no_terminal",
10322 "invalid_terminal",
10323 "stale",
10324 "storage_failure",
10325 "append_failure",
10326 ] {
10327 let temp = tempfile::tempdir().unwrap();
10328 let store = Arc::new(
10329 bamboo_storage::SessionStoreV2::new(temp.path().into())
10330 .await
10331 .unwrap(),
10332 );
10333 let root = Session::new("notify-root", "model");
10334 store.save_session(&root).await.unwrap();
10335 let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10336 store.save_session(&child).await.unwrap();
10337 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10338 store.clone(),
10339 bamboo_domain::SessionInboxLimits::default(),
10340 ));
10341 let binding = actor_binding(
10342 store.clone(),
10343 inbox,
10344 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10345 );
10346 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10347 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10348 let activation = PlainActorActivation::start(
10349 store.clone(),
10350 &child,
10351 &binding,
10352 Some("notify-run"),
10353 Some(policy),
10354 false,
10355 None,
10356 )
10357 .await
10358 .unwrap();
10359 let _owner = binding
10360 .router
10361 .register_run(&child.id, "notify-run")
10362 .await
10363 .unwrap();
10364 let batch = ActorEventBatch {
10365 logical_session: Some(LogicalSessionIdentity {
10366 session_id: child.id.clone(),
10367 parent_session_id: child.parent_session_id.clone(),
10368 root_session_id: child.root_session_id.clone(),
10369 creation: None,
10370 }),
10371 activation_id: Some("notify-run".into()),
10372 execution_epoch: 9,
10373 source_node_id: None,
10374 source_actor_id: Some("notify-worker".into()),
10375 first_seq: 1,
10376 last_seq: 2,
10377 qos: bamboo_subagent::ActorEventQos::Durable,
10378 events: vec![
10379 serde_json::to_value(AgentEvent::Token {
10380 content: "PRIVATE_PROVIDER_TOKEN".into(),
10381 })
10382 .unwrap(),
10383 serde_json::to_value(AgentEvent::message_appended(
10384 &child.id,
10385 &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10386 ))
10387 .unwrap(),
10388 ],
10389 };
10390 let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10391 if cut != "no_terminal" {
10392 let mut terminal = completed_actor_frame();
10393 if cut == "invalid_terminal" {
10394 if let ChildFrame::Terminal { status, .. } = &mut terminal {
10395 *status = TerminalStatus::Suspended;
10396 }
10397 }
10398 frames.push_back(terminal);
10399 }
10400 let mut link = CompletionLink(frames);
10401 let (tx, mut rx) = mpsc::channel(8);
10402 let (_live, mut live_rx) = mpsc::unbounded_channel();
10403 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10404 let cancel = CancellationToken::new();
10405 let outcome = drive(ActorDriveContext {
10406 client: &mut link,
10407 parent_session_id: "notify-root",
10408 child_session_id: "notify-child",
10409 child_attempt: 0,
10410 approval_registry: None,
10411 approval_decider: None,
10412 approval_reviewer: None,
10413 escalation_bridge: None,
10414 event_tx: &tx,
10415 cancel_token: &cancel,
10416 live_rx: &mut live_rx,
10417 delivery_rx: &mut delivery_rx,
10418 logical_session: &mut child,
10419 expected_permission_posture: None,
10420 expected_creation: None,
10421 session_inbox_runtime: Some(&binding),
10422 actor_directory_store: Some(store.as_ref()),
10423 canonical_subagent_tool: None,
10424 ticket_service: None,
10425 activation_run_id: Some("notify-run"),
10426 execution_epoch: 9,
10427 expected_source_actor_id: "notify-worker",
10428 initial_inflight_claims: VecDeque::new(),
10429 plain_actor: true,
10430 remote_environment_lease: false,
10431 readonly_output: None,
10432 local_history_tools: None,
10433 local_history_read_only: false,
10434 strict_history_output: None,
10435 plain_input: Some(&activation),
10436 canonical_activation: None,
10437 canonical_placement_ref: None,
10438 actor_event_observer: None,
10439 plain_run: None,
10440 first_frame_timeout: Some(Duration::from_secs(1)),
10441 })
10442 .await;
10443 let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10444 assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10445 assert_eq!(
10446 activation.completion_router.lock().unwrap().is_some(),
10447 admitted,
10448 "{cut}"
10449 );
10450 while rx.try_recv().is_ok() {}
10451 let authority = temp
10452 .path()
10453 .join(store.resolve_rel_path(&child.id).await.unwrap())
10454 .join("actor-authority.json");
10455 let observer = CommittedObserver {
10456 authority: authority.clone(),
10457 events: Default::default(),
10458 };
10459 if cut == "append_failure" {
10460 child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10461 assert!(activation
10462 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10463 .await
10464 .is_err());
10465 activation
10466 .finish(ActorActivationFinish::Failed)
10467 .await
10468 .unwrap();
10469 assert!(observer.events.lock().unwrap().is_empty());
10470 continue;
10471 }
10472 activation
10473 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10474 .await
10475 .unwrap();
10476 assert!(
10477 rx.try_recv().is_ok(),
10478 "legacy content notification retained"
10479 );
10480 assert!(
10481 observer.events.lock().unwrap().is_empty(),
10482 "reply alone must not publish"
10483 );
10484 if cut == "stale" {
10485 store
10486 .retire_actor(&child.id, chrono::Utc::now())
10487 .await
10488 .unwrap();
10489 } else if cut == "storage_failure" {
10490 std::fs::remove_file(&authority).unwrap();
10493 std::fs::create_dir(&authority).unwrap();
10494 }
10495 let result = activation
10496 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10497 .await;
10498 assert_eq!(
10499 result.is_ok(),
10500 matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10501 "{cut}"
10502 );
10503 let events = observer.events.lock().unwrap().clone();
10504 assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10505 if let Some(event) = events.first() {
10506 assert_eq!(event.actor_id, child.id);
10507 assert_eq!(event.root_actor_id, root.id);
10508 assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10509 assert_eq!(event.source_order.sequence, 3);
10510 let json = serde_json::to_string(event).unwrap();
10511 assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10512 let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10513 assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10514 }
10515 assert!(activation
10516 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10517 .await
10518 .is_err());
10519 assert_eq!(
10520 observer.events.lock().unwrap().len(),
10521 events.len(),
10522 "repeat finish cannot publish"
10523 );
10524 }
10525 }
10526
10527 async fn bind_local_control_plane(
10528 store: &bamboo_storage::SessionStoreV2,
10529 actor_id: &str,
10530 policy: &bamboo_tools::permission::PermissionConfig,
10531 ) {
10532 store
10533 .bind_control_plane(
10534 actor_id,
10535 bamboo_domain::ActorControlPlaneBinding {
10536 policy_revision: Some(policy.policy_revision()),
10537 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10538 },
10539 )
10540 .await
10541 .unwrap();
10542 }
10543
10544 fn local_tool_sample(
10545 host: &Session,
10546 denied_write: bool,
10547 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10548 local_tool_sample_with_reasoning(host, denied_write, None)
10549 }
10550
10551 fn local_tool_sample_with_reasoning(
10552 host: &Session,
10553 denied_write: bool,
10554 reasoning: Option<&str>,
10555 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10556 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10557 let calls = ["Read", "Write"]
10558 .map(|name| ToolCall {
10559 id: format!("local-{name}"),
10560 tool_type: "function".into(),
10561 function: FunctionCall {
10562 name: name.into(),
10563 arguments: "{\"file_path\":\"answer.txt\"}".into(),
10564 },
10565 })
10566 .to_vec();
10567 let mut rows = vec![Message::assistant("checking", Some(calls))];
10568 rows[0].reasoning = reasoning.map(str::to_owned);
10569 let mut collector = LocalToolCollector::default();
10570 if let Some(reasoning) = reasoning {
10571 assert!(collector
10572 .event(
10573 &serde_json::to_value(AgentEvent::ReasoningToken {
10574 content: reasoning.into(),
10575 })
10576 .unwrap()
10577 )
10578 .unwrap());
10579 }
10580 for name in ["Read", "Write"] {
10581 let denied = name == "Write" && denied_write;
10582 let id = format!("local-{name}");
10583 if !denied {
10584 collector
10585 .event(
10586 &serde_json::to_value(AgentEvent::ToolStart {
10587 tool_call_id: id.clone(),
10588 tool_name: name.into(),
10589 arguments: serde_json::json!({"file_path":"answer.txt"}),
10590 })
10591 .unwrap(),
10592 )
10593 .unwrap();
10594 }
10595 let text = if denied {
10596 "Error: native_tool_ceiling_denied: Write"
10597 } else {
10598 "actual file result"
10599 };
10600 let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10601 message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10602 "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10603 rows.push(message);
10604 let event = if denied {
10605 AgentEvent::ToolError {
10606 tool_call_id: id,
10607 error: "native_tool_ceiling_denied: Write".into(),
10608 }
10609 } else {
10610 AgentEvent::ToolComplete {
10611 tool_call_id: id,
10612 result: bamboo_agent_core::tools::ToolResult::text(true, text),
10613 }
10614 };
10615 collector
10616 .event(&serde_json::to_value(event).unwrap())
10617 .unwrap();
10618 }
10619 rows.push(Message::assistant("complete exact report", None));
10620 rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10621 if let Some(reasoning) = reasoning {
10622 assert!(collector
10623 .event(
10624 &serde_json::to_value(AgentEvent::ReasoningToken {
10625 content: reasoning.into(),
10626 })
10627 .unwrap()
10628 )
10629 .unwrap());
10630 }
10631 let mut messages = host.messages.clone();
10632 if let Some(system) = messages
10633 .iter_mut()
10634 .find(|message| message.role == Role::System)
10635 {
10636 system.content = "worker-private system never imported".into();
10637 }
10638 messages.extend(rows.iter().cloned());
10639 let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10640 version: 1,
10641 messages: messages
10642 .iter()
10643 .map(|message| serde_json::to_value(message).unwrap())
10644 .collect(),
10645 };
10646 assert!(!collector
10647 .event(&serde_json::to_value(data).unwrap())
10648 .unwrap());
10649 (collector, rows)
10650 }
10651
10652 #[test]
10653 fn ticket_task_event_requires_exact_host_callback_receipt() {
10654 let mut collector = LocalToolCollector::default();
10655 let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10656 let start = serde_json::to_value(AgentEvent::ToolStart {
10657 tool_call_id: "task-call".into(),
10658 tool_name: "Task".into(),
10659 arguments: args.clone(),
10660 })
10661 .unwrap();
10662 let mut receipts = HashMap::new();
10663 assert!(collector.ticket_event(&start, &receipts).unwrap());
10664 collector.event(&start).unwrap();
10665 let complete = serde_json::to_value(AgentEvent::ToolComplete {
10666 tool_call_id: "task-call".into(),
10667 result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10668 })
10669 .unwrap();
10670 assert!(collector.ticket_event(&complete, &receipts).is_err());
10671 receipts.insert("other-call".into(), args.clone());
10672 assert!(collector.ticket_event(&complete, &receipts).is_err());
10673 receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10674 assert!(collector.ticket_event(&complete, &receipts).is_err());
10675 receipts.insert("task-call".into(), args);
10676 assert!(collector.ticket_event(&complete, &receipts).unwrap());
10677 let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10678 task_list: bamboo_tools::TaskTool::task_list_from_args(
10679 &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10680 "root",
10681 )
10682 .unwrap(),
10683 version: Some(999),
10684 })
10685 .unwrap();
10686 assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10687 }
10688
10689 #[test]
10690 fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10691 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10692 let mut host = Session::new("question-child", "model");
10693 host.messages.push(Message::user("own assignment"));
10694 let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10695 "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10696 "generation":1, "contract_revision":1, "prompt_revision":1,
10697 "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10698 "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10699 })).unwrap();
10700 let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10701 for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10702 let mut returned =
10703 serde_json::json!({"status":"waiting_for_answer", "request":question});
10704 if case == "forged_request" {
10705 returned["request"]["id"] = serde_json::json!("sibling-question");
10706 }
10707 let text = returned.to_string();
10708 let rows = [
10709 Message::assistant(
10710 "",
10711 Some(vec![ToolCall {
10712 id: "own-call".into(),
10713 tool_type: "function".into(),
10714 function: FunctionCall {
10715 name: "Task".into(),
10716 arguments: args.to_string(),
10717 },
10718 }]),
10719 ),
10720 Message::tool_result_with_status("own-call", &text, true),
10721 ];
10722 let collector = LocalToolCollector {
10723 messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10724 starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10725 outcomes: HashMap::from([("own-call".into(), (true, text))]),
10726 ticket_yield: (case != "no_host_receipt")
10727 .then(|| ("own-call".into(), question.clone())),
10728 };
10729 let terminal = if case == "fake_report" {
10730 Some("fake delivery")
10731 } else {
10732 Some("")
10733 };
10734 assert_eq!(
10735 collector
10736 .suffix(&host, &["Task".into()], false, terminal)
10737 .is_ok(),
10738 case == "exact",
10739 "{case}"
10740 );
10741 }
10742 }
10743
10744 #[test]
10745 fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10746 let mut host = Session::new("tool-history-prefix", "model");
10747 host.messages
10748 .push(bamboo_agent_core::Message::user("assignment"));
10749 let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10750 confirmed.metadata = Some(
10751 serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10752 );
10753 host.messages.push(confirmed);
10754 let tools = vec!["Read".into(), "Write".into()];
10755 let (good, rows) = local_tool_sample(&host, false);
10756 assert_eq!(
10757 serde_json::to_value(
10758 good.suffix(&host, &tools, false, Some("complete exact report"))
10759 .unwrap()
10760 )
10761 .unwrap(),
10762 serde_json::to_value(&rows).unwrap()
10763 );
10764 for case in [
10765 "missing",
10766 "changed",
10767 "metadata",
10768 "system",
10769 "user",
10770 "collision",
10771 "dangling",
10772 "result",
10773 "terminal",
10774 "tool_metadata",
10775 "grant",
10776 "start_arguments",
10777 "ledger",
10778 ] {
10779 let (mut collector, _) = local_tool_sample(&host, false);
10780 let mut current = host.clone();
10781 let messages = collector.messages.as_mut().unwrap();
10782 match case {
10783 "missing" => {
10784 messages.remove(host.messages.len() - 1);
10785 }
10786 "changed" => messages[0].content.push('!'),
10787 "metadata" => messages[1].metadata = None,
10788 "system" => messages.last_mut().unwrap().role = Role::System,
10789 "user" => messages.last_mut().unwrap().role = Role::User,
10790 "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10791 "dangling" => {
10792 messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10793 }
10794 "result" => messages[host.messages.len() + 1].content.push('!'),
10795 "tool_metadata" => {
10796 messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10797 true.into()
10798 }
10799 "grant" => {
10800 messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10801 .function
10802 .name = "Bash".into()
10803 }
10804 "start_arguments" => {
10805 collector.starts.get_mut("local-Read").unwrap().1 =
10806 serde_json::json!({"file_path":"different.txt"})
10807 }
10808 "ledger" => {
10809 current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10810 bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10811 bamboo_domain::ProviderFamily::OpenAi,
10812 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10813 bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10814 bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10815 serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10816 ).unwrap(),
10817 ]).unwrap();
10818 }
10819 "terminal" => {}
10820 _ => unreachable!(),
10821 }
10822 let terminal = if case == "terminal" {
10823 "different"
10824 } else {
10825 "complete exact report"
10826 };
10827 assert!(
10828 collector
10829 .suffix(¤t, &tools, false, Some(terminal))
10830 .is_err(),
10831 "{case}"
10832 );
10833 }
10834 let (denied, _) = local_tool_sample(&host, true);
10835 assert!(denied
10836 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10837 .is_ok());
10838 let (mut contradicted, _) = local_tool_sample(&host, true);
10839 contradicted.starts.insert(
10840 "local-Write".into(),
10841 ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10842 );
10843 assert!(contradicted
10844 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10845 .is_err());
10846 let (successful, _) = local_tool_sample(&host, false);
10847 assert!(successful
10848 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10849 .is_err());
10850 let mut empty = LocalToolCollector::default();
10851 assert!(empty
10852 .event(
10853 &serde_json::to_value(AgentEvent::ReasoningToken {
10854 content: "private".into()
10855 })
10856 .unwrap()
10857 )
10858 .unwrap());
10859 assert!(
10860 empty
10861 .suffix(&host, &tools, false, Some("complete exact report"))
10862 .is_err(),
10863 "stream observations alone cannot commit history"
10864 );
10865 let (mut completed, _) = local_tool_sample(&host, false);
10866 assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10867 }
10868
10869 struct LocalToolConcurrentCheckpoint {
10870 real: SessionInboxRuntimeBinding,
10871 claim: SessionInboxClaim,
10872 }
10873 #[async_trait]
10874 impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10875 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10876 self.real.persistence.save_runtime_session(session).await
10877 }
10878 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10879 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10882 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10883 .await
10884 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10885 self.real
10886 .persistence
10887 .checkpoint_runtime_session(session)
10888 .await
10889 }
10890 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10891 self.real.storage.load_session(id).await
10892 }
10893 }
10894
10895 #[tokio::test]
10896 async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
10897 for case in [
10898 "success",
10899 "reasoning",
10900 "concurrent_input",
10901 "checkpoint_error",
10902 "stale_prefix",
10903 "cancelled",
10904 "lost_owner",
10905 ] {
10906 let temp = tempfile::tempdir().unwrap();
10907 let store = Arc::new(
10908 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10909 .await
10910 .unwrap(),
10911 );
10912 let root = Session::new("local-tool-root", "model");
10913 store.save_session(&root).await.unwrap();
10914 let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
10915 let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
10916 system.role = Role::System;
10917 child.messages.push(system);
10918 child
10919 .messages
10920 .push(bamboo_agent_core::Message::user("assignment"));
10921 store.save_session(&child).await.unwrap();
10922 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10923 store.clone(),
10924 bamboo_domain::SessionInboxLimits::default(),
10925 ));
10926 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
10927 let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
10928 let mut owner = Some(
10929 binding
10930 .router
10931 .register_run(&child.id, "current-run")
10932 .await
10933 .unwrap(),
10934 );
10935 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
10936 &child.id,
10937 "current confirmed User",
10938 );
10939 let receipt = inbox.deliver(&envelope).await.unwrap();
10940 inbox
10941 .mark_activation_eligible(
10942 &child.id,
10943 receipt.generation,
10944 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10945 )
10946 .await
10947 .unwrap();
10948 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10949 checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
10950 .await
10951 .unwrap();
10952 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
10953 let before = serde_json::to_value(&child).unwrap();
10954 let reasoning = (case == "reasoning")
10955 .then_some("Complete plain reasoning before Read and the final reply 🪷.");
10956 let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
10957 let mut binding = binding;
10958 let late = bamboo_domain::SessionMessageEnvelope::user_input(
10959 &child.id,
10960 "concurrent confirmed User",
10961 );
10962 if case == "concurrent_input" {
10963 let receipt = inbox.deliver(&late).await.unwrap();
10964 inbox
10965 .mark_activation_eligible(
10966 &child.id,
10967 receipt.generation,
10968 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10969 )
10970 .await
10971 .unwrap();
10972 let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10973 binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
10974 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
10975 claim: late_claim,
10976 });
10977 }
10978 if case == "checkpoint_error" {
10979 binding.persistence = Arc::new(ActorFaultingPersistence {
10980 inner: locked.clone(),
10981 fail_checkpoint_once: AtomicBool::new(true),
10982 });
10983 }
10984 if case == "stale_prefix" {
10985 let mut latest = child.clone();
10986 latest
10987 .messages
10988 .push(bamboo_agent_core::Message::user("new confirmed prefix"));
10989 store.save_session(&latest).await.unwrap();
10990 }
10991 let cancel = CancellationToken::new();
10992 if case == "cancelled" {
10993 cancel.cancel();
10994 }
10995 if case == "lost_owner" {
10996 owner.take().unwrap().abandon().await;
10997 }
10998 let durable_before =
10999 serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11000 let result = commit_local_tool_history(
11001 collector,
11002 &mut child,
11003 &binding,
11004 "current-run",
11005 (&["Read".into(), "Write".into()], false),
11006 LocalToolTerminal::Completed(Some("complete exact report")),
11007 &cancel,
11008 )
11009 .await;
11010 let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11011 .await
11012 .unwrap()
11013 .load_session(&child.id)
11014 .await
11015 .unwrap()
11016 .unwrap();
11017 if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11018 assert!(result.is_err(), "{case}");
11019 assert_eq!(
11020 serde_json::to_value(&child).unwrap(),
11021 before,
11022 "{case}: no partial mutable success"
11023 );
11024 assert_eq!(
11025 serde_json::to_value(Some(&cold)).unwrap(),
11026 durable_before,
11027 "{case}: no write"
11028 );
11029 continue;
11030 }
11031 result.unwrap();
11032 assert_eq!(
11033 serde_json::to_value(&cold.messages[..3]).unwrap(),
11034 before["messages"]
11035 );
11036 let suffix_start = if case == "concurrent_input" {
11037 assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11038 assert_eq!(
11039 serde_json::to_value(&cold.messages[3]).unwrap(),
11040 serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11041 );
11042 assert!(child
11043 .messages
11044 .iter()
11045 .any(|message| message.id == late.id.as_str()));
11046 4
11047 } else {
11048 3
11049 };
11050 assert_eq!(
11051 serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11052 serde_json::to_value(rows).unwrap()
11053 );
11054 assert_eq!(
11055 cold.messages
11056 .iter()
11057 .filter(|message| message.id == envelope.id.as_str())
11058 .count(),
11059 1
11060 );
11061 assert_eq!(
11062 cold.messages
11063 .iter()
11064 .filter(|message| message.content == "complete exact report")
11065 .count(),
11066 1
11067 );
11068 assert_eq!(cold.metadata, child.metadata);
11069 assert!(cold.provider_transcript.is_empty());
11070 }
11071 }
11072
11073 #[tokio::test]
11074 async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11075 for case in [
11076 "legacy",
11077 "missing",
11078 "gap",
11079 "duplicate",
11080 "epoch",
11081 "source",
11082 "birth",
11083 "qos",
11084 "ticket_forged_success",
11085 ] {
11086 let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11087 session
11088 .messages
11089 .push(bamboo_agent_core::Message::user("original"));
11090 if case == "ticket_forged_success" {
11091 session.metadata.insert(
11092 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11093 "assignment".into(),
11094 );
11095 }
11096 let before = serde_json::to_value(&session).unwrap();
11097 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11098 created_at: session.created_at,
11099 spawn_depth: session.spawn_depth,
11100 };
11101 let mut messages = session.messages.clone();
11102 messages.push(bamboo_agent_core::Message::assistant("complete", None));
11103 let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11104 version: 1,
11105 messages: messages
11106 .iter()
11107 .map(|message| serde_json::to_value(message).unwrap())
11108 .collect(),
11109 })
11110 .unwrap();
11111 let mut batch = ActorEventBatch {
11112 logical_session: Some(LogicalSessionIdentity {
11113 session_id: session.id.clone(),
11114 parent_session_id: session.parent_session_id.clone(),
11115 root_session_id: session.root_session_id.clone(),
11116 creation: Some(creation.clone()),
11117 }),
11118 activation_id: Some("current".into()),
11119 execution_epoch: 7,
11120 source_node_id: None,
11121 source_actor_id: Some("selected".into()),
11122 first_seq: 1,
11123 last_seq: 1,
11124 qos: bamboo_subagent::proto::ActorEventQos::Durable,
11125 events: vec![event.clone()],
11126 };
11127 match case {
11128 "gap" => {
11129 batch.first_seq = 2;
11130 batch.last_seq = 2;
11131 }
11132 "epoch" => batch.execution_epoch = 6,
11133 "source" => batch.source_actor_id = Some("replaced".into()),
11134 "birth" => {
11135 batch
11136 .logical_session
11137 .as_mut()
11138 .unwrap()
11139 .creation
11140 .as_mut()
11141 .unwrap()
11142 .created_at += chrono::Duration::nanoseconds(1)
11143 }
11144 "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11145 "ticket_forged_success" => {
11146 batch.last_seq = 2;
11147 batch.events = vec![
11148 serde_json::to_value(AgentEvent::ToolStart {
11149 tool_call_id: "forged".into(), tool_name: "Task".into(),
11150 arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11151 }).unwrap(),
11152 serde_json::to_value(AgentEvent::ToolComplete {
11153 tool_call_id: "forged".into(),
11154 result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11155 }).unwrap(),
11156 ];
11157 }
11158 _ => {}
11159 }
11160 let terminal = ChildFrame::Terminal {
11161 status: TerminalStatus::Completed,
11162 result: Some("complete".into()),
11163 error: None,
11164 final_event_watermark: None,
11165 transcript: vec![],
11166 };
11167 let frames = match case {
11168 "legacy" => vec![ChildFrame::Event { event }, terminal],
11169 "missing" => vec![terminal],
11170 "duplicate" => vec![
11171 ChildFrame::EventBatch {
11172 batch: batch.clone(),
11173 },
11174 ChildFrame::EventBatch { batch },
11175 terminal,
11176 ],
11177 _ => vec![ChildFrame::EventBatch { batch }, terminal],
11178 };
11179 let mut link = ConfirmationSequenceLink {
11180 frames: frames.into(),
11181 sent: vec![],
11182 };
11183 let (tx, mut rx) = mpsc::channel(32);
11184 let (_live, mut live_rx) = mpsc::unbounded_channel();
11185 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11186 let cancel = CancellationToken::new();
11187 let tools = vec![if case == "ticket_forged_success" {
11188 "Task"
11189 } else {
11190 "Read"
11191 }
11192 .into()];
11193 let outcome = drive(ActorDriveContext {
11194 client: &mut link,
11195 parent_session_id: "frame-root",
11196 child_session_id: "frame-child",
11197 child_attempt: 0,
11198 approval_registry: None,
11199 approval_decider: None,
11200 approval_reviewer: None,
11201 escalation_bridge: None,
11202 event_tx: &tx,
11203 cancel_token: &cancel,
11204 live_rx: &mut live_rx,
11205 delivery_rx: &mut delivery_rx,
11206 logical_session: &mut session,
11207 expected_permission_posture: None,
11208 expected_creation: Some(&creation),
11209 session_inbox_runtime: None,
11210 actor_directory_store: None,
11211 canonical_subagent_tool: None,
11212 ticket_service: None,
11213 activation_run_id: Some("current"),
11214 execution_epoch: 7,
11215 expected_source_actor_id: "selected",
11216 initial_inflight_claims: vec![].into(),
11217 plain_actor: false,
11218 remote_environment_lease: false,
11219 readonly_output: None,
11220 local_history_tools: Some(&tools),
11221 local_history_read_only: true,
11222 strict_history_output: None,
11223 plain_input: None,
11224 canonical_activation: None,
11225 canonical_placement_ref: None,
11226 actor_event_observer: None,
11227 plain_run: None,
11228 first_frame_timeout: Some(Duration::from_secs(1)),
11229 })
11230 .await;
11231 assert!(outcome.is_err(), "{case}: no last-text success fallback");
11232 if case == "ticket_forged_success" {
11233 assert!(outcome
11234 .unwrap_err()
11235 .to_string()
11236 .contains("no exact Host receipt"));
11237 assert!(matches!(
11238 rx.try_recv().unwrap(),
11239 AgentEvent::ToolStart { .. }
11240 ));
11241 }
11242 assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11243 assert!(link.sent.is_empty());
11244 assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11245 }
11246 }
11247
11248 struct HistoryTerminalProbe {
11249 frames: VecDeque<ChildFrame>,
11250 accepted: Option<TerminalStatus>,
11251 acks: usize,
11252 }
11253
11254 #[async_trait]
11255 impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11256 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11257 Ok(())
11258 }
11259 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11260 Ok(self.frames.pop_front())
11261 }
11262 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11263 self.accepted = Some(status);
11264 }
11265 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11266 Some(bamboo_subagent::DurableChildDeliveryReceipt {
11267 broker_identity: "fixture".into(),
11268 parent_mailbox: "parent".into(),
11269 correlation_id: "current".into(),
11270 message_ids: vec!["tail".into(), "terminal".into()],
11271 terminal_status: self.accepted?,
11272 })
11273 }
11274 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11275 self.acks += 1;
11276 Ok(())
11277 }
11278 }
11279
11280 struct CancelConcurrentInputCheckpoint {
11281 real: SessionInboxRuntimeBinding,
11282 envelope: bamboo_domain::SessionMessageEnvelope,
11283 }
11284 #[async_trait]
11285 impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11286 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11287 self.real.persistence.save_runtime_session(session).await
11288 }
11289 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11290 let receipt = self
11291 .real
11292 .inbox
11293 .deliver(&self.envelope)
11294 .await
11295 .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11296 self.real
11297 .inbox
11298 .mark_activation_eligible(
11299 &session.id,
11300 receipt.generation,
11301 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11302 )
11303 .await
11304 .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11305 let claim = self
11306 .real
11307 .inbox
11308 .claim(&session.id, 1)
11309 .await
11310 .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11311 .remove(0);
11312 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11313 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11314 .await
11315 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11316 self.real
11317 .persistence
11318 .checkpoint_runtime_session(session)
11319 .await
11320 }
11321 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11322 self.real.storage.load_session(id).await
11323 }
11324 }
11325
11326 struct CancelHistoryProbe {
11327 inner: HistoryTerminalProbe,
11328 cancel_requested: bool,
11329 }
11330
11331 #[async_trait]
11332 impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11333 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11334 self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11335 Ok(())
11336 }
11337 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11338 if !self.cancel_requested {
11339 std::future::pending().await
11340 } else {
11341 self.inner.next_frame().await
11342 }
11343 }
11344 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11345 self.inner.accept_durable_terminal(status);
11346 }
11347 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11348 self.inner.durable_delivery_receipt()
11349 }
11350 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11351 self.inner.acknowledge_durable_frames().await
11352 }
11353 }
11354
11355 #[tokio::test]
11356 async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11357 use bamboo_subagent::ChildLink;
11358 for case in [
11359 "success",
11360 "empty",
11361 "missing",
11362 "unclosed",
11363 "unobserved",
11364 "prefix",
11365 "metadata",
11366 "write",
11367 "not_read_only",
11368 "watermark",
11369 "epoch",
11370 "birth",
11371 "permission",
11372 "inbox",
11373 "pending",
11374 "pending_eligible",
11375 "claimed",
11376 "inspect_error",
11377 "checkpoint_error",
11378 "lost_owner",
11379 "concurrent_input",
11380 ] {
11381 let temp = tempfile::tempdir().unwrap();
11382 let store = Arc::new(
11383 bamboo_storage::SessionStoreV2::new(temp.path().into())
11384 .await
11385 .unwrap(),
11386 );
11387 let root = Session::new("cancel-history-root", "model");
11388 store.save_session(&root).await.unwrap();
11389 let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11390 child
11391 .messages
11392 .push(bamboo_agent_core::Message::user("exact assignment"));
11393 child.metadata.insert(
11394 bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11395 "true".into(),
11396 );
11397 store.save_session(&child).await.unwrap();
11398 let before = serde_json::to_value(&child.messages).unwrap();
11399 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11400 store.clone(),
11401 bamboo_domain::SessionInboxLimits::default(),
11402 ));
11403 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11404 let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11405 let mut owner = Some(
11406 binding
11407 .router
11408 .register_run(&child.id, "current")
11409 .await
11410 .unwrap(),
11411 );
11412 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11413 created_at: child.created_at,
11414 spawn_depth: child.spawn_depth,
11415 };
11416 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11417 let tool = if case == "write" { "Write" } else { "Read" };
11418 let arguments = serde_json::json!({"file_path":"assigned.txt"});
11419 let call = bamboo_agent_core::ToolCall {
11420 id: "cancel-read".into(),
11421 tool_type: "function".into(),
11422 function: bamboo_agent_core::FunctionCall {
11423 name: tool.into(),
11424 arguments: arguments.to_string(),
11425 },
11426 };
11427 let mut rows = child.messages.clone();
11428 let mut events = Vec::new();
11429 if case != "empty" {
11430 rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11431 events.push(
11432 serde_json::to_value(AgentEvent::ToolStart {
11433 tool_call_id: "cancel-read".into(),
11434 tool_name: tool.into(),
11435 arguments,
11436 })
11437 .unwrap(),
11438 );
11439 if case != "unclosed" {
11440 let mut result = bamboo_agent_core::Message::tool_result_with_status(
11441 "cancel-read",
11442 "exact Read result",
11443 true,
11444 );
11445 if case == "metadata" {
11446 result.metadata = Some(serde_json::json!({"permission":true}));
11447 }
11448 rows.push(result);
11449 events.push(
11450 serde_json::to_value(AgentEvent::ToolComplete {
11451 tool_call_id: "cancel-read".into(),
11452 result: bamboo_agent_core::tools::ToolResult::text(
11453 true,
11454 "exact Read result",
11455 ),
11456 })
11457 .unwrap(),
11458 );
11459 }
11460 }
11461 if case == "unobserved" {
11462 events.clear();
11463 }
11464 if case == "prefix" {
11465 rows[0].content.push('!');
11466 }
11467 events.push(
11468 serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11469 version: 1,
11470 messages: rows
11471 .iter()
11472 .map(|row| serde_json::to_value(row).unwrap())
11473 .collect(),
11474 })
11475 .unwrap(),
11476 );
11477 if case == "missing" {
11478 events.clear();
11479 }
11480 let final_seq = events.len() as u64;
11481 let mut watermark = bamboo_subagent::ActorEventWatermark {
11482 version: 1,
11483 logical_session: Some(identity.clone()),
11484 activation_id: Some("current".into()),
11485 execution_epoch: 7,
11486 final_seq,
11487 };
11488 match case {
11489 "watermark" => watermark.final_seq += 1,
11490 "epoch" => watermark.execution_epoch -= 1,
11491 "birth" => {
11492 watermark
11493 .logical_session
11494 .as_mut()
11495 .unwrap()
11496 .creation
11497 .as_mut()
11498 .unwrap()
11499 .created_at += chrono::Duration::nanoseconds(1)
11500 }
11501 _ => {}
11502 }
11503 let mut frames = VecDeque::new();
11504 if !events.is_empty() {
11505 frames.push_back(ChildFrame::EventBatch {
11506 batch: ActorEventBatch {
11507 logical_session: Some(identity),
11508 activation_id: Some("current".into()),
11509 execution_epoch: 7,
11510 source_node_id: None,
11511 source_actor_id: Some("selected".into()),
11512 first_seq: 1,
11513 last_seq: final_seq,
11514 qos: bamboo_subagent::ActorEventQos::Durable,
11515 events,
11516 },
11517 });
11518 }
11519 frames.push_back(ChildFrame::Terminal {
11520 status: TerminalStatus::Cancelled,
11521 result: None,
11522 error: None,
11523 transcript: vec![],
11524 final_event_watermark: Some(watermark),
11525 });
11526 let mut claims = VecDeque::new();
11527 let pending_input =
11528 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11529 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11530 let receipt = inbox.deliver(&pending_input).await.unwrap();
11531 if case != "pending" {
11532 inbox
11533 .mark_activation_eligible(
11534 &child.id,
11535 receipt.generation,
11536 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11537 )
11538 .await
11539 .unwrap();
11540 }
11541 if matches!(case, "inbox" | "claimed") {
11542 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11543 if case == "inbox" {
11544 claims.push_back(claim);
11545 }
11546 }
11547 }
11548 if case == "concurrent_input" {
11549 binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11550 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11551 envelope: pending_input.clone(),
11552 });
11553 }
11554 if case == "inspect_error" {
11555 let rel = store.resolve_rel_path(&child.id).await.unwrap();
11556 let dir = store.bamboo_home_dir().join(rel).join("inbox");
11557 std::fs::create_dir_all(&dir).unwrap();
11558 std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11559 assert!(inbox.inspect(&child.id).await.is_err());
11560 }
11561 let backlog_before =
11562 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11563 Some(inbox.inspect(&child.id).await.unwrap())
11564 } else {
11565 None
11566 };
11567 if case == "checkpoint_error" {
11568 binding.persistence = Arc::new(ActorFaultingPersistence {
11569 inner: locked.clone(),
11570 fail_checkpoint_once: AtomicBool::new(true),
11571 });
11572 }
11573 if case == "lost_owner" {
11574 owner.take().unwrap().abandon().await;
11575 }
11576 let mut link = CancelHistoryProbe {
11577 inner: HistoryTerminalProbe {
11578 frames,
11579 accepted: None,
11580 acks: 0,
11581 },
11582 cancel_requested: false,
11583 };
11584 let (tx, mut rx) = mpsc::channel(32);
11585 let (_live, mut live_rx) = mpsc::unbounded_channel();
11586 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11587 let cancel = CancellationToken::new();
11588 cancel.cancel();
11589 let tools = vec!["Read".into(), "Glob".into()];
11590 let mut strict_history = None;
11591 let outcome = drive(ActorDriveContext {
11592 client: &mut link,
11593 parent_session_id: &root.id,
11594 child_session_id: &child.id.clone(),
11595 child_attempt: 0,
11596 approval_registry: None,
11597 approval_decider: None,
11598 approval_reviewer: None,
11599 escalation_bridge: None,
11600 event_tx: &tx,
11601 cancel_token: &cancel,
11602 live_rx: &mut live_rx,
11603 delivery_rx: &mut delivery_rx,
11604 logical_session: &mut child,
11605 expected_permission_posture: (case == "permission")
11606 .then(|| expected_default_permission_posture(7)),
11607 expected_creation: Some(&creation),
11608 session_inbox_runtime: Some(&binding),
11609 actor_directory_store: None,
11610 canonical_subagent_tool: None,
11611 ticket_service: None,
11612 activation_run_id: Some("current"),
11613 execution_epoch: 7,
11614 expected_source_actor_id: "selected",
11615 initial_inflight_claims: claims,
11616 plain_actor: false,
11617 remote_environment_lease: false,
11618 readonly_output: None,
11619 local_history_tools: Some(&tools),
11620 local_history_read_only: case != "not_read_only",
11621 strict_history_output: Some(&mut strict_history),
11622 plain_input: None,
11623 canonical_activation: None,
11624 canonical_placement_ref: None,
11625 actor_event_observer: None,
11626 plain_run: None,
11627 first_frame_timeout: None,
11628 })
11629 .await;
11630 let proven = matches!(case, "success" | "empty");
11631 assert_eq!(strict_history.is_some(), proven, "{case}");
11632 assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11633 assert_eq!(
11634 link.inner.acks, 0,
11635 "{case}: pump cannot ACK before SDK save/proof"
11636 );
11637 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11638 .await
11639 .unwrap()
11640 .load_session(&child.id)
11641 .await
11642 .unwrap()
11643 .unwrap();
11644 if proven {
11645 assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11646 assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11647 let history = strict_history.unwrap();
11648 assert_eq!(history.proof.execution_epoch(), 7);
11649 assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11650 assert_eq!(
11651 serde_json::to_value(&cold.messages).unwrap(),
11652 serde_json::to_value(&rows).unwrap()
11653 );
11654 } else {
11655 assert!(outcome.is_err(), "{case}");
11656 if case == "concurrent_input" {
11657 assert!(
11658 cold.messages
11659 .iter()
11660 .any(|message| message.id == pending_input.id.as_str()),
11661 "new input must survive refusal"
11662 );
11663 } else {
11664 assert_eq!(
11665 serde_json::to_value(&cold.messages).unwrap(),
11666 before,
11667 "{case}: no unvalidated history write"
11668 );
11669 }
11670 }
11671 if let Some(backlog) = backlog_before {
11672 assert!(!inbox
11673 .was_admitted(&child.id, &pending_input.id)
11674 .await
11675 .unwrap());
11676 assert_eq!(
11677 inbox.inspect(&child.id).await.unwrap(),
11678 backlog,
11679 "{case}: proof refusal must neither claim nor ACK the input"
11680 );
11681 }
11682 while let Ok(event) = rx.try_recv() {
11683 assert!(
11684 !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11685 "private DATA is not a canonical public barrier"
11686 );
11687 }
11688 }
11689 }
11690
11691 #[tokio::test]
11692 async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11693 use bamboo_subagent::ChildLink;
11694 for case in [
11695 "success",
11696 "tail",
11697 "short",
11698 "missing",
11699 "version",
11700 "activation",
11701 "epoch",
11702 "birth",
11703 "duplicate",
11704 "overlap",
11705 "reorder",
11706 "midgap",
11707 "qos",
11708 "zero_missing",
11709 "zero_valid",
11710 "error_tail",
11711 "cancelled_tail",
11712 "cancelled_valid",
11713 "cancelled_zero_valid",
11714 "error_valid",
11715 "error_zero_valid",
11716 "ordinary_lossy",
11717 ] {
11718 let temp = tempfile::tempdir().unwrap();
11719 let store = Arc::new(
11720 bamboo_storage::SessionStoreV2::new(temp.path().into())
11721 .await
11722 .unwrap(),
11723 );
11724 let root = Session::new("watermark-root", "model");
11725 store.save_session(&root).await.unwrap();
11726 let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11727 child
11728 .messages
11729 .push(bamboo_agent_core::Message::user("assignment"));
11730 store.save_session(&child).await.unwrap();
11731 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11732 store.clone(),
11733 bamboo_domain::SessionInboxLimits::default(),
11734 ));
11735 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11736 let binding = actor_binding(store.clone(), inbox, locked);
11737 let _owner = binding
11738 .router
11739 .register_run(&child.id, "current")
11740 .await
11741 .unwrap();
11742 let before = serde_json::to_value(&child).unwrap();
11743 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11744 created_at: child.created_at,
11745 spawn_depth: child.spawn_depth,
11746 };
11747 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11748 let mut rows = child.messages.clone();
11749 rows.push(bamboo_agent_core::Message::assistant("complete", None));
11750 let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11751 version: 1,
11752 messages: rows
11753 .iter()
11754 .map(|row| serde_json::to_value(row).unwrap())
11755 .collect(),
11756 })
11757 .unwrap();
11758 let batch = |seq, event, qos| ActorEventBatch {
11759 logical_session: Some(identity.clone()),
11760 activation_id: Some("current".into()),
11761 execution_epoch: 7,
11762 source_node_id: None,
11763 source_actor_id: Some("selected".into()),
11764 first_seq: seq,
11765 last_seq: seq,
11766 qos,
11767 events: vec![event],
11768 };
11769 let durable = bamboo_subagent::ActorEventQos::Durable;
11770 let token = serde_json::json!({"type":"token", "content":"observation"});
11771 let mut frames = vec![ChildFrame::EventBatch {
11772 batch: batch(1, data.clone(), durable),
11773 }];
11774 let mut marker = bamboo_subagent::ActorEventWatermark {
11775 version: 1,
11776 logical_session: Some(identity.clone()),
11777 activation_id: Some("current".into()),
11778 execution_epoch: 7,
11779 final_seq: 1,
11780 };
11781 match case {
11782 "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11783 "short" => marker.final_seq = 0,
11784 "version" => marker.version = 99,
11785 "activation" => marker.activation_id = Some("old".into()),
11786 "epoch" => marker.execution_epoch = 6,
11787 "birth" => {
11788 marker
11789 .logical_session
11790 .as_mut()
11791 .unwrap()
11792 .creation
11793 .as_mut()
11794 .unwrap()
11795 .created_at += chrono::Duration::nanoseconds(1)
11796 }
11797 "duplicate" => frames.push(frames[0].clone()),
11798 "overlap" | "midgap" | "qos" => {
11799 frames = vec![
11800 ChildFrame::EventBatch {
11801 batch: batch(
11802 1,
11803 token.clone(),
11804 if case == "qos" {
11805 bamboo_subagent::ActorEventQos::Ephemeral
11806 } else {
11807 durable
11808 },
11809 ),
11810 },
11811 ChildFrame::EventBatch {
11812 batch: batch(
11813 if case == "overlap" {
11814 1
11815 } else if case == "midgap" {
11816 3
11817 } else {
11818 2
11819 },
11820 data,
11821 durable,
11822 ),
11823 },
11824 ];
11825 marker.final_seq = 2;
11826 }
11827 "reorder" => {
11828 frames = vec![
11829 ChildFrame::EventBatch {
11830 batch: batch(2, data, durable),
11831 },
11832 ChildFrame::EventBatch {
11833 batch: batch(1, token, durable),
11834 },
11835 ]
11836 }
11837 "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11838 frames.clear();
11839 marker.final_seq = 0;
11840 }
11841 "ordinary_lossy" => {
11842 frames = vec![ChildFrame::EventBatch {
11843 batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11844 }]
11845 }
11846 _ => {}
11847 }
11848 let status = match case {
11849 "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11850 "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11851 TerminalStatus::Cancelled
11852 }
11853 _ => TerminalStatus::Completed,
11854 };
11855 frames.push(ChildFrame::Terminal {
11856 status,
11857 result: Some("complete".into()),
11858 error: Some("worker failed".into()),
11859 transcript: vec![],
11860 final_event_watermark: (!matches!(
11861 case,
11862 "missing" | "zero_missing" | "ordinary_lossy"
11863 ))
11864 .then_some(marker),
11865 });
11866 let mut link = HistoryTerminalProbe {
11867 frames: frames.into(),
11868 accepted: None,
11869 acks: 0,
11870 };
11871 let (tx, mut rx) = mpsc::channel(32);
11872 let (_live, mut live_rx) = mpsc::unbounded_channel();
11873 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11874 let cancel = CancellationToken::new();
11875 let tools = vec!["Read".into()];
11876 let mut strict_history = None;
11877 let outcome = drive(ActorDriveContext {
11878 client: &mut link,
11879 parent_session_id: &root.id,
11880 child_session_id: "watermark-child",
11881 child_attempt: 0,
11882 approval_registry: None,
11883 approval_decider: None,
11884 approval_reviewer: None,
11885 escalation_bridge: None,
11886 event_tx: &tx,
11887 cancel_token: &cancel,
11888 live_rx: &mut live_rx,
11889 delivery_rx: &mut delivery_rx,
11890 logical_session: &mut child,
11891 expected_permission_posture: None,
11892 expected_creation: Some(&creation),
11893 session_inbox_runtime: Some(&binding),
11894 actor_directory_store: None,
11895 canonical_subagent_tool: None,
11896 ticket_service: None,
11897 activation_run_id: Some("current"),
11898 execution_epoch: 7,
11899 expected_source_actor_id: "selected",
11900 initial_inflight_claims: vec![].into(),
11901 plain_actor: false,
11902 remote_environment_lease: false,
11903 readonly_output: None,
11904 local_history_tools: (case != "ordinary_lossy").then_some(&tools),
11905 local_history_read_only: true,
11906 strict_history_output: Some(&mut strict_history),
11907 plain_input: None,
11908 canonical_activation: None,
11909 canonical_placement_ref: None,
11910 actor_event_observer: None,
11911 plain_run: None,
11912 first_frame_timeout: Some(Duration::from_secs(1)),
11913 })
11914 .await;
11915 assert_eq!(strict_history.is_some(), case == "success", "{case}");
11916 if let Some(history) = strict_history {
11917 assert_eq!(history.proof.execution_epoch(), 7);
11918 assert_eq!(history.proof.contiguous_applied_seq(), 1);
11919 assert_eq!(history.watermark.final_seq, 1);
11920 }
11921 let cold = store.load_session(&child.id).await.unwrap().unwrap();
11922 if matches!(case, "success" | "ordinary_lossy") {
11923 assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
11924 } else {
11925 let error = outcome.unwrap_err();
11926 if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
11927 assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
11928 }
11929 let error = error.to_string();
11930 if !matches!(
11931 case,
11932 "duplicate"
11933 | "overlap"
11934 | "reorder"
11935 | "midgap"
11936 | "qos"
11937 | "zero_valid"
11938 | "error_valid"
11939 | "error_zero_valid"
11940 | "cancelled_valid"
11941 | "cancelled_zero_valid"
11942 ) {
11943 assert!(
11944 error.contains("actor_history_tail_incomplete"),
11945 "{case}: {error}"
11946 );
11947 }
11948 }
11949 if case == "success" {
11950 assert_eq!(
11951 serde_json::to_value(&cold.messages).unwrap(),
11952 serde_json::to_value(rows).unwrap()
11953 );
11954 } else {
11955 assert_eq!(
11956 serde_json::to_value(&cold).unwrap(),
11957 before,
11958 "{case}: no cold history commit"
11959 );
11960 assert_eq!(
11961 serde_json::to_value(&child).unwrap(),
11962 before,
11963 "{case}: no mutable history commit"
11964 );
11965 }
11966 let accepted = matches!(case, "success" | "ordinary_lossy");
11967 assert_eq!(link.accepted.is_some(), accepted, "{case}");
11968 assert_eq!(
11969 link.durable_delivery_receipt().is_some(),
11970 accepted,
11971 "{case}"
11972 );
11973 assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
11974 while let Ok(event) = rx.try_recv() {
11975 assert!(
11976 matches!(event, AgentEvent::Token { .. }),
11977 "typed DATA cannot leak"
11978 );
11979 }
11980 }
11981 }
11982
11983 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
11984 PooledWorker {
11985 worker: SpawnedChild::remote(AgentRecord {
11986 agent_id: mailbox_id.to_string(),
11987 role: "test".to_string(),
11988 labels: Vec::new(),
11989 endpoint: "ws://127.0.0.1:1".to_string(),
11990 pid: 0,
11991 version: String::new(),
11992 started_at: chrono::Utc::now(),
11993 lease_expires_at: chrono::Utc::now(),
11994 }),
11995 mailbox_id: mailbox_id.to_string(),
11996 parked_at: None,
11997 }
11998 }
11999
12000 #[test]
12001 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12002 let now = Instant::now();
12003 let timeout = Duration::from_secs(300);
12004 assert!(!parked_worker_expired(None, now, timeout));
12005 assert!(!parked_worker_expired(
12006 Some(now - timeout + Duration::from_millis(1)),
12007 now,
12008 timeout
12009 ));
12010 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12011 }
12012
12013 #[test]
12014 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12015 let mut pool = WorkerPool::new();
12016 pool.insert(
12017 "fingerprint-a".to_string(),
12018 vec![processless_pool_worker("a")],
12019 );
12020 pool.insert(
12021 "fingerprint-b".to_string(),
12022 vec![processless_pool_worker("b")],
12023 );
12024
12025 assert_eq!(
12026 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12027 Some("pool_global_limit")
12028 );
12029 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12030 assert_eq!(
12031 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12032 Some("pool_per_key_limit")
12033 );
12034 }
12035
12036 #[test]
12037 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12038 let mut pool = WorkerPool::new();
12039 pool.insert(
12040 "fingerprint-a".to_string(),
12041 vec![processless_pool_worker("dead")],
12042 );
12043
12044 let reaped = take_reapable_workers(
12045 &mut pool,
12046 Instant::now(),
12047 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12048 );
12049
12050 assert!(pool.is_empty());
12051 assert_eq!(reaped.len(), 1);
12052 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12053 }
12054
12055 #[test]
12056 fn actor_preflight_counts_only_current_session_scoped_denies() {
12057 let config = bamboo_tools::permission::PermissionConfig::new();
12058 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12059 config.deny_scoped_session_permission(
12060 "target-session",
12061 bamboo_tools::permission::PermissionType::ExecuteCommand,
12062 secret_matcher,
12063 );
12064 config.deny_scoped_session_permission(
12065 "other-session",
12066 bamboo_tools::permission::PermissionType::WriteFile,
12067 "/other/**",
12068 );
12069
12070 assert_eq!(
12071 active_scoped_session_deny_count(&config, "target-session"),
12072 1
12073 );
12074 assert_eq!(
12075 active_scoped_session_deny_count(&config, "clean-session"),
12076 0
12077 );
12078 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12079 .unwrap_err()
12080 .to_string();
12081 assert!(!error.contains(secret_matcher));
12082 ensure_no_active_scoped_session_denies(&config, "clean-session")
12083 .expect("another session's deny must not block this activation");
12084 }
12085
12086 #[test]
12087 fn permission_posture_event_rejects_oversized_executor_mapping() {
12088 let session = Session::new("mapping-bound", "model");
12089 let event = AgentEvent::PermissionPostureActivated {
12090 session_id: session.id.clone(),
12091 policy_revision: 1,
12092 requested_mode: "default".to_string(),
12093 effective_mode: "default".to_string(),
12094 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12095 };
12096
12097 assert!(permission_posture_seed_from_event(&session, &event)
12098 .unwrap_err()
12099 .contains("executor mapping"));
12100 }
12101
12102 #[test]
12103 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12104 let mut session = Session::new("host-resigns-audit", "model");
12105 let hostile_timestamp = "9".repeat(1024);
12106 let event: AgentEvent = serde_json::from_value(serde_json::json!({
12107 "type": "permission_posture_activated",
12108 "session_id": session.id.clone(),
12109 "policy_revision": 31,
12110 "requested_mode": "default",
12111 "effective_mode": "default",
12112 "executor_mapping": "codex_exec:approval_policy=never",
12113 "audit_revision": u64::MAX,
12114 "transitioned_at": hostile_timestamp,
12115 }))
12116 .expect("unknown remote audit fields are ignored by the typed event");
12117 let seed = permission_posture_seed_from_event(&session, &event)
12118 .unwrap()
12119 .expect("permission event");
12120
12121 let host_revision =
12122 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12123 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12124 .expect("host-generated complete audit");
12125 assert_eq!(host_audit.audit_revision, host_revision);
12126 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12127 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12128 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12129 }
12130
12131 struct ActorFaultingPersistence {
12132 inner: Arc<bamboo_storage::LockedSessionStore>,
12133 fail_checkpoint_once: std::sync::atomic::AtomicBool,
12134 }
12135
12136 #[async_trait]
12137 impl RuntimeSessionPersistence for ActorFaultingPersistence {
12138 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12139 self.inner.merge_save_runtime(session).await
12140 }
12141
12142 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12143 if self
12144 .fail_checkpoint_once
12145 .swap(false, std::sync::atomic::Ordering::SeqCst)
12146 {
12147 return Err(std::io::Error::other("injected actor checkpoint failure"));
12148 }
12149 self.inner.checkpoint_runtime_session(session).await
12150 }
12151
12152 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12153 self.inner.storage().load_session(session_id).await
12154 }
12155 }
12156
12157 struct ActorFailBeforeAckInbox {
12158 inner: Arc<dyn SessionInboxPort>,
12159 fail_once: std::sync::atomic::AtomicBool,
12160 }
12161
12162 #[async_trait]
12163 impl SessionInboxPort for ActorFailBeforeAckInbox {
12164 async fn deliver(
12165 &self,
12166 envelope: &bamboo_domain::SessionMessageEnvelope,
12167 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12168 self.inner.deliver(envelope).await
12169 }
12170
12171 async fn mark_activation_eligible(
12172 &self,
12173 target_session_id: &str,
12174 generation: u64,
12175 policy: bamboo_domain::SessionActivationPolicy,
12176 ) -> Result<(), bamboo_domain::SessionInboxError> {
12177 self.inner
12178 .mark_activation_eligible(target_session_id, generation, policy)
12179 .await
12180 }
12181
12182 async fn claim(
12183 &self,
12184 target_session_id: &str,
12185 limit: usize,
12186 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12187 self.inner.claim(target_session_id, limit).await
12188 }
12189
12190 async fn was_admitted(
12191 &self,
12192 target_session_id: &str,
12193 id: &bamboo_domain::SessionMessageId,
12194 ) -> Result<bool, bamboo_domain::SessionInboxError> {
12195 self.inner.was_admitted(target_session_id, id).await
12196 }
12197
12198 async fn ack(
12199 &self,
12200 target_session_id: &str,
12201 claim: &SessionInboxClaim,
12202 ) -> Result<(), bamboo_domain::SessionInboxError> {
12203 if self
12204 .fail_once
12205 .swap(false, std::sync::atomic::Ordering::SeqCst)
12206 {
12207 return Err(bamboo_domain::SessionInboxError::Storage(
12208 "injected actor pre-ack failure".to_string(),
12209 ));
12210 }
12211 self.inner.ack(target_session_id, claim).await
12212 }
12213
12214 async fn inspect(
12215 &self,
12216 target_session_id: &str,
12217 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12218 self.inner.inspect(target_session_id).await
12219 }
12220 }
12221
12222 async fn actor_inbox_fixture(
12223 session_id: &str,
12224 ) -> (
12225 tempfile::TempDir,
12226 Arc<bamboo_storage::SessionStoreV2>,
12227 Arc<bamboo_storage::LockedSessionStore>,
12228 Arc<dyn SessionInboxPort>,
12229 Session,
12230 SessionInboxClaim,
12231 ) {
12232 let temp = tempfile::tempdir().unwrap();
12233 let store = Arc::new(
12234 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12235 .await
12236 .unwrap(),
12237 );
12238 let storage: Arc<dyn Storage> = store.clone();
12239 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12240 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12241 store.clone(),
12242 bamboo_domain::SessionInboxLimits::default(),
12243 ));
12244 let session = Session::new(session_id, "model");
12245 store.save_session(&session).await.unwrap();
12246 let mut envelope =
12247 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12248 envelope.id =
12249 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12250 let receipt = inbox.deliver(&envelope).await.unwrap();
12251 inbox
12252 .mark_activation_eligible(
12253 session_id,
12254 receipt.generation,
12255 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12256 )
12257 .await
12258 .unwrap();
12259 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12260 (temp, store, locked, inbox, session, claim)
12261 }
12262
12263 fn actor_binding(
12264 store: Arc<bamboo_storage::SessionStoreV2>,
12265 inbox: Arc<dyn SessionInboxPort>,
12266 persistence: Arc<dyn RuntimeSessionPersistence>,
12267 ) -> SessionInboxRuntimeBinding {
12268 let storage: Arc<dyn Storage> = store;
12269 SessionInboxRuntimeBinding {
12270 router: SessionActivationRouter::new(),
12271 inbox,
12272 storage,
12273 persistence,
12274 parent_question_lock: None,
12275 }
12276 }
12277
12278 #[tokio::test]
12279 async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12280 let (_temp, store, locked, inbox, mut session, claim) =
12281 actor_inbox_fixture("unsupported-remote-inbox").await;
12282 let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12283 inner: inbox.clone(),
12284 fail_once: std::sync::atomic::AtomicBool::new(false),
12285 });
12286 let binding = actor_binding(store.clone(), wrapped, locked);
12287 let fence = ActorActivationFence {
12288 schema_version: 1,
12289 actor_id: session.id.clone(),
12290 activation_id: "unsupported-inbox-activation".into(),
12291 attempt: 1,
12292 run_id: "unsupported-inbox-run".into(),
12293 lease_owner: "unsupported-inbox-host".into(),
12294 lease_epoch: 1,
12295 };
12296 let before = session.clone();
12297
12298 let error = checkpoint_claim_context_before_dispatch(
12299 &binding,
12300 &mut session,
12301 &[claim],
12302 Some((store.as_ref(), &fence)),
12303 )
12304 .await
12305 .unwrap_err();
12306 assert!(error
12307 .to_string()
12308 .contains("requires canonical FileSessionInbox"));
12309 assert_eq!(
12310 serde_json::to_value(&session.messages).unwrap(),
12311 serde_json::to_value(&before.messages).unwrap()
12312 );
12313 assert_eq!(
12314 serde_json::to_value(
12315 &store
12316 .load_session(&session.id)
12317 .await
12318 .unwrap()
12319 .unwrap()
12320 .messages
12321 )
12322 .unwrap(),
12323 serde_json::to_value(&before.messages).unwrap()
12324 );
12325 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12326 }
12327
12328 #[tokio::test]
12329 async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12330 let (_temp, store, locked, inbox, parent, _claim) =
12331 actor_inbox_fixture("remote-parent").await;
12332 let child = Session::new_child_of("remote-child", &parent, "model", "child");
12333 store.save_session(&child).await.unwrap();
12334 let binding = actor_binding(store.clone(), inbox, locked);
12335 let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12336 assert_eq!(first.len(), 2);
12337 let mut changed = child.clone();
12338 changed.set_project_id_meta("different-project");
12339 assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12340 store.delete_session(&parent.id).await.unwrap();
12341 assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12342 }
12343
12344 #[tokio::test]
12345 async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12346 let (_temp, store, locked, inbox, root, _claim) =
12347 actor_inbox_fixture("deep-remote-root").await;
12348 let mut parent = root;
12349 for depth in 1..=6 {
12350 let child =
12351 Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12352 store.save_session(&child).await.unwrap();
12353 parent = child;
12354 }
12355 let binding = actor_binding(store, inbox, locked);
12356 let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12357 assert_eq!(chain.len(), 7);
12358 assert_eq!(chain.last().unwrap().spawn_depth, 0);
12359 }
12360
12361 #[tokio::test]
12362 async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12363 let (_temp, store, locked, inbox, parent, _claim) =
12364 actor_inbox_fixture("creation-parent").await;
12365 let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12366 child.set_project_id_meta("creation-project");
12367 store.save_session(&child).await.unwrap();
12368 let binding = actor_binding(store.clone(), inbox, locked);
12369 let job = SpawnJob {
12370 parent_session_id: parent.id.clone(),
12371 child_session_id: child.id.clone(),
12372 model: "model".into(),
12373 disabled_tools: None,
12374 };
12375 let creation = canonical_child_creation(&binding, &child, &job)
12376 .await
12377 .unwrap();
12378 assert_eq!(creation.created_at, child.created_at);
12379 assert_eq!(creation.spawn_depth, child.spawn_depth);
12380 for damage in 0..7 {
12381 let mut held = child.clone();
12382 match damage {
12383 0 => held.created_at += chrono::Duration::nanoseconds(1),
12384 1 => held.parent_session_id = Some("foreign-parent".into()),
12385 2 => held.root_session_id = "foreign-root".into(),
12386 3 => held.spawn_depth += 1,
12387 4 => held.set_project_id_meta("foreign-project"),
12388 5 => held.kind = bamboo_domain::SessionKind::Root,
12389 _ => held.id = " creation-child ".into(),
12390 }
12391 assert!(
12392 canonical_child_creation(&binding, &held, &job)
12393 .await
12394 .is_err(),
12395 "damage {damage}"
12396 );
12397 }
12398 store.delete_session(&child.id).await.unwrap();
12399 assert!(canonical_child_creation(&binding, &child, &job)
12400 .await
12401 .is_err());
12402 let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12403 replacement.set_project_id_meta("creation-project");
12404 assert_ne!(replacement.created_at, child.created_at);
12405 store.save_session(&replacement).await.unwrap();
12406 assert!(
12407 canonical_child_creation(&binding, &child, &job)
12408 .await
12409 .is_err(),
12410 "old held spec cannot adopt replacement birth"
12411 );
12412 canonical_child_creation(&binding, &replacement, &job)
12413 .await
12414 .unwrap();
12415 }
12416
12417 #[tokio::test]
12418 async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12419 use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12420 for policies in [
12421 [
12422 SessionActivationPolicy::RespectSpecificWait,
12423 SessionActivationPolicy::InterruptSpecificWait,
12424 ],
12425 [
12426 SessionActivationPolicy::InterruptSpecificWait,
12427 SessionActivationPolicy::RespectSpecificWait,
12428 ],
12429 ] {
12430 let temp = tempfile::tempdir().unwrap();
12431 let store = Arc::new(
12432 bamboo_storage::SessionStoreV2::new(temp.path().into())
12433 .await
12434 .unwrap(),
12435 );
12436 let session_id = "actor-mixed-intent-policy";
12437 store
12438 .save_session(&Session::new(session_id, "model"))
12439 .await
12440 .unwrap();
12441 let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12442 let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12443 let staged_receipt = inbox.deliver(&staged).await.unwrap();
12444 let mut sent = Vec::new();
12445 for policy in policies {
12446 let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12447 let receipt = inbox
12448 .deliver_with_activation_intent(&envelope, policy, None)
12449 .await
12450 .unwrap();
12451 sent.push((envelope, policy, receipt));
12452 }
12453 let restarted = Arc::new(
12454 bamboo_storage::SessionStoreV2::new(temp.path().into())
12455 .await
12456 .unwrap(),
12457 );
12458 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12459 restarted.clone(),
12460 SessionInboxLimits::default(),
12461 ));
12462 let binding = actor_binding(
12463 restarted.clone(),
12464 inbox.clone(),
12465 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12466 );
12467 let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12468 let pairs =
12469 claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12470 .await
12471 .unwrap();
12472 assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12473 for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12474 assert_eq!(claim.envelope.id, envelope.id);
12475 assert_eq!(claim.generation, receipt.generation);
12476 assert_eq!(claim.activation_policy, *policy);
12477 assert_eq!(delivery.activation_policy, *policy);
12478 checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12479 .await
12480 .unwrap();
12481 }
12482 let restarted = Arc::new(
12483 bamboo_storage::SessionStoreV2::new(temp.path().into())
12484 .await
12485 .unwrap(),
12486 );
12487 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12488 restarted.clone(),
12489 SessionInboxLimits::default(),
12490 ));
12491 let binding = actor_binding(
12492 restarted.clone(),
12493 inbox.clone(),
12494 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12495 );
12496 session = restarted.load_session(session_id).await.unwrap().unwrap();
12497 for (envelope, policy, receipt) in &sent {
12498 assert_eq!(
12499 inbox
12500 .deliver_with_activation_intent(envelope, *policy, None)
12501 .await
12502 .unwrap(),
12503 *receipt
12504 );
12505 }
12506 assert!(
12507 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12508 .await
12509 .unwrap()
12510 .is_empty()
12511 );
12512 inbox
12513 .mark_activation_eligible(
12514 session_id,
12515 staged_receipt.generation,
12516 SessionActivationPolicy::RespectSpecificWait,
12517 )
12518 .await
12519 .unwrap();
12520 let pairs =
12521 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12522 .await
12523 .unwrap();
12524 assert_eq!(pairs.len(), 1);
12525 assert_eq!(pairs[0].0.envelope.id, staged.id);
12526 assert_eq!(
12527 pairs[0].1.activation_policy,
12528 SessionActivationPolicy::RespectSpecificWait
12529 );
12530 }
12531 }
12532
12533 #[tokio::test]
12534 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12535 let (_temp, store, locked, inbox, mut session, claim) =
12536 actor_inbox_fixture("actor-live-id-collision").await;
12537 let mut forged = claim.envelope.to_provider_message().unwrap();
12538 forged.metadata = Some(serde_json::json!({
12539 "session_message": {
12540 "id": claim.envelope.id,
12541 "target_session_id": "different-session"
12542 }
12543 }));
12544 session.add_message(forged);
12545 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12546 let binding = actor_binding(store, inbox.clone(), persistence);
12547
12548 assert!(
12549 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12550 .await
12551 .is_err()
12552 );
12553 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12554 assert!(!inbox
12555 .was_admitted(&session.id, &claim.envelope.id)
12556 .await
12557 .unwrap());
12558 }
12559
12560 #[tokio::test]
12561 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12562 let (_temp, store, locked, inbox, mut session, claim) =
12563 actor_inbox_fixture("actor-durable-id-collision").await;
12564 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12565 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12566 forged.id = claim.envelope.id.to_string();
12567 concurrent.add_message(forged);
12568 store.save_session(&concurrent).await.unwrap();
12569 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12570 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12571
12572 assert!(
12573 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12574 .await
12575 .is_err()
12576 );
12577 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12578 assert!(!inbox
12579 .was_admitted(&session.id, &claim.envelope.id)
12580 .await
12581 .unwrap());
12582 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12583 assert!(!durable.messages.iter().any(|message| {
12584 bamboo_domain::is_matching_session_message(message, &claim.envelope)
12585 }));
12586 }
12587
12588 #[tokio::test]
12589 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12590 let (_temp, store, locked, inbox, mut session, claim) =
12591 actor_inbox_fixture("actor-durable-typed-body-collision").await;
12592 let mut different = claim.envelope.clone();
12593 different.body = bamboo_domain::SessionMessageBody::Content(
12594 bamboo_domain::SessionMessageContent::text("forged actor body"),
12595 );
12596 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12597 concurrent.add_message(different.to_provider_message().unwrap());
12598 store.save_session(&concurrent).await.unwrap();
12599 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12600 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12601
12602 assert!(
12603 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12604 .await
12605 .is_err()
12606 );
12607 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12608 assert!(!inbox
12609 .was_admitted(&session.id, &claim.envelope.id)
12610 .await
12611 .unwrap());
12612 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12613 assert!(!durable
12614 .messages
12615 .iter()
12616 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12617 }
12618
12619 #[tokio::test]
12620 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12621 let (_temp, store, locked, inbox, mut session, claim) =
12622 actor_inbox_fixture("actor-checkpoint-failure").await;
12623 let envelope_id = claim.envelope.id.clone();
12624 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12625 inner: locked.clone(),
12626 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12627 });
12628 let binding = actor_binding(store.clone(), inbox.clone(), fault);
12629
12630 assert!(
12631 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12632 .await
12633 .is_err()
12634 );
12635 assert!(!session
12636 .messages
12637 .iter()
12638 .any(|message| message.id == envelope_id.as_str()));
12639 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12640 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12641
12642 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12643 store.clone(),
12644 bamboo_domain::SessionInboxLimits::default(),
12645 ));
12646 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12647 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12648 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12649 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12650 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12651 .await
12652 .unwrap();
12653 assert_eq!(
12654 restarted
12655 .messages
12656 .iter()
12657 .filter(|message| message.id == envelope_id.as_str())
12658 .count(),
12659 1
12660 );
12661 assert!(reopened
12662 .was_admitted(&session.id, &envelope_id)
12663 .await
12664 .unwrap());
12665 let backlog = reopened.inspect(&session.id).await.unwrap();
12666 assert_eq!(backlog.pending + backlog.claimed, 0);
12667 }
12668
12669 #[tokio::test]
12670 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12671 let (_temp, store, locked, real_inbox, mut session, claim) =
12672 actor_inbox_fixture("actor-pre-ack-failure").await;
12673 let envelope_id = claim.envelope.id.clone();
12674 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12675 inner: real_inbox.clone(),
12676 fail_once: std::sync::atomic::AtomicBool::new(true),
12677 });
12678 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12679 let binding = actor_binding(store.clone(), faulted, persistence);
12680
12681 assert!(
12682 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12683 .await
12684 .is_err()
12685 );
12686 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12687 assert_eq!(
12688 durable
12689 .messages
12690 .iter()
12691 .filter(|message| message.id == envelope_id.as_str())
12692 .count(),
12693 1
12694 );
12695 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12696 assert!(!real_inbox
12697 .was_admitted(&session.id, &envelope_id)
12698 .await
12699 .unwrap());
12700
12701 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12702 store.clone(),
12703 bamboo_domain::SessionInboxLimits::default(),
12704 ));
12705 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12706 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12707 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12708 let mut restarted = durable;
12709 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12710 .await
12711 .unwrap();
12712 assert_eq!(
12713 restarted
12714 .messages
12715 .iter()
12716 .filter(|message| message.id == envelope_id.as_str())
12717 .count(),
12718 1
12719 );
12720 assert!(reopened
12721 .was_admitted(&session.id, &envelope_id)
12722 .await
12723 .unwrap());
12724 let backlog = reopened.inspect(&session.id).await.unwrap();
12725 assert_eq!(backlog.pending + backlog.claimed, 0);
12726 }
12727
12728 struct ConfirmationSequenceLink {
12729 frames: VecDeque<ChildFrame>,
12730 sent: Vec<ParentFrame>,
12731 }
12732
12733 #[async_trait]
12734 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12735 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12736 self.sent.push(frame);
12737 Ok(())
12738 }
12739
12740 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12741 match self.frames.pop_front() {
12742 Some(frame) => Ok(Some(frame)),
12743 None => std::future::pending().await,
12744 }
12745 }
12746 }
12747
12748 #[tokio::test]
12749 async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12750 for case in [
12751 "accepted",
12752 "readonly-refusal",
12753 "prefix-refusal",
12754 "old-claim",
12755 "old-checkpoint",
12756 "succeeded",
12757 "changed-birth",
12758 "oversize",
12759 ] {
12760 let temp = tempfile::tempdir().unwrap();
12761 let store = Arc::new(
12762 bamboo_storage::SessionStoreV2::new(temp.path().into())
12763 .await
12764 .unwrap(),
12765 );
12766 let parent = Session::new("retry-parent", "model");
12767 store.save_session(&parent).await.unwrap();
12768 let mut child =
12769 Session::new_child_of("retry-child", &parent, "model", "original assignment");
12770 child.add_message(bamboo_agent_core::Message::user("original assignment"));
12771 store.save_session(&child).await.unwrap();
12772 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12773 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12774 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12775 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12776 store.clone(),
12777 bamboo_domain::SessionInboxLimits::default(),
12778 ));
12779 let binding = actor_binding(store.clone(), inbox, locked);
12780 let registration = binding
12781 .router
12782 .register_run(&child.id, "first-run")
12783 .await
12784 .unwrap();
12785 let first = PlainActorActivation::start(
12786 store.clone(),
12787 &child,
12788 &binding,
12789 Some("first-run"),
12790 Some(policy.clone()),
12791 true,
12792 None,
12793 )
12794 .await
12795 .unwrap();
12796 if matches!(case, "old-claim" | "old-checkpoint") {
12797 let old = bamboo_domain::SessionMessageEnvelope::user_input(
12798 &child.id,
12799 "unconfirmed old input",
12800 );
12801 let receipt = first.input_inbox.deliver(&old).await.unwrap();
12802 first
12803 .input_inbox
12804 .mark_activation_eligible(
12805 &child.id,
12806 receipt.generation,
12807 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12808 )
12809 .await
12810 .unwrap();
12811 let claim = first
12812 .claim_input(&binding, &child, "first-run")
12813 .await
12814 .unwrap()
12815 .unwrap();
12816 if case == "old-checkpoint" {
12817 first
12818 .checkpoint_input(&mut child, "first-run", &claim)
12819 .await
12820 .unwrap();
12821 }
12822 }
12823 first
12824 .finish(if case == "succeeded" {
12825 ActorActivationFinish::Succeeded
12826 } else {
12827 ActorActivationFinish::Failed
12828 })
12829 .await
12830 .unwrap();
12831 drop(registration);
12832 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12833 &child.id,
12834 if case == "oversize" {
12835 "x".repeat(8193)
12836 } else {
12837 "NEW_CORRECTION".into()
12838 },
12839 );
12840 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12841 first
12842 .input_inbox
12843 .mark_activation_eligible(
12844 &child.id,
12845 receipt.generation,
12846 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12847 )
12848 .await
12849 .unwrap();
12850 let before = store.load_session(&child.id).await.unwrap().unwrap();
12851 let expected_messages = serde_json::to_value(&before.messages).unwrap();
12852 child = before.clone();
12853 if case == "prefix-refusal" {
12854 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12855 }
12856 if case == "changed-birth" {
12857 child.created_at += chrono::Duration::milliseconds(1);
12858 }
12859 let _retry_registration = binding
12860 .router
12861 .register_run(&child.id, "retry-run")
12862 .await
12863 .unwrap();
12864 let result = PlainActorActivation::start_and_prepare(
12865 store.clone(),
12866 &mut child,
12867 &binding,
12868 Some("retry-run"),
12869 Some(policy.clone()),
12870 case != "readonly-refusal",
12871 None,
12872 )
12873 .await;
12874 if case != "accepted" {
12875 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12876 let actual = store.load_session(&before.id).await.unwrap().unwrap();
12877 assert_eq!(
12878 serde_json::to_value(&actual.messages).unwrap(),
12879 expected_messages,
12880 "{case}: failed preparation preserves Main history"
12881 );
12882 assert!(
12883 !first
12884 .input_inbox
12885 .was_admitted(&before.id, &envelope.id)
12886 .await
12887 .unwrap(),
12888 "{case}: no ACK"
12889 );
12890 if case == "readonly-refusal" {
12891 let entry = store.inspect_actor(&before.id).await.unwrap();
12892 assert_eq!(entry.actor.current_attempt, 1);
12893 assert_eq!(
12894 entry.activation.unwrap().activation_id,
12895 first.fence.activation_id
12896 );
12897 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12898 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12899 }
12900 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
12901 assert_eq!(
12902 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
12903 1
12904 );
12905 assert_eq!(
12906 store
12907 .inspect_actor(&before.id)
12908 .await
12909 .unwrap()
12910 .activation
12911 .unwrap()
12912 .status,
12913 bamboo_domain::ActorActivationStatus::Failed
12914 );
12915 }
12916 if case == "oversize" {
12917 let reopened = Arc::new(
12918 bamboo_storage::SessionStoreV2::new(temp.path().into())
12919 .await
12920 .unwrap(),
12921 );
12922 let cold_inbox = bamboo_storage::FileSessionInbox::new(
12923 reopened,
12924 bamboo_domain::SessionInboxLimits::default(),
12925 );
12926 let now = chrono::Utc::now();
12927 let leases = cold_inbox
12928 .inspect_owned_leases(&before.id, 2, now)
12929 .await
12930 .unwrap();
12931 assert_eq!(leases.len(), 1);
12932 assert_eq!(leases[0].failure_count, 1);
12933 assert_eq!(
12934 leases[0].last_error_code.as_deref(),
12935 Some("unsupported_actor_input")
12936 );
12937 let retry_after = leases[0].retry_after.unwrap();
12938 assert!(retry_after > now);
12939 let readiness = cold_inbox
12940 .inspect_wake_readiness(&before.id, now)
12941 .await
12942 .unwrap();
12943 assert!(
12944 readiness.ready.is_none(),
12945 "failed correction must not hot-loop"
12946 );
12947 assert_eq!(readiness.next_due_at, Some(retry_after));
12948 }
12949 continue;
12950 }
12951 let (retry, prepared) = result.unwrap();
12952 let (prefix, delivery) = prepared.unwrap();
12953 assert_eq!(
12954 prefix,
12955 serde_json::to_value(&before.messages)
12956 .unwrap()
12957 .as_array()
12958 .unwrap()
12959 .clone()
12960 );
12961 assert_eq!(delivery.envelope, envelope);
12962 assert_eq!(delivery.activation_run_id, "retry-run");
12963 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
12964 assert!(store
12965 .validate_fence(&first.fence, chrono::Utc::now())
12966 .await
12967 .is_err());
12968 let running = store.inspect_actor(&child.id).await.unwrap();
12969 assert_eq!(running.actor.current_attempt, 2);
12970 assert_eq!(
12971 running.activation.as_ref().unwrap().status,
12972 bamboo_domain::ActorActivationStatus::Running
12973 );
12974 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
12975 let claim = retry.initial_input.as_ref().unwrap();
12976 assert!(child
12977 .session_inbox_admission()
12978 .unwrap()
12979 .contains(&envelope.id));
12980 assert!(
12981 !retry
12982 .input_inbox
12983 .was_admitted(&child.id, &envelope.id)
12984 .await
12985 .unwrap(),
12986 "checkpoint is not startup confirmation"
12987 );
12988 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
12989 confirmation.activation_run_id = "first-run".into();
12990 assert!(retry
12991 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
12992 .await
12993 .is_err());
12994 assert!(!retry
12995 .input_inbox
12996 .was_admitted(&child.id, &envelope.id)
12997 .await
12998 .unwrap());
12999 confirmation.activation_run_id = "retry-run".into();
13000 retry
13001 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13002 .await
13003 .unwrap();
13004 let (tx, mut rx) = mpsc::channel(1);
13005 retry
13006 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13007 .await
13008 .unwrap();
13009 rx.recv().await.unwrap();
13010 retry
13011 .finish(ActorActivationFinish::Succeeded)
13012 .await
13013 .unwrap();
13014 let reopened = Arc::new(
13015 bamboo_storage::SessionStoreV2::new(temp.path().into())
13016 .await
13017 .unwrap(),
13018 );
13019 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13020 assert_eq!(cold.created_at, before.created_at);
13021 assert_eq!(cold.messages.len(), before.messages.len() + 2);
13022 assert_eq!(
13023 cold.messages[before.messages.len()].id,
13024 envelope.id.as_str()
13025 );
13026 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13027 assert_eq!(
13028 cold.messages
13029 .iter()
13030 .filter(|m| m.id == envelope.id.as_str())
13031 .count(),
13032 1
13033 );
13034 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13035 reopened,
13036 bamboo_domain::SessionInboxLimits::default(),
13037 );
13038 assert!(cold_inbox
13039 .was_admitted(&cold.id, &envelope.id)
13040 .await
13041 .unwrap());
13042 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13043 }
13044 }
13045
13046 #[tokio::test]
13047 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13048 for case in [
13049 "accepted",
13050 "actor-live",
13051 "claim-live",
13052 "legacy",
13053 "ack",
13054 "readonly",
13055 "prefix",
13056 "duplicate",
13057 "missing-marker",
13058 "mixed",
13059 "project",
13060 "birth",
13061 ] {
13062 let temp = tempfile::tempdir().unwrap();
13063 let store = Arc::new(
13064 bamboo_storage::SessionStoreV2::new(temp.path().into())
13065 .await
13066 .unwrap(),
13067 );
13068 let parent = Session::new("recovery-parent", "model");
13069 store.save_session(&parent).await.unwrap();
13070 let mut child =
13071 Session::new_child_of("recovery-child", &parent, "model", "original task");
13072 child.add_message(bamboo_agent_core::Message::user("original task"));
13073 store.save_session(&child).await.unwrap();
13074 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13075 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13076 let inbox = bamboo_storage::FileSessionInbox::new(
13077 store.clone(),
13078 bamboo_domain::SessionInboxLimits::default(),
13079 );
13080 let envelope =
13082 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13083 let receipt = inbox.deliver(&envelope).await.unwrap();
13084 inbox
13085 .mark_activation_eligible(
13086 &child.id,
13087 receipt.generation,
13088 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13089 )
13090 .await
13091 .unwrap();
13092 let now = chrono::Utc::now();
13093 let cutoff = now + chrono::Duration::seconds(10);
13094 let consumer = SessionInboxConsumerId::new();
13095 let old = store
13096 .claim_activation(&ActorActivationClaim {
13097 actor_id: child.id.clone(),
13098 run_id: "old-run".into(),
13099 lease_owner: consumer.as_str().into(),
13100 lease_expires_at: if case == "actor-live" {
13101 now + chrono::Duration::minutes(5)
13102 } else {
13103 cutoff
13104 },
13105 inbox_generation: 0,
13106 placement_ref: if case == "legacy" {
13107 None
13108 } else {
13109 Some(bamboo_domain::ActorPlacementRef {
13110 class: bamboo_domain::ActorPlacementClass::Local,
13111 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13112 slot_epoch: None,
13113 })
13114 },
13115 now,
13116 })
13117 .await
13118 .unwrap();
13119 store
13120 .start_activation(&old.fence(), chrono::Utc::now())
13121 .await
13122 .unwrap();
13123 let claim_now = chrono::Utc::now();
13124 let claim = inbox
13125 .claim_owned(
13126 &child.id,
13127 1,
13128 Some("old-run"),
13129 &SessionInboxLeaseRequest {
13130 consumer,
13131 now: claim_now,
13132 duration: if case == "claim-live" {
13133 chrono::Duration::minutes(5)
13134 } else {
13135 cutoff - claim_now
13136 },
13137 },
13138 )
13139 .await
13140 .unwrap()
13141 .pop()
13142 .unwrap();
13143 child = inbox
13144 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13145 fence: old.fence(),
13146 expected_created_at: child.created_at,
13147 claim: claim.clone(),
13148 expected_messages: child.messages.clone(),
13149 expected_provider_transcript: child.provider_transcript.clone(),
13150 expected_admission: None,
13151 })
13152 .await
13153 .unwrap()
13154 .session;
13155 store
13157 .validate_fence(&old.fence(), chrono::Utc::now())
13158 .await
13159 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13160 let setup_leases = inbox
13161 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13162 .await
13163 .unwrap();
13164 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13165 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13166 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13167 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13168 let setup_at = chrono::Utc::now();
13169 assert!(
13170 setup_at < old.lease_expires_at,
13171 "{case}: setup Actor expired"
13172 );
13173 assert!(
13174 setup_at < claim.lease.expires_at,
13175 "{case}: setup claim expired"
13176 );
13177 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13178 if case == "ack" {
13179 inbox
13180 .ack_owned(&child.id, &claim, chrono::Utc::now())
13181 .await
13182 .unwrap();
13183 }
13184 if case == "mixed" {
13185 inbox
13186 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13187 &child.id,
13188 "different input",
13189 ))
13190 .await
13191 .unwrap();
13192 }
13193 let directory = store
13194 .bamboo_home_dir()
13195 .join(store.resolve_rel_path(&child.id).await.unwrap());
13196 let main = std::fs::read(directory.join("session.json")).unwrap();
13197 let canonical = child.clone();
13198 match case {
13199 "prefix" => child.messages[0].content.push_str(" stale"),
13200 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13201 "missing-marker" => {
13202 child
13203 .messages
13204 .last_mut()
13205 .unwrap()
13206 .metadata
13207 .as_mut()
13208 .unwrap()
13209 .as_object_mut()
13210 .unwrap()
13211 .remove("_bamboo_owned_input_checkpoint");
13212 }
13213 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13214 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13215 _ => {}
13216 }
13217 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13220 tokio::time::sleep(delay + Duration::from_millis(10)).await;
13221 let recovery_at = chrono::Utc::now();
13222 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13223 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13224 let original = before_recovery.activation.unwrap();
13225 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13226 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13227 assert_eq!(
13228 store
13229 .validate_fence(&old.fence(), recovery_at)
13230 .await
13231 .is_ok(),
13232 case == "actor-live",
13233 "{case}: Actor expiry control"
13234 );
13235 let recovery_leases = inbox
13236 .inspect_owned_leases(&child.id, 2, recovery_at)
13237 .await
13238 .unwrap();
13239 if case == "ack" {
13240 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13241 } else {
13242 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13243 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13244 assert_eq!(
13245 recovery_leases[0].expires_at, claim.lease.expires_at,
13246 "{case}"
13247 );
13248 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13249 }
13250 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13251 let second = Arc::new(
13252 bamboo_storage::SessionStoreV2::new(temp.path().into())
13253 .await
13254 .unwrap(),
13255 );
13256 let binding = actor_binding(
13257 second.clone(),
13258 Arc::new(bamboo_storage::FileSessionInbox::new(
13259 second.clone(),
13260 bamboo_domain::SessionInboxLimits::default(),
13261 )),
13262 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13263 );
13264 let _owner = binding
13265 .router
13266 .register_run(&child.id, "replacement-run")
13267 .await
13268 .unwrap();
13269 let prepared = PlainActorActivation::start_and_prepare(
13270 second.clone(),
13271 &mut child,
13272 &binding,
13273 Some("replacement-run"),
13274 Some(policy.clone()),
13275 case != "readonly",
13276 Some("probed-new-worker"),
13277 )
13278 .await;
13279 assert_eq!(
13280 std::fs::read(directory.join("session.json")).unwrap(),
13281 main,
13282 "{case}: Already or refusal never rewrites Main"
13283 );
13284 if case != "accepted" {
13285 assert!(prepared.is_err(), "{case}");
13286 assert_eq!(
13287 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13288 case == "ack"
13289 );
13290 continue;
13291 }
13292 let (replacement, delivery) = prepared.unwrap();
13293 assert!(replacement.recovering_pre_ack);
13294 let (messages, delivery) = delivery.unwrap();
13295 let new_claim = replacement.initial_input.as_ref().unwrap();
13296 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13297 assert_eq!(new_claim.claim.generation, claim.claim.generation);
13298 assert_eq!(
13299 new_claim.claim.activation_policy,
13300 claim.claim.activation_policy
13301 );
13302 assert!(new_claim.lease.epoch > claim.lease.epoch);
13303 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13304 assert_eq!(
13305 replacement.inbox_generation, 1,
13306 "old Actor generation was zero"
13307 );
13308 assert_eq!(
13309 second
13310 .inspect_actor(&child.id)
13311 .await
13312 .unwrap()
13313 .actor
13314 .current_attempt,
13315 2
13316 );
13317 assert!(second
13318 .validate_fence(&old.fence(), chrono::Utc::now())
13319 .await
13320 .is_err());
13321 assert!(inbox
13322 .ack_owned(&child.id, &claim, chrono::Utc::now())
13323 .await
13324 .is_err());
13325 let authority = second.inspect_actor(&child.id).await.unwrap();
13329 let leases = inbox
13330 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13331 .await
13332 .unwrap();
13333 let delayed = inbox
13334 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13335 fence: old.fence(),
13336 expected_created_at: child.created_at,
13337 claim: claim.clone(),
13338 expected_messages: child.messages.clone(),
13339 expected_provider_transcript: child.provider_transcript.clone(),
13340 expected_admission: child.session_inbox_admission().cloned(),
13341 })
13342 .await;
13343 assert!(matches!(
13344 delayed,
13345 Err(bamboo_storage::ActorInputCheckpointError::Actor(
13346 bamboo_domain::ActorDirectoryError::StaleFence
13347 ))
13348 ));
13349 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13350 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13351 assert_eq!(
13352 inbox
13353 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13354 .await
13355 .unwrap(),
13356 leases
13357 );
13358 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13359 assert_eq!(
13360 serde_json::to_value(&child.messages).unwrap(),
13361 serde_json::to_value(&canonical.messages).unwrap()
13362 );
13363 let mut worker: Vec<bamboo_agent_core::Message> = messages
13364 .iter()
13365 .cloned()
13366 .map(|v| serde_json::from_value(v).unwrap())
13367 .collect();
13368 let target = worker.last_mut().unwrap();
13369 assert!(target
13370 .metadata
13371 .as_ref()
13372 .unwrap()
13373 .get("_bamboo_owned_input_checkpoint")
13374 .is_none());
13375 assert!(bamboo_domain::is_matching_session_message(
13376 target, &envelope
13377 ));
13378 let mut wanted = canonical.messages.last().unwrap().clone();
13379 wanted
13380 .metadata
13381 .as_mut()
13382 .unwrap()
13383 .as_object_mut()
13384 .unwrap()
13385 .remove("_bamboo_owned_input_checkpoint");
13386 assert_eq!(
13387 serde_json::to_value(target).unwrap(),
13388 serde_json::to_value(wanted).unwrap()
13389 );
13390 let run = RunSpec {
13391 assignment: "original task".into(),
13392 logical_session: Some(LogicalSessionIdentity {
13393 session_id: child.id.clone(),
13394 parent_session_id: child.parent_session_id.clone(),
13395 root_session_id: child.root_session_id.clone(),
13396 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13397 created_at: child.created_at,
13398 spawn_depth: child.spawn_depth,
13399 }),
13400 }),
13401 project_id: None,
13402 reasoning_effort: None,
13403 permission_policy: Some(PermissionPolicyContext {
13404 revision: policy.policy_revision(),
13405 requested_mode: "default".into(),
13406 effective_mode: "default".into(),
13407 bypass_permissions: false,
13408 auto_approve_permissions: false,
13409 session_id: child.id.clone(),
13410 workspace_path: child.workspace.clone(),
13411 environment_lease: None,
13412 inherit_session_grants: false,
13413 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13414 }),
13415 messages,
13416 activation_run_id: Some("replacement-run".into()),
13417 execution_epoch: 2,
13418 initial_session_messages: vec![delivery.clone()],
13419 secrets: Default::default(),
13420 };
13421 let policy_revision = policy.policy_revision();
13422 let mut expected = Some(expected_default_permission_posture(policy_revision));
13423 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13424 else {
13425 unreachable!()
13426 };
13427 let (events, mut rx) = mpsc::channel(1);
13428 process_actor_event(
13429 event,
13430 true,
13431 &mut PermissionPostureHandshake::Awaiting,
13432 expected.as_ref(),
13433 Some(&binding),
13434 &mut child,
13435 &events,
13436 &mut ActorEventDisplay::default(),
13437 )
13438 .await
13439 .unwrap();
13440 rx.recv().await.unwrap();
13441 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13442 &run,
13443 &delivery,
13444 uuid::Uuid::new_v4().to_string(),
13445 )
13446 .unwrap();
13447 replacement
13448 .release_initial_input(
13449 &binding,
13450 &child,
13451 &run,
13452 2,
13453 new_claim,
13454 &request,
13455 &mut expected,
13456 None,
13457 )
13458 .await
13459 .unwrap();
13460 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13461 replacement
13462 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13463 .await
13464 .unwrap();
13465 rx.recv().await.unwrap();
13466 replacement
13467 .finish(ActorActivationFinish::Succeeded)
13468 .await
13469 .unwrap();
13470 let cold = second.load_session(&child.id).await.unwrap().unwrap();
13471 assert_eq!(cold.created_at, canonical.created_at);
13472 assert_eq!(
13473 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13474 serde_json::to_value(&canonical.messages).unwrap()
13475 );
13476 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13477 assert_eq!(
13478 cold.messages
13479 .iter()
13480 .filter(|m| m.id == envelope.id.as_str())
13481 .count(),
13482 1
13483 );
13484 }
13485 }
13486
13487 #[cfg(unix)]
13488 #[tokio::test]
13489 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13490 use bamboo_subagent::provision::*;
13491 use std::os::unix::fs::PermissionsExt;
13492 let temp = tempfile::tempdir().unwrap();
13493 let worker = temp.path().join("mixed-worker");
13494 let spawned = temp.path().join("spawned");
13495 let run = temp.path().join("run");
13496 let probes = temp.path().join("probes");
13497 let fabric = temp.path().join("fabric");
13498 let mut report = WorkerCapabilityReport::current();
13499 report
13500 .capabilities
13501 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13502 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13503 let report = serde_json::to_string(&report).unwrap();
13504 std::fs::write(&worker, format!(
13505 "#!/bin/sh\nfor arg in \"$@\"; do last=\"$arg\"; done\nif [ \"$last\" = --print-capabilities ]; then\nprintf '%s\\n' '{report}'\nprintf 'probe\\n' >> \"$3\"\nelse\n: > \"$1\"\n: > \"$2\"\ncat >/dev/null\nfi\n"
13506 )).unwrap();
13507 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13508 let args = vec![
13509 spawned.to_str().unwrap().into(),
13510 run.to_str().unwrap().into(),
13511 probes.to_str().unwrap().into(),
13512 ];
13513 for capability in [
13515 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13516 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13517 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13518 TYPED_READ_ONLY_WORKER_CAPABILITY,
13519 ] {
13520 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13521 .await
13522 .unwrap();
13523 }
13524 let runner = ActorChildRunner::new(
13525 "mixed".into(),
13526 worker,
13527 args,
13528 fabric.clone(),
13529 ExecutorSpec::BambooRuntime,
13530 vec![],
13531 "openai".into(),
13532 1,
13533 );
13534 let error = runner
13536 .require_initial_input_release_worker()
13537 .await
13538 .unwrap_err();
13539 assert!(error
13540 .to_string()
13541 .contains("owned initial release worker capability unconfirmed"));
13542 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13543 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13544 assert!(runner.pool.lock().await.is_empty());
13545 }
13546
13547 #[tokio::test]
13548 async fn ordinary_glob_only_observes_own_initialization_signals() {
13549 let temp = tempfile::tempdir().unwrap();
13550 let home = temp.path().canonicalize().unwrap();
13551 let store = bamboo_storage::SessionStoreV2::new(home.clone())
13552 .await
13553 .unwrap();
13554 let parent = Session::new("glob-standard-parent", "model");
13555 store.save_session(&parent).await.unwrap();
13556 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13557 store.save_session(&child).await.unwrap();
13558 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13559 let unrelated = directory.parent().unwrap().join("unrelated");
13562 std::fs::create_dir(&unrelated).unwrap();
13563 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13564 require_unowned_glob(&store, &child).await.unwrap();
13565 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13566 let path = directory.join(name);
13567 std::fs::write(&path, b"invalid prior signal").unwrap();
13568 assert!(require_unowned_glob(&store, &child).await.is_err());
13569 std::fs::remove_file(&path).unwrap();
13570 }
13571 #[cfg(unix)]
13572 {
13573 let marker = directory.join("actor-authority.initialized.json");
13574 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13575 assert!(require_unowned_glob(&store, &child).await.is_err());
13576 }
13577 }
13578
13579 #[test]
13580 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13581 let call = bamboo_domain::ToolCall {
13582 id: "glob".into(),
13583 tool_type: "function".into(),
13584 function: bamboo_domain::FunctionCall {
13585 name: "Glob".into(),
13586 arguments: r#"{"pattern":"marker.txt"}"#.into(),
13587 },
13588 };
13589 let messages = vec![
13590 bamboo_agent_core::Message::assistant("", Some(vec![call])),
13591 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13592 bamboo_agent_core::Message::assistant("done", None),
13593 ];
13594 let start = serde_json::to_value(AgentEvent::ToolStart {
13595 tool_call_id: "glob".into(),
13596 tool_name: "Glob".into(),
13597 arguments: serde_json::json!({"pattern":"marker.txt"}),
13598 })
13599 .unwrap();
13600 let complete = serde_json::to_value(AgentEvent::ToolComplete {
13601 tool_call_id: "glob".into(),
13602 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13603 })
13604 .unwrap();
13605 let tail =
13606 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13607 messages: messages
13608 .iter()
13609 .map(serde_json::to_value)
13610 .collect::<Result<_, _>>()
13611 .unwrap(),
13612 })
13613 .unwrap();
13614 let mut ordered = ReadOnlyActorCollector::default();
13615 assert!(ordered.event(&start).unwrap());
13616 assert!(ordered.event(&complete).unwrap());
13617 assert!(!ordered.event(&tail).unwrap());
13618 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13619 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13620 assert!(ReadOnlyActorCollector::default()
13621 .finish(Some("done"))
13622 .is_err());
13623 let mut unfinished = ReadOnlyActorCollector::default();
13624 unfinished.event(&start).unwrap();
13625 unfinished.event(&complete).unwrap();
13626 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13627 let mut closed = tail.clone();
13628 closed["grant"] = true.into();
13629 assert!(
13630 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13631 .is_err()
13632 );
13633 let mut oversized = tail.clone();
13634 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13635 assert!(
13636 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13637 .unwrap()
13638 .validate()
13639 .is_err()
13640 );
13641 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13642 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13643 let mut bad = start.clone();
13644 bad["tool_name"] = "Bash".into();
13645 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13646 let mut duplicate = ReadOnlyActorCollector::default();
13647 duplicate.event(&start).unwrap();
13648 assert!(duplicate.event(&start).is_err());
13649 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13650 content: "hidden".into(),
13651 })
13652 .unwrap();
13653 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13654 let mut mismatched = ReadOnlyActorCollector::default();
13655 mismatched.event(&start).unwrap();
13656 let mut bad = complete.clone();
13657 bad["tool_call_id"] = "foreign".into();
13658 assert!(mismatched.event(&bad).is_err());
13659 mismatched.event(&complete).unwrap();
13660 let mut changed = tail.clone();
13661 changed["messages"][1]["content"] = "fabricated".into();
13662 assert!(mismatched.event(&changed).is_err());
13663 }
13664
13665 #[cfg(unix)]
13666 #[tokio::test]
13667 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13668 let temp = tempfile::tempdir().unwrap();
13669 let store = Arc::new(
13670 bamboo_storage::SessionStoreV2::new(temp.path().into())
13671 .await
13672 .unwrap(),
13673 );
13674 let parent = Session::new("release-host-parent", "model");
13675 store.save_session(&parent).await.unwrap();
13676 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13677 store.save_session(&child).await.unwrap();
13678 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13679 store.clone(),
13680 bamboo_domain::SessionInboxLimits::default(),
13681 ));
13682 let binding = actor_binding(
13683 store.clone(),
13684 inbox,
13685 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13686 );
13687 let _owner = binding
13688 .router
13689 .register_run(&child.id, "release-host-run")
13690 .await
13691 .unwrap();
13692 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13693 let revision = policy.policy_revision();
13694 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13695 let activation = PlainActorActivation::start(
13696 store.clone(),
13697 &child,
13698 &binding,
13699 Some("release-host-run"),
13700 Some(policy.clone()),
13701 true,
13702 Some("probed-physical-worker"),
13703 )
13704 .await
13705 .unwrap();
13706 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13707 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13708 activation
13709 .input_inbox
13710 .mark_activation_eligible(
13711 &child.id,
13712 receipt.generation,
13713 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13714 )
13715 .await
13716 .unwrap();
13717 let claim = activation
13718 .claim_input(&binding, &child, "release-host-run")
13719 .await
13720 .unwrap()
13721 .unwrap();
13722 let (prefix, delivery) = activation
13723 .checkpoint_input(&mut child, "release-host-run", &claim)
13724 .await
13725 .unwrap();
13726 let mut expected = Some(expected_default_permission_posture(revision));
13727 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13728 unreachable!()
13729 };
13730 let (events, mut events_rx) = mpsc::channel(1);
13731 process_actor_event(
13732 event,
13733 true,
13734 &mut PermissionPostureHandshake::Awaiting,
13735 expected.as_ref(),
13736 Some(&binding),
13737 &mut child,
13738 &events,
13739 &mut ActorEventDisplay::default(),
13740 )
13741 .await
13742 .unwrap();
13743 events_rx.recv().await.unwrap();
13744 let run = RunSpec {
13745 assignment: "assignment".into(),
13746 logical_session: Some(LogicalSessionIdentity {
13747 session_id: child.id.clone(),
13748 parent_session_id: child.parent_session_id.clone(),
13749 root_session_id: child.root_session_id.clone(),
13750 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13751 created_at: child.created_at,
13752 spawn_depth: child.spawn_depth,
13753 }),
13754 }),
13755 project_id: None,
13756 reasoning_effort: None,
13757 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13758 revision,
13759 requested_mode: "default".into(),
13760 effective_mode: "default".into(),
13761 bypass_permissions: false,
13762 auto_approve_permissions: false,
13763 session_id: child.id.clone(),
13764 workspace_path: child.workspace.clone(),
13765 environment_lease: None,
13766 inherit_session_grants: false,
13767 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13768 }),
13769 messages: prefix,
13770 activation_run_id: Some("release-host-run".into()),
13771 execution_epoch: 9,
13772 initial_session_messages: vec![delivery.clone()],
13773 secrets: Default::default(),
13774 };
13775 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13776 &run,
13777 &delivery,
13778 uuid::Uuid::new_v4().to_string(),
13779 )
13780 .unwrap();
13781 let mut stale = child.clone();
13782 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13783 assert!(activation
13784 .release_initial_input(
13785 &binding,
13786 &stale,
13787 &run,
13788 9,
13789 &claim,
13790 &request,
13791 &mut expected,
13792 None
13793 )
13794 .await
13795 .is_err());
13796 assert!(!activation
13797 .input_inbox
13798 .was_admitted(&child.id, &envelope.id)
13799 .await
13800 .unwrap());
13801 use std::os::unix::fs::PermissionsExt;
13804 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13805 impl Drop for RestorePermissions {
13806 fn drop(&mut self) {
13807 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13808 }
13809 }
13810 let admitted = store
13811 .bamboo_home_dir()
13812 .join(store.resolve_rel_path(&child.id).await.unwrap())
13813 .join("inbox/admitted");
13814 std::fs::create_dir_all(&admitted).unwrap();
13815 let restore = RestorePermissions(
13816 admitted.clone(),
13817 std::fs::metadata(&admitted).unwrap().permissions(),
13818 );
13819 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13820 let verified = activation
13821 .input_inbox
13822 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13823 fence: activation.fence.clone(),
13824 expected_created_at: child.created_at,
13825 claim: claim.clone(),
13826 expected_messages: child.messages.clone(),
13827 expected_provider_transcript: child.provider_transcript.clone(),
13828 expected_admission: child.session_inbox_admission().cloned(),
13829 })
13830 .await
13831 .unwrap();
13832 assert_eq!(
13833 verified.status,
13834 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13835 );
13836 let error = activation
13837 .release_initial_input(
13838 &binding,
13839 &child,
13840 &run,
13841 9,
13842 &claim,
13843 &request,
13844 &mut expected,
13845 None,
13846 )
13847 .await
13848 .unwrap_err();
13849 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13850 drop(restore);
13851 assert!(
13852 error.to_string().contains("ACK unresolved"),
13853 "actual release error (<=512B): {}",
13854 error.to_string().chars().take(128).collect::<String>()
13855 );
13856 let release = activation
13857 .release_initial_input(
13858 &binding,
13859 &child,
13860 &run,
13861 9,
13862 &claim,
13863 &request,
13864 &mut expected,
13865 None,
13866 )
13867 .await
13868 .unwrap();
13869 assert!(activation
13870 .input_inbox
13871 .was_admitted(&child.id, &envelope.id)
13872 .await
13873 .unwrap());
13874 assert_eq!(
13875 release.expires_at,
13876 activation
13877 .actor_deadline
13878 .min(claim.lease.expires_at)
13879 .min(activation.input_deadline)
13880 );
13881 assert_eq!(
13882 activation
13883 .release_initial_input(
13884 &binding,
13885 &child,
13886 &run,
13887 9,
13888 &claim,
13889 &request,
13890 &mut expected,
13891 Some(&release)
13892 )
13893 .await
13894 .unwrap(),
13895 release
13896 );
13897 let mut wrong = request.clone();
13898 wrong.nonce = uuid::Uuid::new_v4().to_string();
13899 assert!(activation
13900 .release_initial_input(
13901 &binding,
13902 &child,
13903 &run,
13904 9,
13905 &claim,
13906 &wrong,
13907 &mut expected,
13908 Some(&release)
13909 )
13910 .await
13911 .is_err());
13912 assert!(activation
13913 .release_initial_input(
13914 &binding,
13915 &child,
13916 &run,
13917 10,
13918 &claim,
13919 &request,
13920 &mut expected,
13921 Some(&release)
13922 )
13923 .await
13924 .is_err());
13925 let deadlines = (
13927 activation.fence.clone(),
13928 activation.actor_deadline,
13929 activation.input_deadline,
13930 );
13931 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
13932 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
13933 activation
13934 .input_inbox
13935 .mark_activation_eligible(
13936 &child.id,
13937 receipt.generation,
13938 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13939 )
13940 .await
13941 .unwrap();
13942 let mut link = ConfirmationSequenceLink {
13943 frames: VecDeque::new(),
13944 sent: Vec::new(),
13945 };
13946 let epochs = AtomicU64::new(9);
13947 let (claim2, epoch2) = activation
13948 .continue_input(
13949 &mut link,
13950 &binding,
13951 &mut child,
13952 "release-host-run",
13953 Some((&run, &epochs)),
13954 &mut expected,
13955 "SECOND_REPLY".into(),
13956 &events,
13957 )
13958 .await
13959 .unwrap()
13960 .unwrap();
13961 events_rx.recv().await.unwrap();
13962 assert_eq!(epoch2, 10);
13963 let ParentFrame::Run(next) = &link.sent[0] else {
13964 panic!("second correlated Run")
13965 };
13966 assert_eq!(next.logical_session, run.logical_session);
13967 assert_eq!(next.activation_run_id, run.activation_run_id);
13968 assert_eq!(next.initial_session_messages.len(), 1);
13969 assert!(next
13970 .messages
13971 .iter()
13972 .any(|m| m["id"] == envelope.id.as_str()));
13973 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
13974 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
13975 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13976 next,
13977 &next.initial_session_messages[0],
13978 uuid::Uuid::new_v4().to_string(),
13979 )
13980 .unwrap();
13981 assert_ne!(request2.nonce, request.nonce);
13982 macro_rules! release_second {
13983 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
13984 activation
13985 .release_initial_input(
13986 &binding,
13987 $candidate,
13988 next,
13989 $epoch,
13990 &claim2,
13991 $request,
13992 &mut expected,
13993 $previous,
13994 )
13995 .await
13996 };
13997 }
13998 assert!(release_second!(&child, &request, epoch2, None).is_err());
13999 assert!(release_second!(&child, &request2, 9, None).is_err());
14000 let mut stale2 = child.clone();
14001 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14002 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14003 assert!(!activation
14004 .input_inbox
14005 .was_admitted(&child.id, &second.id)
14006 .await
14007 .unwrap());
14008 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14009 assert_eq!(
14010 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14011 release2
14012 );
14013 let mut wrong2 = request2.clone();
14014 wrong2.nonce = request.nonce.clone();
14015 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14016 assert!(activation
14017 .input_inbox
14018 .was_admitted(&child.id, &second.id)
14019 .await
14020 .unwrap());
14021 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14022 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14023 activation
14024 .input_inbox
14025 .mark_activation_eligible(
14026 &child.id,
14027 receipt.generation,
14028 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14029 )
14030 .await
14031 .unwrap();
14032 let now = chrono::Utc::now();
14033 let expired_claim = activation
14034 .input_inbox
14035 .claim_owned(
14036 &child.id,
14037 1,
14038 Some("release-host-run"),
14039 &SessionInboxLeaseRequest {
14040 consumer: activation.input_consumer.clone(),
14041 now,
14042 duration: chrono::Duration::seconds(1),
14043 },
14044 )
14045 .await
14046 .unwrap()
14047 .pop()
14048 .unwrap();
14049 let (prefix, delivery3) = activation
14050 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14051 .await
14052 .unwrap();
14053 let mut expired_run = next.clone();
14054 expired_run.execution_epoch = 11;
14055 expired_run.messages = prefix;
14056 expired_run.initial_session_messages = vec![delivery3.clone()];
14057 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14058 &expired_run,
14059 &delivery3,
14060 uuid::Uuid::new_v4().to_string(),
14061 )
14062 .unwrap();
14063 tokio::time::sleep(
14064 (expired_claim.lease.expires_at - chrono::Utc::now())
14065 .to_std()
14066 .unwrap_or_default(),
14067 )
14068 .await;
14069 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14070 assert!(activation
14071 .release_initial_input(
14072 &binding,
14073 &child,
14074 &expired_run,
14075 11,
14076 &expired_claim,
14077 &expired_request,
14078 &mut expected,
14079 None
14080 )
14081 .await
14082 .is_err());
14083 assert!(!activation
14084 .input_inbox
14085 .was_admitted(&child.id, &expired.id)
14086 .await
14087 .unwrap());
14088 assert_eq!(
14089 (
14090 activation.fence.clone(),
14091 activation.actor_deadline,
14092 activation.input_deadline
14093 ),
14094 deadlines
14095 );
14096 assert_eq!(
14097 link.sent.len(),
14098 1,
14099 "no dispatch from rejected release checks"
14100 );
14101 let cold = store.load_session(&child.id).await.unwrap().unwrap();
14102 for id in [&envelope.id, &second.id] {
14103 assert_eq!(
14104 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14105 1
14106 );
14107 assert!(cold.session_inbox_admission().unwrap().contains(id));
14108 }
14109 policy.deny_scoped_session_permission(
14111 &child.id,
14112 bamboo_tools::permission::PermissionType::ExecuteCommand,
14113 "blocked-action",
14114 );
14115 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14116 assert!(activation
14117 .input_inbox
14118 .was_admitted(&child.id, &second.id)
14119 .await
14120 .unwrap());
14121 activation
14122 .finish(ActorActivationFinish::Failed)
14123 .await
14124 .unwrap();
14125 activation
14127 .input_inbox
14128 .ack_owned(&child.id, &claim, chrono::Utc::now())
14129 .await
14130 .unwrap();
14131 assert!(activation
14132 .release_initial_input(
14133 &binding,
14134 &child,
14135 &run,
14136 9,
14137 &claim,
14138 &request,
14139 &mut expected,
14140 Some(&release)
14141 )
14142 .await
14143 .is_err());
14144 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14145 .await
14146 .unwrap()
14147 .load_session(&child.id)
14148 .await
14149 .unwrap()
14150 .unwrap();
14151 assert_eq!(
14152 cold.messages
14153 .iter()
14154 .filter(|m| m.id == envelope.id.as_str())
14155 .count(),
14156 1
14157 );
14158 }
14159
14160 #[tokio::test]
14161 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14162 for reject_checkpoint in [false, true] {
14163 let temp = tempfile::tempdir().unwrap();
14164 let store = Arc::new(
14165 bamboo_storage::SessionStoreV2::new(temp.path().into())
14166 .await
14167 .unwrap(),
14168 );
14169 let parent = Session::new("owned-input-parent", "model");
14170 store.save_session(&parent).await.unwrap();
14171 let mut child =
14172 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14173 store.save_session(&child).await.unwrap();
14174 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14175 let raw_storage: Arc<dyn Storage> = store.clone();
14176 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14177 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14178 store.clone(),
14179 bamboo_domain::SessionInboxLimits::default(),
14180 ));
14181 let binding = actor_binding(store.clone(), inbox, locked);
14182 let run_id = "owned-input-run";
14183 let _registration = binding
14184 .router
14185 .register_run(&child.id, run_id)
14186 .await
14187 .unwrap();
14188 assert!(PlainActorActivation::start(
14189 store.clone(),
14190 &child,
14191 &binding,
14192 Some(run_id),
14193 None,
14194 true,
14195 None,
14196 )
14197 .await
14198 .is_err());
14199 assert!(PlainActorActivation::start(
14200 store.clone(),
14201 &child,
14202 &binding,
14203 Some(run_id),
14204 Some(policy.clone()),
14205 true,
14206 None,
14207 )
14208 .await
14209 .is_err());
14210 assert_eq!(
14211 store
14212 .inspect_actor(&child.id)
14213 .await
14214 .unwrap()
14215 .actor
14216 .current_attempt,
14217 0
14218 );
14219 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14220 let activation = PlainActorActivation::start(
14221 store.clone(),
14222 &child,
14223 &binding,
14224 Some(run_id),
14225 Some(policy.clone()),
14226 true,
14227 None,
14228 )
14229 .await
14230 .unwrap();
14231 let envelope =
14232 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14233 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14234 activation
14235 .input_inbox
14236 .mark_activation_eligible(
14237 &child.id,
14238 receipt.generation,
14239 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14240 )
14241 .await
14242 .unwrap();
14243 let mut link = ConfirmationSequenceLink {
14244 frames: VecDeque::new(),
14245 sent: Vec::new(),
14246 };
14247 let policy_revision = policy.policy_revision();
14248 let run = RunSpec {
14249 assignment: "one plain task".into(),
14250 logical_session: None,
14251 project_id: None,
14252 reasoning_effort: None,
14253 permission_policy: Some(PermissionPolicyContext {
14254 revision: policy_revision,
14255 requested_mode: "default".into(),
14256 effective_mode: "default".into(),
14257 bypass_permissions: false,
14258 auto_approve_permissions: false,
14259 session_id: child.id.clone(),
14260 workspace_path: child.workspace.clone(),
14261 environment_lease: None,
14262 inherit_session_grants: false,
14263 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14264 }),
14265 messages: vec![],
14266 activation_run_id: Some(run_id.into()),
14267 execution_epoch: 1,
14268 initial_session_messages: vec![],
14269 secrets: Default::default(),
14270 };
14271 let epochs = AtomicU64::new(1);
14272 let (tx, mut rx) = mpsc::channel(1);
14273 let mut expected = Some(expected_default_permission_posture(policy_revision));
14274 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14275 else {
14276 unreachable!()
14277 };
14278 process_actor_event(
14279 event,
14280 true,
14281 &mut PermissionPostureHandshake::Awaiting,
14282 expected.as_ref(),
14283 Some(&binding),
14284 &mut child,
14285 &tx,
14286 &mut ActorEventDisplay::default(),
14287 )
14288 .await
14289 .unwrap();
14290 rx.recv().await.unwrap();
14291 let first_audit =
14292 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14293 .unwrap()
14294 .audit_revision;
14295 if reject_checkpoint {
14296 tx.send(AgentEvent::Complete {
14299 usage: Default::default(),
14300 })
14301 .await
14302 .unwrap();
14303 }
14304 let mut operation = activation.continue_input(
14305 &mut link,
14306 &binding,
14307 &mut child,
14308 run_id,
14309 Some((&run, &epochs)),
14310 &mut expected,
14311 "FIRST_PLAIN_REPLY".into(),
14312 &tx,
14313 );
14314 if reject_checkpoint {
14315 tokio::select! {
14316 result = &mut operation => panic!("publication barrier released early: {result:?}"),
14317 _ = async {
14318 tokio::time::timeout(Duration::from_secs(5), async {
14319 loop {
14320 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14321 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14322 tokio::task::yield_now().await;
14323 }
14324 }).await.unwrap();
14325 activation.finish(ActorActivationFinish::Failed).await.unwrap();
14326 rx.recv().await.unwrap();
14327 } => {}
14328 }
14329 }
14330 let result = operation.await;
14331 if reject_checkpoint {
14332 assert!(result
14333 .unwrap_err()
14334 .to_string()
14335 .contains("checkpoint unconfirmed"));
14336 assert!(
14337 link.sent.is_empty(),
14338 "zero worker/provider correction dispatch"
14339 );
14340 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14341 assert!(durable
14342 .messages
14343 .iter()
14344 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14345 assert!(!durable
14346 .messages
14347 .iter()
14348 .any(|m| m.id == envelope.id.as_str()));
14349 assert!(!activation
14350 .input_inbox
14351 .was_admitted(&child.id, &envelope.id)
14352 .await
14353 .unwrap());
14354 assert_eq!(
14355 activation
14356 .input_inbox
14357 .inspect(&child.id)
14358 .await
14359 .unwrap()
14360 .claimed,
14361 1
14362 );
14363 continue;
14364 }
14365 let (claim, epoch) = result.unwrap().unwrap();
14366 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
14368 assert_eq!(link.sent.len(), 1);
14369 let ParentFrame::Run(next) = &link.sent[0] else {
14370 panic!("correlated continuation Run")
14371 };
14372 assert_eq!(next.execution_epoch, 2);
14373 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14374 assert!(next
14375 .messages
14376 .iter()
14377 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14378 assert!(!next
14379 .messages
14380 .iter()
14381 .any(|m| m["id"] == envelope.id.as_str()));
14382 let delivery = &next.initial_session_messages[0];
14383 assert_eq!(delivery.envelope, envelope);
14384 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14385 assert!(durable
14386 .session_inbox_admission()
14387 .unwrap()
14388 .contains(&envelope.id));
14389 assert_eq!(
14390 durable
14391 .messages
14392 .iter()
14393 .filter(|m| m.id == envelope.id.as_str())
14394 .count(),
14395 1
14396 );
14397 assert!(
14398 !activation
14399 .input_inbox
14400 .was_admitted(&child.id, &envelope.id)
14401 .await
14402 .unwrap(),
14403 "checkpoint is not a worker admission receipt"
14404 );
14405 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14406 confirmation.canonical_claim_generation += 1;
14407 assert!(activation
14408 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14409 .await
14410 .is_err());
14411 assert!(!activation
14412 .input_inbox
14413 .was_admitted(&child.id, &envelope.id)
14414 .await
14415 .unwrap());
14416 confirmation.canonical_claim_generation = claim.claim.generation;
14417 activation
14418 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14419 .await
14420 .unwrap();
14421 assert!(activation
14422 .input_inbox
14423 .was_admitted(&child.id, &envelope.id)
14424 .await
14425 .unwrap());
14426 assert_eq!(
14427 expected.as_ref().unwrap().expected_audit_revision,
14428 Some(first_audit)
14429 );
14430 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14431 else {
14432 unreachable!()
14433 };
14434 process_actor_event(
14435 event,
14436 true,
14437 &mut PermissionPostureHandshake::Awaiting,
14438 expected.as_ref(),
14439 Some(&binding),
14440 &mut child,
14441 &tx,
14442 &mut ActorEventDisplay::default(),
14443 )
14444 .await
14445 .unwrap();
14446 rx.recv().await.unwrap();
14447 assert!(
14448 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14449 .unwrap()
14450 .audit_revision
14451 > first_audit
14452 );
14453 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14454 .await
14455 .unwrap();
14456 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14457 assert_eq!(
14458 cold.messages
14459 .iter()
14460 .filter(|m| m.id == envelope.id.as_str())
14461 .count(),
14462 1
14463 );
14464 assert!(cold
14465 .session_inbox_admission()
14466 .unwrap()
14467 .contains(&envelope.id));
14468 }
14469 }
14470
14471 fn admission_confirmation(
14472 session_id: &str,
14473 claim: &SessionInboxClaim,
14474 run_id: &str,
14475 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14476 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14477 target_session_id: session_id.to_string(),
14478 envelope_id: claim.envelope.id.to_string(),
14479 canonical_claim_generation: claim.generation,
14480 activation_run_id: run_id.to_string(),
14481 }
14482 }
14483
14484 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14485 ExpectedPermissionPosture {
14486 policy_revision,
14487 resolution: bamboo_domain::PermissionModeResolution {
14488 requested: bamboo_domain::SessionPermissionMode::Default,
14489 effective: bamboo_domain::PermissionMode::Default,
14490 },
14491 expected_audit_revision: None,
14492 executor_mapping: "test_actor:permission_mode=default".to_string(),
14493 }
14494 }
14495
14496 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14497 ChildFrame::Event {
14498 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14499 session_id: session_id.to_string(),
14500 policy_revision,
14501 requested_mode: "default".to_string(),
14502 effective_mode: "default".to_string(),
14503 executor_mapping: "test_actor:permission_mode=default".to_string(),
14504 })
14505 .expect("serialize permission posture event"),
14506 }
14507 }
14508
14509 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14510 ChildFrame::Event {
14511 event: serde_json::to_value(event).expect("serialize actor event"),
14512 }
14513 }
14514
14515 #[test]
14516 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14517 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14518 let mut batch = ActorEventBatch {
14519 logical_session: Some(LogicalSessionIdentity {
14520 creation: None,
14521 session_id: session.id.clone(),
14522 parent_session_id: session.parent_session_id.clone(),
14523 root_session_id: session.root_session_id.clone(),
14524 }),
14525 activation_id: Some("activation-7".into()),
14526 execution_epoch: 11,
14527 source_node_id: Some("node-a".into()),
14528 source_actor_id: Some("worker-a".into()),
14529 first_seq: 1,
14530 last_seq: 1,
14531 qos: bamboo_subagent::ActorEventQos::Ephemeral,
14532 events: vec![serde_json::json!({"type":"token","content":"ok"})],
14533 };
14534 validate_actor_event_batch(
14535 &batch,
14536 &session,
14537 "logical-parent",
14538 Some("activation-7"),
14539 11,
14540 "worker-a",
14541 None,
14542 )
14543 .unwrap();
14544
14545 let creation = bamboo_subagent::proto::ChildCreationIdentity {
14546 created_at: session.created_at,
14547 spawn_depth: session.spawn_depth,
14548 };
14549 assert!(validate_actor_event_batch(
14550 &batch,
14551 &session,
14552 "logical-parent",
14553 Some("activation-7"),
14554 11,
14555 "worker-a",
14556 Some(&creation)
14557 )
14558 .is_err());
14559 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14560 validate_actor_event_batch(
14561 &batch,
14562 &session,
14563 "logical-parent",
14564 Some("activation-7"),
14565 11,
14566 "worker-a",
14567 Some(&creation),
14568 )
14569 .unwrap();
14570 batch
14571 .logical_session
14572 .as_mut()
14573 .unwrap()
14574 .creation
14575 .as_mut()
14576 .unwrap()
14577 .created_at += chrono::Duration::nanoseconds(1);
14578 assert!(validate_actor_event_batch(
14579 &batch,
14580 &session,
14581 "logical-parent",
14582 Some("activation-7"),
14583 11,
14584 "worker-a",
14585 Some(&creation)
14586 )
14587 .is_err());
14588 batch.logical_session.as_mut().unwrap().creation = None;
14589
14590 batch.execution_epoch = 10;
14591 assert!(validate_actor_event_batch(
14592 &batch,
14593 &session,
14594 "logical-parent",
14595 Some("activation-7"),
14596 11,
14597 "worker-a",
14598 None,
14599 )
14600 .unwrap_err()
14601 .to_string()
14602 .contains("execution epoch"));
14603 batch.execution_epoch = 11;
14604 batch.activation_id = Some("replaced-activation".into());
14605 assert!(validate_actor_event_batch(
14606 &batch,
14607 &session,
14608 "logical-parent",
14609 Some("activation-7"),
14610 11,
14611 "worker-a",
14612 None,
14613 )
14614 .unwrap_err()
14615 .to_string()
14616 .contains("stale activation"));
14617 batch.activation_id = Some("activation-7".into());
14618 batch
14619 .logical_session
14620 .as_mut()
14621 .expect("logical identity")
14622 .session_id = "foreign-child".into();
14623 assert!(validate_actor_event_batch(
14624 &batch,
14625 &session,
14626 "logical-parent",
14627 Some("activation-7"),
14628 11,
14629 "worker-a",
14630 None,
14631 )
14632 .unwrap_err()
14633 .to_string()
14634 .contains("different logical session"));
14635 batch
14636 .logical_session
14637 .as_mut()
14638 .expect("logical identity")
14639 .session_id = session.id.clone();
14640 batch.source_actor_id = Some("worker-b".into());
14641 assert!(validate_actor_event_batch(
14642 &batch,
14643 &session,
14644 "logical-parent",
14645 Some("activation-7"),
14646 11,
14647 "worker-a",
14648 None,
14649 )
14650 .unwrap_err()
14651 .to_string()
14652 .contains("different physical actor"));
14653 }
14654
14655 #[tokio::test]
14656 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14657 use bamboo_domain::Storage;
14658
14659 let temp = tempfile::tempdir().unwrap();
14660 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14661 .await
14662 .unwrap();
14663 let parent = Session::new("event-parent", "model");
14664 let child = Session::new_child_of("event-child", &parent, "model", "task");
14665 store.save_session(&parent).await.unwrap();
14666 store.save_session(&child).await.unwrap();
14667 store.ensure_actor(&child.id).await.unwrap();
14668 let now = chrono::Utc::now();
14669 let claim = |run_id: &str| ActorActivationClaim {
14670 actor_id: child.id.clone(),
14671 run_id: run_id.into(),
14672 lease_owner: "event-owner".into(),
14673 lease_expires_at: now + chrono::Duration::minutes(2),
14674 inbox_generation: 0,
14675 placement_ref: None,
14676 now,
14677 };
14678 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14679 store.start_activation(&first.fence(), now).await.unwrap();
14680 let first_fence = first.fence();
14681 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14682 .await
14683 .unwrap();
14684 validate_directory_actor_event(&store, &first_fence, &child, None)
14685 .await
14686 .unwrap(); assert!(
14688 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14689 .await
14690 .is_err()
14691 );
14692 let mut forged = child.clone();
14693 forged.root_session_id = "foreign-root".into();
14694 assert!(
14695 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14696 .await
14697 .is_err()
14698 );
14699
14700 store
14701 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14702 .await
14703 .unwrap();
14704 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14705 store.start_activation(&second.fence(), now).await.unwrap();
14706 assert!(
14707 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14708 .await
14709 .is_err()
14710 );
14711 assert!(
14712 validate_directory_actor_event(&store, &first_fence, &child, None)
14713 .await
14714 .is_err()
14715 );
14716 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14717 .await
14718 .unwrap();
14719 }
14720
14721 fn completed_actor_frame() -> ChildFrame {
14722 ChildFrame::Terminal {
14723 status: TerminalStatus::Completed,
14724 result: Some("done".to_string()),
14725 error: None,
14726 final_event_watermark: None,
14727 transcript: Vec::new(),
14728 }
14729 }
14730
14731 async fn drive_permission_handshake_frames(
14732 session_id: &str,
14733 frames: impl IntoIterator<Item = ChildFrame>,
14734 expected: ExpectedPermissionPosture,
14735 ) -> (
14736 crate::runtime::runner::Result<Option<String>>,
14737 Session,
14738 Vec<AgentEvent>,
14739 Vec<ParentFrame>,
14740 ) {
14741 let mut link = ConfirmationSequenceLink {
14742 frames: frames.into_iter().collect(),
14743 sent: Vec::new(),
14744 };
14745 let (event_tx, mut event_rx) = mpsc::channel(128);
14746 let cancel = CancellationToken::new();
14747 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14748 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14749 let mut session = Session::new(session_id, "model");
14750 let result = drive(ActorDriveContext {
14751 expected_creation: None,
14752 client: &mut link,
14753 parent_session_id: "permission-parent",
14754 child_session_id: session_id,
14755 child_attempt: 0,
14756 approval_registry: None,
14757 approval_decider: None,
14758 approval_reviewer: None,
14759 escalation_bridge: None,
14760 event_tx: &event_tx,
14761 cancel_token: &cancel,
14762 live_rx: &mut live_rx,
14763 delivery_rx: &mut delivery_rx,
14764 logical_session: &mut session,
14765 expected_permission_posture: Some(expected),
14766 session_inbox_runtime: None,
14767 actor_directory_store: None,
14768 canonical_subagent_tool: None,
14769 ticket_service: None,
14770 activation_run_id: None,
14771 execution_epoch: 0,
14772 expected_source_actor_id: session_id,
14773 initial_inflight_claims: VecDeque::new(),
14774 plain_actor: false,
14775 remote_environment_lease: false,
14776 readonly_output: None,
14777 local_history_tools: None,
14778 local_history_read_only: false,
14779 strict_history_output: None,
14780 plain_input: None,
14781 canonical_activation: None,
14782 canonical_placement_ref: None,
14783 actor_event_observer: None,
14784 plain_run: None,
14785 first_frame_timeout: Some(Duration::from_secs(1)),
14786 })
14787 .await;
14788 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14789 (result, session, events, link.sent)
14790 }
14791
14792 #[tokio::test]
14793 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14794 let session_id = "permission-missing";
14795 let (result, session, events, _) = drive_permission_handshake_frames(
14796 session_id,
14797 [completed_actor_frame()],
14798 expected_default_permission_posture(7),
14799 )
14800 .await;
14801
14802 assert!(result
14803 .unwrap_err()
14804 .to_string()
14805 .contains("terminated before permission posture confirmation"));
14806 assert!(events.is_empty());
14807 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14808 }
14809
14810 #[tokio::test]
14811 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14812 struct TerminalProofProbe {
14813 frame: Option<ChildFrame>,
14814 accepted: bool,
14815 }
14816
14817 #[async_trait]
14818 impl bamboo_subagent::ChildLink for TerminalProofProbe {
14819 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14820 Ok(())
14821 }
14822
14823 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14824 Ok(self.frame.take())
14825 }
14826
14827 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14828 self.accepted = status == TerminalStatus::Error;
14829 }
14830
14831 fn has_pending_durable_terminal(&self) -> bool {
14832 self.accepted
14833 }
14834 }
14835
14836 for (status, error, expected_proof) in [
14837 (
14838 TerminalStatus::Error,
14839 Some("remote_environment_checkout_not_clean"),
14840 true,
14841 ),
14842 (TerminalStatus::Error, Some("untrusted worker error"), false),
14843 (
14844 TerminalStatus::Cancelled,
14845 Some("remote_environment_checkout_not_clean"),
14846 false,
14847 ),
14848 ] {
14849 let mut link = TerminalProofProbe {
14850 frame: Some(ChildFrame::Terminal {
14851 status,
14852 result: None,
14853 error: error.map(str::to_owned),
14854 final_event_watermark: None,
14855 transcript: Vec::new(),
14856 }),
14857 accepted: false,
14858 };
14859 let (event_tx, _event_rx) = mpsc::channel(8);
14860 let cancel = CancellationToken::new();
14861 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14862 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14863 let mut session = Session::new("remote-terminal-proof", "model");
14864 let result = drive(ActorDriveContext {
14865 client: &mut link,
14866 parent_session_id: "proof-parent",
14867 child_session_id: "remote-terminal-proof",
14868 child_attempt: 0,
14869 approval_registry: None,
14870 approval_decider: None,
14871 approval_reviewer: None,
14872 escalation_bridge: None,
14873 event_tx: &event_tx,
14874 cancel_token: &cancel,
14875 live_rx: &mut live_rx,
14876 delivery_rx: &mut delivery_rx,
14877 logical_session: &mut session,
14878 expected_permission_posture: Some(expected_default_permission_posture(7)),
14879 expected_creation: None,
14880 session_inbox_runtime: None,
14881 actor_directory_store: None,
14882 canonical_subagent_tool: None,
14883 ticket_service: None,
14884 activation_run_id: None,
14885 execution_epoch: 0,
14886 expected_source_actor_id: "remote-terminal-proof",
14887 initial_inflight_claims: VecDeque::new(),
14888 plain_actor: false,
14889 remote_environment_lease: true,
14890 readonly_output: None,
14891 local_history_tools: None,
14892 local_history_read_only: false,
14893 strict_history_output: None,
14894 plain_input: None,
14895 canonical_activation: None,
14896 canonical_placement_ref: None,
14897 actor_event_observer: None,
14898 plain_run: None,
14899 first_frame_timeout: Some(Duration::from_secs(1)),
14900 })
14901 .await;
14902 assert!(result.is_err());
14903 assert_eq!(
14904 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14905 expected_proof
14906 );
14907 }
14908 }
14909
14910 #[tokio::test(start_paused = true)]
14911 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
14912 struct CancelOutcomeLink {
14913 cancel_sent: bool,
14914 settle: bool,
14915 accepted: bool,
14916 }
14917
14918 #[async_trait]
14919 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
14920 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14921 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
14922 Ok(())
14923 }
14924
14925 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14926 if !self.cancel_sent || !self.settle {
14927 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
14928 .await
14929 } else {
14930 self.cancel_sent = false;
14931 Ok(Some(ChildFrame::Terminal {
14932 status: TerminalStatus::Cancelled,
14933 result: None,
14934 error: None,
14935 final_event_watermark: None,
14936 transcript: Vec::new(),
14937 }))
14938 }
14939 }
14940
14941 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14942 self.accepted = status == TerminalStatus::Cancelled;
14943 }
14944
14945 fn has_pending_durable_terminal(&self) -> bool {
14946 self.accepted
14947 }
14948 }
14949
14950 let temp = tempfile::tempdir().unwrap();
14951 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14952 .await
14953 .unwrap();
14954 let fence = ActorActivationFence {
14955 schema_version: 2,
14956 actor_id: "cancelled-remote-child".into(),
14957 activation_id: "cancelled-remote-activation".into(),
14958 attempt: 1,
14959 run_id: "cancelled-remote-run".into(),
14960 lease_owner: "cancelled-remote-host".into(),
14961 lease_epoch: 1,
14962 };
14963 for settle in [true, false] {
14964 let mut link = CancelOutcomeLink {
14965 cancel_sent: false,
14966 settle,
14967 accepted: false,
14968 };
14969 let (event_tx, _event_rx) = mpsc::channel(8);
14970 let cancel = CancellationToken::new();
14971 cancel.cancel();
14972 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14973 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14974 let mut session = Session::new("cancelled-remote-child", "model");
14975 let result = drive(ActorDriveContext {
14976 expected_creation: None,
14977 client: &mut link,
14978 parent_session_id: "cancelled-remote-parent",
14979 child_session_id: "cancelled-remote-child",
14980 child_attempt: 1,
14981 approval_registry: None,
14982 approval_decider: None,
14983 approval_reviewer: None,
14984 escalation_bridge: None,
14985 event_tx: &event_tx,
14986 cancel_token: &cancel,
14987 live_rx: &mut live_rx,
14988 delivery_rx: &mut delivery_rx,
14989 logical_session: &mut session,
14990 expected_permission_posture: Some(expected_default_permission_posture(7)),
14991 session_inbox_runtime: None,
14992 actor_directory_store: Some(&store),
14993 canonical_subagent_tool: None,
14994 ticket_service: None,
14995 activation_run_id: Some("cancelled-remote-run"),
14996 execution_epoch: 0,
14997 expected_source_actor_id: "cancelled-remote-worker",
14998 initial_inflight_claims: VecDeque::new(),
14999 plain_actor: false,
15000 remote_environment_lease: true,
15001 readonly_output: None,
15002 local_history_tools: None,
15003 local_history_read_only: false,
15004 strict_history_output: None,
15005 plain_input: None,
15006 canonical_activation: Some(&fence),
15007 canonical_placement_ref: None,
15008 actor_event_observer: None,
15009 plain_run: None,
15010 first_frame_timeout: None,
15011 })
15012 .await;
15013 assert!(matches!(result, Err(AgentError::Cancelled)));
15014 assert_eq!(
15015 link.accepted, settle,
15016 "only a consumed exact terminal can release the remote fence"
15017 );
15018 assert_eq!(
15019 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15020 settle
15021 );
15022 }
15023 }
15024
15025 #[tokio::test]
15026 async fn actor_permission_handshake_rejects_malformed_agent_event() {
15027 let session_id = "permission-malformed";
15028 let (result, session, events, _) = drive_permission_handshake_frames(
15029 session_id,
15030 [ChildFrame::Event {
15031 event: serde_json::json!({"type": "token", "content": 42}),
15032 }],
15033 expected_default_permission_posture(7),
15034 )
15035 .await;
15036
15037 assert!(result
15038 .unwrap_err()
15039 .to_string()
15040 .contains("malformed AgentEvent"));
15041 assert!(events.is_empty());
15042 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15043
15044 let (result, _, events, _) = drive_permission_handshake_frames(
15045 session_id,
15046 [
15047 permission_posture_frame(session_id, 7),
15048 ChildFrame::Event {
15049 event: serde_json::json!({"type":"private-download-secret"}),
15050 },
15051 ],
15052 expected_default_permission_posture(7),
15053 )
15054 .await;
15055 assert!(!result
15056 .unwrap_err()
15057 .to_string()
15058 .contains("private-download-secret"));
15059 assert_eq!(
15060 events.len(),
15061 1,
15062 "only the confirmed posture may be forwarded"
15063 );
15064 }
15065
15066 #[tokio::test]
15067 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15068 let session_id = "permission-early-event";
15069 let early_events = [
15070 (
15071 "progress",
15072 AgentEvent::RunnerProgress {
15073 session_id: session_id.to_string(),
15074 round_count: 1,
15075 },
15076 ),
15077 (
15078 "token",
15079 AgentEvent::Token {
15080 content: "must-not-forward".to_string(),
15081 },
15082 ),
15083 (
15084 "tool",
15085 AgentEvent::ToolStart {
15086 tool_call_id: "early-tool".to_string(),
15087 tool_name: "Read".to_string(),
15088 arguments: serde_json::json!({"file_path": "README.md"}),
15089 },
15090 ),
15091 ];
15092 for (kind, event) in early_events {
15093 let (result, session, events, _) = drive_permission_handshake_frames(
15094 session_id,
15095 [
15096 actor_event_frame(event),
15097 permission_posture_frame(session_id, 7),
15098 completed_actor_frame(),
15099 ],
15100 expected_default_permission_posture(7),
15101 )
15102 .await;
15103
15104 assert!(
15105 result
15106 .unwrap_err()
15107 .to_string()
15108 .contains("execution event before permission posture confirmation"),
15109 "{kind} must fail closed before posture"
15110 );
15111 assert!(events.is_empty(), "{kind} must not be forwarded");
15112 assert!(
15113 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15114 "{kind} must not advance the permission audit"
15115 );
15116 }
15117 }
15118
15119 #[tokio::test]
15120 async fn actor_permission_handshake_rejects_approval_before_posture() {
15121 let session_id = "permission-early-approval";
15122 let (result, session, events, sent) = drive_permission_handshake_frames(
15123 session_id,
15124 [ChildFrame::ApprovalRequest {
15125 id: "approval-before-posture".to_string(),
15126 body: serde_json::json!({
15127 "tool_name": "Bash",
15128 "permission": "execute",
15129 "resource": "echo must-not-run"
15130 }),
15131 }],
15132 expected_default_permission_posture(7),
15133 )
15134 .await;
15135
15136 assert!(result
15137 .unwrap_err()
15138 .to_string()
15139 .contains("requested approval before permission posture confirmation"));
15140 assert!(events.is_empty());
15141 assert!(
15142 sent.is_empty(),
15143 "an unconfirmed actor must receive no approval reply"
15144 );
15145 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15146 }
15147
15148 #[tokio::test]
15149 async fn actor_permission_handshake_rejects_mismatched_posture() {
15150 let session_id = "permission-mismatch";
15151 let (result, session, events, _) = drive_permission_handshake_frames(
15152 session_id,
15153 [permission_posture_frame(session_id, 8)],
15154 expected_default_permission_posture(7),
15155 )
15156 .await;
15157
15158 assert!(result
15159 .unwrap_err()
15160 .to_string()
15161 .contains("does not match the host-dispatched policy"));
15162 assert!(events.is_empty());
15163 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15164 }
15165
15166 #[tokio::test]
15167 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15168 let session_id = "permission-hostile-mapping";
15169 for hostile_mapping in [
15170 "wrong_executor:permission_mode=default",
15171 "test_actor:permission_mode=default;credential=must-not-persist",
15172 ] {
15173 let frame = ChildFrame::Event {
15174 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15175 session_id: session_id.to_string(),
15176 policy_revision: 7,
15177 requested_mode: "default".to_string(),
15178 effective_mode: "default".to_string(),
15179 executor_mapping: hostile_mapping.to_string(),
15180 })
15181 .expect("serialize hostile posture fixture"),
15182 };
15183 let (result, session, events, _) = drive_permission_handshake_frames(
15184 session_id,
15185 [frame],
15186 expected_default_permission_posture(7),
15187 )
15188 .await;
15189
15190 let error = result.unwrap_err().to_string();
15191 assert!(error.contains("host-dispatched executor mapping"));
15192 assert!(
15193 !error.contains(hostile_mapping),
15194 "untrusted mapping must not be reflected in host errors"
15195 );
15196 assert!(events.is_empty());
15197 assert!(
15198 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15199 "untrusted mapping must not reach durable or in-memory audit state"
15200 );
15201 }
15202 }
15203
15204 #[tokio::test]
15205 async fn actor_permission_handshake_rejects_duplicate_posture() {
15206 let session_id = "permission-duplicate";
15207 let (result, session, events, _) = drive_permission_handshake_frames(
15208 session_id,
15209 [
15210 permission_posture_frame(session_id, 7),
15211 permission_posture_frame(session_id, 7),
15212 completed_actor_frame(),
15213 ],
15214 expected_default_permission_posture(7),
15215 )
15216 .await;
15217
15218 assert!(result
15219 .unwrap_err()
15220 .to_string()
15221 .contains("duplicate permission posture activation"));
15222 assert_eq!(
15223 events.len(),
15224 1,
15225 "only the confirmed posture may be forwarded"
15226 );
15227 assert!(matches!(
15228 events[0],
15229 AgentEvent::PermissionPostureActivated { .. }
15230 ));
15231 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15232 .expect("the first matching posture must be recorded");
15233 assert_eq!(audit.policy_revision, 7);
15234 }
15235
15236 #[tokio::test]
15237 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15238 let session_id = "permission-happy";
15239 let (result, session, events, _) = drive_permission_handshake_frames(
15240 session_id,
15241 [
15242 permission_posture_frame(session_id, 7),
15243 actor_event_frame(AgentEvent::RunnerProgress {
15244 session_id: session_id.to_string(),
15245 round_count: 1,
15246 }),
15247 actor_event_frame(AgentEvent::Token {
15248 content: "working".to_string(),
15249 }),
15250 actor_event_frame(AgentEvent::ToolStart {
15251 tool_call_id: "tool-1".to_string(),
15252 tool_name: "Read".to_string(),
15253 arguments: serde_json::json!({"file_path": "README.md"}),
15254 }),
15255 completed_actor_frame(),
15256 ],
15257 expected_default_permission_posture(7),
15258 )
15259 .await;
15260
15261 assert_eq!(result.unwrap().as_deref(), Some("done"));
15262 assert!(matches!(
15263 events.as_slice(),
15264 [
15265 AgentEvent::PermissionPostureActivated { .. },
15266 AgentEvent::RunnerProgress { .. },
15267 AgentEvent::Token { .. },
15268 AgentEvent::ToolStart { .. }
15269 ]
15270 ));
15271 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15272 .expect("matching posture must be recorded before execution events are accepted");
15273 assert_eq!(audit.policy_revision, 7);
15274 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15275 }
15276
15277 #[tokio::test]
15278 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15279 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15280 .into_iter()
15281 .enumerate()
15282 {
15283 let session_id = format!("actor-download-{index}");
15284 let args = serde_json::json!({
15285 "action":"download",
15286 "selector":"a[data-secret='private-selector']",
15287 "expected_epoch":17,
15288 "data_base64":"private-extra-bytes",
15289 "extra":{"url":"https://private.test/file"},
15290 });
15291 let original = args.clone();
15292 let read_args = serde_json::json!({"file_path":"README.md"});
15293 let (result, _session, events, _) = drive_permission_handshake_frames(
15294 &session_id,
15295 [
15296 permission_posture_frame(&session_id, 7),
15297 actor_event_frame(AgentEvent::ToolStart {
15298 tool_call_id: "download-call".to_string(),
15299 tool_name: tool_name.to_string(),
15300 arguments: args.clone(),
15301 }),
15302 actor_event_frame(AgentEvent::ToolStart {
15303 tool_call_id: "read-call".to_string(),
15304 tool_name: "Read".to_string(),
15305 arguments: read_args.clone(),
15306 }),
15307 completed_actor_frame(),
15308 ],
15309 expected_default_permission_posture(7),
15310 )
15311 .await;
15312 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15313 assert_eq!(args, original);
15314 let starts: Vec<_> = events
15315 .iter()
15316 .filter_map(|event| match event {
15317 AgentEvent::ToolStart {
15318 tool_name,
15319 arguments,
15320 ..
15321 } => Some((tool_name.as_str(), arguments)),
15322 _ => None,
15323 })
15324 .collect();
15325 assert_eq!(starts.len(), 2);
15326 assert_eq!(starts[0].0, "browser");
15327 assert_eq!(
15328 starts[0].1,
15329 &serde_json::json!({"action":"download","expected_epoch":17})
15330 );
15331 assert_eq!(starts[1], ("Read", &read_args));
15332 assert!(!format!("{events:?}").contains("private"));
15333 }
15334 }
15335
15336 #[tokio::test]
15337 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15338 let session_id = "actor-hook-display";
15339 let private = "private-selector-url-filename-response";
15340 let hook_name = format!("audit-{private}");
15341 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15342 hook_name: hook_name.clone(),
15343 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15344 phase: phase.into(),
15345 duration_ms,
15346 decision,
15347 };
15348 let decisions = [
15349 HookResult::Deny {
15350 reason: private.into(),
15351 },
15352 HookResult::InjectContext {
15353 text: private.into(),
15354 },
15355 HookResult::WithContext {
15356 result: Box::new(HookResult::WithContext {
15357 result: Box::new(HookResult::Deny {
15358 reason: private.into(),
15359 }),
15360 text: private.into(),
15361 }),
15362 text: private.into(),
15363 },
15364 HookResult::Suspend {
15365 reason: private.into(),
15366 },
15367 HookResult::Abort {
15368 reason: private.into(),
15369 },
15370 ];
15371 let originals = decisions.to_vec();
15372 let batch_events = [
15373 hook(2, "completed", decisions[1].clone()),
15374 hook(3, "completed", decisions[2].clone()),
15375 hook(4, "completed", decisions[3].clone()),
15376 ]
15377 .into_iter()
15378 .map(|event| serde_json::to_value(event).unwrap())
15379 .collect::<Vec<_>>();
15380 let batch = ActorEventBatch {
15381 logical_session: Some(LogicalSessionIdentity {
15382 creation: None,
15383 session_id: session_id.into(),
15384 parent_session_id: Some("permission-parent".into()),
15385 root_session_id: session_id.into(),
15386 }),
15387 activation_id: None,
15388 execution_epoch: 0,
15389 source_node_id: None,
15390 source_actor_id: Some(session_id.into()),
15391 first_seq: 1,
15392 last_seq: batch_events.len() as u64,
15393 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15394 events: batch_events,
15395 };
15396 let nested = AgentEvent::SubAgentEvent {
15397 parent_session_id: "parent".into(),
15398 child_session_id: "child".into(),
15399 event: Box::new(hook(5, "completed", decisions[4].clone())),
15400 };
15401 let raw = serde_json::to_string(&originals).unwrap();
15402 assert!(raw.contains(private));
15403
15404 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15405 session_id,
15406 [
15407 permission_posture_frame(session_id, 7),
15408 actor_event_frame(hook(1, private, decisions[0].clone())),
15409 ChildFrame::EventBatch { batch },
15410 actor_event_frame(nested),
15411 completed_actor_frame(),
15412 ],
15413 expected_default_permission_posture(7),
15414 )
15415 .await;
15416 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15417 let wire = serde_json::to_string(&forwarded).unwrap();
15418 assert!(
15419 !wire.contains(private),
15420 "private hook text reached SSE: {wire}"
15421 );
15422
15423 let hooks = forwarded
15424 .iter()
15425 .filter_map(|event| match event {
15426 AgentEvent::HookLifecycle {
15427 hook_name,
15428 point,
15429 phase,
15430 duration_ms,
15431 decision,
15432 } => Some((hook_name, point, phase, duration_ms, decision)),
15433 _ => None,
15434 })
15435 .collect::<Vec<_>>();
15436 assert_eq!(hooks.len(), 4);
15437 assert!(hooks.iter().all(|(name, point, _, _, _)| {
15438 name == &&"actor-hook-1".to_string()
15439 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15440 }));
15441 assert_eq!(hooks[0].2, "event");
15442 assert_eq!(hooks[1].2, "completed");
15443 assert_eq!(*hooks[0].3, 1);
15444 assert_eq!(*hooks[3].3, 4);
15445 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15446 assert!(
15447 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15448 );
15449 assert!(
15450 matches!(hooks[2].4, HookResult::WithContext { result, text }
15451 if text == "Hook context hidden" && matches!(result.as_ref(),
15452 HookResult::WithContext { result, text }
15453 if text == "Hook context hidden" && matches!(result.as_ref(),
15454 HookResult::Deny { reason } if reason == "Hook denied")))
15455 );
15456 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15457 assert!(forwarded.iter().any(|event| matches!(event,
15458 AgentEvent::SubAgentEvent { event, .. }
15459 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15460 hook_name, duration_ms: 5,
15461 decision: HookResult::Abort { reason }, ..
15462 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15463 )));
15464 }
15465
15466 #[test]
15467 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15468 let mut display = ActorEventDisplay::default();
15469 let mut deep = HookResult::Allow;
15470 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15471 deep = HookResult::WithContext {
15472 result: Box::new(deep),
15473 text: "private-download-bytes".into(),
15474 };
15475 }
15476 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15477 hook_name,
15478 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15479 phase: "completed".into(),
15480 duration_ms: 7,
15481 decision,
15482 };
15483 assert!(display.project(event("deep".into(), deep), 0).is_none());
15484 assert!(display.overflowed);
15485 assert!(display
15486 .project(
15487 event(
15488 "private-download-bytes".into(),
15489 HookResult::Deny {
15490 reason: "private-download-bytes".into(),
15491 },
15492 ),
15493 0,
15494 )
15495 .is_none());
15496
15497 let mut oversized = ActorEventDisplay::default();
15498 assert!(oversized
15499 .project(
15500 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15501 0,
15502 )
15503 .is_none());
15504 assert!(oversized.overflowed);
15505
15506 let mut reconnected = ActorEventDisplay::default();
15510 let private = "private-download-bytes";
15511 for _ in 0..2 {
15512 let projected = reconnected
15513 .project(event(private.into(), HookResult::Allow), 0)
15514 .unwrap();
15515 assert!(
15516 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15517 if hook_name == "actor-hook-1")
15518 );
15519 }
15520 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15521 for index in 1..MAX_DISPLAY_CALLS {
15522 assert!(reconnected
15523 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15524 .is_some());
15525 }
15526 assert!(reconnected
15527 .project(event("overflow-private".into(), HookResult::Allow), 0)
15528 .is_none());
15529 assert!(reconnected.overflowed);
15530 assert!(reconnected.hook_names.is_empty());
15531 }
15532
15533 #[test]
15534 fn actor_hook_display_preserves_nontext_decision_kinds() {
15535 for result in [
15536 HookResult::Continue,
15537 HookResult::Mutated,
15538 HookResult::Allow,
15539 HookResult::Ask,
15540 ] {
15541 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15542 }
15543 }
15544
15545 #[tokio::test]
15546 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15547 let session_id = "actor-malformed-hook";
15548 let private = "private-download-selector";
15549 let original = AgentEvent::HookLifecycle {
15550 hook_name: "audit".into(),
15551 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15552 phase: "completed".into(),
15553 duration_ms: 9,
15554 decision: HookResult::Deny {
15555 reason: private.into(),
15556 },
15557 };
15558 let mut invalid = serde_json::to_value(&original).unwrap();
15559 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15560 let nested = AgentEvent::SubAgentEvent {
15561 parent_session_id: "parent".into(),
15562 child_session_id: "child".into(),
15563 event: Box::new(original),
15564 };
15565 let mut invalid_nested = serde_json::to_value(nested).unwrap();
15566 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15567 let batch = ActorEventBatch {
15568 logical_session: Some(LogicalSessionIdentity {
15569 creation: None,
15570 session_id: session_id.into(),
15571 parent_session_id: Some("permission-parent".into()),
15572 root_session_id: session_id.into(),
15573 }),
15574 activation_id: None,
15575 execution_epoch: 0,
15576 source_node_id: None,
15577 source_actor_id: Some(session_id.into()),
15578 first_seq: 1,
15579 last_seq: 1,
15580 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15581 events: vec![invalid.clone()],
15582 };
15583 for frame in [
15584 ChildFrame::Event {
15585 event: invalid.clone(),
15586 },
15587 ChildFrame::EventBatch { batch },
15588 ChildFrame::Event {
15589 event: invalid_nested,
15590 },
15591 ] {
15592 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15593 session_id,
15594 [permission_posture_frame(session_id, 7), frame],
15595 expected_default_permission_posture(7),
15596 )
15597 .await;
15598 let error = outcome.unwrap_err().to_string();
15599 assert!(error.contains("malformed AgentEvent"), "{error}");
15600 assert!(!error.contains(private));
15601 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15602 }
15603
15604 let (event_tx, mut event_rx) = mpsc::channel(8);
15605 let mut session = Session::new("legacy-hook", "model");
15606 let mut handshake = PermissionPostureHandshake::NotRequired;
15607 let mut display = ActorEventDisplay::default();
15608 process_actor_event(
15609 invalid,
15610 false,
15611 &mut handshake,
15612 None,
15613 None,
15614 &mut session,
15615 &event_tx,
15616 &mut display,
15617 )
15618 .await
15619 .unwrap();
15620 assert!(display.overflowed);
15621 assert!(event_rx.try_recv().is_err());
15622 }
15623
15624 #[tokio::test]
15625 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15626 let session_id = "actor-download-batch";
15627 let private = "private-download-bytes-and-url";
15628 let events = vec![
15629 AgentEvent::ToolStart {
15630 tool_call_id: "download-call".into(),
15631 tool_name: "default::browser".into(),
15632 arguments: serde_json::json!({
15633 "action":"download", "selector": private, "expected_epoch": 17,
15634 }),
15635 },
15636 AgentEvent::ToolApprovalRequested {
15637 tool_call_id: "download-call".into(),
15638 tool_name: format!("{private}::browser"),
15639 parameters: serde_json::json!({
15640 "action":"download", "selector": private, "expected_epoch": 17,
15641 }),
15642 },
15643 AgentEvent::ToolStart {
15644 tool_call_id: "read-call".into(),
15645 tool_name: "Read".into(),
15646 arguments: serde_json::json!({"file_path":"README.md"}),
15647 },
15648 ];
15649 let batch = ActorEventBatch {
15650 logical_session: Some(LogicalSessionIdentity {
15651 creation: None,
15652 session_id: session_id.into(),
15653 parent_session_id: Some("permission-parent".into()),
15654 root_session_id: session_id.into(),
15655 }),
15656 activation_id: None,
15657 execution_epoch: 0,
15658 source_node_id: None,
15659 source_actor_id: Some(session_id.into()),
15660 first_seq: 1,
15661 last_seq: events.len() as u64,
15662 qos: bamboo_subagent::ActorEventQos::Durable,
15663 events: events
15664 .into_iter()
15665 .map(|event| serde_json::to_value(event).unwrap())
15666 .collect(),
15667 };
15668 let result = bamboo_agent_core::tools::ToolResult {
15669 success: true,
15670 result: format!("{{\"data_base64\":\"{private}\"}}"),
15671 display_preference: Some(private.into()),
15672 images: vec![bamboo_agent_core::tools::ToolResultImage {
15673 mime_type: "image/png".into(),
15674 data: private.into(),
15675 }],
15676 };
15677 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15678 session_id,
15679 [
15680 permission_posture_frame(session_id, 7),
15681 ChildFrame::EventBatch { batch },
15682 actor_event_frame(AgentEvent::ToolToken {
15683 tool_call_id: "download-call".into(),
15684 content: private.into(),
15685 }),
15686 actor_event_frame(AgentEvent::ToolToken {
15687 tool_call_id: "read-call".into(),
15688 content: "public live output".into(),
15689 }),
15690 actor_event_frame(AgentEvent::ToolComplete {
15691 tool_call_id: "download-call".into(),
15692 result,
15693 }),
15694 actor_event_frame(AgentEvent::ToolLifecycle {
15695 tool_call_id: "download-call".into(),
15696 tool_name: "browser".into(),
15697 phase: "finished".into(),
15698 elapsed_ms: Some(1),
15699 is_mutating: false,
15700 auto_approved: false,
15701 summary: Some(private.into()),
15702 error: Some(private.into()),
15703 }),
15704 actor_event_frame(AgentEvent::ToolError {
15705 tool_call_id: "download-call".into(),
15706 error: private.into(),
15707 }),
15708 actor_event_frame(AgentEvent::ToolComplete {
15709 tool_call_id: "read-call".into(),
15710 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15711 }),
15712 completed_actor_frame(),
15713 ],
15714 expected_default_permission_posture(7),
15715 )
15716 .await;
15717 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15718 let wire = serde_json::to_string(&forwarded).unwrap();
15719 assert!(
15720 !wire.contains(private),
15721 "private content reached actor SSE: {wire}"
15722 );
15723 assert!(wire.contains("public README text"));
15724 assert!(wire.contains("public live output"));
15725 assert!(forwarded.iter().any(|event| matches!(event,
15726 AgentEvent::ToolComplete { tool_call_id, result }
15727 if tool_call_id == "download-call"
15728 && result.result == "Browser tool result hidden"
15729 && result.images.is_empty() && result.display_preference.is_none()
15730 )));
15731 }
15732
15733 #[tokio::test]
15734 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15735 let private = "private-download-content";
15736 let nested = |event| AgentEvent::SubAgentEvent {
15737 parent_session_id: "parent".into(),
15738 child_session_id: "nested-child".into(),
15739 event: Box::new(event),
15740 };
15741 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15742 "actor-reorder",
15743 [
15744 permission_posture_frame("actor-reorder", 7),
15745 actor_event_frame(AgentEvent::ToolComplete {
15746 tool_call_id: "missing".into(),
15747 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15748 }),
15749 actor_event_frame(AgentEvent::ToolLifecycle {
15750 tool_call_id: "missing".into(),
15751 tool_name: "browser".into(),
15752 phase: private.into(),
15753 elapsed_ms: None,
15754 is_mutating: false,
15755 auto_approved: false,
15756 summary: Some(private.into()),
15757 error: Some(private.into()),
15758 }),
15759 actor_event_frame(AgentEvent::ToolApprovalRequested {
15760 tool_call_id: "approval-only".into(),
15761 tool_name: "browser".into(),
15762 parameters: serde_json::json!({"action":"download","selector":private}),
15763 }),
15764 actor_event_frame(AgentEvent::ToolToken {
15765 tool_call_id: "approval-only".into(),
15766 content: private.into(),
15767 }),
15768 actor_event_frame(AgentEvent::ToolStart {
15769 tool_call_id: "malformed-browser".into(),
15770 tool_name: format!("{private}::browser"),
15771 arguments: serde_json::json!({"selector":private}),
15772 }),
15773 actor_event_frame(AgentEvent::ToolComplete {
15774 tool_call_id: "malformed-browser".into(),
15775 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15776 }),
15777 actor_event_frame(AgentEvent::ToolStart {
15778 tool_call_id: "reused".into(),
15779 tool_name: "Read".into(),
15780 arguments: serde_json::json!({"file_path":"README.md"}),
15781 }),
15782 actor_event_frame(AgentEvent::ToolStart {
15783 tool_call_id: "reused".into(),
15784 tool_name: "browser".into(),
15785 arguments: serde_json::json!({"action":"download","selector":private}),
15786 }),
15787 actor_event_frame(AgentEvent::ToolToken {
15788 tool_call_id: "reused".into(),
15789 content: private.into(),
15790 }),
15791 actor_event_frame(nested(AgentEvent::ToolStart {
15792 tool_call_id: "nested".into(),
15793 tool_name: "browser".into(),
15794 arguments: serde_json::json!({"action":"download","selector":private}),
15795 })),
15796 actor_event_frame(nested(AgentEvent::ToolComplete {
15797 tool_call_id: "nested".into(),
15798 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15799 })),
15800 actor_event_frame(AgentEvent::ChildApprovalRequested {
15801 child_session_id: "nested-child".into(),
15802 request_id: "approval".into(),
15803 tool_name: format!("{private}::browser"),
15804 permission: private.into(),
15805 resource: private.into(),
15806 }),
15807 actor_event_frame(AgentEvent::ChildApprovalChanged {
15808 parent_session_id: "parent".into(),
15809 child_session_id: "nested-child".into(),
15810 child_attempt: 1,
15811 request_id: "approval".into(),
15812 version: 1,
15813 status: "pending".into(),
15814 reason: Some(private.into()),
15815 tool_name: "browser".into(),
15816 permission: private.into(),
15817 resource: private.into(),
15818 created_at: "2026-01-01T00:00:00Z".into(),
15819 resolved_at: None,
15820 }),
15821 completed_actor_frame(),
15822 ],
15823 expected_default_permission_posture(7),
15824 )
15825 .await;
15826 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15827 let wire = serde_json::to_string(&forwarded).unwrap();
15828 assert!(
15829 !wire.contains(private),
15830 "private content reached actor SSE: {wire}"
15831 );
15832 assert!(wire.contains("Tool result hidden"));
15833 assert!(wire.contains("Browser tool result hidden"));
15834 assert!(wire.contains("Browser interaction approval"));
15835 assert!(!forwarded
15836 .iter()
15837 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15838 }
15839
15840 #[test]
15841 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15842 let mut display = ActorEventDisplay::default();
15843 display
15844 .project(
15845 AgentEvent::ToolStart {
15846 tool_call_id: "private-call".into(),
15847 tool_name: "browser".into(),
15848 arguments: serde_json::json!({"action":"download","selector":"private"}),
15849 },
15850 0,
15851 )
15852 .unwrap();
15853 for index in 0..MAX_DISPLAY_CALLS {
15854 display
15855 .project(
15856 AgentEvent::ToolStart {
15857 tool_call_id: format!("ordinary-{index}"),
15858 tool_name: "Read".into(),
15859 arguments: serde_json::json!({"file_path":"README.md"}),
15860 },
15861 0,
15862 )
15863 .unwrap();
15864 }
15865 assert!(display.overflowed);
15866 let forwarded = display
15867 .project(
15868 AgentEvent::ToolComplete {
15869 tool_call_id: "private-call".into(),
15870 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15871 },
15872 0,
15873 )
15874 .unwrap();
15875 assert!(!serde_json::to_string(&forwarded)
15876 .unwrap()
15877 .contains("private-bytes"));
15878 }
15879
15880 #[test]
15881 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15882 let mut display = ActorEventDisplay::default();
15883 let nested = |index: usize| AgentEvent::SubAgentEvent {
15884 parent_session_id: "parent".into(),
15885 child_session_id: format!("child-{index}"),
15886 event: Box::new(AgentEvent::ToolStart {
15887 tool_call_id: format!("read-{index}"),
15888 tool_name: "Read".into(),
15889 arguments: serde_json::json!({"file_path":"README.md"}),
15890 }),
15891 };
15892 for index in 0..(MAX_DISPLAY_CALLS / 2) {
15893 assert!(display.project(nested(index), 0).is_some());
15894 }
15895 assert_eq!(
15896 display.identities.load(Ordering::Relaxed),
15897 MAX_DISPLAY_CALLS
15898 );
15899 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
15900 assert!(display.overflowed);
15901 assert!(display.children.is_empty());
15902
15903 let mut oversized = ActorEventDisplay::default();
15904 assert!(oversized
15905 .project(
15906 AgentEvent::SubAgentEvent {
15907 parent_session_id: "parent".into(),
15908 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15909 event: Box::new(AgentEvent::ToolComplete {
15910 tool_call_id: "call".into(),
15911 result: bamboo_agent_core::tools::ToolResult::text(
15912 true,
15913 "private-download-bytes",
15914 ),
15915 }),
15916 },
15917 0,
15918 )
15919 .is_none());
15920 assert!(oversized.overflowed);
15921 assert!(oversized
15922 .project(
15923 AgentEvent::ToolStart {
15924 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15925 tool_name: "Read".into(),
15926 arguments: serde_json::json!({"file_path":"README.md"}),
15927 },
15928 0,
15929 )
15930 .is_none());
15931 }
15932
15933 #[tokio::test]
15934 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
15935 let session_id = "actor-gap";
15936 let batch = |seq, event: AgentEvent| {
15937 let event = serde_json::to_value(event).unwrap();
15938 ChildFrame::EventBatch {
15939 batch: ActorEventBatch {
15940 logical_session: Some(LogicalSessionIdentity {
15941 creation: None,
15942 session_id: session_id.into(),
15943 parent_session_id: Some("permission-parent".into()),
15944 root_session_id: session_id.into(),
15945 }),
15946 activation_id: None,
15947 execution_epoch: 0,
15948 source_node_id: None,
15949 source_actor_id: Some(session_id.into()),
15950 first_seq: seq,
15951 last_seq: seq,
15952 qos: bamboo_subagent::ActorEventQos::classify(&event),
15953 events: vec![event],
15954 },
15955 }
15956 };
15957 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15958 session_id,
15959 [
15960 permission_posture_frame(session_id, 7),
15961 batch(
15962 1,
15963 AgentEvent::ToolStart {
15964 tool_call_id: "reused-call".into(),
15965 tool_name: "Read".into(),
15966 arguments: serde_json::json!({"file_path":"README.md"}),
15967 },
15968 ),
15969 batch(
15972 3,
15973 AgentEvent::ToolComplete {
15974 tool_call_id: "reused-call".into(),
15975 result: bamboo_agent_core::tools::ToolResult::text(
15976 true,
15977 "private-download-bytes",
15978 ),
15979 },
15980 ),
15981 completed_actor_frame(),
15982 ],
15983 expected_default_permission_posture(7),
15984 )
15985 .await;
15986 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15987 let wire = serde_json::to_string(&forwarded).unwrap();
15988 assert!(!wire.contains("private-download-bytes"), "{wire}");
15989 assert!(wire.contains("Tool result hidden"));
15990 }
15991
15992 #[tokio::test]
15993 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
15994 let (event_tx, mut event_rx) = mpsc::channel(8);
15995 let mut session = Session::new("legacy-malformed-private", "model");
15996 let mut handshake = PermissionPostureHandshake::NotRequired;
15997 let mut display = ActorEventDisplay::default();
15998 let read_start = serde_json::to_value(AgentEvent::ToolStart {
15999 tool_call_id: "reused-call".into(),
16000 tool_name: "Read".into(),
16001 arguments: serde_json::json!({"file_path":"README.md"}),
16002 })
16003 .unwrap();
16004 process_actor_event(
16005 read_start,
16006 false,
16007 &mut handshake,
16008 None,
16009 None,
16010 &mut session,
16011 &event_tx,
16012 &mut display,
16013 )
16014 .await
16015 .unwrap();
16016
16017 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16020 tool_call_id: "reused-call".into(),
16021 tool_name: "browser".into(),
16022 arguments: serde_json::json!({
16023 "action":"download", "selector":"private-download-selector"
16024 }),
16025 })
16026 .unwrap();
16027 malformed["tool_name"] = serde_json::json!(7);
16028 process_actor_event(
16029 malformed,
16030 false,
16031 &mut handshake,
16032 None,
16033 None,
16034 &mut session,
16035 &event_tx,
16036 &mut display,
16037 )
16038 .await
16039 .unwrap();
16040 process_actor_event(
16041 serde_json::to_value(AgentEvent::ToolComplete {
16042 tool_call_id: "reused-call".into(),
16043 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16044 })
16045 .unwrap(),
16046 false,
16047 &mut handshake,
16048 None,
16049 None,
16050 &mut session,
16051 &event_tx,
16052 &mut display,
16053 )
16054 .await
16055 .unwrap();
16056 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16057 assert_eq!(forwarded.len(), 2);
16058 let wire = serde_json::to_string(&forwarded).unwrap();
16059 assert!(!wire.contains("private-download"), "{wire}");
16060 assert!(wire.contains("Tool result hidden"));
16061 }
16062
16063 #[tokio::test]
16064 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16065 let temp = tempfile::tempdir().unwrap();
16066 let store = Arc::new(
16067 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16068 .await
16069 .unwrap(),
16070 );
16071 let storage: Arc<dyn Storage> = store.clone();
16072 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16073 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16074 store.clone(),
16075 bamboo_domain::SessionInboxLimits::default(),
16076 ));
16077 let session_id = "actor-confirmation-order";
16078 let run_id = "actor-run-current";
16079 let mut session = Session::new(session_id, "model");
16080 store.save_session(&session).await.unwrap();
16081 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16082 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16083 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16084 inbox.deliver(&envelope).await.unwrap();
16085 }
16086 inbox
16087 .mark_activation_eligible(
16088 session_id,
16089 2,
16090 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16091 )
16092 .await
16093 .unwrap();
16094 let router = SessionActivationRouter::new();
16095 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16096 let binding = SessionInboxRuntimeBinding {
16097 router,
16098 inbox: inbox.clone(),
16099 storage,
16100 persistence: locked,
16101 parent_question_lock: None,
16102 };
16103 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16104 .await
16105 .unwrap();
16106 assert_eq!(
16107 pairs
16108 .iter()
16109 .map(|(claim, _)| claim.envelope.id.as_str())
16110 .collect::<Vec<_>>(),
16111 vec!["actor-first", "actor-second"]
16112 );
16113 let seeded = store.load_session(session_id).await.unwrap().unwrap();
16114 assert_eq!(
16115 seeded
16116 .messages
16117 .iter()
16118 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16119 .map(|message| message.id.as_str())
16120 .collect::<Vec<_>>(),
16121 vec!["actor-first", "actor-second"],
16122 "host context must be durable before actor dispatch"
16123 );
16124 for (claim, _) in &pairs {
16125 assert_eq!(
16126 seeded
16127 .messages
16128 .iter()
16129 .filter(|message| bamboo_domain::is_matching_session_message(
16130 message,
16131 &claim.envelope
16132 ))
16133 .count(),
16134 1,
16135 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16136 claim.envelope.id
16137 );
16138 }
16139 assert!(
16140 seeded.session_inbox_admission().is_none_or(|cursor| {
16141 !cursor.contains(&pairs[0].0.envelope.id)
16142 && !cursor.contains(&pairs[1].0.envelope.id)
16143 }),
16144 "pre-dispatch transcript seeding must not forge worker confirmation"
16145 );
16146 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16147 let claims = pairs
16148 .into_iter()
16149 .map(|(claim, _)| claim)
16150 .collect::<VecDeque<_>>();
16151 let first = claims[0].clone();
16152 let second = claims[1].clone();
16153 let mut stale = admission_confirmation(session_id, &first, "stale-run");
16154 stale.canonical_claim_generation = second.generation;
16155 let mut link = ConfirmationSequenceLink {
16156 frames: VecDeque::from([
16157 ChildFrame::SessionMessageAdmitted {
16158 confirmation: stale,
16159 },
16160 ChildFrame::SessionMessageAdmitted {
16161 confirmation: admission_confirmation(session_id, &first, run_id),
16162 },
16163 ChildFrame::SessionMessageAdmitted {
16164 confirmation: admission_confirmation(session_id, &second, run_id),
16165 },
16166 ChildFrame::Terminal {
16167 status: TerminalStatus::Completed,
16168 result: Some("done".to_string()),
16169 error: None,
16170 final_event_watermark: None,
16171 transcript: Vec::new(),
16172 },
16173 ]),
16174 sent: Vec::new(),
16175 };
16176 let (event_tx, _event_rx) = mpsc::channel(8);
16177 let cancel = CancellationToken::new();
16178 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16179 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16180 let result = drive(ActorDriveContext {
16181 expected_creation: None,
16182 client: &mut link,
16183 parent_session_id: "parent",
16184 child_session_id: session_id,
16185 child_attempt: 0,
16186 approval_registry: None,
16187 approval_decider: None,
16188 approval_reviewer: None,
16189 escalation_bridge: None,
16190 event_tx: &event_tx,
16191 cancel_token: &cancel,
16192 live_rx: &mut live_rx,
16193 delivery_rx: &mut delivery_rx,
16194 logical_session: &mut session,
16195 expected_permission_posture: None,
16196 session_inbox_runtime: Some(&binding),
16197 actor_directory_store: None,
16198 canonical_subagent_tool: None,
16199 ticket_service: None,
16200 activation_run_id: Some(run_id),
16201 execution_epoch: 0,
16202 expected_source_actor_id: session_id,
16203 initial_inflight_claims: claims,
16204 plain_actor: false,
16205 remote_environment_lease: false,
16206 readonly_output: None,
16207 local_history_tools: None,
16208 local_history_read_only: false,
16209 strict_history_output: None,
16210 plain_input: None,
16211 canonical_activation: None,
16212 canonical_placement_ref: None,
16213 actor_event_observer: None,
16214 plain_run: None,
16215 first_frame_timeout: Some(Duration::from_secs(1)),
16216 })
16217 .await
16218 .unwrap();
16219 assert_eq!(result.as_deref(), Some("done"));
16220 assert_eq!(
16221 session
16222 .messages
16223 .iter()
16224 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16225 .map(|message| message.id.as_str())
16226 .collect::<Vec<_>>(),
16227 vec!["actor-first", "actor-second"]
16228 );
16229 let backlog = inbox.inspect(session_id).await.unwrap();
16230 assert_eq!(backlog.pending + backlog.claimed, 0);
16231 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16232 let cursor = confirmed
16233 .session_inbox_admission()
16234 .expect("exact worker confirmation must checkpoint the admission cursor");
16235 assert!(cursor.contains(&first.envelope.id));
16236 assert!(cursor.contains(&second.envelope.id));
16237 for claim in [&first, &second] {
16238 assert_eq!(
16239 confirmed
16240 .messages
16241 .iter()
16242 .filter(|message| bamboo_domain::is_matching_session_message(
16243 message,
16244 &claim.envelope
16245 ))
16246 .count(),
16247 1,
16248 "confirmation must retain one exact canonical marker for {}",
16249 claim.envelope.id
16250 );
16251 }
16252 assert!(inbox
16253 .was_admitted(session_id, &first.envelope.id)
16254 .await
16255 .unwrap());
16256 assert!(inbox
16257 .was_admitted(session_id, &second.envelope.id)
16258 .await
16259 .unwrap());
16260 owner_registration.begin_finalization().await;
16261 owner_registration.finish(2).await.unwrap();
16262 }
16263
16264 #[derive(Default)]
16265 struct RecordingCodexTokenAuthority {
16266 issued_for: std::sync::Mutex<Vec<String>>,
16267 revoked: std::sync::Mutex<Vec<String>>,
16268 }
16269
16270 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16271 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16272 self.issued_for
16273 .lock()
16274 .expect("issued fixture lock")
16275 .push(session_id.to_string());
16276 Ok(IssuedCodexRunToken {
16277 token_id: format!("id-{session_id}"),
16278 token: format!("bcx1_secret-{session_id}"),
16279 })
16280 }
16281
16282 fn revoke(&self, token_id: &str) {
16283 self.revoked
16284 .lock()
16285 .expect("revoked fixture lock")
16286 .push(token_id.to_string());
16287 }
16288 }
16289
16290 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16291 let bamboo_mode = auth_mode == Some("bamboo")
16292 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16293 ExecutorSpec::Codex {
16294 binary: None,
16295 model: None,
16296 mode: None,
16297 sandbox: None,
16298 inherit_user_config,
16299 auth_mode: auth_mode.map(str::to_string),
16300 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16301 wire_api: Some("responses".to_string()),
16302 provider_key_ref: None,
16303 forward_env: None,
16304 approval_policy: None,
16305 network_access: None,
16306 allow_danger_bypass: None,
16307 permission_profile: None,
16308 workspace_owned: None,
16309 }
16310 }
16311
16312 fn permission_resolution(
16313 requested: bamboo_domain::SessionPermissionMode,
16314 effective: bamboo_domain::PermissionMode,
16315 ) -> bamboo_domain::PermissionModeResolution {
16316 bamboo_domain::PermissionModeResolution {
16317 requested,
16318 effective,
16319 }
16320 }
16321
16322 #[test]
16323 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16324 use bamboo_domain::{PermissionMode, SessionPermissionMode};
16325
16326 let default =
16327 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16328 assert_eq!(
16329 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16330 .unwrap()
16331 .as_deref(),
16332 Some("bamboo_runtime:default")
16333 );
16334 assert_eq!(
16335 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16336 None,
16337 "transport-only Echo must not claim the typed permission contract"
16338 );
16339 assert_eq!(
16340 expected_permission_executor_mapping(
16341 &ExecutorSpec::CliAdapter {
16342 command: "must-not-appear-in-contract".to_string(),
16343 args: vec!["credential-like-argument".to_string()],
16344 },
16345 default,
16346 false,
16347 )
16348 .unwrap(),
16349 None,
16350 "unimplemented CliAdapter must not leak command data into a contract"
16351 );
16352
16353 let claude = ExecutorSpec::ClaudeCode {
16354 binary: None,
16355 model: None,
16356 permission_mode: Some("default".to_string()),
16357 inherit_user_config: None,
16358 forward_env: None,
16359 };
16360 for (resolution, mapping) in [
16361 (
16362 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16363 "claude_code:permission_mode=plan",
16364 ),
16365 (
16366 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16367 "claude_code:permission_mode=bypassPermissions",
16368 ),
16369 (
16370 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16371 "claude_code:permission_mode=acceptEdits",
16372 ),
16373 (
16374 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16375 "claude_code:permission_mode=dontAsk",
16376 ),
16377 (
16378 permission_resolution(
16379 SessionPermissionMode::Bypass,
16380 PermissionMode::BypassPermissions,
16381 ),
16382 "claude_code:permission_mode=default",
16383 ),
16384 ] {
16385 assert_eq!(
16386 expected_permission_executor_mapping(&claude, resolution, false)
16387 .unwrap()
16388 .as_deref(),
16389 Some(mapping)
16390 );
16391 }
16392 assert_eq!(
16393 expected_permission_executor_mapping(&claude, default, true)
16394 .unwrap()
16395 .as_deref(),
16396 Some("claude_code:blocked_explicit_deny")
16397 );
16398
16399 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16400 if let ExecutorSpec::Codex {
16401 approval_policy, ..
16402 } = &mut codex_exec
16403 {
16404 *approval_policy = Some("on-failure".to_string());
16405 }
16406 assert_eq!(
16407 expected_permission_executor_mapping(&codex_exec, default, false)
16408 .unwrap()
16409 .as_deref(),
16410 Some("codex_exec:approval_policy=on-failure")
16411 );
16412 assert_eq!(
16413 expected_permission_executor_mapping(
16414 &codex_exec,
16415 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16416 false,
16417 )
16418 .unwrap()
16419 .as_deref(),
16420 Some("codex_exec:approval_policy=never")
16421 );
16422 assert_eq!(
16423 expected_permission_executor_mapping(&codex_exec, default, true)
16424 .unwrap()
16425 .as_deref(),
16426 Some("codex_exec:blocked_explicit_deny")
16427 );
16428
16429 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16430 if let ExecutorSpec::Codex {
16431 mode,
16432 approval_policy,
16433 ..
16434 } = &mut codex_app_server
16435 {
16436 *mode = Some("app_server".to_string());
16437 *approval_policy = Some("on-request".to_string());
16438 }
16439 assert_eq!(
16440 expected_permission_executor_mapping(&codex_app_server, default, false)
16441 .unwrap()
16442 .as_deref(),
16443 Some("codex_app_server:approvalPolicy=on-request")
16444 );
16445 assert_eq!(
16446 expected_permission_executor_mapping(
16447 &codex_app_server,
16448 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16449 false,
16450 )
16451 .unwrap()
16452 .as_deref(),
16453 Some("codex_app_server:approvalPolicy=never")
16454 );
16455 assert_eq!(
16456 expected_permission_executor_mapping(&codex_app_server, default, true)
16457 .unwrap()
16458 .as_deref(),
16459 Some("codex_app_server:blocked_explicit_deny")
16460 );
16461 }
16462
16463 #[test]
16464 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16465 let project = tempfile::tempdir().unwrap();
16466 let managed = project.path().join(".bamboo/worktree/child-571");
16467 std::fs::create_dir_all(&managed).unwrap();
16468 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16469 let marker = project
16470 .path()
16471 .join(".bamboo/worktree/.bamboo-owned/child-571");
16472 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16473 std::fs::write(&marker, "bamboo/child-571").unwrap();
16474 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16475 let nested = managed.join("nested/path");
16476 std::fs::create_dir_all(&nested).unwrap();
16477 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16478
16479 let arbitrary = tempfile::tempdir().unwrap();
16480 assert!(!workspace_is_bamboo_owned(
16481 arbitrary.path().to_str().unwrap()
16482 ));
16483 }
16484
16485 #[test]
16486 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16487 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16488 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16489
16490 let (secrets, guard) = build_codex_run_secrets(
16491 &codex_executor(Some("bamboo"), None),
16492 Some(authority_dyn),
16493 "child-570",
16494 )
16495 .unwrap();
16496
16497 let token = secrets
16498 .codex_provider_token
16499 .as_ref()
16500 .expect("bamboo mode mints a token");
16501 assert_eq!(token.expose(), "bcx1_secret-child-570");
16502 assert!(!format!("{token:?}").contains("secret-child-570"));
16503 assert_eq!(
16504 authority.issued_for.lock().unwrap().as_slice(),
16505 ["child-570"]
16506 );
16507 assert!(authority.revoked.lock().unwrap().is_empty());
16508
16509 drop(guard);
16510 assert_eq!(
16511 authority.revoked.lock().unwrap().as_slice(),
16512 ["id-child-570"]
16513 );
16514 }
16515
16516 #[test]
16517 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16518 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16519 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16520 let (secrets, guard) = build_codex_run_secrets(
16521 &codex_executor(Some("custom"), None),
16522 Some(authority_dyn),
16523 "child-custom",
16524 )
16525 .unwrap();
16526 assert!(secrets.codex_provider_token.is_none());
16527 assert!(guard.is_none());
16528 assert!(authority.issued_for.lock().unwrap().is_empty());
16529
16530 let error = build_codex_run_secrets(
16531 &codex_executor(Some("bamboo"), None),
16532 None,
16533 "child-no-authority",
16534 )
16535 .err()
16536 .expect("bamboo mode without an authority must fail closed");
16537 assert!(error.to_string().contains("per-run token authority"));
16538 }
16539
16540 #[test]
16541 fn codex_provisioning_never_leaks_the_session_provider_credential() {
16542 let credentials = vec![ScopedCredential {
16543 provider: "openai".to_string(),
16544 api_key: "upstream-secret-must-not-cross".to_string(),
16545 base_url: None,
16546 provider_type: Some("openai".to_string()),
16547 credential_ref: Some("provider.openai.api_key".to_string()),
16548 }];
16549
16550 for (mode, label) in [
16551 (Some("inherit"), "inherit"),
16552 (Some("api_key"), "api_key"),
16553 (Some("bamboo"), "bamboo"),
16554 (None, "default-bamboo"),
16555 ] {
16556 let runner = ActorChildRunner::new(
16557 format!("codex-{label}-test"),
16558 PathBuf::from("/bin/false"),
16559 Vec::new(),
16560 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16561 codex_executor(mode, None),
16562 credentials.clone(),
16563 "openai".to_string(),
16564 1,
16565 );
16566 let mut session = Session::new(format!("child-{label}"), "model");
16567 session.add_message(bamboo_agent_core::Message::user("test"));
16568 let spec = runner.build_spec(
16569 &session,
16570 &crate::runtime::execution::SpawnJob {
16571 parent_session_id: "parent".to_string(),
16572 child_session_id: format!("child-{label}"),
16573 model: "gpt-5.4".to_string(),
16574 disabled_tools: None,
16575 },
16576 );
16577 assert!(
16578 spec.secrets.provider_credentials.is_empty(),
16579 "{label} Codex must not receive the session provider key"
16580 );
16581 }
16582 }
16583
16584 #[test]
16585 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16586 let credentials = vec![
16587 ScopedCredential {
16588 provider: "openai".to_string(),
16589 api_key: "selected-openai-secret".to_string(),
16590 base_url: None,
16591 provider_type: Some("openai".to_string()),
16592 credential_ref: Some("provider.openai.api_key".to_string()),
16593 },
16594 ScopedCredential {
16595 provider: "other".to_string(),
16596 api_key: "unrelated-secret".to_string(),
16597 base_url: None,
16598 provider_type: Some("openai".to_string()),
16599 credential_ref: Some("provider.other.api_key".to_string()),
16600 },
16601 ];
16602 let runner = ActorChildRunner::new(
16603 "echo-test".to_string(),
16604 PathBuf::from("/bin/false"),
16605 Vec::new(),
16606 std::env::temp_dir().join("bamboo-echo-provider-570"),
16607 ExecutorSpec::Echo,
16608 credentials,
16609 "openai".to_string(),
16610 1,
16611 );
16612 let spec = runner.build_spec(
16613 &Session::new("child-echo", "model"),
16614 &crate::runtime::execution::SpawnJob {
16615 parent_session_id: "parent".to_string(),
16616 child_session_id: "child-echo".to_string(),
16617 model: "gpt-5.4".to_string(),
16618 disabled_tools: None,
16619 },
16620 );
16621
16622 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16623 assert_eq!(
16624 spec.secrets.provider_credentials[0].api_key,
16625 "selected-openai-secret"
16626 );
16627 }
16628
16629 #[tokio::test]
16630 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16631 fn provider_instance(
16632 provider_type: &str,
16633 api_key: &str,
16634 ) -> bamboo_config::ProviderInstanceConfig {
16635 bamboo_config::ProviderInstanceConfig {
16636 provider_type: provider_type.to_string(),
16637 label: None,
16638 api_key: api_key.to_string(),
16639 api_key_encrypted: None,
16640 credential_ref: None,
16641 base_url: None,
16642 model: None,
16643 fast_model: None,
16644 vision_model: None,
16645 reasoning_effort: None,
16646 responses_only_models: Vec::new(),
16647 request_overrides: None,
16648 enabled: true,
16649 extra: Default::default(),
16650 }
16651 }
16652
16653 let mut initial = bamboo_llm::Config::default();
16654 initial.provider_instances.insert(
16655 "old-provider".to_string(),
16656 provider_instance("openai", "old-key"),
16657 );
16658 initial.default_provider_instance = Some("old-provider".to_string());
16659 let live = Arc::new(tokio::sync::RwLock::new(initial));
16660
16661 let runner = ActorChildRunner::new(
16662 "echo-live-config-test".to_string(),
16663 PathBuf::from("/bin/false"),
16664 Vec::new(),
16665 std::env::temp_dir().join("bamboo-echo-live-config"),
16666 ExecutorSpec::Echo,
16667 Vec::new(),
16668 "stale-provider".to_string(),
16669 1,
16670 )
16671 .with_live_provider_config(live.clone());
16672 let job = crate::runtime::execution::SpawnJob {
16673 parent_session_id: "parent".to_string(),
16674 child_session_id: "child".to_string(),
16675 model: "model".to_string(),
16676 disabled_tools: None,
16677 };
16678
16679 let first = runner
16680 .build_live_spec(&Session::new("child", "model"), &job)
16681 .await;
16682 assert_eq!(
16683 first.model.as_ref().map(|model| model.provider.as_str()),
16684 Some("old-provider")
16685 );
16686 assert_eq!(first.secrets.provider_credentials.len(), 1);
16687 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16688
16689 let mut reloaded = bamboo_llm::Config::default();
16690 reloaded.provider_instances.insert(
16691 "new-provider".to_string(),
16692 provider_instance("openai", "new-key"),
16693 );
16694 reloaded.default_provider_instance = Some("new-provider".to_string());
16695 *live.write().await = reloaded;
16696
16697 let second = runner
16698 .build_live_spec(&Session::new("child", "model"), &job)
16699 .await;
16700 assert_eq!(
16701 second.model.as_ref().map(|model| model.provider.as_str()),
16702 Some("new-provider")
16703 );
16704 assert_eq!(second.secrets.provider_credentials.len(), 1);
16705 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16706 }
16707
16708 #[test]
16709 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16710 let mut executor = codex_executor(Some("custom"), None);
16711 if let ExecutorSpec::Codex {
16712 base_url,
16713 provider_key_ref,
16714 ..
16715 } = &mut executor
16716 {
16717 *base_url = Some("https://provider.example/v1".to_string());
16718 *provider_key_ref = Some("provider.custom.api_key".to_string());
16719 }
16720 let credentials = vec![
16721 ScopedCredential {
16722 provider: "openai".to_string(),
16723 api_key: "session-provider-secret".to_string(),
16724 base_url: None,
16725 provider_type: Some("openai".to_string()),
16726 credential_ref: Some("provider.openai.api_key".to_string()),
16727 },
16728 ScopedCredential {
16729 provider: "custom".to_string(),
16730 api_key: "selected-secret".to_string(),
16731 base_url: None,
16732 provider_type: Some("openai".to_string()),
16733 credential_ref: Some("provider.custom.api_key".to_string()),
16734 },
16735 ScopedCredential {
16736 provider: "other".to_string(),
16737 api_key: "unrelated-secret".to_string(),
16738 base_url: None,
16739 provider_type: Some("openai".to_string()),
16740 credential_ref: Some("provider.other.api_key".to_string()),
16741 },
16742 ];
16743 let runner = ActorChildRunner::new(
16744 "codex-test".to_string(),
16745 PathBuf::from("/bin/false"),
16746 Vec::new(),
16747 std::env::temp_dir().join("bamboo-codex-570"),
16748 executor,
16749 credentials,
16750 "openai".to_string(),
16751 1,
16752 );
16753 let mut session = Session::new("child-custom", "model");
16754 session.add_message(bamboo_agent_core::Message::user("test"));
16755 let spec = runner.build_spec(
16756 &session,
16757 &crate::runtime::execution::SpawnJob {
16758 parent_session_id: "parent".to_string(),
16759 child_session_id: "child-custom".to_string(),
16760 model: "gpt-5.4".to_string(),
16761 disabled_tools: None,
16762 },
16763 );
16764
16765 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16766 assert_eq!(
16767 spec.secrets.provider_credentials[0]
16768 .credential_ref
16769 .as_deref(),
16770 Some("provider.custom.api_key")
16771 );
16772 assert_eq!(
16773 spec.secrets.provider_credentials[0].api_key,
16774 "selected-secret"
16775 );
16776 }
16777
16778 fn spec_with(
16779 role: &str,
16780 provider: &str,
16781 model: &str,
16782 workspace: Option<&str>,
16783 disabled: Option<Vec<&str>>,
16784 ) -> ProvisionSpec {
16785 let mut spec = ProvisionSpec::new(
16786 ChildIdentity {
16787 child_id: "c".into(),
16788 parent_id: None,
16789 project_key: None,
16790 role: role.into(),
16791 depth: 0,
16792 },
16793 ExecutorSpec::Echo,
16794 "/tmp/fab".into(),
16795 );
16796 spec.workspace = workspace.map(|w| w.to_string());
16797 spec.model = Some(ModelRefSpec {
16798 provider: provider.into(),
16799 model: model.into(),
16800 });
16801 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16802 spec
16803 }
16804
16805 #[test]
16806 fn fingerprint_matches_interchangeable_children() {
16807 let a = spec_with(
16810 "explorer",
16811 "p",
16812 "m",
16813 Some("/ws"),
16814 Some(vec!["Bash", "Edit"]),
16815 );
16816 let mut b = spec_with(
16817 "explorer",
16818 "p",
16819 "m",
16820 Some("/ws"),
16821 Some(vec!["Edit", "Bash"]),
16822 );
16823 b.identity.child_id = "other".into();
16824 assert_eq!(
16825 ActorChildRunner::fingerprint(&a),
16826 ActorChildRunner::fingerprint(&b)
16827 );
16828 }
16829
16830 #[test]
16831 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16832 let mut session =
16833 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16834 session.root_session_id = "logical-root-681".to_string();
16835 let job = SpawnJob {
16836 parent_session_id: "logical-parent-681".to_string(),
16837 child_session_id: "logical-child-681".to_string(),
16838 model: "model".to_string(),
16839 disabled_tools: None,
16840 };
16841 let expected = LogicalSessionIdentity {
16842 creation: None,
16843 session_id: "logical-child-681".to_string(),
16844 parent_session_id: Some("logical-parent-681".to_string()),
16845 root_session_id: "logical-root-681".to_string(),
16846 };
16847
16848 let placements_and_transport_ids = [
16849 (Placement::Local, "local-mailbox-first"),
16850 (
16851 Placement::Remote {
16852 endpoint: "wss://remote.example/actor".to_string(),
16853 },
16854 "remote-process-44",
16855 ),
16856 (
16857 Placement::Schedulable {
16858 pool: "gpu-pool".to_string(),
16859 },
16860 "scheduled-mailbox-9",
16861 ),
16862 (Placement::Local, "warm-mailbox-reused-77"),
16864 ];
16865 for (placement, transport_id) in placements_and_transport_ids {
16866 let mut provision = spec_with("worker", "provider", "model", None, None);
16867 provision.placement = placement;
16868 provision.identity.child_id = transport_id.to_string();
16869 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16870 assert_ne!(
16871 provision.identity.child_id, expected.session_id,
16872 "test fixture must prove transport identity is independent"
16873 );
16874 }
16875 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16876 original.executor = ExecutorSpec::BambooRuntime;
16877 original.identity.depth = session.spawn_depth;
16878 original.capabilities.required_child_context = true;
16879 original.capabilities.child_creation_identity = true;
16880 original.capabilities.enforce_permissions = true;
16881 original.capabilities.native_tool_ceiling_required = true;
16882 original.capabilities.native_tool_ceiling =
16883 Some(bamboo_subagent::proto::NativeToolCeiling {
16884 version: 1,
16885 child_session_id: session.id.clone(),
16886 parent_session_id: job.parent_session_id.clone(),
16887 root_session_id: session.root_session_id.clone(),
16888 created_at: session.created_at,
16889 spawn_depth: session.spawn_depth,
16890 project_id: None,
16891 tools: vec![],
16892 });
16893 original.validate().unwrap();
16894 let first = ActorChildRunner::fresh_required_worker_spec(&original);
16895 let second = ActorChildRunner::fresh_required_worker_spec(&original);
16896 assert_ne!(first.identity.child_id, second.identity.child_id);
16897 assert_ne!(first.identity.child_id, original.identity.child_id);
16898 for physical in [first, second] {
16899 physical.validate().unwrap();
16900 let mut actual = serde_json::to_value(&physical).unwrap();
16901 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
16902 assert_eq!(actual, serde_json::to_value(&original).unwrap());
16903 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16904 }
16905 }
16906
16907 #[test]
16908 fn fingerprint_separates_distinct_runtimes() {
16909 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
16910 let base_fp = ActorChildRunner::fingerprint(&base);
16911 assert_ne!(
16913 base_fp,
16914 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
16915 );
16916 assert_ne!(
16917 base_fp,
16918 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
16919 );
16920 assert_ne!(
16921 base_fp,
16922 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
16923 );
16924 assert_ne!(
16925 base_fp,
16926 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
16927 );
16928 assert_ne!(
16929 base_fp,
16930 ActorChildRunner::fingerprint(&spec_with(
16931 "explorer",
16932 "p",
16933 "m",
16934 Some("/ws"),
16935 Some(vec!["Bash"])
16936 ))
16937 );
16938 }
16939
16940 #[test]
16941 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
16942 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
16943 before.secrets.provider_credentials.push(ScopedCredential {
16944 provider: "provider".to_string(),
16945 api_key: "old-secret-key".to_string(),
16946 base_url: Some("https://provider.example/v1".to_string()),
16947 provider_type: Some("openai".to_string()),
16948 credential_ref: None,
16949 });
16950 let mut after = before.clone();
16951 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
16952
16953 let before_fingerprint = ActorChildRunner::fingerprint(&before);
16954 let after_fingerprint = ActorChildRunner::fingerprint(&after);
16955 assert_ne!(before_fingerprint, after_fingerprint);
16956 assert!(!before_fingerprint.contains("old-secret-key"));
16957 assert!(!after_fingerprint.contains("new-secret-key"));
16958 }
16959
16960 #[test]
16961 fn fingerprint_splits_on_baked_capabilities() {
16962 let base_fp =
16967 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
16968
16969 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
16970 creation.capabilities.child_creation_identity = true;
16971 let typed = ActorChildRunner::fingerprint(&creation);
16972 assert_ne!(
16973 base_fp, typed,
16974 "legacy worker pool cannot satisfy birth-required Run"
16975 );
16976 creation.identity.child_id = "different-logical-child".into();
16977 assert_eq!(
16978 typed,
16979 ActorChildRunner::fingerprint(&creation),
16980 "birth-required mode retains cross-Child pool reuse"
16981 );
16982
16983 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
16984 depth.identity.depth = 2;
16985 assert_ne!(
16986 base_fp,
16987 ActorChildRunner::fingerprint(&depth),
16988 "depth must split"
16989 );
16990
16991 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
16992 nested.capabilities.nested_spawn = true;
16993 assert_ne!(
16994 base_fp,
16995 ActorChildRunner::fingerprint(&nested),
16996 "nested_spawn must split"
16997 );
16998
16999 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17000 bypass.capabilities.bypass = true;
17001 assert_ne!(
17002 base_fp,
17003 ActorChildRunner::fingerprint(&bypass),
17004 "bypass must split"
17005 );
17006
17007 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17008 auto.capabilities.auto_approve_permissions = true;
17009 assert_ne!(
17010 base_fp,
17011 ActorChildRunner::fingerprint(&auto),
17012 "auto_approve_permissions must split"
17013 );
17014
17015 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17016 global_auto.capabilities.permission_requested_mode = "default".to_string();
17017 global_auto.capabilities.permission_effective_mode = "auto".to_string();
17018 global_auto.capabilities.auto_approve_permissions = true;
17019 let mut explicit_auto = global_auto.clone();
17020 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17021 assert_ne!(
17022 ActorChildRunner::fingerprint(&global_auto),
17023 ActorChildRunner::fingerprint(&explicit_auto),
17024 "permission_requested_mode must split global and explicit Auto"
17025 );
17026
17027 let mut plan_overlay = explicit_auto.clone();
17028 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17029 assert_ne!(
17030 ActorChildRunner::fingerprint(&explicit_auto),
17031 ActorChildRunner::fingerprint(&plan_overlay),
17032 "permission_effective_mode must split Plan overlay from Auto"
17033 );
17034
17035 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17036 enforce.capabilities.enforce_permissions = true;
17037 assert_ne!(
17038 base_fp,
17039 ActorChildRunner::fingerprint(&enforce),
17040 "enforce_permissions must split"
17041 );
17042
17043 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17044 cap.capabilities.max_spawn_depth = Some(8);
17045 assert_ne!(
17046 base_fp,
17047 ActorChildRunner::fingerprint(&cap),
17048 "max_spawn_depth must split"
17049 );
17050
17051 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17055 nha.capabilities.no_human_approver = true;
17056 assert_ne!(
17057 base_fp,
17058 ActorChildRunner::fingerprint(&nha),
17059 "no_human_approver must split"
17060 );
17061
17062 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17065 read_only.capabilities.read_only = true;
17066 assert_ne!(
17067 base_fp,
17068 ActorChildRunner::fingerprint(&read_only),
17069 "read_only must split"
17070 );
17071
17072 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17074 gro.capabilities.guardian_read_only = true;
17075 assert_ne!(
17076 base_fp,
17077 ActorChildRunner::fingerprint(&gro),
17078 "guardian_read_only must split"
17079 );
17080 }
17081
17082 #[test]
17083 fn fingerprint_splits_codex_exec_and_app_server_workers() {
17084 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17085 exec.executor = codex_executor(Some("inherit"), None);
17086 let mut app_server = exec.clone();
17087 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17088 *mode = Some("app_server".to_string());
17089 }
17090 assert_ne!(
17091 ActorChildRunner::fingerprint(&exec),
17092 ActorChildRunner::fingerprint(&app_server)
17093 );
17094 }
17095
17096 struct StaticDecider(bool);
17097
17098 #[async_trait]
17099 impl ChildApprovalDecider for StaticDecider {
17100 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17101 self.0
17102 }
17103 }
17104
17105 struct RecordingReviewer {
17106 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17107 }
17108
17109 #[async_trait]
17110 impl ChildApprovalReviewer for RecordingReviewer {
17111 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17112 let _ = self
17113 .reviewed
17114 .send((parent.to_string(), child.to_string(), request.clone()));
17115 true
17116 }
17117 }
17118
17119 struct SilentLink;
17124 #[async_trait]
17125 impl bamboo_subagent::ChildLink for SilentLink {
17126 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17127 Ok(())
17128 }
17129 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17130 std::future::pending().await
17131 }
17132 }
17133
17134 struct InstantTerminalLink {
17136 done: bool,
17137 }
17138
17139 struct ApprovalRoundTripLink {
17140 step: u8,
17141 approval_reply: Option<(String, bool)>,
17142 }
17143
17144 #[async_trait]
17145 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17146 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17147 if let ParentFrame::ApprovalReply { id, approved } = frame {
17148 self.approval_reply = Some((id, approved));
17149 self.step = 2;
17150 }
17151 Ok(())
17152 }
17153
17154 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17155 match self.step {
17156 0 => {
17157 self.step = 1;
17158 Ok(Some(ChildFrame::ApprovalRequest {
17159 id: "approval-1".into(),
17160 body: serde_json::json!({
17161 "tool_name": "Bash",
17162 "permission": "execute",
17163 "resource": "rm -rf target",
17164 "permission_request": {"reason_code": "hard_dangerous"}
17165 }),
17166 }))
17167 }
17168 1 => std::future::pending().await,
17169 2 => {
17170 self.step = 3;
17171 Ok(Some(ChildFrame::Terminal {
17172 status: TerminalStatus::Completed,
17173 result: Some("done".into()),
17174 error: None,
17175 final_event_watermark: None,
17176 transcript: vec![],
17177 }))
17178 }
17179 _ => std::future::pending().await,
17180 }
17181 }
17182 }
17183
17184 #[tokio::test]
17185 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17186 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17187 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17188 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17189 reviewed: review_tx,
17190 });
17191 let cancel = CancellationToken::new();
17192 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17193 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17194 let mut logical_session = Session::new("child-reviewer", "model");
17195 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17196 let mut link = ApprovalRoundTripLink {
17197 step: 0,
17198 approval_reply: None,
17199 };
17200
17201 let result = tokio::time::timeout(
17202 Duration::from_secs(1),
17203 drive(ActorDriveContext {
17204 expected_creation: None,
17205 client: &mut link,
17206 parent_session_id: "parent-reviewer",
17207 child_session_id: "child-reviewer",
17208 child_attempt: 0,
17209 approval_registry: None,
17210 approval_decider: None,
17211 approval_reviewer: Some(&reviewer),
17212 escalation_bridge: None,
17213 event_tx: &event_tx,
17214 cancel_token: &cancel,
17215 live_rx: &mut live_rx,
17216 delivery_rx: &mut delivery_rx,
17217 logical_session: &mut logical_session,
17218 expected_permission_posture: None,
17219 session_inbox_runtime: None,
17220 actor_directory_store: None,
17221 canonical_subagent_tool: None,
17222 ticket_service: None,
17223 activation_run_id: None,
17224 execution_epoch: 0,
17225 expected_source_actor_id: "child-reviewer",
17226 initial_inflight_claims: VecDeque::new(),
17227 plain_actor: false,
17228 remote_environment_lease: false,
17229 readonly_output: None,
17230 local_history_tools: None,
17231 local_history_read_only: false,
17232 strict_history_output: None,
17233 plain_input: None,
17234 canonical_activation: None,
17235 canonical_placement_ref: None,
17236 actor_event_observer: None,
17237 plain_run: None,
17238 first_frame_timeout: None,
17239 }),
17240 )
17241 .await
17242 .expect("worker must receive the reviewer verdict before terminating");
17243
17244 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17245 assert_eq!(
17246 link.approval_reply,
17247 Some(("approval-1".to_string(), true)),
17248 "reviewer verdict must traverse the live route back to the worker"
17249 );
17250 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17251 .await
17252 .expect("reviewer should be invoked off-loop")
17253 .expect("review channel should remain open");
17254 assert_eq!(parent, "parent-reviewer");
17255 assert_eq!(child, "child-reviewer");
17256 assert_eq!(
17257 body.pointer("/permission_request/reason_code")
17258 .and_then(serde_json::Value::as_str),
17259 Some("hard_dangerous")
17260 );
17261 assert!(
17262 event_rx.try_recv().is_err(),
17263 "must not emit a human-review event"
17264 );
17265 drop(live_guard);
17266 }
17267
17268 #[tokio::test]
17269 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17270 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17271 let cancel = CancellationToken::new();
17272 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17273 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17274 let mut logical_session = Session::new("child-no-reviewer", "model");
17275 let mut link = ApprovalRoundTripLink {
17276 step: 0,
17277 approval_reply: None,
17278 };
17279
17280 let result = tokio::time::timeout(
17281 Duration::from_secs(1),
17282 drive(ActorDriveContext {
17283 expected_creation: None,
17284 client: &mut link,
17285 parent_session_id: "parent-no-reviewer",
17286 child_session_id: "child-no-reviewer",
17287 child_attempt: 0,
17288 approval_registry: None,
17289 approval_decider: None,
17290 approval_reviewer: None,
17291 escalation_bridge: None,
17292 event_tx: &event_tx,
17293 cancel_token: &cancel,
17294 live_rx: &mut live_rx,
17295 delivery_rx: &mut delivery_rx,
17296 logical_session: &mut logical_session,
17297 expected_permission_posture: None,
17298 session_inbox_runtime: None,
17299 actor_directory_store: None,
17300 canonical_subagent_tool: None,
17301 ticket_service: None,
17302 activation_run_id: None,
17303 execution_epoch: 0,
17304 expected_source_actor_id: "child-no-reviewer",
17305 initial_inflight_claims: VecDeque::new(),
17306 plain_actor: false,
17307 remote_environment_lease: false,
17308 readonly_output: None,
17309 local_history_tools: None,
17310 local_history_read_only: false,
17311 strict_history_output: None,
17312 plain_input: None,
17313 canonical_activation: None,
17314 canonical_placement_ref: None,
17315 actor_event_observer: None,
17316 plain_run: None,
17317 first_frame_timeout: None,
17318 }),
17319 )
17320 .await
17321 .expect("fail-closed reply must unblock the child immediately");
17322
17323 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17324 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17325 assert!(
17326 event_rx.try_recv().is_err(),
17327 "missing parent review must not surface a manual approval event"
17328 );
17329 }
17330 #[async_trait]
17331 impl bamboo_subagent::ChildLink for InstantTerminalLink {
17332 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17333 Ok(())
17334 }
17335 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17336 if self.done {
17337 std::future::pending().await
17338 } else {
17339 self.done = true;
17340 Ok(Some(ChildFrame::Terminal {
17341 status: TerminalStatus::Completed,
17342 result: Some("done".into()),
17343 error: None,
17344 final_event_watermark: None,
17345 transcript: vec![],
17346 }))
17347 }
17348 }
17349 }
17350
17351 #[tokio::test]
17352 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17353 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17354 let cancel = CancellationToken::new();
17355 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17356 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17357 let mut logical_session = Session::new("child-x", "model");
17358 let mut link = SilentLink;
17359 let r = drive(ActorDriveContext {
17360 expected_creation: None,
17361 client: &mut link,
17362 parent_session_id: "parent-x",
17363 child_session_id: "child-x",
17364 child_attempt: 0,
17365 approval_registry: None,
17366 approval_decider: None,
17367 approval_reviewer: None,
17368 escalation_bridge: None,
17369 event_tx: &event_tx,
17370 cancel_token: &cancel,
17371 live_rx: &mut live_rx,
17372 delivery_rx: &mut delivery_rx,
17373 logical_session: &mut logical_session,
17374 expected_permission_posture: None,
17375 session_inbox_runtime: None,
17376 actor_directory_store: None,
17377 canonical_subagent_tool: None,
17378 ticket_service: None,
17379 activation_run_id: None,
17380 execution_epoch: 0,
17381 expected_source_actor_id: "child-x",
17382 initial_inflight_claims: VecDeque::new(),
17383 plain_actor: false,
17384 remote_environment_lease: false,
17385 readonly_output: None,
17386 local_history_tools: None,
17387 local_history_read_only: false,
17388 strict_history_output: None,
17389 plain_input: None,
17390 canonical_activation: None,
17391 canonical_placement_ref: None,
17392 actor_event_observer: None,
17393 plain_run: None,
17394 first_frame_timeout: Some(Duration::from_millis(100)),
17395 })
17396 .await;
17397 assert!(
17398 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17399 "a silent worker must trip the first-frame watchdog, got {r:?}"
17400 );
17401 }
17402
17403 #[tokio::test]
17404 async fn drive_does_not_trip_when_a_frame_arrives() {
17405 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17406 let cancel = CancellationToken::new();
17407 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17408 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17409 let mut logical_session = Session::new("child-y", "model");
17410 let mut link = InstantTerminalLink { done: false };
17411 let r = drive(ActorDriveContext {
17414 expected_creation: None,
17415 client: &mut link,
17416 parent_session_id: "parent-y",
17417 child_session_id: "child-y",
17418 child_attempt: 0,
17419 approval_registry: None,
17420 approval_decider: None,
17421 approval_reviewer: None,
17422 escalation_bridge: None,
17423 event_tx: &event_tx,
17424 cancel_token: &cancel,
17425 live_rx: &mut live_rx,
17426 delivery_rx: &mut delivery_rx,
17427 logical_session: &mut logical_session,
17428 expected_permission_posture: None,
17429 session_inbox_runtime: None,
17430 actor_directory_store: None,
17431 canonical_subagent_tool: None,
17432 ticket_service: None,
17433 activation_run_id: None,
17434 execution_epoch: 0,
17435 expected_source_actor_id: "child-y",
17436 initial_inflight_claims: VecDeque::new(),
17437 plain_actor: false,
17438 remote_environment_lease: false,
17439 readonly_output: None,
17440 local_history_tools: None,
17441 local_history_read_only: false,
17442 strict_history_output: None,
17443 plain_input: None,
17444 canonical_activation: None,
17445 canonical_placement_ref: None,
17446 actor_event_observer: None,
17447 plain_run: None,
17448 first_frame_timeout: Some(Duration::from_millis(50)),
17449 })
17450 .await;
17451 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17452 }
17453
17454 #[tokio::test]
17455 async fn child_approval_fails_closed_without_decider() {
17456 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17458 assert!(!decide_child_approval(None, "child-1", &body).await);
17459 }
17460
17461 #[tokio::test]
17462 async fn child_approval_honors_wired_decider() {
17463 let body =
17464 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17465 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17466 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17467 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17468 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17469 }
17470
17471 use crate::runtime::execution::SpawnJob;
17474 use bamboo_agent_core::Session;
17475
17476 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17479 ActorChildRunner::new(
17480 "test-actor".into(),
17481 PathBuf::from("/bin/false"),
17482 vec![],
17483 std::env::temp_dir().join("bamboo-test-fab-193"),
17484 ExecutorSpec::Echo,
17485 vec![],
17486 "anthropic".into(),
17487 4,
17488 )
17489 .with_remote_placements(placements)
17490 }
17491
17492 fn session_of_role(role: &str, assignment: &str) -> Session {
17495 let mut s = Session::new("child-1", "test-model");
17496 s.metadata
17497 .insert("subagent_type".to_string(), role.to_string());
17498 s.add_message(bamboo_agent_core::Message::user(assignment));
17499 s
17500 }
17501
17502 fn job_for(child: &str) -> SpawnJob {
17503 SpawnJob {
17504 parent_session_id: "parent-1".into(),
17505 child_session_id: child.into(),
17506 model: String::new(),
17507 disabled_tools: None,
17508 }
17509 }
17510
17511 #[tokio::test]
17512 async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17513 let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17514 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17515 let mut remote = HashMap::new();
17516 remote.insert(
17517 "remote-role".into(),
17518 ResolvedRemotePlacement {
17519 endpoint: "wss://worker.example:8443".into(),
17520 token: None,
17521 ca_cert_file: None,
17522 host_label: None,
17523 broker_peer: None,
17524 requirements: None,
17525 },
17526 );
17527 let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17528 let root = Session::new("provision-root", "model");
17529 for role in ["local-role", "remote-role"] {
17530 let mut child =
17531 Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17532 child.metadata.insert("subagent_type".into(), role.into());
17533 let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17534 assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17535 assert!(!first.capabilities.nested_spawn);
17536 assert_eq!(
17537 matches!(first.placement, Placement::Remote { .. }),
17538 role == "remote-role"
17539 );
17540 live.write().await.subagents_mut().max_spawn_depth = Some(7);
17541 let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17542 assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17543 assert!(second.capabilities.nested_spawn);
17544 assert_ne!(
17545 ActorChildRunner::fingerprint(&first),
17546 ActorChildRunner::fingerprint(&second)
17547 );
17548 assert_eq!(second.identity.depth, 1);
17549 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17550 }
17551 let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17552 let spec = fallback.build_spec(&root, &job_for(&root.id));
17553 assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17554 assert!(!spec.capabilities.nested_spawn);
17555 }
17556
17557 struct RecordingChildSessionPort {
17558 parent: Session,
17559 saved: std::sync::Mutex<Option<Session>>,
17560 }
17561
17562 impl RecordingChildSessionPort {
17563 fn saved_child(&self) -> Session {
17564 self.saved
17565 .lock()
17566 .expect("saved-child fixture lock")
17567 .clone()
17568 .expect("create_child_action must save the child")
17569 }
17570 }
17571
17572 #[async_trait]
17573 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17574 async fn load_root_session(
17575 &self,
17576 root_id: &str,
17577 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17578 assert_eq!(root_id, self.parent.id);
17579 Ok(self.parent.clone())
17580 }
17581
17582 async fn load_child_for_parent(
17583 &self,
17584 _parent_id: &str,
17585 _child_id: &str,
17586 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17587 unreachable!("create_child_action does not reload the child")
17588 }
17589
17590 async fn save_child_session(
17591 &self,
17592 child: &mut Session,
17593 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17594 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17595 Ok(())
17596 }
17597
17598 async fn save_child_session_authoritative_flags(
17599 &self,
17600 _child: &mut Session,
17601 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17602 unreachable!("new-child creation uses the ordinary save")
17603 }
17604
17605 async fn is_child_running(&self, _child_id: &str) -> bool {
17606 false
17607 }
17608
17609 async fn list_children(
17610 &self,
17611 _parent_id: &str,
17612 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17613 Vec::new()
17614 }
17615
17616 async fn enqueue_child_run(
17617 &self,
17618 _parent: &Session,
17619 _child: &Session,
17620 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17621 unreachable!("fixture creates the child with auto_run=false")
17622 }
17623
17624 async fn cancel_child_run_and_wait(
17625 &self,
17626 _child_id: &str,
17627 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17628 unreachable!("create_child_action does not cancel")
17629 }
17630
17631 async fn delete_child_session(
17632 &self,
17633 _parent_id: &str,
17634 _child_id: &str,
17635 ) -> Result<
17636 crate::session_app::child_session::DeleteChildResult,
17637 crate::session_app::child_session::ChildSessionError,
17638 > {
17639 unreachable!("create_child_action does not delete")
17640 }
17641
17642 async fn get_child_runner_info(
17643 &self,
17644 _child_id: &str,
17645 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17646 None
17647 }
17648
17649 async fn register_parent_wait_for_child(
17650 &self,
17651 _parent_session_id: &str,
17652 _child_session_id: &str,
17653 _tool_call_id: Option<&str>,
17654 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17655 unreachable!("create_child_action does not register a wait")
17656 }
17657
17658 async fn register_parent_wait_for_children(
17659 &self,
17660 _parent_session_id: &str,
17661 _child_session_ids: &[String],
17662 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17663 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17664 unreachable!("create_child_action does not register a wait")
17665 }
17666
17667 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17668 Vec::new()
17669 }
17670
17671 async fn find_resident_child(
17672 &self,
17673 _root_session_id: &str,
17674 _resident_name: &str,
17675 ) -> Option<String> {
17676 None
17677 }
17678
17679 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17680 }
17681
17682 #[test]
17683 fn build_spec_sets_remote_placement_for_matching_role() {
17684 let mut placements = HashMap::new();
17685 placements.insert(
17686 "explorer".to_string(),
17687 ResolvedRemotePlacement {
17688 endpoint: "wss://gpu-host:8443".into(),
17689 token: Some("T-secret".into()),
17690 ca_cert_file: None,
17691 host_label: None,
17692 broker_peer: None,
17693 requirements: None,
17694 },
17695 );
17696 let runner = bogus_runner(placements);
17697
17698 let s = session_of_role("explorer", "do the thing");
17700 let spec = runner.build_spec(&s, &job_for("child-1"));
17701 match &spec.placement {
17702 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17703 other => panic!("expected Remote, got {other:?}"),
17704 }
17705 assert!(spec.secrets.worker_auth_token.is_none());
17706 }
17707
17708 #[test]
17709 fn build_spec_leaves_local_for_unmatched_role() {
17710 let mut placements = HashMap::new();
17711 placements.insert(
17712 "explorer".to_string(),
17713 ResolvedRemotePlacement {
17714 endpoint: "wss://gpu-host:8443".into(),
17715 token: Some("T".into()),
17716 ca_cert_file: None,
17717 host_label: None,
17718 broker_peer: None,
17719 requirements: None,
17720 },
17721 );
17722 let runner = bogus_runner(placements);
17723
17724 let s = session_of_role("writer", "do the thing");
17726 let spec = runner.build_spec(&s, &job_for("child-1"));
17727 assert_eq!(spec.placement, Placement::Local);
17728 assert!(spec.secrets.worker_auth_token.is_none());
17729 }
17730
17731 #[test]
17732 fn build_spec_local_when_no_placements() {
17733 let runner = bogus_runner(HashMap::new());
17734 let s = session_of_role("explorer", "do the thing");
17735 let spec = runner.build_spec(&s, &job_for("child-1"));
17736 assert_eq!(spec.placement, Placement::Local);
17737 assert!(spec.secrets.worker_auth_token.is_none());
17738 }
17739
17740 #[test]
17741 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17742 for mode in [
17743 bamboo_domain::SessionPermissionMode::Auto,
17744 bamboo_domain::SessionPermissionMode::Bypass,
17745 ] {
17746 let runner = bogus_runner(HashMap::new());
17747 let mut child = session_of_role("planner", "inspect and plan");
17748 let runtime = child
17749 .agent_runtime_state
17750 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17751 runtime.set_permission_mode(mode);
17752 runtime.read_only = true;
17753
17754 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17755
17756 assert!(spec.capabilities.read_only);
17757 assert!(spec.capabilities.read_only_enforced());
17758 assert!(
17759 spec.capabilities.guardian_read_only,
17760 "the compatibility alias must protect planners on older workers"
17761 );
17762 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17763 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17764 assert!(
17765 !spec.capabilities.bypass,
17766 "read-only must win over requested {mode:?}"
17767 );
17768 assert_eq!(
17769 spec.capabilities.auto_approve_permissions,
17770 mode == bamboo_domain::SessionPermissionMode::Auto,
17771 "Auto may suppress prompts but must not widen Plan authorization"
17772 );
17773 let disabled = spec
17774 .disabled_tools
17775 .as_ref()
17776 .expect("read-only state must provision a host-owned denylist");
17777 for tool_name in [
17778 "Edit",
17779 "Write",
17780 "Plan",
17781 "SubAgent",
17782 "session_control",
17783 "request_permissions",
17784 ] {
17785 assert!(
17786 disabled.iter().any(|name| name == tool_name),
17787 "read-only provisioning must disable {tool_name}"
17788 );
17789 }
17790 }
17791
17792 let runner = bogus_runner(HashMap::new());
17793 let cosmetic_planner = runner.build_spec(
17794 &session_of_role("planner", "label only"),
17795 &job_for("cosmetic"),
17796 );
17797 assert!(
17798 !cosmetic_planner.capabilities.read_only_enforced(),
17799 "a cosmetic role label must not change authority"
17800 );
17801 assert!(cosmetic_planner.disabled_tools.is_none());
17802
17803 let cosmetic_guardian = runner.build_spec(
17804 &session_of_role("guardian", "label only"),
17805 &job_for("cosmetic-guardian"),
17806 );
17807 assert!(
17808 !cosmetic_guardian.capabilities.read_only_enforced(),
17809 "a Guardian label without typed state or the legacy denylist is cosmetic"
17810 );
17811
17812 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17813 let mut legacy_job = job_for("legacy-guardian");
17814 legacy_job.disabled_tools = Some(vec![
17815 "Edit".to_string(),
17816 "Write".to_string(),
17817 "SubAgent".to_string(),
17818 "WebFetch".to_string(),
17819 ]);
17820 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17821 assert!(
17822 legacy_spec.capabilities.read_only_enforced(),
17823 "an in-flight pre-upgrade Guardian keeps its read-only authority"
17824 );
17825
17826 let mut differently_named = session_of_role("explorer", "typed authority");
17827 differently_named
17828 .agent_runtime_state
17829 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17830 .read_only = true;
17831 let typed = runner.build_spec(&differently_named, &job_for("typed"));
17832 assert!(
17833 typed.capabilities.read_only_enforced(),
17834 "typed state must enforce read-only independently of role"
17835 );
17836 }
17837
17838 #[test]
17839 fn typed_read_only_forces_native_external_executor_profiles() {
17840 let mut planner = session_of_role("planner", "inspect and plan");
17841 planner
17842 .agent_runtime_state
17843 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17844 .read_only = true;
17845
17846 let claude_runner = ActorChildRunner::new(
17847 "test-actor".into(),
17848 PathBuf::from("/bin/false"),
17849 vec![],
17850 std::env::temp_dir().join("bamboo-test-plan-claude"),
17851 ExecutorSpec::ClaudeCode {
17852 binary: None,
17853 model: None,
17854 permission_mode: Some("bypassPermissions".to_string()),
17855 inherit_user_config: Some(true),
17856 forward_env: None,
17857 },
17858 vec![],
17859 "anthropic".into(),
17860 4,
17861 );
17862 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17863 let disabled = claude_spec
17864 .disabled_tools
17865 .as_ref()
17866 .expect("typed read-only child must receive a host-owned denylist");
17867 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17868 assert!(
17869 disabled.iter().any(|name| name == tool),
17870 "typed read-only child must not advertise {tool}"
17871 );
17872 }
17873 assert!(matches!(
17874 claude_spec.executor,
17875 ExecutorSpec::ClaudeCode {
17876 permission_mode: Some(ref mode),
17877 inherit_user_config: Some(false),
17878 ..
17879 } if mode == "plan"
17880 ));
17881 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17882
17883 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17884 if let ExecutorSpec::Codex {
17885 sandbox,
17886 approval_policy,
17887 network_access,
17888 allow_danger_bypass,
17889 permission_profile,
17890 ..
17891 } = &mut codex_exec
17892 {
17893 *sandbox = Some("danger-full-access".to_string());
17894 *approval_policy = Some("on-failure".to_string());
17895 *network_access = Some(true);
17896 *allow_danger_bypass = Some(true);
17897 *permission_profile = Some("full-access".to_string());
17898 }
17899 let codex_runner = ActorChildRunner::new(
17900 "test-actor".into(),
17901 PathBuf::from("/bin/false"),
17902 vec![],
17903 std::env::temp_dir().join("bamboo-test-plan-codex"),
17904 codex_exec,
17905 vec![],
17906 "openai".into(),
17907 4,
17908 );
17909 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
17910 assert_eq!(
17911 expected_permission_executor_mapping(
17912 &codex_spec.executor,
17913 codex_spec.capabilities.permission_resolution().unwrap(),
17914 false,
17915 )
17916 .unwrap()
17917 .as_deref(),
17918 Some("codex_exec:approval_policy=never")
17919 );
17920 assert!(matches!(
17921 codex_spec.executor,
17922 ExecutorSpec::Codex {
17923 sandbox: Some(ref sandbox),
17924 inherit_user_config: Some(false),
17925 approval_policy: Some(ref approval_policy),
17926 network_access: Some(false),
17927 allow_danger_bypass: Some(false),
17928 permission_profile: Some(ref profile),
17929 ..
17930 } if sandbox == "read-only"
17931 && approval_policy == "never"
17932 && profile == "read-only"
17933 ));
17934
17935 let mut app_server = codex_executor(Some("inherit"), Some(true));
17936 if let ExecutorSpec::Codex {
17937 mode,
17938 sandbox,
17939 approval_policy,
17940 network_access,
17941 allow_danger_bypass,
17942 permission_profile,
17943 ..
17944 } = &mut app_server
17945 {
17946 *mode = Some("app_server".to_string());
17947 *sandbox = Some("danger-full-access".to_string());
17948 *approval_policy = Some("on-request".to_string());
17949 *network_access = Some(true);
17950 *allow_danger_bypass = Some(true);
17951 *permission_profile = Some("full-access".to_string());
17952 }
17953 let app_server_runner = ActorChildRunner::new(
17954 "test-actor".into(),
17955 PathBuf::from("/bin/false"),
17956 vec![],
17957 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
17958 app_server,
17959 vec![],
17960 "openai".into(),
17961 4,
17962 );
17963 let app_server_spec =
17964 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
17965 assert_eq!(
17966 expected_permission_executor_mapping(
17967 &app_server_spec.executor,
17968 app_server_spec
17969 .capabilities
17970 .permission_resolution()
17971 .unwrap(),
17972 false,
17973 )
17974 .unwrap()
17975 .as_deref(),
17976 Some("codex_app_server:approvalPolicy=never")
17977 );
17978 assert!(matches!(
17979 app_server_spec.executor,
17980 ExecutorSpec::Codex {
17981 mode: Some(ref mode),
17982 sandbox: Some(ref sandbox),
17983 inherit_user_config: Some(false),
17984 approval_policy: Some(ref approval_policy),
17985 network_access: Some(false),
17986 allow_danger_bypass: Some(false),
17987 permission_profile: Some(ref profile),
17988 ..
17989 } if mode == "app_server"
17990 && sandbox == "read-only"
17991 && approval_policy == "on-request"
17992 && profile == "read-only"
17993 ));
17994 }
17995
17996 #[test]
17997 fn typed_read_only_rejects_both_codex_executor_modes() {
17998 let mut planner = session_of_role("planner", "inspect and plan");
17999 planner
18000 .agent_runtime_state
18001 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18002 .read_only = true;
18003
18004 for mode in ["exec", "app_server"] {
18005 let mut executor = codex_executor(Some("inherit"), Some(true));
18006 if let ExecutorSpec::Codex {
18007 mode: configured_mode,
18008 ..
18009 } = &mut executor
18010 {
18011 *configured_mode = Some(mode.to_string());
18012 }
18013 let runner = ActorChildRunner::new(
18014 "test-actor".into(),
18015 PathBuf::from("/bin/false"),
18016 vec![],
18017 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18018 executor,
18019 vec![],
18020 "openai".into(),
18021 4,
18022 );
18023 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18024 let error = ensure_read_only_activation_is_enforceable(&spec)
18025 .expect_err("Codex command execution must fail closed for read-only activations");
18026 let message = error.to_string();
18027 assert!(message.contains("Codex executor"), "{message}");
18028 assert!(message.contains("command execution"), "{message}");
18029 }
18030 }
18031
18032 #[tokio::test]
18033 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18034 let mut planner = session_of_role("planner", "inspect and plan");
18035 planner
18036 .agent_runtime_state
18037 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18038 .read_only = true;
18039
18040 let runner = ActorChildRunner::new(
18041 "test-actor".into(),
18042 PathBuf::from("/bin/false"),
18043 vec![],
18044 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18045 codex_executor(Some("inherit"), Some(true)),
18046 vec![],
18047 "openai".into(),
18048 4,
18049 );
18050 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18051 let error = runner
18052 .execute_external_child(
18053 &mut planner,
18054 &job_for("planner-codex-no-spawn"),
18055 event_tx,
18056 CancellationToken::new(),
18057 )
18058 .await
18059 .expect_err("typed read-only Codex must fail before invoking /bin/false");
18060 let message = error.to_string();
18061 assert!(message.contains("Codex executor"), "{message}");
18062 assert!(message.contains("command execution"), "{message}");
18063 }
18064
18065 #[test]
18066 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18067 let runner = bogus_runner(HashMap::new());
18068 let ordinary = session_of_role("worker", "ordinary work");
18069 for placement in [
18070 Placement::Local,
18071 Placement::Remote {
18072 endpoint: "wss://fixture.invalid/actor".into(),
18073 },
18074 Placement::Schedulable {
18075 pool: "fixture-pool".into(),
18076 },
18077 ] {
18078 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18079 spec.placement = placement;
18080 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18081 for marker in [
18082 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18083 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18084 ] {
18085 let mut ticket = ordinary.clone();
18086 ticket.metadata.insert(marker.into(), "fixture".into());
18087 assert_eq!(
18088 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18089 matches!(spec.placement, Placement::Local)
18090 );
18091 }
18092 }
18093 }
18094
18095 #[tokio::test]
18096 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18097 for marker in [
18098 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18099 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18100 ] {
18101 for schedulable in [false, true] {
18102 let mut session = session_of_role("worker", "Ticket work");
18103 session.metadata.insert(marker.into(), "fixture".into());
18104 let before = serde_json::to_value(&session).unwrap();
18105 let remote = if schedulable {
18106 HashMap::new()
18107 } else {
18108 HashMap::from([(
18109 "worker".into(),
18110 ResolvedRemotePlacement {
18111 endpoint: "wss://fixture.invalid/actor".into(),
18112 ..Default::default()
18113 },
18114 )])
18115 };
18116 let sched = if schedulable {
18117 HashMap::from([(
18118 "worker".into(),
18119 sched_placement("fixture-pool", "wss://fixture.invalid"),
18120 )])
18121 } else {
18122 HashMap::new()
18123 };
18124 let runner = bogus_sched_runner(remote, sched);
18125 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18126 let error = runner
18127 .execute_external_child(
18128 &mut session,
18129 &job_for("ticket-no-remote"),
18130 events,
18131 CancellationToken::new(),
18132 )
18133 .await
18134 .unwrap_err();
18135 assert!(
18136 error
18137 .to_string()
18138 .contains("Ticket execution requires local placement"),
18139 "{error}"
18140 );
18141 assert_eq!(serde_json::to_value(&session).unwrap(), before);
18142 assert!(observations.try_recv().is_err());
18143 }
18144 }
18145 }
18146
18147 #[test]
18148 fn typed_read_only_rejects_resident_actor_placements() {
18149 let runner = bogus_runner(HashMap::new());
18150 let mut planner = session_of_role("planner", "inspect and plan");
18151 planner
18152 .agent_runtime_state
18153 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18154 .read_only = true;
18155
18156 for placement in [
18157 Placement::Remote {
18158 endpoint: "wss://resident.example/actor".to_string(),
18159 },
18160 Placement::Schedulable {
18161 pool: "resident-pool".to_string(),
18162 },
18163 ] {
18164 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18165 spec.placement = placement;
18166
18167 let error = ensure_read_only_activation_is_enforceable(&spec)
18168 .expect_err("resident placement must fail closed for read-only activation");
18169 let message = error.to_string();
18170 assert!(message.contains("requires local placement"), "{message}");
18171 assert!(message.contains("read-only tool boundary"), "{message}");
18172 }
18173
18174 let local = runner.build_spec(&planner, &job_for("read-only-local"));
18175 ensure_read_only_activation_is_enforceable(&local)
18176 .expect("a locally provisioned read-only worker is enforceable");
18177 }
18178
18179 #[tokio::test]
18180 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18181 for (label, mode) in [
18185 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18186 ("auto", bamboo_domain::SessionPermissionMode::Auto),
18187 ] {
18188 let runner = bogus_runner(HashMap::new());
18189 let mut parent = Session::new(format!("parent-{label}"), "test-model");
18190 parent
18191 .agent_runtime_state
18192 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18193 .set_permission_mode(mode);
18194 let workspace = tempfile::tempdir().expect("workspace fixture");
18195 let port = RecordingChildSessionPort {
18196 parent: parent.clone(),
18197 saved: Default::default(),
18198 };
18199 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18200 crate::session_app::child_session::create_child_action(
18201 &port,
18202 crate::session_app::child_session::CreateChildInput {
18203 parent_session: parent,
18204 child_id: child_id.clone(),
18205 title: format!("{label} child"),
18206 responsibility: "Run ordinary commands".to_string(),
18207 assignment_prompt: "run an ordinary command".to_string(),
18208 subagent_type: "explorer".to_string(),
18209 workspace: workspace.path().to_string_lossy().into_owned(),
18210 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18211 model_override: None,
18212 model_ref_override: None,
18213 runtime_metadata: HashMap::new(),
18214 read_only: false,
18215 auto_run: false,
18216 reasoning_effort: None,
18217 lifecycle: None,
18218 resident_name: None,
18219 resident_context: None,
18220 disabled_tools: None,
18221 context_fork: None,
18222 },
18223 )
18224 .await
18225 .expect("create inherited-permission child");
18226 let child = port.saved_child();
18227
18228 assert_eq!(
18229 child
18230 .agent_runtime_state
18231 .as_ref()
18232 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18233 Some(mode),
18234 "create_child_action must inherit {label} from the parent"
18235 );
18236
18237 let spec = runner.build_spec(&child, &job_for(&child_id));
18238
18239 assert_eq!(
18240 spec.capabilities.bypass,
18241 mode == bamboo_domain::SessionPermissionMode::Bypass
18242 );
18243 assert_eq!(
18244 spec.capabilities.auto_approve_permissions,
18245 mode == bamboo_domain::SessionPermissionMode::Auto
18246 );
18247 assert!(
18248 spec.capabilities.enforce_permissions,
18249 "policy evaluation must remain active under {label}"
18250 );
18251 }
18252 }
18253
18254 #[tokio::test]
18255 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18256 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18257 let workspace = tempfile::tempdir().expect("workspace fixture");
18258
18259 for (role, lifecycle, resident_name) in [
18260 ("explorer", None, None),
18261 ("resident", Some("resident"), Some("stable-reviewer")),
18262 ("guardian", None, None),
18263 ] {
18264 let mut parent = Session::new(format!("parent-{role}"), "test-model");
18265 parent.set_project_id_meta(project_id.to_string());
18266 let port = RecordingChildSessionPort {
18267 parent: parent.clone(),
18268 saved: Default::default(),
18269 };
18270 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18271 crate::session_app::child_session::create_child_action(
18272 &port,
18273 crate::session_app::child_session::CreateChildInput {
18274 parent_session: parent,
18275 child_id: child_id.clone(),
18276 title: format!("{role} child"),
18277 responsibility: "Review the assigned work".to_string(),
18278 assignment_prompt: "inspect the change".to_string(),
18279 subagent_type: role.to_string(),
18280 workspace: workspace.path().to_string_lossy().into_owned(),
18281 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18282 model_override: None,
18283 model_ref_override: None,
18284 runtime_metadata: HashMap::new(),
18285 read_only: false,
18286 auto_run: false,
18287 reasoning_effort: None,
18288 lifecycle: lifecycle.map(str::to_string),
18289 resident_name: resident_name.map(str::to_string),
18290 resident_context: None,
18291 disabled_tools: None,
18292 context_fork: None,
18293 },
18294 )
18295 .await
18296 .expect("create Project-inheriting child");
18297 let child = port.saved_child();
18298 assert_eq!(
18299 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18300 Some(project_id.clone()),
18301 "{role} child must inherit its parent's Project"
18302 );
18303
18304 assert_eq!(
18305 project_id_for_actor_run(&child).expect("valid actor Project identity"),
18306 Some(project_id.clone()),
18307 "{role} actor RunSpec must preserve inherited Project identity"
18308 );
18309 }
18310 }
18311
18312 #[test]
18313 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18314 assert_eq!(placement_metadata(&Placement::Local, None), None);
18316
18317 let r = placement_metadata(
18319 &Placement::Remote {
18320 endpoint: "wss://10.0.0.5:8443/stream".into(),
18321 },
18322 None,
18323 )
18324 .unwrap();
18325 assert!(r.contains(r#""kind":"remote""#), "{r}");
18326 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18327
18328 let labeled = placement_metadata(
18330 &Placement::Remote {
18331 endpoint: "ws://169.254.230.101:8899".into(),
18332 },
18333 Some("mini"),
18334 )
18335 .unwrap();
18336 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18337
18338 let s = placement_metadata(
18340 &Placement::Schedulable {
18341 pool: "explorers".into(),
18342 },
18343 Some("mini"),
18344 )
18345 .unwrap();
18346 assert!(s.contains(r#""kind":"remote""#), "{s}");
18347 assert!(s.contains(r#""host":"mini""#), "{s}");
18348
18349 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18351 assert_eq!(p.kind, "remote");
18352 assert_eq!(p.host, "mini");
18353 }
18354
18355 #[tokio::test]
18358 async fn execute_external_child_rejects_direct_remote_worker() {
18359 let mut placements = HashMap::new();
18360 placements.insert(
18361 "explorer".to_string(),
18362 ResolvedRemotePlacement {
18363 endpoint: "ws://127.0.0.1:1".into(),
18364 token: Some("remote-test-token".into()),
18365 ca_cert_file: None,
18366 host_label: Some("mini-e2e".into()),
18367 broker_peer: None,
18368 requirements: None,
18369 },
18370 );
18371 let runner = bogus_runner(placements);
18372 let mut session = session_of_role("explorer", "hello remote");
18373 let job = job_for("child-1");
18374 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18375 let error = runner
18376 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18377 .await
18378 .err()
18379 .expect("direct remote transport must fail closed");
18380 assert!(error
18381 .to_string()
18382 .contains("remote_broker_activation_unavailable"));
18383 assert!(session
18384 .messages
18385 .iter()
18386 .all(|message| message.role != Role::Assistant));
18387 }
18388
18389 fn bogus_sched_runner(
18395 remote: HashMap<String, ResolvedRemotePlacement>,
18396 sched: HashMap<String, ResolvedSchedulablePlacement>,
18397 ) -> ActorChildRunner {
18398 ActorChildRunner::new(
18399 "test-actor".into(),
18400 PathBuf::from("/bin/false"),
18401 vec![],
18402 std::env::temp_dir().join("bamboo-test-fab-181"),
18403 ExecutorSpec::Echo,
18404 vec![],
18405 "anthropic".into(),
18406 4,
18407 )
18408 .with_remote_placements(remote)
18409 .with_schedulable_placements(sched)
18410 }
18411
18412 fn sched_placement(
18413 pool: &str,
18414 _registry_url: impl Into<String>,
18415 ) -> ResolvedSchedulablePlacement {
18416 ResolvedSchedulablePlacement {
18417 pool: pool.into(),
18418 host_label: None,
18419 requirements: None,
18420 ..Default::default()
18421 }
18422 }
18423
18424 #[test]
18425 fn build_spec_sets_schedulable_placement_for_matching_role() {
18426 let mut sched = HashMap::new();
18427 sched.insert(
18428 "explorer".to_string(),
18429 sched_placement("gpu-pool", "unused"),
18430 );
18431 let runner = bogus_sched_runner(HashMap::new(), sched);
18432
18433 let s = session_of_role("explorer", "do the thing");
18434 let spec = runner.build_spec(&s, &job_for("child-1"));
18435 match &spec.placement {
18436 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18437 other => panic!("expected Schedulable, got {other:?}"),
18438 }
18439 assert!(spec.secrets.worker_auth_token.is_none());
18441 }
18442
18443 #[test]
18444 fn build_spec_remote_wins_when_role_in_both_maps() {
18445 let mut remote = HashMap::new();
18448 remote.insert(
18449 "explorer".to_string(),
18450 ResolvedRemotePlacement {
18451 endpoint: "wss://fixed-host:8443".into(),
18452 token: Some("T-remote".into()),
18453 ca_cert_file: None,
18454 host_label: None,
18455 broker_peer: None,
18456 requirements: None,
18457 },
18458 );
18459 let mut sched = HashMap::new();
18460 sched.insert(
18461 "explorer".to_string(),
18462 sched_placement("gpu-pool", "https://control-plane:9562"),
18463 );
18464 let runner = bogus_sched_runner(remote, sched);
18465
18466 let s = session_of_role("explorer", "do the thing");
18467 let spec = runner.build_spec(&s, &job_for("child-1"));
18468 match &spec.placement {
18469 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18470 other => panic!("expected Remote (precedence), got {other:?}"),
18471 }
18472 assert!(spec.secrets.worker_auth_token.is_none());
18473 }
18474
18475 #[test]
18476 fn build_spec_local_for_unmatched_schedulable_role() {
18477 let mut sched = HashMap::new();
18478 sched.insert(
18479 "explorer".to_string(),
18480 sched_placement("gpu-pool", "https://control-plane:9562"),
18481 );
18482 let runner = bogus_sched_runner(HashMap::new(), sched);
18483 let s = session_of_role("writer", "do the thing");
18484 let spec = runner.build_spec(&s, &job_for("child-1"));
18485 assert_eq!(spec.placement, Placement::Local);
18486 assert!(spec.secrets.worker_auth_token.is_none());
18487 }
18488
18489 #[test]
18494 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18495 let mut remote = HashMap::new();
18497 remote.insert(
18498 "explorer".to_string(),
18499 ResolvedRemotePlacement {
18500 endpoint: "ws://169.254.230.101:8899".into(),
18501 token: None,
18502 ca_cert_file: None,
18503 host_label: Some("mini".into()),
18504 broker_peer: None,
18505 requirements: None,
18506 },
18507 );
18508 let runner = bogus_runner(remote);
18509 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18510 let stamp = runner
18511 .placement_stamp_for(&spec)
18512 .expect("remote child is stamped");
18513 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18514 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18515
18516 let mut remote_nolabel = HashMap::new();
18518 remote_nolabel.insert(
18519 "explorer".to_string(),
18520 ResolvedRemotePlacement {
18521 endpoint: "ws://169.254.230.101:8899".into(),
18522 token: None,
18523 ca_cert_file: None,
18524 host_label: None,
18525 broker_peer: None,
18526 requirements: None,
18527 },
18528 );
18529 let r2 = bogus_runner(remote_nolabel);
18530 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18531 assert!(r2
18532 .placement_stamp_for(&spec2)
18533 .unwrap()
18534 .contains(r#""host":"169.254.230.101""#));
18535
18536 let mut sched = HashMap::new();
18538 sched.insert(
18539 "mac-mini-monitor".to_string(),
18540 ResolvedSchedulablePlacement {
18541 pool: "mac-mini-monitor".into(),
18542 host_label: Some("mini".into()),
18543 requirements: None,
18544 ..Default::default()
18545 },
18546 );
18547 let sr = bogus_sched_runner(HashMap::new(), sched);
18548 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18549 let stamp3 = sr
18550 .placement_stamp_for(&spec3)
18551 .expect("scheduled child is stamped");
18552 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18553 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18554
18555 let local = bogus_runner(HashMap::new());
18557 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18558 assert_eq!(local.placement_stamp_for(&spec4), None);
18559 }
18560
18561 #[tokio::test]
18562 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18563 let mut sched = HashMap::new();
18564 sched.insert(
18565 "explorer".to_string(),
18566 sched_placement("gpu-pool", "unused"),
18567 );
18568 let runner = bogus_sched_runner(HashMap::new(), sched);
18569 let mut session = session_of_role("explorer", "do the thing");
18570 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18571 let error = runner
18572 .execute_external_child(
18573 &mut session,
18574 &job_for("child-1"),
18575 event_tx,
18576 CancellationToken::new(),
18577 )
18578 .await
18579 .expect_err("legacy bus presence cannot grant placement");
18580 assert!(error
18581 .to_string()
18582 .contains("remote_broker_activation_unavailable"));
18583 }
18584}