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_conversation_generation: session.conversation_generation_observation(),
611 expected_messages: session.messages.clone(),
612 expected_provider_transcript: session.provider_transcript.clone(),
613 messages: vec![message],
614 native_groups: Vec::new(),
615 })
616 .await
617 .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
618 session.messages = committed.messages;
619 session.provider_transcript = committed.provider_transcript;
620 session.updated_at = committed.updated_at;
621 Ok(())
622}
623
624impl ActivePlacementLease {
625 async fn reserve(
626 registry: bamboo_storage::v2::FileHostRegistry,
627 actor_store: Arc<bamboo_storage::SessionStoreV2>,
628 route: ScopedPlacementRoute,
629 mailbox: String,
630 actor_id: String,
631 run_id: String,
632 project_id: Option<bamboo_domain::ProjectId>,
633 required_tools: BTreeSet<String>,
634 parent_cancel: &CancellationToken,
635 ) -> Result<Self, AgentError> {
636 let scheduler = PlacementScheduler::new(registry.clone());
637 let mut query = route.query().await?;
638 let observed = query
639 .observe_host_capacity(&mailbox, &route.worker_role)
640 .await
641 .map_err(|_| remote_broker_unavailable())?
642 .ok_or_else(remote_broker_unavailable)?;
643 let host = scheduler
644 .observe_scoped_host(
645 &mut query,
646 &observed.host_ref,
647 &mailbox,
648 &route.worker_role,
649 chrono::Duration::seconds(110),
650 )
651 .await
652 .map_err(|_| remote_broker_unavailable())?;
653 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
654 || host.connection_generation != observed.connection_generation
655 || route
656 .pinned_mailbox
657 .as_deref()
658 .is_some_and(|pinned| pinned != mailbox)
659 {
660 return Err(remote_broker_unavailable());
661 }
662 let now = chrono::Utc::now();
663 let request = HostPlacementRequest {
664 intent: HostPlacementIntent::PinnedHost {
665 host_ref: host.host_ref.clone(),
666 },
667 actor_id,
668 run_id,
669 project_id: project_id.clone(),
670 trust_zone: route.requirements.trust_zone.clone(),
671 workspace_label: route.requirements.workspace_label.clone(),
672 executor: "bamboo-runtime".into(),
673 required_tools,
674 network_zone: route.requirements.network_zone.clone(),
675 require_network_isolation: route.requirements.require_network_isolation,
676 preferred_host_ref: None,
677 now,
678 lease_expires_at: now + chrono::Duration::seconds(80),
679 };
680 if !request.eligible(&host) {
681 return Err(remote_broker_unavailable());
682 }
683 let lease = scheduler
684 .reserve(request)
685 .await
686 .map_err(|_| remote_broker_unavailable())?;
687 let placement_ref = bamboo_domain::ActorPlacementRef {
688 class: bamboo_domain::ActorPlacementClass::Remote,
689 lease_id: lease.lease_id.clone(),
690 slot_epoch: Some(lease.epoch),
691 };
692 let mut claimed_fence = None;
693 let actor_binding = async {
694 let actor = actor_store
695 .ensure_actor(&lease.actor_id)
696 .await
697 .map_err(|_| remote_broker_unavailable())?;
698 if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
699 return Err(remote_broker_unavailable());
700 }
701 let placement_intent = if route.pinned_mailbox.is_some() {
702 bamboo_domain::ActorPlacementIntent::PinnedHost {
703 host_ref: host.host_ref.clone(),
704 }
705 } else {
706 bamboo_domain::ActorPlacementIntent::Pool {
707 pool_id: route.worker_role.clone(),
708 }
709 };
710 actor_store
711 .bind_control_plane(
712 &lease.actor_id,
713 bamboo_domain::ActorControlPlaneBinding {
714 policy_revision: None,
715 placement_intent: Some(placement_intent),
716 },
717 )
718 .await
719 .map_err(|_| {
720 tracing::warn!(stage = "actor_bind", "remote placement admission failed");
721 remote_broker_unavailable()
722 })?;
723 let activation = actor_store
724 .claim_activation(&ActorActivationClaim {
725 actor_id: lease.actor_id.clone(),
726 run_id: lease.run_id.clone(),
727 lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
728 lease_expires_at: lease.expires_at,
729 inbox_generation: 0,
730 placement_ref: Some(placement_ref.clone()),
731 now: chrono::Utc::now(),
732 })
733 .await
734 .map_err(|_| {
735 tracing::warn!(stage = "actor_claim", "remote placement admission failed");
736 remote_broker_unavailable()
737 })?;
738 let fence = activation.fence();
739 claimed_fence = Some(fence.clone());
740 actor_store
741 .start_activation(&fence, chrono::Utc::now())
742 .await
743 .map_err(|_| {
744 tracing::warn!(stage = "actor_start", "remote placement admission failed");
745 remote_broker_unavailable()
746 })?;
747 let reservation = registry
748 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
749 placement_ref: placement_ref.clone(),
750 actor_id: lease.actor_id.clone(),
751 run_id: lease.run_id.clone(),
752 observed_host_ref: host.host_ref.clone(),
753 observed_connection_generation: host.connection_generation.clone(),
754 now: chrono::Utc::now(),
755 })
756 .await
757 .map_err(|error| {
758 tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
759 remote_broker_unavailable()
760 })?;
761 if reservation.mailbox != mailbox || reservation.lease != lease {
762 tracing::warn!(stage = "slot_binding", "remote placement admission failed");
763 return Err(remote_broker_unavailable());
764 }
765 Ok::<_, AgentError>(fence)
766 }
767 .await;
768 let fence = match actor_binding {
769 Ok(fence) => fence,
770 Err(error) => {
771 if let Some(fence) = claimed_fence.as_ref() {
772 if let Err(rollback_error) =
773 fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
774 {
775 tracing::warn!(
776 stage = "actor_pre_dispatch_rollback",
777 reason = %rollback_error,
778 "remote placement activation rollback failed"
779 );
780 }
781 }
782 let _ = registry.release_slot(&lease).await;
783 return Err(error);
784 }
785 };
786 let lease = Arc::new(tokio::sync::Mutex::new(lease));
787 let host_ref = host.host_ref.clone();
788 let lost = Arc::new(AtomicBool::new(false));
789 let run_cancel = parent_cancel.child_token();
790 let heartbeat = {
791 let registry = registry.clone();
792 let actor_store = actor_store.clone();
793 let route = route.clone();
794 let host_ref = host_ref.clone();
795 let mailbox = mailbox.clone();
796 let fence = fence.clone();
797 let placement_ref = placement_ref.clone();
798 let lease = lease.clone();
799 let lost = lost.clone();
800 let run_cancel = run_cancel.clone();
801 tokio::spawn(async move {
802 let mut interval = tokio::time::interval(Duration::from_secs(30));
803 interval.tick().await;
804 loop {
805 tokio::select! {
806 _ = run_cancel.cancelled() => break,
807 _ = interval.tick() => {}
808 }
809 let refreshed = async {
810 let mut query = route.query().await?;
811 let host = PlacementScheduler::new(registry.clone())
812 .observe_scoped_host(
813 &mut query,
814 &host_ref,
815 &mailbox,
816 &route.worker_role,
817 chrono::Duration::seconds(110),
818 )
819 .await
820 .map_err(|_| remote_broker_unavailable())?;
821 let current = lease.lock().await.clone();
822 if host.connection_generation != current.connection_generation {
823 return Err(remote_broker_unavailable());
824 }
825 let entry = actor_store
826 .inspect_actor(¤t.actor_id)
827 .await
828 .map_err(|_| remote_broker_unavailable())?;
829 if entry.activation.as_ref().is_none_or(|activation| {
830 activation.fence() != fence
831 || activation.placement_ref.as_ref() != Some(&placement_ref)
832 }) {
833 return Err(remote_broker_unavailable());
834 }
835 let now = chrono::Utc::now();
836 registry
837 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
838 placement_ref: placement_ref.clone(),
839 actor_id: current.actor_id.clone(),
840 run_id: current.run_id.clone(),
841 observed_host_ref: host.host_ref.clone(),
842 observed_connection_generation: host.connection_generation.clone(),
843 now,
844 })
845 .await
846 .map_err(|_| remote_broker_unavailable())?;
847 let renewed = registry
848 .renew_slot(¤t, now, now + chrono::Duration::seconds(80))
849 .await
850 .map_err(|_| remote_broker_unavailable())?;
851 *lease.lock().await = renewed;
852 let expiry = lease.lock().await.expires_at;
853 actor_store
854 .renew_activation(&fence, now, expiry)
855 .await
856 .map_err(|_| remote_broker_unavailable())?;
857 Ok::<(), AgentError>(())
858 }
859 .await;
860 if refreshed.is_err() {
861 lost.store(true, Ordering::SeqCst);
862 run_cancel.cancel();
863 break;
864 }
865 }
866 })
867 };
868 Ok(Self {
869 registry,
870 actor_store,
871 route,
872 mailbox,
873 host_ref,
874 fence,
875 placement_ref,
876 lease,
877 heartbeat,
878 run_cancel,
879 lost,
880 release_on_drop: AtomicBool::new(false),
881 })
882 }
883
884 fn lost(&self) -> bool {
885 self.lost.load(Ordering::SeqCst)
886 }
887
888 async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
889 if send_invoked {
890 return Ok(false);
891 }
892 self.abort_before_dispatch().await?;
893 Ok(true)
894 }
895
896 async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
897 self.heartbeat.abort();
898 let _ = (&mut self.heartbeat).await;
899 let current = self.lease.lock().await.clone();
900 let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
901 let released = self.registry.release_slot(¤t).await;
902 if let Err(error) = failed {
903 tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
904 return Err(remote_broker_unavailable());
905 }
906 if let Err(error) = released {
907 if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
908 tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
909 return Err(remote_broker_unavailable());
910 }
911 }
912 Ok(())
913 }
914
915 async fn complete(
916 &mut self,
917 outcome: ActorActivationFinish,
918 terminal_proven: bool,
919 ) -> Result<(), AgentError> {
920 if !terminal_proven {
921 return if outcome == ActorActivationFinish::Succeeded {
924 Err(remote_broker_unavailable())
925 } else {
926 Ok(())
927 };
928 }
929 self.heartbeat.abort();
932 let _ = (&mut self.heartbeat).await;
933 let current = self.lease.lock().await.clone();
934 let mut query = self.route.query().await?;
938 let observed = query
939 .observe_host_capacity(&self.mailbox, &self.route.worker_role)
940 .await
941 .map_err(|_| remote_broker_unavailable())?
942 .ok_or_else(remote_broker_unavailable)?;
943 if observed.host_ref != self.host_ref
944 || observed.connection_generation != current.connection_generation
945 || observed.mailbox != self.mailbox
946 || observed.role.as_deref() != Some(self.route.worker_role.as_str())
947 {
948 return Err(remote_broker_unavailable());
949 }
950 let entry = self
951 .actor_store
952 .inspect_actor(¤t.actor_id)
953 .await
954 .map_err(|_| remote_broker_unavailable())?;
955 if entry.activation.as_ref().is_none_or(|activation| {
956 activation.fence() != self.fence
957 || activation.placement_ref.as_ref() != Some(&self.placement_ref)
958 }) {
959 return Err(remote_broker_unavailable());
960 }
961 self.registry
962 .validate_slot(¤t, chrono::Utc::now())
963 .await
964 .map_err(|_| remote_broker_unavailable())?;
965 self.actor_store
966 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
967 .await
968 .map_err(|_| remote_broker_unavailable())?;
969 self.release_on_drop.store(true, Ordering::SeqCst);
972 match self.registry.release_slot(¤t).await {
973 Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
974 self.release_on_drop.store(false, Ordering::SeqCst);
975 Ok(())
976 }
977 Err(error) => {
978 tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
979 Err(remote_broker_unavailable())
980 }
981 }
982 }
983}
984
985impl Drop for ActivePlacementLease {
986 fn drop(&mut self) {
987 self.run_cancel.cancel();
988 self.heartbeat.abort();
989 if !self.release_on_drop.load(Ordering::SeqCst) {
990 return;
991 }
992 let registry = self.registry.clone();
993 let actor_store = self.actor_store.clone();
994 let fence = self.fence.clone();
995 let placement_ref = self.placement_ref.clone();
996 let lease = self.lease.clone();
997 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
998 runtime.spawn(async move {
999 let current = lease.lock().await.clone();
1000 if actor_store
1001 .inspect_actor(¤t.actor_id)
1002 .await
1003 .ok()
1004 .and_then(|entry| entry.activation)
1005 .is_some_and(|activation| {
1006 activation.fence() == fence
1007 && activation.placement_ref.as_ref() == Some(&placement_ref)
1008 })
1009 {
1010 let _ = registry.release_slot(¤t).await;
1011 }
1012 });
1013 }
1014 }
1015}
1016
1017pub struct BrokerTerminalReceiptReconciler {
1022 store: Arc<bamboo_storage::SessionStoreV2>,
1023 bus: Option<bamboo_subagent::BusEndpoint>,
1024 remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1025 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1026}
1027
1028struct ReceiptRemoteRoute {
1029 endpoint: String,
1030 token: String,
1031 ca_cert_file: PathBuf,
1032 parent: bamboo_subagent::AgentRef,
1033}
1034
1035#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1036pub struct BrokerReceiptRepairReport {
1037 pub candidates: usize,
1038 pub repaired: usize,
1039 pub blocked: usize,
1040}
1041
1042impl BrokerTerminalReceiptReconciler {
1043 pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1044 let subagents = config.subagents();
1045 let mut counts = HashMap::<&str, usize>::new();
1046 for placement in &subagents.remote_placements {
1047 *counts.entry(placement.role.as_str()).or_default() += 1;
1048 }
1049 let mut remote = HashMap::new();
1050 for placement in &subagents.remote_placements {
1051 let route = (counts.get(placement.role.as_str()) == Some(&1))
1052 .then(|| receipt_remote_route(placement))
1053 .flatten();
1054 remote.insert(placement.role.clone(), route);
1055 }
1056 Self {
1057 store,
1058 ticket_service: None,
1059 bus: subagents.broker.clone().and_then(|broker| {
1060 (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1061 bamboo_subagent::BusEndpoint {
1062 endpoint: broker.endpoint,
1063 token: broker.token,
1064 },
1065 )
1066 }),
1067 remote,
1068 }
1069 }
1070
1071 pub fn with_ticket_service(
1072 mut self,
1073 service: Option<Arc<bamboo_tickets::TicketService>>,
1074 ) -> Self {
1075 self.ticket_service = service;
1076 self
1077 }
1078
1079 pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1082 let candidates = match self
1083 .store
1084 .discover_unconfirmed_broker_terminal_children()
1085 .await
1086 {
1087 Ok(candidates) => candidates,
1088 Err(error) => {
1089 tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1090 return BrokerReceiptRepairReport {
1091 blocked: 1,
1092 ..BrokerReceiptRepairReport::default()
1093 };
1094 }
1095 };
1096 let mut report = BrokerReceiptRepairReport {
1097 candidates: candidates.len(),
1098 ..BrokerReceiptRepairReport::default()
1099 };
1100 for child in candidates {
1101 let result =
1102 tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1103 match result {
1104 Ok(Ok(())) => report.repaired += 1,
1105 Ok(Err(reason)) => {
1106 report.blocked += 1;
1107 tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1108 }
1109 Err(_) => {
1110 report.blocked += 1;
1111 tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1112 }
1113 }
1114 }
1115 report
1116 }
1117
1118 async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1119 if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1120 || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1121 || child.metadata.get("external.agent_id").map(String::as_str)
1122 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1123 {
1124 return Err("Child has no trusted built-in actor recovery route");
1125 }
1126 let current = self
1127 .store
1128 .load_session(&child.id)
1129 .await
1130 .map_err(|_| "canonical Child reload failed")?
1131 .ok_or("canonical Child disappeared")?;
1132 if current.kind != bamboo_domain::SessionKind::Child
1133 || current.id != child.id
1134 || current.created_at != child.created_at
1135 || current.parent_session_id != child.parent_session_id
1136 || current.root_session_id != child.root_session_id
1137 || current.project_id_meta() != child.project_id_meta()
1138 || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1139 || current
1140 .metadata
1141 .get("external.protocol")
1142 .map(String::as_str)
1143 != Some("actor")
1144 || current
1145 .metadata
1146 .get("external.agent_id")
1147 .map(String::as_str)
1148 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1149 || bamboo_domain::ActorSession::from_session(¤t).is_err()
1150 {
1151 return Err("canonical Child birth, Root, or Project changed");
1152 }
1153 let root = self
1154 .store
1155 .load_session(&child.root_session_id)
1156 .await
1157 .map_err(|_| "canonical Root reload failed")?
1158 .ok_or("canonical Root disappeared")?;
1159 if root.kind != bamboo_domain::SessionKind::Root
1160 || root.id != child.root_session_id
1161 || root.root_session_id != child.root_session_id
1162 || root.project_id_meta() != child.project_id_meta()
1163 {
1164 return Err("canonical Root identity changed");
1165 }
1166 let parent_id = child
1167 .parent_session_id
1168 .as_deref()
1169 .ok_or("Child parent identity missing")?;
1170 let parent = self
1171 .store
1172 .load_session(parent_id)
1173 .await
1174 .map_err(|_| "canonical parent reload failed")?
1175 .ok_or("canonical parent disappeared")?;
1176 if parent.id != parent_id
1177 || !matches!(
1178 parent.kind,
1179 bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1180 )
1181 || parent.root_session_id != child.root_session_id
1182 || parent.project_id_meta() != child.project_id_meta()
1183 || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1184 {
1185 return Err("canonical parent lineage changed");
1186 }
1187
1188 let receipts = self
1192 .store
1193 .recover_broker_terminal_receipts(¤t)
1194 .await
1195 .map_err(|_| "Host broker terminal checkpoint unverified")?;
1196 if receipts.is_empty() {
1197 return Ok(());
1198 }
1199 let role = current
1200 .metadata
1201 .get("subagent_type")
1202 .map(String::as_str)
1203 .unwrap_or("worker");
1204 for receipt in receipts {
1205 publish_recovered_completion_source(&self.store, &receipt)
1206 .await
1207 .map_err(|_| "committed Child completion source remains pending")?;
1208 if current
1209 .metadata
1210 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1211 {
1212 let service = self
1213 .ticket_service
1214 .as_ref()
1215 .ok_or("Ticket authority unavailable; no result ACK")?;
1216 crate::ticket_runtime::checkpoint_ticket_result(
1217 service,
1218 self.store.as_ref(),
1219 ¤t,
1220 &receipt.activation_run_id,
1221 )
1222 .await
1223 .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1224 }
1225 let local_mailbox = format!("p-{}", child.id);
1226 let mut link = if receipt.parent_mailbox == local_mailbox {
1229 let bus = self
1230 .bus
1231 .as_ref()
1232 .ok_or("local broker receipt bus unavailable")?;
1233 bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1234 &bus.endpoint,
1235 bamboo_subagent::AgentRef {
1236 session_id: local_mailbox,
1237 role: None,
1238 },
1239 &bus.token,
1240 )
1241 .await
1242 .map_err(|_| "local broker receipt connection unavailable")?
1243 } else if let Some(remote) = self.remote.get(role) {
1244 let route = remote
1245 .as_ref()
1246 .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1247 if receipt.parent_mailbox != route.parent.session_id {
1248 return Err("fixed remote parent mailbox changed since Host checkpoint");
1249 }
1250 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1251 .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1252 bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1253 &route.endpoint,
1254 route.parent.clone(),
1255 &route.token,
1256 tls,
1257 )
1258 .await
1259 .map_err(|_| "fixed remote broker receipt connection unavailable")?
1260 } else {
1261 return Err("committed broker receipt route is unavailable");
1262 };
1263 if link.parent_mailbox() != receipt.parent_mailbox {
1264 return Err("broker receipt link mailbox differs from Host checkpoint");
1265 }
1266 bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1267 &mut link,
1268 &receipt.broker_identity,
1269 &receipt.message_ids,
1270 )
1271 .await
1272 .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1273 self.store
1274 .clear_acknowledged_broker_terminal_receipt(
1275 &receipt.session_id,
1276 receipt.created_at,
1277 &receipt.activation_run_id,
1278 &receipt.parent_mailbox,
1279 )
1280 .await
1281 .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1282 }
1283 Ok(())
1284 }
1285}
1286
1287async fn publish_recovered_completion_source(
1291 store: &Arc<bamboo_storage::SessionStoreV2>,
1292 receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1293) -> Result<(), String> {
1294 let expected = store
1295 .load_session(&receipt.session_id)
1296 .await
1297 .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1298 .ok_or("Child completion recovery snapshot missing")?;
1299 if expected.id != receipt.session_id
1300 || expected.created_at != receipt.created_at
1301 || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1302 || expected.root_session_id != receipt.root_session_id
1303 || expected.project_id_meta() != receipt.project_id
1304 {
1305 return Err("Child completion recovery identity changed".into());
1306 }
1307 let mut candidate = expected.clone();
1308 if candidate.last_run_status().as_deref() == Some("running") {
1309 candidate.set_last_run_status(receipt.terminal_status.clone());
1310 if let Some(error) = &receipt.terminal_error {
1311 candidate.set_last_run_error(error.clone());
1312 } else {
1313 candidate.clear_last_run_error();
1314 }
1315 }
1316 if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1317 &mut candidate,
1318 &receipt.activation_run_id,
1319 )? {
1320 let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1321 .ok_or("Child completion recovery source invalid")?;
1322 let published = candidate
1323 .metadata
1324 .get("runtime.child_completion_source_v1")
1325 .ok_or("Child completion recovery source missing")?;
1326 let saved = store
1330 .publish_broker_terminal_source(receipt, &expected, published)
1331 .await
1332 .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1333 if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1334 != Some(&source)
1335 {
1336 return Err("Child completion recovery source changed".into());
1337 }
1338 }
1339 Ok(())
1340}
1341
1342fn receipt_remote_route(
1343 placement: &bamboo_config::RemoteActorPlacement,
1344) -> Option<ReceiptRemoteRoute> {
1345 let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1346 let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1347 if placement.endpoint.len() > 2048
1348 || endpoint.scheme() != "wss"
1349 || endpoint.host_str().is_none()
1350 || !endpoint.username().is_empty()
1351 || endpoint.password().is_some()
1352 || endpoint.query().is_some()
1353 || endpoint.fragment().is_some()
1354 {
1355 return None;
1356 }
1357 let token_env = placement
1358 .token_env
1359 .as_deref()
1360 .filter(|name| !name.is_empty() && name.len() <= 256)?;
1361 let token = std::env::var(token_env).ok()?;
1362 if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1363 return None;
1364 }
1365 let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1366 bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1367 Some(ReceiptRemoteRoute {
1368 endpoint: placement.endpoint.clone(),
1369 token,
1370 ca_cert_file,
1371 parent: bamboo_subagent::AgentRef {
1372 session_id: peer.parent_mailbox.clone(),
1373 role: peer.parent_role.clone(),
1374 },
1375 })
1376}
1377
1378enum PlacementKind {
1383 Local,
1384 Remote,
1385 Schedulable,
1386}
1387
1388fn ensure_ticket_placement_is_enforceable(
1390 session: &Session,
1391 spec: &ProvisionSpec,
1392) -> Result<(), AgentError> {
1393 let ticket = session
1394 .metadata
1395 .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1396 || session
1397 .metadata
1398 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1399 if ticket && !matches!(spec.placement, Placement::Local) {
1400 return Err(AgentError::LLM(
1401 "Ticket execution requires local placement and owned stop proof".into(),
1402 ));
1403 }
1404 Ok(())
1405}
1406
1407fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1416 if !spec.capabilities.read_only_enforced() {
1417 return Ok(());
1418 }
1419
1420 if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1421 return Err(AgentError::LLM(format!(
1422 "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1423 spec.identity.role
1424 )));
1425 }
1426
1427 if !matches!(spec.placement, Placement::Local) {
1428 return Err(AgentError::LLM(format!(
1429 "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1430 spec.identity.role
1431 )));
1432 }
1433
1434 Ok(())
1435}
1436
1437pub struct ActorChildRunner {
1439 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1440 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1441 agent_id: String,
1442 worker_bin: PathBuf,
1443 worker_args: Vec<String>,
1444 builtin_required_context_route: bool,
1445 native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1446 fabric_dir: PathBuf,
1447 executor: ExecutorSpec,
1448 credentials: Vec<ScopedCredential>,
1453 default_provider: String,
1455 max_spawn_depth: u32,
1457 live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1461 bus: Option<bamboo_subagent::BusEndpoint>,
1464 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1468 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1474 max_idle_per_key: usize,
1475 max_idle_total: usize,
1476 pool_reaper_started: AtomicBool,
1477 next_execution_epoch: AtomicU64,
1481 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1485 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1489 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1500 remote_placements: HashMap<String, ResolvedRemotePlacement>,
1506 strict_remote_subscription: tokio::sync::Mutex<()>,
1508 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1516 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1522 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1525 actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1526 canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1527 ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1528 actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1529 pending_durable_links: tokio::sync::Mutex<
1532 HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1533 >,
1534}
1535
1536struct PendingDurableChildLink {
1537 link: Box<dyn bamboo_subagent::ChildLink>,
1538 host_nested_wait_handoff: bool,
1541 strict_history: Option<StrictTerminalHistory>,
1543 strict_history_required: bool,
1544 strict_history_committed: bool,
1545}
1546
1547#[derive(Clone)]
1548struct StrictTerminalHistory {
1549 proof: bamboo_storage::v2::HostTerminalCompleteness,
1550 watermark: bamboo_subagent::ActorEventWatermark,
1551}
1552
1553fn effective_broker_terminal_status(
1554 raw: TerminalStatus,
1555 host_nested_wait_handoff: bool,
1556) -> Result<&'static str, String> {
1557 if host_nested_wait_handoff {
1558 if raw != TerminalStatus::Completed {
1559 return Err("nested wait cannot project a failed broker terminal".into());
1560 }
1561 Ok("suspended")
1562 } else {
1563 Ok(broker_terminal_status_label(raw))
1564 }
1565}
1566
1567fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1568 match status {
1569 TerminalStatus::Completed => "completed",
1570 TerminalStatus::Suspended => "suspended",
1571 TerminalStatus::Error => "error",
1572 TerminalStatus::Cancelled => "cancelled",
1573 }
1574}
1575
1576#[async_trait]
1586pub trait ChildApprovalDecider: Send + Sync {
1587 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1590}
1591
1592async fn decide_child_approval(
1595 decider: Option<&Arc<dyn ChildApprovalDecider>>,
1596 child_session_id: &str,
1597 request: &serde_json::Value,
1598) -> bool {
1599 match decider {
1600 Some(decider) => decider.decide(child_session_id, request).await,
1601 None => false,
1602 }
1603}
1604
1605const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1609
1610#[derive(Clone)]
1621pub struct ChildApprovalScope {
1622 stamp: serde_json::Value,
1623 deadline: chrono::DateTime<chrono::Utc>,
1624 parent: String,
1625 child: String,
1626 run: String,
1627 epoch: u64,
1628 current_epoch: Arc<AtomicU64>,
1629 cancel: CancellationToken,
1630 router: Arc<crate::SessionActivationRouter>,
1631 admission: Arc<AtomicUsize>,
1632}
1633
1634pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1635impl Drop for ChildApprovalAdmission {
1636 fn drop(&mut self) {
1637 self.0.store(2, Ordering::Release);
1638 }
1639}
1640
1641impl ChildApprovalScope {
1642 pub fn new(
1644 parent: &str,
1645 child: &str,
1646 transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1647 router: Arc<crate::SessionActivationRouter>,
1648 cancel: CancellationToken,
1649 current_epoch: Arc<AtomicU64>,
1650 ) -> Self {
1651 let (attempt, run, epoch, reply, deadline) = transport;
1652 let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1653 Self {
1654 stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1655 "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1656 "deadline": deadline}),
1657 deadline,
1658 parent: parent.into(),
1659 child: child.into(),
1660 run: run.into(),
1661 epoch,
1662 current_epoch,
1663 cancel,
1664 router,
1665 admission: Arc::new(AtomicUsize::new(0)),
1666 }
1667 }
1668 pub fn stamp(&self) -> &serde_json::Value {
1669 &self.stamp
1670 }
1671 pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1672 self.deadline
1673 }
1674 pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1675 self.admission
1676 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1677 .ok()
1678 .map(|_| ChildApprovalAdmission(self.admission.clone()))
1679 }
1680 pub fn is_admitting(&self) -> bool {
1681 self.admission.load(Ordering::Acquire) == 1
1682 }
1683 pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1684 self.parent == parent
1685 && self.child == child
1686 && !self.cancel.is_cancelled()
1687 && self.current_epoch.load(Ordering::Acquire) == self.epoch
1688 && self.router.owns_run(child, &self.run).await
1689 }
1690}
1691
1692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1693pub enum ChildApprovalReview {
1694 Reply(bool),
1695 NoReply,
1696}
1697
1698#[async_trait]
1699pub trait ChildApprovalReviewer: Send + Sync {
1700 async fn review(
1703 &self,
1704 parent_session_id: &str,
1705 child_session_id: &str,
1706 request: &serde_json::Value,
1707 ) -> bool;
1708
1709 async fn review_scoped(
1710 &self,
1711 parent: &str,
1712 child: &str,
1713 request: &serde_json::Value,
1714 _scope: &ChildApprovalScope,
1715 ) -> ChildApprovalReview {
1716 ChildApprovalReview::Reply(self.review(parent, child, request).await)
1717 }
1718}
1719
1720fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1721 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1722 &SLOT
1723}
1724
1725pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1727 let _ = child_approval_reviewer_slot().set(reviewer);
1728}
1729
1730pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1732 child_approval_reviewer_slot().get().cloned()
1733}
1734
1735impl ActorChildRunner {
1736 #[allow(clippy::too_many_arguments)]
1737 pub fn new(
1738 agent_id: String,
1739 worker_bin: PathBuf,
1740 worker_args: Vec<String>,
1741 fabric_dir: PathBuf,
1742 executor: ExecutorSpec,
1743 credentials: Vec<ScopedCredential>,
1744 default_provider: String,
1745 max_concurrent: usize,
1746 ) -> Self {
1747 Self {
1748 approval_registry: None,
1749 permission_config: None,
1750 agent_id,
1751 worker_bin,
1752 worker_args,
1753 builtin_required_context_route: false,
1754 fabric_dir,
1755 executor,
1756 credentials,
1757 default_provider,
1758 max_spawn_depth: MAX_SPAWN_DEPTH,
1759 live_provider_config: None,
1760 native_tool_ceiling: None,
1761 bus: None,
1762 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1763 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1764 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1765 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1766 pool_reaper_started: AtomicBool::new(false),
1767 next_execution_epoch: AtomicU64::new(0),
1768 approval_decider: None,
1769 approval_reviewer: None,
1770 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1771 remote_placements: HashMap::new(),
1772 strict_remote_subscription: tokio::sync::Mutex::new(()),
1773 schedulable_placements: HashMap::new(),
1774 codex_run_tokens: None,
1775 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1776 actor_directory_store: std::sync::Mutex::new(None),
1777 canonical_subagent_tool: std::sync::Mutex::new(None),
1778 ticket_service: std::sync::Mutex::new(None),
1779 actor_event_observer: std::sync::Mutex::new(None),
1780 pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1781 }
1782 }
1783
1784 pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1789 self.builtin_required_context_route = supported;
1790 self
1791 }
1792
1793 pub(crate) fn with_native_tool_ceiling_source(
1794 mut self,
1795 source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1796 ) -> Self {
1797 self.native_tool_ceiling = source;
1798 self
1799 }
1800
1801 async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1802 bamboo_subagent::fleet::require_worker_capability(
1803 &self.worker_bin,
1804 &self.worker_args,
1805 bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1806 )
1807 .await
1808 .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1809 }
1810
1811 pub fn with_live_provider_config(
1812 mut self,
1813 config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1814 ) -> Self {
1815 self.live_provider_config = Some(config);
1816 self
1817 }
1818
1819 pub fn with_max_spawn_depth(mut self, max_spawn_depth: u32) -> Self {
1820 self.max_spawn_depth = max_spawn_depth;
1821 self
1822 }
1823
1824 pub fn with_approval_registry(
1825 mut self,
1826 registry: super::approval_registry::SharedApprovalRegistry,
1827 ) -> Self {
1828 self.approval_registry = Some(registry);
1829 self
1830 }
1831
1832 pub fn with_permission_config(
1833 mut self,
1834 config: Arc<bamboo_tools::permission::PermissionConfig>,
1835 ) -> Self {
1836 self.permission_config = Some(config);
1837 self
1838 }
1839
1840 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1845 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1846 self
1847 }
1848
1849 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1852 self.approval_decider = Some(decider);
1853 self
1854 }
1855
1856 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1857 self.approval_reviewer = Some(reviewer);
1858 self
1859 }
1860
1861 pub fn with_codex_run_tokens(
1862 mut self,
1863 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1864 ) -> Self {
1865 self.codex_run_tokens = authority;
1866 self
1867 }
1868
1869 pub fn with_remote_placements(
1875 mut self,
1876 placements: HashMap<String, ResolvedRemotePlacement>,
1877 ) -> Self {
1878 self.remote_placements = placements;
1879 self
1880 }
1881
1882 pub fn with_schedulable_placements(
1888 mut self,
1889 placements: HashMap<String, ResolvedSchedulablePlacement>,
1890 ) -> Self {
1891 self.schedulable_placements = placements;
1892 self
1893 }
1894
1895 fn fingerprint(spec: &ProvisionSpec) -> String {
1914 let role = spec.identity.role.as_str();
1915 let (provider, model) = spec
1916 .model
1917 .as_ref()
1918 .map(|m| (m.provider.as_str(), m.model.as_str()))
1919 .unwrap_or(("", ""));
1920 let workspace = spec.workspace.as_deref().unwrap_or("");
1921 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1922 tools.sort();
1923 let caps = &spec.capabilities;
1924 let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1929 "none".to_string()
1930 } else {
1931 let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1932 .expect("scoped provider credentials are serializable");
1933 hex::encode(Sha256::digest(serialized))
1934 };
1935 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1938 format!(
1939 "{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}",
1940 tools.join(","),
1941 spec.identity.depth,
1942 caps.nested_spawn,
1943 caps.permission_requested_mode,
1944 caps.permission_effective_mode,
1945 caps.bypass,
1946 caps.auto_approve_permissions,
1947 caps.enforce_permissions,
1948 caps.max_spawn_depth.unwrap_or(0),
1949 caps.no_human_approver,
1956 caps.read_only,
1959 caps.guardian_read_only,
1962 caps.child_creation_identity,
1963 )
1964 }
1965
1966 fn ensure_pool_reaper(&self) {
1969 if self
1970 .pool_reaper_started
1971 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1972 .is_err()
1973 {
1974 return;
1975 }
1976 let pool = Arc::downgrade(&self.pool);
1977 tokio::spawn(async move {
1978 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1979 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1980 interval.tick().await;
1983 loop {
1984 interval.tick().await;
1985 let Some(pool) = pool.upgrade() else {
1986 break;
1987 };
1988 let _ = reap_worker_pool_once(&pool).await;
1989 }
1990 });
1991 }
1992
1993 fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1994 let mut physical = spec.clone();
1995 physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1999 physical
2000 }
2001
2002 async fn acquire_bus_worker(
2008 &self,
2009 key: &str,
2010 spec: &ProvisionSpec,
2011 ) -> crate::runtime::runner::Result<PooledWorker> {
2012 if spec.capabilities.required_child_context {
2013 let physical = Self::fresh_required_worker_spec(spec);
2014 let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2015 .await
2016 .map_err(|error| {
2017 AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2018 })?;
2019 let mailbox_id = worker.record.agent_id.clone();
2020 return Ok(PooledWorker {
2021 worker,
2022 mailbox_id,
2023 parked_at: None,
2024 });
2025 }
2026 self.ensure_pool_reaper();
2027 loop {
2031 let candidate = {
2032 let mut pool = self.pool.lock().await;
2033 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2034 if pool.get(key).is_some_and(Vec::is_empty) {
2035 pool.remove(key);
2036 }
2037 candidate
2038 };
2039 let Some(mut candidate) = candidate else {
2040 break;
2041 };
2042 let now = Instant::now();
2043 let expired = parked_worker_expired(
2044 candidate.parked_at,
2045 now,
2046 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2047 );
2048 if candidate.worker.is_alive() && !expired {
2049 candidate.parked_at = None;
2050 return Ok(candidate);
2051 }
2052 tracing::info!(
2053 pool_key = key,
2054 mailbox_id = %candidate.mailbox_id,
2055 worker_pid = ?candidate.worker.pid(),
2056 shutdown_reason = if expired {
2057 PoolReapReason::IdleTimeout.as_str()
2058 } else {
2059 PoolReapReason::ProcessExited.as_str()
2060 },
2061 "discarding unusable parked sub-agent worker during checkout"
2062 );
2063 candidate.worker.kill().await;
2064 }
2065
2066 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2067 .await
2068 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2069 let mailbox_id = spawned.record.agent_id.clone();
2070 Ok(PooledWorker {
2071 worker: spawned,
2072 mailbox_id,
2073 parked_at: None,
2074 })
2075 }
2076
2077 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2081 self.ensure_pool_reaper();
2082 if !worker.worker.is_alive() {
2083 tracing::info!(
2084 pool_key = key,
2085 mailbox_id = %worker.mailbox_id,
2086 worker_pid = ?worker.worker.pid(),
2087 shutdown_reason = "process_exited_before_park",
2088 "discarding sub-agent worker instead of parking"
2089 );
2090 worker.worker.kill().await;
2091 return;
2092 }
2093 worker.parked_at = Some(Instant::now());
2094 let mut pool = self.pool.lock().await;
2095 let key_count = pool.get(key).map_or(0, Vec::len);
2096 let total_count = pool.values().map(Vec::len).sum::<usize>();
2097 let limit_reason =
2098 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2099 if let Some(shutdown_reason) = limit_reason {
2100 drop(pool);
2101 tracing::info!(
2102 pool_key = key,
2103 mailbox_id = %worker.mailbox_id,
2104 worker_pid = ?worker.worker.pid(),
2105 idle_workers_for_key = key_count,
2106 idle_workers_total = total_count,
2107 max_idle_per_key = self.max_idle_per_key,
2108 max_idle_total = self.max_idle_total,
2109 shutdown_reason,
2110 "discarding sub-agent worker because the warm pool is full"
2111 );
2112 worker.worker.kill().await;
2113 return;
2114 }
2115 pool.entry(key.to_string()).or_default().push(worker);
2116 }
2117
2118 fn build_spec_with_provider_config(
2121 &self,
2122 session: &Session,
2123 job: &SpawnJob,
2124 credentials: &[ScopedCredential],
2125 default_provider: &str,
2126 max_spawn_depth: u32,
2127 ) -> ProvisionSpec {
2128 let mut spec = ProvisionSpec::new(
2129 ChildIdentity {
2130 child_id: job.child_session_id.clone(),
2131 parent_id: Some(job.parent_session_id.clone()),
2132 project_key: None,
2133 role: session
2134 .metadata
2135 .get("subagent_type")
2136 .cloned()
2137 .unwrap_or_else(|| "worker".to_string()),
2138 depth: session.spawn_depth,
2143 },
2144 self.executor.clone(),
2145 self.fabric_dir.to_string_lossy().into_owned(),
2146 );
2147 spec.workspace = session.workspace.clone();
2148 if let ExecutorSpec::Codex {
2149 workspace_owned, ..
2150 } = &mut spec.executor
2151 {
2152 *workspace_owned = Some(
2153 spec.workspace
2154 .as_deref()
2155 .is_some_and(workspace_is_bamboo_owned),
2156 );
2157 }
2158 spec.bus = self.bus.clone();
2161 spec.model = session
2164 .model_ref
2165 .as_ref()
2166 .map(|r| ModelRefSpec {
2167 provider: r.provider.clone(),
2168 model: r.model.clone(),
2169 })
2170 .or_else(|| {
2171 let m = job.model.trim();
2172 (!m.is_empty()).then(|| ModelRefSpec {
2173 provider: default_provider.to_string(),
2174 model: m.to_string(),
2175 })
2176 });
2177 spec.disabled_tools = job.disabled_tools.clone();
2178 match &spec.executor {
2179 ExecutorSpec::Codex {
2185 auth_mode,
2186 provider_key_ref,
2187 ..
2188 } => {
2189 if auth_mode.as_deref() == Some("custom") {
2190 if let Some(reference) = provider_key_ref {
2191 if let Some(credential) = credentials.iter().find(|credential| {
2192 credential.credential_ref.as_deref() == Some(reference)
2193 }) {
2194 spec.secrets.provider_credentials.push(credential.clone());
2195 } else {
2196 tracing::warn!(
2197 "actor child {}: custom Codex credential reference '{}' did not resolve",
2198 job.child_session_id,
2199 reference
2200 );
2201 }
2202 } else {
2203 tracing::warn!(
2204 "actor child {}: custom Codex executor has no credential reference",
2205 job.child_session_id
2206 );
2207 }
2208 }
2209 }
2210 _ => {
2213 let provider = spec
2214 .model
2215 .as_ref()
2216 .map(|model| model.provider.as_str())
2217 .filter(|provider| !provider.trim().is_empty())
2218 .unwrap_or(default_provider);
2219 if let Some(credential) = credentials
2220 .iter()
2221 .find(|credential| credential.provider == provider)
2222 {
2223 spec.secrets.provider_credentials.push(credential.clone());
2224 } else {
2225 tracing::warn!(
2226 "actor child {}: no credential found for provider '{}'",
2227 job.child_session_id,
2228 provider
2229 );
2230 }
2231 }
2232 }
2233 spec.capabilities.nested_spawn = session.spawn_depth < max_spawn_depth;
2236 spec.capabilities.max_spawn_depth = Some(max_spawn_depth);
2237 spec.capabilities.enforce_permissions = true;
2243 let requested_permission_mode = session
2248 .agent_runtime_state
2249 .as_ref()
2250 .map(|state| state.effective_permission_mode())
2251 .unwrap_or_default();
2252 let configured_permission_mode = self
2253 .permission_config
2254 .as_ref()
2255 .map(|config| config.mode())
2256 .unwrap_or_default();
2257 spec.capabilities.no_human_approver = session
2262 .agent_runtime_state
2263 .as_ref()
2264 .is_some_and(|s| s.no_human_approver);
2265 let typed_read_only = session
2271 .agent_runtime_state
2272 .as_ref()
2273 .is_some_and(|state| state.read_only);
2274 let legacy_guardian_read_only = spec.identity.role == "guardian"
2275 && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2276 ["Edit", "Write", "SubAgent", "WebFetch"]
2277 .iter()
2278 .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2279 });
2280 let read_only = typed_read_only || legacy_guardian_read_only;
2281 spec.capabilities.read_only = read_only;
2282 spec.capabilities.guardian_read_only = read_only;
2286 if spec.capabilities.read_only_enforced() {
2287 spec.capabilities.nested_spawn = false;
2290 spec.disabled_tools
2295 .get_or_insert_with(Default::default)
2296 .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2297 match &mut spec.executor {
2298 ExecutorSpec::ClaudeCode {
2299 permission_mode,
2300 inherit_user_config,
2301 ..
2302 } => {
2303 *permission_mode = Some("plan".to_string());
2304 *inherit_user_config = Some(false);
2307 }
2308 ExecutorSpec::Codex {
2309 mode,
2310 sandbox,
2311 inherit_user_config,
2312 approval_policy,
2313 network_access,
2314 allow_danger_bypass,
2315 permission_profile,
2316 ..
2317 } => {
2318 *sandbox = Some("read-only".to_string());
2319 *inherit_user_config = Some(false);
2320 *approval_policy = Some(
2323 if mode.as_deref() == Some("app_server") {
2324 "on-request"
2325 } else {
2326 "never"
2327 }
2328 .to_string(),
2329 );
2330 *network_access = Some(false);
2331 *allow_danger_bypass = Some(false);
2332 *permission_profile = Some("read-only".to_string());
2333 }
2334 _ => {}
2335 }
2336 }
2337 let read_only_overlay = session
2338 .agent_runtime_state
2339 .as_ref()
2340 .is_some_and(|state| state.plan_mode.is_some())
2341 || spec.capabilities.read_only_enforced()
2342 || executor_has_read_only_permission_profile(&spec.executor);
2343 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2344 requested_permission_mode,
2345 configured_permission_mode,
2346 read_only_overlay,
2347 );
2348 spec.capabilities.bypass = permission_resolution.bypass_permissions();
2349 spec.capabilities.auto_approve_permissions =
2350 permission_resolution.suppress_approval_prompts();
2351 spec.capabilities.permission_requested_mode =
2352 permission_resolution.requested.as_str().to_string();
2353 spec.capabilities.permission_effective_mode =
2354 permission_resolution.effective.as_str().to_string();
2355 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2362 spec.placement = Placement::Remote {
2363 endpoint: placement.endpoint.clone(),
2364 };
2365 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2366 {
2367 spec.placement = Placement::Schedulable {
2375 pool: placement.pool.clone(),
2376 };
2377 }
2378 spec
2379 }
2380
2381 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2384 self.build_spec_with_provider_config(
2385 session,
2386 job,
2387 &self.credentials,
2388 &self.default_provider,
2389 self.max_spawn_depth,
2390 )
2391 }
2392
2393 async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2398 let Some(config) = self.live_provider_config.as_ref() else {
2399 return self.build_spec(session, job);
2400 };
2401
2402 let config = config.read().await;
2403 let credentials = super::runtime::extract_provider_credentials(&config);
2404 let default_provider = config.effective_default_provider().to_string();
2405 self.build_spec_with_provider_config(
2406 session,
2407 job,
2408 &credentials,
2409 &default_provider,
2410 config.subagents().effective_max_spawn_depth(),
2411 )
2412 }
2413
2414 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2420 let host_label = match &spec.placement {
2421 Placement::Remote { .. } => self
2422 .remote_placements
2423 .get(spec.identity.role.as_str())
2424 .and_then(|p| p.host_label.as_deref()),
2425 Placement::Schedulable { .. } => self
2426 .schedulable_placements
2427 .get(spec.identity.role.as_str())
2428 .and_then(|p| p.host_label.as_deref()),
2429 Placement::Local => None,
2430 };
2431 placement_metadata(&spec.placement, host_label)
2432 }
2433
2434 fn scoped_placement_route(
2435 &self,
2436 spec: &ProvisionSpec,
2437 ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2438 match &spec.placement {
2439 Placement::Local => Ok(None),
2440 Placement::Remote { .. } => {
2441 let placement = self
2442 .remote_placements
2443 .get(&spec.identity.role)
2444 .ok_or_else(remote_broker_unavailable)?;
2445 let peer = placement
2446 .broker_peer
2447 .as_ref()
2448 .and_then(|peer| peer.as_ref().ok())
2449 .ok_or_else(remote_broker_unavailable)?;
2450 let worker_role = peer
2451 .worker_role
2452 .clone()
2453 .ok_or_else(remote_broker_unavailable)?;
2454 Ok(Some(ScopedPlacementRoute {
2455 endpoint: placement.endpoint.clone(),
2456 token: placement
2457 .token
2458 .clone()
2459 .ok_or_else(remote_broker_unavailable)?,
2460 ca_cert_file: placement
2461 .ca_cert_file
2462 .clone()
2463 .ok_or_else(remote_broker_unavailable)?,
2464 parent: bamboo_subagent::AgentRef {
2465 session_id: peer.parent_mailbox.clone(),
2466 role: peer.parent_role.clone(),
2467 },
2468 worker_role,
2469 pinned_mailbox: Some(peer.worker_mailbox.clone()),
2470 requirements: placement
2471 .requirements
2472 .clone()
2473 .ok_or_else(remote_broker_unavailable)?,
2474 }))
2475 }
2476 Placement::Schedulable { .. } => {
2477 let placement = self
2478 .schedulable_placements
2479 .get(&spec.identity.role)
2480 .ok_or_else(remote_broker_unavailable)?;
2481 let parent = placement
2482 .broker_parent
2483 .as_ref()
2484 .ok_or_else(remote_broker_unavailable)?;
2485 Ok(Some(ScopedPlacementRoute {
2486 endpoint: placement.endpoint.clone(),
2487 token: placement
2488 .token
2489 .clone()
2490 .ok_or_else(remote_broker_unavailable)?,
2491 ca_cert_file: placement
2492 .ca_cert_file
2493 .clone()
2494 .ok_or_else(remote_broker_unavailable)?,
2495 parent: bamboo_subagent::AgentRef {
2496 session_id: parent.parent_mailbox.clone(),
2497 role: Some(parent.parent_role.clone()),
2498 },
2499 worker_role: placement.pool.clone(),
2500 pinned_mailbox: None,
2501 requirements: placement
2502 .requirements
2503 .clone()
2504 .ok_or_else(remote_broker_unavailable)?,
2505 }))
2506 }
2507 }
2508 }
2509
2510 async fn select_scoped_worker(
2511 &self,
2512 route: &ScopedPlacementRoute,
2513 registry: &bamboo_storage::v2::FileHostRegistry,
2514 actor_id: &str,
2515 project_id: Option<bamboo_domain::ProjectId>,
2516 required_tools: &BTreeSet<String>,
2517 ) -> Result<String, AgentError> {
2518 if let Some(mailbox) = &route.pinned_mailbox {
2519 return Ok(mailbox.clone());
2520 }
2521 let mut query = route.query().await?;
2522 let candidates = query
2523 .list_connected(&route.worker_role)
2524 .await
2525 .map_err(|_| remote_broker_unavailable())?;
2526 let scheduler = PlacementScheduler::new(registry.clone());
2527 let mut selected: Option<(String, usize, u16)> = None;
2528 for mailbox in candidates {
2529 let Some(observed) = query
2530 .observe_host_capacity(&mailbox, &route.worker_role)
2531 .await
2532 .map_err(|_| remote_broker_unavailable())?
2533 else {
2534 continue;
2535 };
2536 let Ok(host) = scheduler
2537 .observe_scoped_host(
2538 &mut query,
2539 &observed.host_ref,
2540 &mailbox,
2541 &route.worker_role,
2542 chrono::Duration::seconds(110),
2543 )
2544 .await
2545 else {
2546 continue;
2547 };
2548 let now = chrono::Utc::now();
2549 let request = HostPlacementRequest {
2550 intent: HostPlacementIntent::Pool {
2551 pool_id: route.worker_role.clone(),
2552 },
2553 actor_id: actor_id.to_owned(),
2554 run_id: "selection".into(),
2555 project_id: project_id.clone(),
2556 trust_zone: route.requirements.trust_zone.clone(),
2557 workspace_label: route.requirements.workspace_label.clone(),
2558 executor: "bamboo-runtime".into(),
2559 required_tools: required_tools.clone(),
2560 network_zone: route.requirements.network_zone.clone(),
2561 require_network_isolation: route.requirements.require_network_isolation,
2562 preferred_host_ref: None,
2563 now,
2564 lease_expires_at: now + chrono::Duration::seconds(80),
2565 };
2566 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2567 || !request.eligible(&host)
2568 {
2569 continue;
2570 }
2571 let load = host.active_slots(now);
2572 if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2573 load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2574 }) {
2575 selected = Some((mailbox, load, host.max_slots));
2576 }
2577 }
2578 selected
2579 .map(|(mailbox, _, _)| mailbox)
2580 .ok_or_else(remote_broker_unavailable)
2581 }
2582}
2583
2584#[async_trait]
2585impl ExternalChildRunner for ActorChildRunner {
2586 async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2587 let role = session
2588 .metadata
2589 .get("subagent_type")
2590 .map(String::as_str)
2591 .unwrap_or("worker");
2592 let current = std::env::current_exe()
2593 .ok()
2594 .and_then(|path| path.canonicalize().ok());
2595 if self.native_tool_ceiling.is_none()
2596 || !self.builtin_required_context_route
2597 || !self.should_handle(session).await
2598 || current.is_none()
2599 || self.worker_bin.canonicalize().ok() != current
2600 || self.worker_args != ["subagent-worker".to_string()]
2601 || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2602 || self.bus.is_none()
2603 || self.remote_placements.contains_key(role)
2604 || self.schedulable_placements.contains_key(role)
2605 {
2606 return Err(
2607 "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2608 .into(),
2609 );
2610 }
2611 bamboo_subagent::fleet::require_worker_capability(
2612 &self.worker_bin,
2613 &self.worker_args,
2614 bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2615 )
2616 .await
2617 .map_err(|_| {
2618 "required_child_context_unsupported: worker capability unconfirmed".to_string()
2619 })?;
2620 bamboo_subagent::fleet::require_worker_capability(
2621 &self.worker_bin,
2622 &self.worker_args,
2623 bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2624 )
2625 .await
2626 .map_err(|_| {
2627 "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2628 })?;
2629 bamboo_subagent::fleet::require_worker_capability(
2630 &self.worker_bin,
2631 &self.worker_args,
2632 bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2633 )
2634 .await
2635 .map_err(|_| {
2636 "required_child_context_unsupported: worker birth capability unconfirmed".into()
2637 })
2638 }
2639 async fn should_handle(&self, session: &Session) -> bool {
2640 session.metadata.get("runtime.kind") == Some(&"external".to_string())
2641 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2642 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2643 }
2644
2645 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2646 *self.escalation_bridge.lock().recover_poison() = bridge;
2647 }
2648
2649 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2650 *self.session_inbox_runtime.lock().recover_poison() = binding;
2651 }
2652
2653 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2654 *self.actor_directory_store.lock().recover_poison() = store;
2655 }
2656
2657 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2658 *self.canonical_subagent_tool.lock().recover_poison() = tool;
2659 }
2660
2661 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2662 *self.ticket_service.lock().recover_poison() = service;
2663 }
2664
2665 fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2666 *self.actor_event_observer.lock().recover_poison() = observer;
2667 }
2668
2669 async fn prepare_durable_child_delivery(
2670 &self,
2671 session: &Session,
2672 activation_run_id: &str,
2673 ) -> Result<bool, String> {
2674 let key = (
2675 session.id.clone(),
2676 session.created_at,
2677 activation_run_id.to_owned(),
2678 );
2679 let (delivery, host_nested_wait_handoff, strict_history) = {
2680 let pending = self.pending_durable_links.lock().await;
2681 let Some(pending) = pending.get(&key) else {
2682 return Ok(false);
2683 };
2684 if pending.strict_history_required && pending.strict_history.is_none() {
2685 return Err("Host strict terminal completeness proof missing".into());
2686 }
2687 (
2688 pending
2689 .link
2690 .durable_delivery_receipt()
2691 .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2692 pending.host_nested_wait_handoff,
2693 pending.strict_history.clone(),
2694 )
2695 };
2696 let expected_status =
2697 effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2698 if session.last_run_status().as_deref() != Some(expected_status) {
2699 tracing::warn!(
2700 child_session_id = %session.id,
2701 activation_run_id,
2702 expected_status,
2703 actual_status = ?session.last_run_status(),
2704 "Host final status differs from accepted broker terminal"
2705 );
2706 return Err("Host final status differs from accepted broker terminal".into());
2707 }
2708 let store = self
2709 .actor_directory_store
2710 .lock()
2711 .recover_poison()
2712 .clone()
2713 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2714 let binding = self
2715 .session_inbox_runtime
2716 .lock()
2717 .recover_poison()
2718 .clone()
2719 .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2720 if !binding
2721 .router
2722 .owns_run(&session.id, activation_run_id)
2723 .await
2724 {
2725 return Err("stale Child activation cannot prepare broker receipt".into());
2726 }
2727 let ticket_service = self.ticket_service.lock().recover_poison().clone();
2728 let yielded_call = if expected_status == "completed"
2729 && session
2730 .metadata
2731 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2732 {
2733 let service = ticket_service
2734 .as_deref()
2735 .ok_or("Ticket authority unavailable for broker question")?;
2736 crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2737 .map_err(|e| e.to_string())?
2738 } else {
2739 None
2740 };
2741 let route = bamboo_storage::v2::BrokerTerminalRoute {
2742 activation_run_id,
2743 broker_identity: &delivery.broker_identity,
2744 parent_mailbox: &delivery.parent_mailbox,
2745 broker_correlation_id: &delivery.correlation_id,
2746 message_ids: &delivery.message_ids,
2747 };
2748 if let Some(call_id) = yielded_call {
2749 let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2750 session,
2751 activation_run_id,
2752 &call_id,
2753 )
2754 .map_err(|error| error.to_string())?;
2755 if let Some(history) = strict_history.as_ref() {
2756 store
2757 .prepare_broker_tool_yield_receipt_with_completeness(
2758 session,
2759 route,
2760 &proof,
2761 &history.proof,
2762 )
2763 .await
2764 } else {
2765 store
2766 .prepare_broker_tool_yield_receipt(session, route, &proof)
2767 .await
2768 }
2769 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2770 } else {
2771 if let Some(history) = strict_history.as_ref() {
2772 store
2773 .prepare_broker_terminal_receipt_with_completeness(
2774 session,
2775 route,
2776 &history.proof,
2777 )
2778 .await
2779 } else {
2780 store
2781 .prepare_broker_terminal_receipt(
2782 session,
2783 activation_run_id,
2784 &delivery.broker_identity,
2785 &delivery.parent_mailbox,
2786 &delivery.correlation_id,
2787 &delivery.message_ids,
2788 )
2789 .await
2790 }
2791 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2792 }
2793 Ok(true)
2794 }
2795
2796 async fn commit_durable_child_delivery(
2797 &self,
2798 session: &Session,
2799 activation_run_id: &str,
2800 ) -> Result<(), String> {
2801 let key = (
2802 session.id.clone(),
2803 session.created_at,
2804 activation_run_id.to_owned(),
2805 );
2806 let mut pending_links = self.pending_durable_links.lock().await;
2807 let Some(pending) = pending_links.get_mut(&key) else {
2808 return Ok(());
2809 };
2810 let delivery = pending
2811 .link
2812 .durable_delivery_receipt()
2813 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2814 let expected_status = effective_broker_terminal_status(
2815 delivery.terminal_status,
2816 pending.host_nested_wait_handoff,
2817 )?;
2818 if session.last_run_status().as_deref() != Some(expected_status) {
2819 return Err("Host final status differs from accepted broker terminal".into());
2820 }
2821 if pending.strict_history_required && pending.strict_history.is_none() {
2822 return Err("Host strict terminal completeness proof missing".into());
2823 }
2824 let store = self
2825 .actor_directory_store
2826 .lock()
2827 .recover_poison()
2828 .clone()
2829 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2830 let committed = if let Some(history) = pending.strict_history.as_ref() {
2831 store
2832 .commit_broker_terminal_receipt_with_completeness(
2833 session,
2834 activation_run_id,
2835 &history.proof,
2836 )
2837 .await
2838 } else {
2839 store
2840 .commit_broker_terminal_receipt(session, activation_run_id)
2841 .await
2842 }
2843 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2844 if committed.broker_identity != delivery.broker_identity
2845 || committed.parent_mailbox != delivery.parent_mailbox
2846 || committed.broker_correlation_id != delivery.correlation_id
2847 || committed.message_ids != delivery.message_ids
2848 || committed.terminal_status != expected_status
2849 {
2850 return Err("Host broker receipt changed before completion".into());
2851 }
2852 if let Some(history) = pending.strict_history.as_ref() {
2853 let proof = committed
2854 .terminal_completeness
2855 .as_ref()
2856 .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2857 let creation = bamboo_subagent::proto::ChildCreationIdentity {
2858 created_at: session.created_at,
2859 spawn_depth: session.spawn_depth,
2860 };
2861 validate_strict_history_terminal(
2862 Some(&history.watermark),
2863 session,
2864 session
2865 .parent_session_id
2866 .as_deref()
2867 .ok_or("Child parent missing")?,
2868 Some(activation_run_id),
2869 proof.execution_epoch,
2870 Some(&creation),
2871 proof.contiguous_applied_seq,
2872 )
2873 .map_err(|error| error.to_string())?;
2874 }
2875 publish_recovered_completion_source(&store, &committed).await?;
2879 pending.strict_history_committed = true;
2880 Ok(())
2881 }
2882
2883 async fn confirm_durable_child_delivery(
2884 &self,
2885 session: &Session,
2886 activation_run_id: &str,
2887 save_succeeded: bool,
2888 ) -> Result<(), String> {
2889 if save_succeeded
2890 && session
2891 .metadata
2892 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2893 {
2894 let service = self
2895 .ticket_service
2896 .lock()
2897 .recover_poison()
2898 .clone()
2899 .ok_or_else(|| {
2900 "Ticket authority unavailable for terminal checkpoint".to_string()
2901 })?;
2902 let runtime = self
2903 .session_inbox_runtime
2904 .lock()
2905 .recover_poison()
2906 .clone()
2907 .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2908 crate::ticket_runtime::checkpoint_ticket_result(
2909 &service,
2910 runtime.storage.as_ref(),
2911 session,
2912 activation_run_id,
2913 )
2914 .await
2915 .map_err(|e| e.to_string())?;
2916 }
2917 let key = (
2918 session.id.clone(),
2919 session.created_at,
2920 activation_run_id.to_owned(),
2921 );
2922 let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2923 return Ok(());
2924 };
2925 let delivery = pending
2928 .link
2929 .durable_delivery_receipt()
2930 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2931 let expected_status = effective_broker_terminal_status(
2932 delivery.terminal_status,
2933 pending.host_nested_wait_handoff,
2934 )?;
2935 if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2936 return Ok(());
2937 }
2938 let store = self
2939 .actor_directory_store
2940 .lock()
2941 .recover_poison()
2942 .clone()
2943 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2944 let committed = if pending.strict_history_required {
2945 if !pending.strict_history_committed {
2946 return Err("Host strict completeness was not committed before ACK".into());
2947 }
2948 let history = pending
2949 .strict_history
2950 .as_ref()
2951 .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2952 store
2953 .commit_broker_terminal_receipt_with_completeness(
2954 session,
2955 activation_run_id,
2956 &history.proof,
2957 )
2958 .await
2959 } else {
2960 store
2961 .commit_broker_terminal_receipt(session, activation_run_id)
2962 .await
2963 }
2964 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2965 if committed.broker_identity != delivery.broker_identity
2966 || committed.parent_mailbox != delivery.parent_mailbox
2967 || committed.broker_correlation_id != delivery.correlation_id
2968 || committed.message_ids != delivery.message_ids
2969 || committed.terminal_status != expected_status
2970 {
2971 return Err("Host broker receipt changed before ACK".into());
2972 }
2973 pending
2974 .link
2975 .acknowledge_durable_frames()
2976 .await
2977 .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2978 store
2979 .clear_acknowledged_broker_terminal_receipt(
2980 &session.id,
2981 session.created_at,
2982 activation_run_id,
2983 &delivery.parent_mailbox,
2984 )
2985 .await
2986 .map_err(|error| {
2987 format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2988 })
2989 }
2990
2991 #[tracing::instrument(
2992 name = "actor_child_run",
2993 level = "warn",
2994 skip_all,
2995 fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2996 )]
2997 async fn execute_external_child(
2998 &self,
2999 session: &mut Session,
3000 job: &SpawnJob,
3001 event_tx: mpsc::Sender<AgentEvent>,
3002 cancel_token: CancellationToken,
3003 ) -> crate::runtime::runner::Result<()> {
3004 crate::session_app::child_session::named_profile::validate_named_profile(session)
3005 .map_err(|error| AgentError::LLM(error.to_string()))?;
3006 let escalation = self.escalation_bridge.lock().recover_poison().clone();
3015 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3016 let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3017 let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3018 let ticket_service = self.ticket_service.lock().recover_poison().clone();
3019 let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3020 let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3021 .map_err(|error| AgentError::Budget(error.to_string()))?;
3022 if crate::session_app::child_session::named_profile::has_named_profile(session)
3023 && required_context.is_none()
3024 {
3025 return Err(AgentError::LLM(
3026 "named_profile_requires_strict_context_route".into(),
3027 ));
3028 }
3029 let assignment = required_context
3030 .as_ref()
3031 .map(|binding| binding.payload.required_assignment.clone())
3032 .unwrap_or_else(|| extract_assignment(session));
3033 let mut spec = self.build_live_spec(session, job).await;
3034 ensure_ticket_placement_is_enforceable(session, &spec)?;
3037 let scoped_route = self.scoped_placement_route(&spec)?;
3038 let strict_remote = scoped_route.as_ref();
3039 if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3040 return Err(remote_broker_unavailable());
3041 }
3042 let lineage = if strict_remote.is_some() {
3043 Some(
3044 remote_canonical_lineage(
3045 session_inbox_runtime
3046 .as_ref()
3047 .ok_or_else(remote_broker_unavailable)?,
3048 session,
3049 )
3050 .await?,
3051 )
3052 } else {
3053 None
3054 };
3055 let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3056 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3057 {
3058 let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3059 AgentError::LLM(
3060 "child creation identity unavailable: canonical host Storage required".into(),
3061 )
3062 })?;
3063 spec.capabilities.child_creation_identity = true;
3064 Some(canonical_child_creation(binding, session, job).await?)
3065 } else {
3066 None
3067 };
3068 spec.reusable = required_context.is_none();
3071 spec.capabilities.required_child_context = required_context.is_some();
3072 if required_context.is_some() {
3073 self.validate_required_child_context_route(session)
3074 .await
3075 .map_err(AgentError::LLM)?;
3076 }
3077 if required_context.is_some() {
3078 let names = self
3079 .native_tool_ceiling
3080 .as_ref()
3081 .unwrap()
3082 .observe(session)
3083 .await
3084 .map_err(AgentError::LLM)?;
3085 let birth = creation
3086 .as_ref()
3087 .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3088 spec.capabilities.native_tool_ceiling_required = true;
3089 spec.capabilities.native_tool_ceiling =
3090 Some(bamboo_subagent::proto::NativeToolCeiling {
3091 version: 1,
3092 child_session_id: session.id.clone(),
3093 parent_session_id: job.parent_session_id.clone(),
3094 root_session_id: session.root_session_id.clone(),
3095 created_at: birth.created_at,
3096 spawn_depth: birth.spawn_depth,
3097 project_id: project_id_for_actor_run(session)?,
3098 tools: names,
3099 });
3100 spec.capabilities.mcp = None;
3102 spec.capabilities.mcp_proxy = None;
3103 spec.capabilities.skills_dir = None;
3104 spec.capabilities.nested_spawn = false;
3105 spec.validate()
3106 .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3107 }
3108 let first_reply_store = actor_directory_store.clone().filter(|_| {
3111 required_context.is_some()
3112 && crate::session_app::child_session::named_profile::has_named_profile(session)
3113 && matches!(spec.placement, Placement::Local)
3114 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3115 && spec.capabilities.native_tool_ceiling_required
3116 && spec
3117 .capabilities
3118 .native_tool_ceiling
3119 .as_ref()
3120 .is_some_and(|ceiling| {
3121 ceiling.tools.is_empty()
3122 || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3123 })
3124 });
3125 let plain_actor_store = if let Some(store) = first_reply_store {
3126 let root = bamboo_agent_core::storage::Storage::load_session(
3127 store.as_ref(),
3128 &session.root_session_id,
3129 )
3130 .await
3131 .map_err(|_| plain_actor_unsupported())?
3132 .ok_or_else(|| plain_actor_unsupported())?;
3133 if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3134 return Err(plain_actor_unsupported());
3135 }
3136 if root.root_orchestration_only_enabled() {
3137 if !plain_initial_history(session)
3138 && (spec
3139 .capabilities
3140 .native_tool_ceiling
3141 .as_ref()
3142 .is_none_or(|ceiling| !ceiling.tools.is_empty())
3143 || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3144 {
3145 return Err(plain_actor_unsupported());
3146 }
3147 Some(store)
3148 } else if spec
3149 .capabilities
3150 .native_tool_ceiling
3151 .as_ref()
3152 .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3153 {
3154 require_unowned_glob(store.as_ref(), session).await?;
3157 None
3158 } else {
3159 let observed = store
3162 .actor_subtree_snapshot(
3163 ActorSnapshotPrincipal::host_owner(),
3164 &session.root_session_id,
3165 &session.id,
3166 ActorSnapshotLimits::default(),
3167 )
3168 .await
3169 .map_err(|_| plain_actor_unsupported())?;
3170 if observed
3171 .nodes
3172 .iter()
3173 .any(|node| node.actor_id == session.id && node.activation.is_some())
3174 {
3175 return Err(plain_actor_unsupported());
3176 }
3177 None
3178 }
3179 } else {
3180 None
3181 };
3182 let readonly_actor = plain_actor_store.is_some()
3183 && spec
3184 .capabilities
3185 .native_tool_ceiling
3186 .as_ref()
3187 .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3188 let local_history_tools = (plain_actor_store.is_none()
3189 && required_context.is_some()
3190 && (crate::session_app::child_session::named_profile::has_named_profile(session)
3191 || session
3192 .metadata
3193 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3194 && matches!(spec.placement, Placement::Local)
3195 && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3196 .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3197 .flatten()
3198 .filter(|ceiling| {
3199 bamboo_subagent::proto::LocalToolMessages::supports_tools(
3200 &ceiling.tools,
3201 spec.capabilities.read_only_enforced(),
3202 )
3203 })
3204 .map(|ceiling| ceiling.tools.clone());
3205 if plain_actor_store.is_some() && !readonly_actor {
3206 self.require_initial_input_release_worker().await?;
3207 spec.capabilities.initial_input_release_required = true;
3208 spec.validate().map_err(|_| plain_actor_unsupported())?;
3209 }
3210 let mut readonly_messages = Vec::new();
3211 let mut plain_activation = None;
3212 if spec.limits.idle_timeout_secs.is_none() {
3213 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3214 }
3215 ensure_read_only_activation_is_enforceable(&spec)?;
3216 let pool_key = Self::fingerprint(&spec);
3217
3218 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3223 {
3224 return Err(AgentError::LLM(
3225 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3226 .to_string(),
3227 ));
3228 }
3229 let project_id = project_id_for_actor_run(session)?;
3230 let requested_permission_mode = session
3231 .agent_runtime_state
3232 .as_ref()
3233 .map(|state| state.effective_permission_mode())
3234 .unwrap_or_default();
3235 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3240 ensure_no_active_scoped_session_denies(config, &session.id)?;
3241 let read_only_overlay = session
3242 .agent_runtime_state
3243 .as_ref()
3244 .is_some_and(|state| state.plan_mode.is_some())
3245 || spec.capabilities.read_only_enforced()
3246 || executor_has_read_only_permission_profile(&spec.executor);
3247 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3248 requested_permission_mode,
3249 config.mode(),
3250 read_only_overlay,
3251 );
3252 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3253 AgentError::LLM(format!(
3254 "failed to serialize permission policy for external executor: {error}"
3255 ))
3256 })?;
3257 Some(PermissionPolicyContext {
3258 revision: config.policy_revision(),
3259 requested_mode: resolution.requested.as_str().to_string(),
3260 effective_mode: resolution.effective.as_str().to_string(),
3261 bypass_permissions: resolution.bypass_permissions(),
3262 auto_approve_permissions: resolution.suppress_approval_prompts(),
3263 session_id: session.id.clone(),
3264 workspace_path: session.workspace.clone(),
3265 environment_lease: None,
3266 inherit_session_grants: false,
3267 policy,
3268 })
3269 } else {
3270 None
3271 };
3272 let provisioned_permission =
3273 spec.capabilities.permission_resolution().map_err(|error| {
3274 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3275 })?;
3276 let policy_resolution = permission_policy
3277 .as_ref()
3278 .map(|context| {
3279 context.resolved_modes().map(|(requested, effective)| {
3280 bamboo_domain::PermissionModeResolution {
3281 requested,
3282 effective,
3283 }
3284 })
3285 })
3286 .transpose()
3287 .map_err(|error| {
3288 AgentError::LLM(format!("invalid host permission posture: {error}"))
3289 })?;
3290 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3291 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3292 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3293 .is_some()
3294 });
3295 let expected_executor_mapping = expected_permission_executor_mapping(
3296 &spec.executor,
3297 policy_resolution.unwrap_or(provisioned_permission),
3298 has_explicit_deny,
3299 )?;
3300 let mut expected_permission_posture =
3301 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3302 policy_revision: permission_policy
3303 .as_ref()
3304 .map(|context| context.revision)
3305 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3306 .unwrap_or_default(),
3307 resolution: policy_resolution.unwrap_or(provisioned_permission),
3308 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3309 executor_mapping,
3310 });
3311
3312 let _slot = if strict_remote.is_some() {
3317 tokio::select! { biased;
3318 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3319 permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3320 }
3321 } else {
3322 self.concurrency
3323 .acquire()
3324 .await
3325 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3326 };
3327 let _remote_subscription = if strict_remote.is_some() {
3328 Some(tokio::select! { biased;
3329 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3330 guard = self.strict_remote_subscription.lock() => guard,
3331 })
3332 } else {
3333 None
3334 };
3335
3336 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3343 &spec.executor,
3344 self.codex_run_tokens.clone(),
3345 &job.child_session_id,
3346 )?;
3347
3348 let kind = match spec.placement {
3356 Placement::Remote { .. } => PlacementKind::Remote,
3357 Placement::Schedulable { .. } => PlacementKind::Schedulable,
3358 Placement::Local => PlacementKind::Local,
3359 };
3360 let remote = !matches!(kind, PlacementKind::Local);
3361 let scoped_registry = if remote {
3362 let store = actor_directory_store
3363 .as_ref()
3364 .ok_or_else(remote_broker_unavailable)?;
3365 Some(
3366 bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3367 .await
3368 .map_err(|_| remote_broker_unavailable())?,
3369 )
3370 } else {
3371 None
3372 };
3373 let scoped_tools = scoped_route.as_ref().map(|route| {
3374 let mut tools = route.requirements.required_tools.clone();
3375 if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3376 tools.extend(ceiling.tools.iter().cloned());
3377 }
3378 tools
3379 });
3380
3381 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3387 session
3388 .metadata
3389 .insert("placement".to_string(), placement_meta);
3390 }
3391
3392 let mut attempt = 0u8;
3399 let (result, actor) = loop {
3400 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3401 let (actor, mut client) = match kind {
3402 PlacementKind::Remote => {
3403 let route = scoped_route
3404 .as_ref()
3405 .ok_or_else(remote_broker_unavailable)?;
3406 let mailbox_id = route
3407 .pinned_mailbox
3408 .clone()
3409 .ok_or_else(remote_broker_unavailable)?;
3410 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3411 .map_err(|_| remote_broker_unavailable())?;
3412 let connect =
3413 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3414 &route.endpoint,
3415 route.parent.clone(),
3416 &route.token,
3417 bamboo_subagent::AgentRef {
3418 session_id: mailbox_id.clone(),
3419 role: Some(route.worker_role.clone()),
3420 },
3421 tls,
3422 );
3423 let link = tokio::select! { biased;
3424 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3425 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3426 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3427 };
3428 let record = AgentRecord {
3429 agent_id: job.child_session_id.clone(),
3430 role: spec.identity.role.clone(),
3431 labels: Vec::new(),
3432 endpoint: String::new(),
3433 pid: 0,
3434 version: String::new(),
3435 started_at: chrono::Utc::now(),
3436 lease_expires_at: chrono::Utc::now(),
3437 };
3438 (
3439 PooledWorker {
3440 worker: SpawnedChild::remote(record),
3441 mailbox_id,
3442 parked_at: None,
3443 },
3444 Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3445 )
3446 }
3447 PlacementKind::Schedulable => {
3448 let route = scoped_route
3449 .as_ref()
3450 .ok_or_else(remote_broker_unavailable)?;
3451 let registry = scoped_registry
3452 .as_ref()
3453 .ok_or_else(remote_broker_unavailable)?;
3454 let mailbox_id = self
3455 .select_scoped_worker(
3456 route,
3457 registry,
3458 &session.id,
3459 project_id.clone(),
3460 scoped_tools
3461 .as_ref()
3462 .ok_or_else(remote_broker_unavailable)?,
3463 )
3464 .await?;
3465 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3466 .map_err(|_| remote_broker_unavailable())?;
3467 let connect =
3468 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3469 &route.endpoint,
3470 route.parent.clone(),
3471 &route.token,
3472 bamboo_subagent::AgentRef {
3473 session_id: mailbox_id.clone(),
3474 role: Some(route.worker_role.clone()),
3475 },
3476 tls,
3477 );
3478 let link = tokio::select! { biased;
3479 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3480 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3481 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3482 };
3483 let actor = PooledWorker {
3484 worker: SpawnedChild::remote(AgentRecord {
3485 agent_id: mailbox_id.clone(),
3486 role: spec.identity.role.clone(),
3487 labels: Vec::new(),
3488 endpoint: route.endpoint.clone(),
3489 pid: 0,
3490 version: String::new(),
3491 started_at: chrono::Utc::now(),
3492 lease_expires_at: chrono::Utc::now(),
3493 }),
3494 mailbox_id,
3495 parked_at: None,
3496 };
3497 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3498 (actor, client)
3499 }
3500 PlacementKind::Local => {
3501 let bus = self.bus.as_ref().ok_or_else(|| {
3508 AgentError::LLM(
3509 "local sub-agents require a mailbox bus (subagents.broker); none is \
3510 configured and the bus could not be embedded"
3511 .to_string(),
3512 )
3513 })?;
3514 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3515 let parent = bamboo_subagent::AgentRef {
3516 session_id: format!("p-{}", job.child_session_id),
3517 role: None,
3518 };
3519 let link = bamboo_broker::BrokerChildLink::connect(
3520 &bus.endpoint,
3521 parent,
3522 &bus.token,
3523 actor.mailbox_id.clone(),
3524 )
3525 .await
3526 .map_err(|e| {
3527 AgentError::LLM(format!("broker child link connect failed: {e}"))
3528 })?;
3529 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3530 (actor, client)
3531 }
3532 };
3533
3534 if let Some(store) = actor_directory_store.as_ref() {
3540 let recovery = async {
3541 let receipts = store
3542 .recover_broker_terminal_receipts(session)
3543 .await
3544 .map_err(|error| {
3545 format!("Host broker receipt recovery blocked: {error}")
3546 })?;
3547 for receipt in receipts {
3548 if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3552 return Err(
3553 "old Child broker parent mailbox changed before ACK".to_string()
3554 );
3555 }
3556 client
3557 .acknowledge_recovered_durable_frames(
3558 &receipt.broker_identity,
3559 &receipt.message_ids,
3560 )
3561 .await
3562 .map_err(|error| {
3563 format!("old Child broker ACK unconfirmed: {error}")
3564 })?;
3565 store
3566 .clear_acknowledged_broker_terminal_receipt(
3567 &receipt.session_id,
3568 receipt.created_at,
3569 &receipt.activation_run_id,
3570 &receipt.parent_mailbox,
3571 )
3572 .await
3573 .map_err(|error| {
3574 format!("old Child broker ACK cleanup failed: {error}")
3575 })?;
3576 }
3577 Ok::<(), String>(())
3578 }
3579 .await;
3580 if let Err(error) = recovery {
3581 if !remote {
3582 actor.worker.kill().await;
3583 }
3584 return Err(AgentError::LLM(error));
3585 }
3586 }
3587
3588 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3593 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3594 Some(binding) => {
3595 let run_id = binding
3596 .router
3597 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3598 .await;
3599 if run_id.is_none() {
3600 tracing::debug!(
3601 session_id = %job.child_session_id,
3602 "actor driver had no current SessionInbox activation owner to bind"
3603 );
3604 }
3605 run_id
3606 }
3607 None => None,
3608 };
3609 tracing::Span::current().record(
3610 "activation_run_id",
3611 bound_activation_run_id.as_deref().unwrap_or("unbound"),
3612 );
3613 drop(delivery_tx);
3614 let initial_pairs = match (
3615 session_inbox_runtime.as_ref(),
3616 bound_activation_run_id.as_deref(),
3617 ) {
3618 (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3619 match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3620 .await
3621 {
3622 Ok(deliveries) => deliveries,
3623 Err(error) => {
3624 binding
3625 .router
3626 .detach_delivery_sink(&job.child_session_id, run_id)
3627 .await;
3628 if !remote {
3629 actor.worker.kill().await;
3630 }
3631 return Err(error);
3632 }
3633 }
3634 }
3635 _ => Vec::new(),
3636 };
3637 let mut initial_session_messages = initial_pairs
3638 .iter()
3639 .map(|(_, delivery)| delivery.clone())
3640 .collect::<Vec<_>>();
3641 let mut initial_inflight_claims = initial_pairs
3642 .into_iter()
3643 .map(|(claim, _)| claim)
3644 .collect::<VecDeque<_>>();
3645 let mut messages = if let Some(binding) = &required_context {
3648 binding
3649 .validate_messages(&session.id, &session.messages)
3650 .map_err(|error| AgentError::Budget(error.to_string()))?;
3651 session
3652 .messages
3653 .iter()
3654 .map(serde_json::to_value)
3655 .collect::<std::result::Result<Vec<_>, _>>()
3656 .map_err(|_| {
3657 AgentError::Budget(
3658 bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3659 )
3660 })?
3661 } else {
3662 session
3663 .messages
3664 .iter()
3665 .filter_map(|message| serde_json::to_value(message).ok())
3666 .collect()
3667 };
3668
3669 if strict_remote.is_some() {
3670 let current =
3671 remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3672 .await;
3673 if current.as_ref().ok() != lineage.as_ref()
3674 || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3675 || cancel_token.is_cancelled()
3676 {
3677 if let (Some(binding), Some(run_id)) = (
3678 session_inbox_runtime.as_ref(),
3679 bound_activation_run_id.as_deref(),
3680 ) {
3681 binding
3682 .router
3683 .detach_delivery_sink(&job.child_session_id, run_id)
3684 .await;
3685 }
3686 return Err(if cancel_token.is_cancelled() {
3687 AgentError::Cancelled
3688 } else {
3689 remote_broker_unavailable()
3690 });
3691 }
3692 }
3693 if let Some(expected) = creation.as_ref() {
3694 let checked =
3695 canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3696 .await;
3697 if checked.as_ref().ok() != Some(expected) {
3698 if let (Some(binding), Some(run_id)) = (
3699 session_inbox_runtime.as_ref(),
3700 bound_activation_run_id.as_deref(),
3701 ) {
3702 binding
3703 .router
3704 .detach_delivery_sink(&job.child_session_id, run_id)
3705 .await;
3706 }
3707 actor.worker.kill().await;
3708 return Err(AgentError::LLM(
3709 "child creation identity changed before dispatch".into(),
3710 ));
3711 }
3712 }
3713 if let Some(store) = &plain_actor_store {
3714 let started = async {
3715 let policy = permission_policy
3720 .as_ref()
3721 .ok_or_else(|| plain_actor_unsupported())?;
3722 let config = self
3723 .permission_config
3724 .as_ref()
3725 .ok_or_else(|| plain_actor_unsupported())?;
3726 if config.policy_revision() != policy.revision
3727 || !matches!(spec.placement, Placement::Local)
3728 {
3729 return Err(plain_actor_unsupported());
3730 }
3731 store
3732 .bind_control_plane(
3733 &session.id,
3734 bamboo_domain::ActorControlPlaneBinding {
3735 policy_revision: Some(policy.revision),
3736 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3737 },
3738 )
3739 .await
3740 .map_err(|error| AgentError::LLM(error.to_string()))?;
3741 PlainActorActivation::start_and_prepare(
3742 store.clone(),
3743 session,
3744 session_inbox_runtime.as_ref().unwrap(),
3745 bound_activation_run_id.as_deref(),
3746 self.permission_config.clone(),
3747 !readonly_actor,
3748 spec.capabilities
3749 .initial_input_release_required
3750 .then_some(actor.mailbox_id.as_str()),
3751 )
3752 .await
3753 }
3754 .await;
3755 match started {
3756 Ok((activation, prepared)) => {
3757 if let Some((prefix, deliveries)) = prepared {
3758 messages = prefix;
3759 initial_session_messages = deliveries;
3760 }
3761 plain_activation = Some(activation);
3762 }
3763 Err(error) => {
3764 if let Some(run_id) = bound_activation_run_id.as_deref() {
3765 session_inbox_runtime
3766 .as_ref()
3767 .unwrap()
3768 .router
3769 .detach_delivery_sink(&job.child_session_id, run_id)
3770 .await;
3771 }
3772 actor.worker.kill().await;
3773 return Err(error);
3774 }
3775 }
3776 }
3777 let mut logical_identity = logical_identity_for_actor_run(session, job);
3778 logical_identity.creation = creation.clone();
3779 let run_permission_policy = if strict_remote.is_some() {
3780 let prepared = async {
3781 let mut policy = permission_policy.clone().ok_or_else(|| {
3782 AgentError::LLM("remote_environment_permission_policy_missing".into())
3783 })?;
3784 let workspace = session.workspace.as_deref().ok_or_else(|| {
3785 AgentError::LLM("remote_environment_workspace_missing".into())
3786 })?;
3787 let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3788 AgentError::LLM("remote_environment_activation_missing".into())
3789 })?;
3790 let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3791 workspace,
3792 &session.id,
3793 run_id,
3794 execution_epoch,
3795 )
3796 .await
3797 .map_err(|error| AgentError::LLM(error.into()))?;
3798 policy.workspace_path = None;
3799 policy.environment_lease = Some(lease);
3800 Ok::<_, AgentError>(Some(policy))
3801 }
3802 .await;
3803 match prepared {
3804 Ok(policy) => policy,
3805 Err(error) => {
3806 if let (Some(binding), Some(run_id)) = (
3807 session_inbox_runtime.as_ref(),
3808 bound_activation_run_id.as_deref(),
3809 ) {
3810 binding
3811 .router
3812 .detach_delivery_sink(&job.child_session_id, run_id)
3813 .await;
3814 }
3815 return Err(error);
3816 }
3817 }
3818 } else {
3819 permission_policy.clone()
3820 };
3821 if session
3822 .metadata
3823 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3824 {
3825 let admission = async {
3826 let service = ticket_service
3827 .as_ref()
3828 .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3829 let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3830 AgentError::LLM("Ticket activation runtime unavailable".into())
3831 })?;
3832 let run_id = bound_activation_run_id
3833 .as_deref()
3834 .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3835 crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3836 .await
3837 .map_err(|e| AgentError::LLM(e.to_string()))
3838 }
3839 .await;
3840 if let Err(error) = admission {
3841 if let (Some(runtime), Some(run_id)) = (
3842 session_inbox_runtime.as_ref(),
3843 bound_activation_run_id.as_deref(),
3844 ) {
3845 runtime
3846 .router
3847 .detach_delivery_sink(&job.child_session_id, run_id)
3848 .await;
3849 }
3850 actor.worker.kill().await;
3851 return Err(error);
3852 }
3853 }
3854 let mut run_spec = RunSpec {
3855 assignment: assignment.clone(),
3857 logical_session: Some(logical_identity),
3858 project_id: project_id.clone(),
3859 reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3863 {
3864 session
3865 .reasoning_effort
3866 .map(|effort| effort.as_str().to_owned())
3867 } else {
3868 None
3869 },
3870 permission_policy: run_permission_policy,
3871 messages,
3872 activation_run_id: bound_activation_run_id.clone(),
3873 execution_epoch,
3874 initial_session_messages,
3875 secrets: run_secrets.clone(),
3876 };
3877 let mut active_lease = if remote {
3878 let reserve = ActivePlacementLease::reserve(
3879 scoped_registry
3880 .as_ref()
3881 .ok_or_else(remote_broker_unavailable)?
3882 .clone(),
3883 actor_directory_store
3884 .as_ref()
3885 .ok_or_else(remote_broker_unavailable)?
3886 .clone(),
3887 scoped_route
3888 .as_ref()
3889 .ok_or_else(remote_broker_unavailable)?
3890 .clone(),
3891 actor.mailbox_id.clone(),
3892 session.id.clone(),
3893 bound_activation_run_id
3894 .clone()
3895 .ok_or_else(remote_broker_unavailable)?,
3896 project_id.clone(),
3897 scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3898 &cancel_token,
3899 )
3900 .await;
3901 match reserve {
3902 Ok(lease) => Some(lease),
3903 Err(error) => {
3904 if let (Some(binding), Some(run_id)) = (
3905 session_inbox_runtime.as_ref(),
3906 bound_activation_run_id.as_deref(),
3907 ) {
3908 binding
3909 .router
3910 .detach_delivery_sink(&job.child_session_id, run_id)
3911 .await;
3912 }
3913 return Err(error);
3914 }
3915 }
3916 } else {
3917 None
3918 };
3919 if let Some(lease) = active_lease.as_mut() {
3920 let seeded = async {
3921 let binding = session_inbox_runtime
3922 .as_ref()
3923 .ok_or_else(remote_broker_unavailable)?;
3924 let run_id = bound_activation_run_id
3925 .as_deref()
3926 .ok_or_else(remote_broker_unavailable)?;
3927 let pairs = claim_canonical_deliveries(
3928 binding,
3929 session,
3930 run_id,
3931 usize::MAX,
3932 Some((lease.actor_store.as_ref(), &lease.fence)),
3933 )
3934 .await?;
3935 if let Some(binding) = &required_context {
3936 binding
3937 .validate_messages(&session.id, &session.messages)
3938 .map_err(|error| AgentError::Budget(error.to_string()))?;
3939 }
3940 run_spec.messages = session
3941 .messages
3942 .iter()
3943 .map(serde_json::to_value)
3944 .collect::<std::result::Result<Vec<_>, _>>()
3945 .map_err(|_| remote_broker_unavailable())?;
3946 run_spec.initial_session_messages =
3947 pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3948 initial_inflight_claims = pairs
3949 .into_iter()
3950 .map(|(claim, _)| claim)
3951 .collect::<VecDeque<_>>();
3952 Ok::<(), AgentError>(())
3953 }
3954 .await;
3955 if let Err(error) = seeded {
3956 if let Err(rollback_error) = lease.abort_before_dispatch().await {
3957 tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3958 }
3959 if let (Some(binding), Some(run_id)) = (
3960 session_inbox_runtime.as_ref(),
3961 bound_activation_run_id.as_deref(),
3962 ) {
3963 binding
3964 .router
3965 .detach_delivery_sink(&job.child_session_id, run_id)
3966 .await;
3967 }
3968 return Err(error);
3969 }
3970 }
3971 let mut run_send_invoked = false;
3972 let dispatch = async {
3973 if let Some(activation) = plain_activation
3974 .as_ref()
3975 .filter(|activation| !activation.initial_inputs.is_empty())
3976 {
3977 activation.refresh_continuation_posture(
3978 session,
3979 &run_spec,
3980 &mut expected_permission_posture,
3981 )?;
3982 }
3983 send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3984 }
3985 .await;
3986 if let Err(e) = dispatch {
3987 if remote {
3988 tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3989 }
3990 if let (Some(binding), Some(run_id)) = (
3991 session_inbox_runtime.as_ref(),
3992 bound_activation_run_id.as_deref(),
3993 ) {
3994 binding
3995 .router
3996 .detach_delivery_sink(&job.child_session_id, run_id)
3997 .await;
3998 }
3999 if !remote {
4000 actor.worker.kill().await;
4001 }
4002 if let Some(activation) = &plain_activation {
4003 activation.finish(ActorActivationFinish::Failed).await?;
4004 }
4005 if let Some(lease) = active_lease.as_mut() {
4006 match lease.abort_if_run_unsent(run_send_invoked).await {
4007 Ok(false) => {
4008 tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4009 }
4010 Ok(true) => {}
4011 Err(rollback_error) => {
4012 tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4013 }
4014 }
4015 }
4016 return Err(e);
4017 }
4018
4019 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4023 let live_guard = super::live::register(
4024 &job.child_session_id,
4025 live_tx,
4026 attempt as u32,
4027 self.approval_registry.clone(),
4028 );
4029
4030 let mut strict_history = None;
4031 let result = drive(ActorDriveContext {
4032 client: &mut *client,
4033 parent_session_id: &job.parent_session_id,
4034 child_session_id: &job.child_session_id,
4035 child_attempt: attempt as u32,
4036 approval_registry: self.approval_registry.as_ref(),
4037 approval_decider: self.approval_decider.as_ref(),
4038 approval_reviewer: self.approval_reviewer.as_ref(),
4039 escalation_bridge: escalation.clone(),
4040 event_tx: &event_tx,
4041 cancel_token: active_lease
4042 .as_ref()
4043 .map_or(&cancel_token, |lease| &lease.run_cancel),
4044 live_rx: &mut live_rx,
4045 delivery_rx: &mut delivery_rx,
4046 logical_session: session,
4047 expected_permission_posture: expected_permission_posture.clone(),
4048 expected_creation: creation.as_ref(),
4049 session_inbox_runtime: session_inbox_runtime.as_ref(),
4050 actor_directory_store: actor_directory_store.as_deref(),
4051 canonical_subagent_tool: canonical_subagent_tool.clone(),
4052 ticket_service: ticket_service.clone(),
4053 activation_run_id: bound_activation_run_id.as_deref(),
4054 execution_epoch,
4055 expected_source_actor_id: &actor.mailbox_id,
4056 initial_inflight_claims,
4057 plain_actor: plain_activation.is_some(),
4064 remote_environment_lease: strict_remote.is_some(),
4065 readonly_output: if readonly_actor {
4066 Some(&mut readonly_messages)
4067 } else {
4068 None
4069 },
4070 local_history_tools: local_history_tools.as_deref(),
4071 local_history_read_only: spec.capabilities.read_only_enforced(),
4072 strict_history_output: Some(&mut strict_history),
4073 plain_input: plain_activation.as_ref(),
4074 canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4075 canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4076 actor_event_observer: actor_event_observer.as_deref(),
4077 plain_run: self
4080 .bus
4081 .as_ref()
4082 .map(|_| (&run_spec, &self.next_execution_epoch)),
4083 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4084 })
4085 .await;
4086 let mut result = if active_lease
4087 .as_ref()
4088 .is_some_and(ActivePlacementLease::lost)
4089 {
4090 Err(remote_broker_unavailable())
4091 } else {
4092 result
4093 };
4094 if local_history_tools.is_none() {
4095 let reply = result
4096 .as_ref()
4097 .ok()
4098 .and_then(|reply| reply.as_deref())
4099 .filter(|reply| !reply.is_empty())
4100 .map(str::to_owned);
4101 if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4102 if let Err(error) = append_fenced_remote_reply(
4103 lease.actor_store.as_ref(),
4104 &lease.fence,
4105 session,
4106 &reply,
4107 )
4108 .await
4109 {
4110 result = Err(error);
4111 }
4112 }
4113 }
4114 if remote {
4115 if let Err(error) = &result {
4116 let code = match error {
4117 AgentError::LLM(message) if message.starts_with("actor event") => {
4118 "actor_event"
4119 }
4120 AgentError::LLM(message) if message.starts_with("remote_actor") => {
4121 "remote_actor"
4122 }
4123 AgentError::LLM(message) if message.starts_with("remote_environment") => {
4124 if message.len() <= 128
4125 && message.bytes().all(|byte| {
4126 byte.is_ascii_lowercase()
4127 || byte.is_ascii_digit()
4128 || byte == b'_'
4129 })
4130 {
4131 message.as_str()
4132 } else {
4133 "environment"
4134 }
4135 }
4136 AgentError::LLM(message) if message.contains("permission posture") => {
4137 "permission_posture"
4138 }
4139 AgentError::LLM(message) if message.contains("broker") => "broker",
4140 AgentError::LLM(_) => "other_llm",
4141 AgentError::WorkerUnresponsive(_) => "unresponsive",
4142 AgentError::Cancelled => "cancelled",
4143 _ => "other",
4144 };
4145 tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4146 }
4147 }
4148 if let Some(lease) = active_lease.as_mut() {
4149 let accepted_terminal = client
4150 .durable_delivery_receipt()
4151 .map(|receipt| receipt.terminal_status);
4152 if let Err(error) = lease
4153 .complete(
4154 remote_activation_finish(&result, accepted_terminal),
4155 client.has_pending_durable_terminal(),
4156 )
4157 .await
4158 {
4159 tracing::warn!(
4160 stage = "actor_complete",
4161 "remote placement admission failed"
4162 );
4163 if let (Some(binding), Some(run_id)) = (
4164 session_inbox_runtime.as_ref(),
4165 bound_activation_run_id.as_deref(),
4166 ) {
4167 binding
4168 .router
4169 .detach_delivery_sink(&job.child_session_id, run_id)
4170 .await;
4171 }
4172 drop(live_guard);
4173 drop(client); drop(active_lease);
4175 return Err(error);
4176 }
4177 }
4178 drop(active_lease);
4179 if let (Some(binding), Some(run_id)) = (
4180 session_inbox_runtime.as_ref(),
4181 bound_activation_run_id.as_deref(),
4182 ) {
4183 binding
4184 .router
4185 .detach_delivery_sink(&job.child_session_id, run_id)
4186 .await;
4187 }
4188 drop(live_guard);
4194 if client.has_pending_durable_terminal() {
4198 if let Some(run_id) = bound_activation_run_id.as_deref() {
4199 let key = (session.id.clone(), session.created_at, run_id.to_owned());
4200 let host_nested_wait_handoff = session
4201 .metadata
4202 .get("runtime.actor_nested_wait_handoff")
4203 .is_some_and(|value| value == "true");
4204 let mut pending = self.pending_durable_links.lock().await;
4205 if pending.contains_key(&key) {
4206 return Err(AgentError::LLM(
4207 "child broker terminal receipt already awaits Host checkpoint".into(),
4208 ));
4209 }
4210 pending.insert(
4211 key,
4212 PendingDurableChildLink {
4213 link: client,
4214 host_nested_wait_handoff,
4215 strict_history,
4216 strict_history_required: local_history_tools.is_some(),
4217 strict_history_committed: false,
4218 },
4219 );
4220 } else {
4221 drop(client);
4223 }
4224 } else {
4225 drop(client);
4226 }
4227
4228 if required_context.is_none()
4232 && attempt == 0
4233 && matches!(kind, PlacementKind::Local)
4234 && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4235 {
4236 tracing::warn!(
4237 "actor child {} got no first frame; reaping the worker and respawning once",
4238 job.child_session_id
4239 );
4240 actor.worker.kill().await;
4241 attempt += 1;
4242 continue;
4243 }
4244 break (result, actor);
4245 };
4246
4247 let result = if strict_remote.is_some() {
4248 result.map_err(|error| match &error {
4249 AgentError::Cancelled => error,
4250 AgentError::LLM(message) => remote_environment_error_code(message)
4251 .map(|code| AgentError::LLM(code.to_owned()))
4252 .unwrap_or_else(remote_broker_unavailable),
4253 _ => remote_broker_unavailable(),
4254 })
4255 } else {
4256 result
4257 };
4258
4259 if remote {
4263 drop(actor);
4264 } else if required_context.is_some() {
4265 if session
4266 .metadata
4267 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4268 {
4269 let service = self.ticket_service.lock().recover_poison().clone();
4270 let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4271 let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4272 .ok()
4273 .flatten()
4274 .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4275 match actor.worker.kill_confirmed().await {
4276 Ok(proof) => {
4277 if let (Some(service), Some(runtime), Some(run_id)) =
4278 (service, runtime, owned_run_id.as_deref())
4279 {
4280 if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4281 &service,
4282 &runtime,
4283 session,
4284 run_id,
4285 &proof,
4286 result.is_ok(),
4287 )
4288 .await
4289 {
4290 tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4293 }
4294 }
4295 }
4296 Err(error) => {
4297 tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4298 }
4299 }
4300 } else {
4301 actor.worker.kill().await;
4302 }
4303 } else {
4304 match &result {
4305 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4306 Err(_) => actor.worker.kill().await,
4307 }
4308 }
4309
4310 if let Some(activation) = plain_activation {
4311 return match result {
4312 Ok(Some(text)) if !text.is_empty() => {
4313 if readonly_actor {
4314 let messages = std::mem::take(&mut readonly_messages);
4315 let committed = activation
4316 .store
4317 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4318 fence: activation.fence.clone(),
4319 expected_created_at: activation.created_at,
4320 expected_conversation_generation: activation
4321 .conversation_generation
4322 .clone(),
4323 expected_messages: session.messages.clone(),
4324 expected_provider_transcript: activation
4325 .provider_transcript
4326 .clone(),
4327 messages: messages.clone(),
4328 native_groups: Vec::new(),
4329 })
4330 .await;
4331 return match committed {
4332 Ok(committed) => {
4333 *session = committed;
4334 for message in messages {
4335 let _ = event_tx
4336 .send(AgentEvent::message_appended(&session.id, &message))
4337 .await;
4338 }
4339 activation
4340 .finish_with_observer(
4341 ActorActivationFinish::Succeeded,
4342 actor_event_observer.as_deref(),
4343 )
4344 .await
4345 }
4346 Err(error) => {
4347 let _ = activation.finish(ActorActivationFinish::Failed).await;
4348 Err(AgentError::LLM(format!(
4349 "actor reply commit failed: {error}"
4350 )))
4351 }
4352 };
4353 }
4354 match activation.append_reply(session, text, &event_tx).await {
4355 Ok(()) => {
4356 activation
4357 .finish_with_observer(
4358 ActorActivationFinish::Succeeded,
4359 actor_event_observer.as_deref(),
4360 )
4361 .await
4362 }
4363 Err(error) => {
4364 let _ = activation.finish(ActorActivationFinish::Failed).await;
4366 Err(AgentError::LLM(format!(
4367 "actor reply commit failed: {error}"
4368 )))
4369 }
4370 }
4371 }
4372 other => {
4373 let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4374 ActorActivationFinish::Cancelled
4375 } else {
4376 ActorActivationFinish::Failed
4377 };
4378 activation.finish(outcome).await?;
4379 match other {
4380 Err(error) => Err(error),
4381 _ => Err(plain_actor_unsupported()),
4382 }
4383 }
4384 };
4385 }
4386
4387 match result {
4391 Ok(Some(text)) => {
4392 if !remote && local_history_tools.is_none() && !text.is_empty() {
4393 session.add_message(bamboo_agent_core::Message::assistant(text, None));
4394 }
4395 Ok(())
4396 }
4397 Ok(None) => Ok(()),
4398 Err(e) => Err(e),
4399 }
4400 }
4401}
4402
4403async fn require_unowned_glob(
4406 store: &bamboo_storage::SessionStoreV2,
4407 session: &Session,
4408) -> Result<(), AgentError> {
4409 let rel = store
4410 .resolve_rel_path(&session.id)
4411 .await
4412 .ok_or_else(|| plain_actor_unsupported())?;
4413 if !store.bamboo_home_dir().is_absolute()
4414 || rel
4415 != format!(
4416 "sessions/{}/children/{}",
4417 session.root_session_id, session.id
4418 )
4419 {
4420 return Err(plain_actor_unsupported());
4421 }
4422 let directory = store.bamboo_home_dir().join(rel);
4423 let mut checked = PathBuf::new();
4424 for component in directory.components() {
4425 if !matches!(
4426 component,
4427 std::path::Component::RootDir | std::path::Component::Normal(_)
4428 ) {
4429 return Err(plain_actor_unsupported());
4430 }
4431 checked.push(component.as_os_str());
4432 if !tokio::fs::symlink_metadata(&checked)
4433 .await
4434 .map_err(|_| plain_actor_unsupported())?
4435 .is_dir()
4436 {
4437 return Err(plain_actor_unsupported());
4438 }
4439 }
4440 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4441 match tokio::fs::symlink_metadata(directory.join(name)).await {
4442 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4443 _ => return Err(plain_actor_unsupported()),
4444 }
4445 }
4446 Ok(())
4447}
4448
4449struct PlainActorActivation {
4455 store: Arc<bamboo_storage::SessionStoreV2>,
4456 fence: ActorActivationFence,
4457 inbox_generation: u64,
4458 created_at: chrono::DateTime<chrono::Utc>,
4459 conversation_generation: bamboo_domain::ConversationGenerationObservation,
4460 provider_transcript: bamboo_domain::ProviderTranscriptState,
4461 input_consumer: SessionInboxConsumerId,
4462 input_deadline: chrono::DateTime<chrono::Utc>,
4463 actor_deadline: chrono::DateTime<chrono::Utc>,
4464 initial_release_required: bool,
4465 input_inbox: bamboo_storage::FileSessionInbox,
4466 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4467 initial_inputs: Vec<SessionInboxOwnedClaim>,
4468 initial_input_count: usize,
4469 recovering_pre_ack: bool,
4470 completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4471}
4472type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4473type PlainInitialBatch = (Vec<serde_json::Value>, Vec<SessionMessageDelivery>);
4474#[track_caller]
4475fn plain_actor_unsupported() -> AgentError {
4476 let source = std::panic::Location::caller();
4477 tracing::warn!(
4478 process_id = std::process::id(),
4479 source_file = source.file(),
4480 source_line = source.line(),
4481 source_column = source.column(),
4482 "plain Actor continuation unsupported"
4483 );
4484 AgentError::LLM("This Actor Child supports a fresh plain activation, a completed plain Child with a bounded batch of new inputs, 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())
4485}
4486fn plain_initial_history(session: &Session) -> bool {
4487 !session.messages.iter().any(|message| {
4488 matches!(message.role, Role::Assistant | Role::Tool)
4489 || message
4490 .metadata
4491 .as_ref()
4492 .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4493 }) && session.provider_transcript.is_empty()
4494 && session.session_inbox_admission().is_none()
4495 && session.pending_injected_messages().is_none()
4496}
4497fn plain_completed_history(session: &Session) -> bool {
4500 session.provider_transcript.is_empty()
4501 && session.pending_injected_messages().is_none()
4502 && session
4503 .messages
4504 .last()
4505 .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4506 && !session
4507 .messages
4508 .iter()
4509 .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4510}
4511fn pre_ack_input(
4514 session: &Session,
4515) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4516 let Some(message) = session.messages.last() else {
4517 return Ok(None);
4518 };
4519 let Some(proof) = message
4520 .metadata
4521 .as_ref()
4522 .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4523 else {
4524 return Ok(None);
4525 };
4526 let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4527 .map_err(|_| plain_actor_unsupported())?;
4528 let generation = proof
4529 .get("generation")
4530 .and_then(serde_json::Value::as_u64)
4531 .filter(|g| *g > 0)
4532 .ok_or_else(|| plain_actor_unsupported())?;
4533 if message.role != Role::User
4534 || session
4535 .messages
4536 .iter()
4537 .filter(|m| m.id == message.id)
4538 .count()
4539 != 1
4540 || !session
4541 .session_inbox_admission()
4542 .is_some_and(|cursor| cursor.contains(&id))
4543 || !session.provider_transcript.is_empty()
4544 || session.pending_injected_messages().is_some()
4545 || session
4546 .messages
4547 .iter()
4548 .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4549 {
4550 return Err(plain_actor_unsupported());
4551 }
4552 Ok(Some((id, generation)))
4553}
4554fn qualified_pre_ack_owner(
4555 entry: &bamboo_domain::ActorDirectoryEntry,
4556 now: chrono::DateTime<chrono::Utc>,
4557) -> bool {
4558 let Some(old) = entry.activation.as_ref() else {
4559 return false;
4560 };
4561 let recoverable = (entry.actor.state == ActorLogicalState::Failed
4562 && old.status == bamboo_domain::ActorActivationStatus::Failed)
4563 || (entry.actor.state == ActorLogicalState::Active
4564 && old.status.is_live()
4565 && old.lease_expires_at <= now);
4566 let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4567 placement.class == bamboo_domain::ActorPlacementClass::Local
4568 && placement
4569 .lease_id
4570 .strip_prefix("owned-initial-release-v1:")
4571 .is_some_and(|worker| {
4572 !worker.is_empty()
4573 && worker.len() <= 256
4574 && !worker.contains("..")
4575 && worker.bytes().all(|b| {
4576 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4577 })
4578 })
4579 });
4580 recoverable && trusted
4581}
4582async fn expired_pre_ack_input(
4583 inbox: &bamboo_storage::FileSessionInbox,
4584 session: &Session,
4585) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4586 let Some((id, generation)) = pre_ack_input(session)? else {
4587 return Ok(None);
4588 };
4589 let backlog = inbox
4590 .inspect(&session.id)
4591 .await
4592 .map_err(|_| plain_actor_unsupported())?;
4593 let leases = inbox
4594 .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4595 .await
4596 .map_err(|_| plain_actor_unsupported())?;
4597 if backlog.pending != 0
4598 || backlog.claimed != 1
4599 || backlog.generation != generation
4600 || backlog.oldest_generation != Some(generation)
4601 || !backlog.activation_pending()
4602 || leases.len() != 1
4603 || !leases[0].expired
4604 || leases[0].generation != generation
4605 || inbox
4606 .was_admitted(&session.id, &id)
4607 .await
4608 .map_err(|_| plain_actor_unsupported())?
4609 {
4610 return Err(plain_actor_unsupported());
4611 }
4612 Ok(Some((id, generation)))
4613}
4614impl PlainActorActivation {
4615 fn start<'a>(
4618 store: Arc<bamboo_storage::SessionStoreV2>,
4619 session: &'a Session,
4620 binding: &'a SessionInboxRuntimeBinding,
4621 run_id: Option<&'a str>,
4622 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4623 allow_failed_retry: bool,
4624 release_worker: Option<&'a str>,
4625 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4626 {
4627 Box::pin(async move {
4628 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4629 let backlog = binding
4630 .inbox
4631 .inspect(&session.id)
4632 .await
4633 .map_err(|_| plain_actor_unsupported())?;
4634 let entry = store
4635 .ensure_actor(&session.id)
4636 .await
4637 .map_err(|error| AgentError::LLM(error.to_string()))?;
4638 let config = permission_config
4639 .as_ref()
4640 .ok_or_else(|| plain_actor_unsupported())?;
4641 if entry.actor.policy_revision != Some(config.policy_revision())
4642 || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4643 {
4644 return Err(plain_actor_unsupported());
4645 }
4646 let fresh = entry.actor.current_attempt == 0
4647 && entry.actor.state == ActorLogicalState::Cold
4648 && entry.activation.is_none()
4649 && backlog.pending == 0
4650 && backlog.claimed == 0
4651 && backlog.generation == 0;
4652 let new_input = backlog.pending == 1
4653 && backlog.claimed == 0
4654 && backlog.oldest_generation == Some(backlog.generation)
4655 && backlog.activation_pending();
4656 let retry = allow_failed_retry
4657 && entry.actor.state == ActorLogicalState::Failed
4658 && entry.actor.current_attempt > 0
4659 && entry.activation.as_ref().is_some_and(|old| {
4660 old.status == bamboo_domain::ActorActivationStatus::Failed
4661 && backlog.generation > old.inbox_generation
4662 })
4663 && new_input;
4664 let new_batch = backlog.pending > 1
4668 && backlog.pending <= bamboo_domain::SessionInboxLimits::default().max_claim_batch
4669 && backlog.claimed == 0
4670 && backlog
4671 .oldest_generation
4672 .is_some_and(|oldest| oldest < backlog.generation)
4673 && backlog.activation_pending();
4674 let continuation = allow_failed_retry
4675 && release_worker.is_some()
4676 && entry.actor.state == ActorLogicalState::Cold
4677 && entry.actor.current_attempt > 0
4678 && entry.activation.as_ref().is_some_and(|old| {
4679 old.status == bamboo_domain::ActorActivationStatus::Succeeded
4680 && backlog
4681 .oldest_generation
4682 .is_some_and(|oldest| oldest > old.inbox_generation)
4683 })
4684 && (new_input || new_batch)
4685 && plain_completed_history(session);
4686 let input_inbox = bamboo_storage::FileSessionInbox::new(
4687 store.clone(),
4688 bamboo_domain::SessionInboxLimits::default(),
4689 );
4690 let now = chrono::Utc::now();
4691 let replacement = allow_failed_retry
4695 && release_worker.is_some()
4696 && entry.actor.state == ActorLogicalState::Active
4697 && qualified_pre_ack_owner(&entry, now)
4698 && entry.activation.as_ref().is_some_and(|old| {
4699 old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4700 })
4701 && new_input
4702 && plain_initial_history(session);
4703 let recovery = if allow_failed_retry
4704 && release_worker.is_some()
4705 && qualified_pre_ack_owner(&entry, now)
4706 {
4707 expired_pre_ack_input(&input_inbox, session).await?
4708 } else {
4709 None
4710 };
4711 if (recovery.is_some() || continuation || replacement)
4712 && (!entry.actor.matches_session(session)
4713 || entry.actor.project_id
4714 != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4715 {
4716 return Err(plain_actor_unsupported());
4717 }
4718 if recovery.is_none()
4719 && !continuation
4720 && !replacement
4721 && ((!fresh && !retry) || !plain_initial_history(session))
4722 {
4723 return Err(plain_actor_unsupported());
4724 }
4725 let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4726 .max_total_secs
4727 .checked_add(60)
4728 .and_then(chrono::Duration::try_seconds)
4729 .ok_or_else(|| plain_actor_unsupported())?;
4730 let expires = now
4731 .checked_add_signed(duration)
4732 .ok_or_else(|| plain_actor_unsupported())?;
4733 let input_consumer = SessionInboxConsumerId::new();
4734 let input_deadline = expires.min(now + chrono::Duration::hours(1));
4735 let recovered = if let Some((id, generation)) = &recovery {
4736 if !binding.router.owns_run(&session.id, run_id).await {
4737 return Err(plain_actor_unsupported());
4738 }
4739 let lease = SessionInboxLeaseRequest {
4740 consumer: input_consumer.clone(),
4741 now,
4742 duration: input_deadline - now,
4743 };
4744 let mut claims = input_inbox
4745 .claim_owned(&session.id, 1, Some(run_id), &lease)
4746 .await
4747 .map_err(|_| plain_actor_unsupported())?;
4748 let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4749 if claim.claim.envelope.id != *id
4750 || claim.claim.generation != *generation
4751 || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4752 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4753 {
4754 return Err(plain_actor_unsupported());
4755 }
4756 Some(claim)
4757 } else {
4758 None
4759 };
4760 let claimed = store
4761 .claim_activation(&ActorActivationClaim {
4762 actor_id: session.id.clone(),
4763 run_id: run_id.into(),
4764 lease_owner: input_consumer.as_str().into(),
4765 lease_expires_at: expires,
4766 inbox_generation: recovered
4767 .as_ref()
4768 .map_or(backlog.generation, |claim| claim.claim.generation),
4769 placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4770 class: bamboo_domain::ActorPlacementClass::Local,
4771 lease_id: format!("owned-initial-release-v1:{worker}"),
4774 slot_epoch: None,
4775 }),
4776 now,
4777 })
4778 .await
4779 .map_err(|error| AgentError::LLM(error.to_string()))?;
4780 let activation = Self {
4781 store,
4782 fence: claimed.fence(),
4783 inbox_generation: claimed.inbox_generation,
4784 created_at: session.created_at,
4785 conversation_generation: session.conversation_generation_observation(),
4786 provider_transcript: session.provider_transcript.clone(),
4787 input_consumer,
4788 input_deadline,
4789 actor_deadline: claimed.lease_expires_at,
4790 initial_release_required: release_worker.is_some(),
4791 input_inbox,
4792 permission_config,
4793 initial_inputs: recovered.into_iter().collect(),
4794 initial_input_count: if continuation {
4795 backlog.pending
4796 } else {
4797 usize::from(claimed.inbox_generation > 0)
4798 },
4799 recovering_pre_ack: recovery.is_some(),
4800 completion_router: std::sync::Mutex::new(None),
4801 };
4802 if let Err(error) = activation
4803 .store
4804 .start_activation(&activation.fence, chrono::Utc::now())
4805 .await
4806 {
4807 let _ = activation.finish(ActorActivationFinish::Failed).await;
4808 return Err(AgentError::LLM(error.to_string()));
4809 }
4810 Ok(activation)
4811 })
4812 }
4813 fn start_and_prepare<'a>(
4814 store: Arc<bamboo_storage::SessionStoreV2>,
4815 session: &'a mut Session,
4816 binding: &'a SessionInboxRuntimeBinding,
4817 run_id: Option<&'a str>,
4818 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4819 allow_failed_retry: bool,
4820 release_worker: Option<&'a str>,
4821 ) -> std::pin::Pin<
4822 Box<
4823 impl std::future::Future<Output = Result<(Self, Option<PlainInitialBatch>), AgentError>>
4824 + Send
4825 + 'a,
4826 >,
4827 > {
4828 Box::pin(async move {
4829 let mut activation = Self::start(
4830 store,
4831 session,
4832 binding,
4833 run_id,
4834 permission_config,
4835 allow_failed_retry,
4836 release_worker,
4837 )
4838 .await?;
4839 if activation.inbox_generation == 0 {
4840 return Ok((activation, None));
4841 }
4842 let prepared = async {
4843 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4844 let backlog = activation
4845 .input_inbox
4846 .inspect(&session.id)
4847 .await
4848 .map_err(|_| plain_actor_unsupported())?;
4849 let expected = if activation.recovering_pre_ack {
4850 (0, 1)
4851 } else {
4852 (activation.initial_input_count, 0)
4853 };
4854 if (backlog.pending, backlog.claimed) != expected
4855 || backlog.generation != activation.inbox_generation
4856 {
4857 return Err(plain_actor_unsupported());
4858 }
4859 let claims = if activation.initial_inputs.is_empty() {
4860 activation
4864 .claim_inputs(binding, session, run_id, activation.initial_input_count)
4865 .await?
4866 } else {
4867 activation.initial_inputs.clone()
4868 };
4869 if claims.len() != activation.initial_input_count
4870 || claims.last().map(|claim| claim.claim.generation)
4871 != Some(activation.inbox_generation)
4872 || claims
4873 .windows(2)
4874 .any(|pair| pair[0].claim.generation >= pair[1].claim.generation)
4875 {
4876 return Err(plain_actor_unsupported());
4877 }
4878 let mut prefix = None;
4883 let mut deliveries = Vec::with_capacity(claims.len());
4884 for claim in &claims {
4885 let (messages, delivery) =
4886 activation.checkpoint_input(session, run_id, claim).await?;
4887 prefix.get_or_insert(messages);
4888 deliveries.push(delivery);
4889 }
4890 activation.initial_inputs = claims;
4891 Ok((prefix.ok_or_else(|| plain_actor_unsupported())?, deliveries))
4892 }
4893 .await;
4894 match prepared {
4895 Ok(delivery) => Ok((activation, Some(delivery))),
4896 Err(error) => {
4897 activation.finish(ActorActivationFinish::Failed).await?;
4898 Err(error)
4899 }
4900 }
4901 })
4902 }
4903 fn claim_input<'a>(
4904 &'a self,
4905 binding: &'a SessionInboxRuntimeBinding,
4906 session: &'a Session,
4907 run_id: &'a str,
4908 ) -> std::pin::Pin<
4909 Box<
4910 impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4911 + Send
4912 + 'a,
4913 >,
4914 > {
4915 Box::pin(async move { Ok(self.claim_inputs(binding, session, run_id, 1).await?.pop()) })
4916 }
4917 fn claim_inputs<'a>(
4918 &'a self,
4919 binding: &'a SessionInboxRuntimeBinding,
4920 session: &'a Session,
4921 run_id: &'a str,
4922 limit: usize,
4923 ) -> std::pin::Pin<
4924 Box<
4925 impl std::future::Future<Output = Result<Vec<SessionInboxOwnedClaim>, AgentError>>
4926 + Send
4927 + 'a,
4928 >,
4929 > {
4930 Box::pin(async move {
4931 if limit == 0 || limit > bamboo_domain::SessionInboxLimits::default().max_claim_batch {
4932 return Err(plain_actor_unsupported());
4933 }
4934 if !self
4935 .input_inbox
4936 .inspect(&session.id)
4937 .await
4938 .map_err(|_| plain_actor_unsupported())?
4939 .activation_pending()
4940 {
4941 return Ok(Vec::new());
4942 }
4943 if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4944 return Err(plain_actor_unsupported());
4945 }
4946 let now = chrono::Utc::now();
4947 if now >= self.input_deadline {
4948 return Err(plain_actor_unsupported());
4949 }
4950 let lease = SessionInboxLeaseRequest {
4951 consumer: self.input_consumer.clone(),
4952 now,
4953 duration: self.input_deadline - now,
4954 };
4955 let claims = self
4956 .input_inbox
4957 .claim_owned(&session.id, limit, Some(run_id), &lease)
4958 .await
4959 .map_err(|error| {
4960 tracing::warn!(%error, "Actor correction claim unconfirmed");
4961 AgentError::LLM(
4962 "Actor correction claim unconfirmed; durable input is preserved".into(),
4963 )
4964 })?;
4965 for claim in &claims {
4966 if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4967 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4968 {
4969 self.input_inbox
4970 .fail_owned(
4971 &session.id,
4972 claim,
4973 &SessionInboxFailureReport {
4974 now: chrono::Utc::now(),
4975 error_code: "unsupported_actor_input".into(),
4976 },
4977 )
4978 .await
4979 .map_err(|error| {
4980 tracing::warn!(%error, "Actor correction failure report unconfirmed");
4981 AgentError::LLM(
4982 "Actor correction failure report unconfirmed; durable input is preserved".into(),
4983 )
4984 })?;
4985 return Err(plain_actor_unsupported());
4986 }
4987 }
4988 Ok(claims)
4989 })
4990 }
4991 fn checkpoint_input<'a>(
4992 &'a self,
4993 session: &'a mut Session,
4994 run_id: &'a str,
4995 claim: &'a SessionInboxOwnedClaim,
4996 ) -> std::pin::Pin<
4997 Box<
4998 impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4999 >,
5000 > {
5001 Box::pin(async move {
5002 let request = bamboo_storage::ActorInputCheckpoint {
5005 fence: self.fence.clone(),
5006 expected_created_at: self.created_at,
5007 expected_conversation_generation: self.conversation_generation.clone(),
5008 claim: claim.clone(),
5009 expected_messages: session.messages.clone(),
5010 expected_provider_transcript: session.provider_transcript.clone(),
5011 expected_admission: session.session_inbox_admission().cloned(),
5012 };
5013 let mut retries = 0;
5014 let committed = loop {
5015 let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
5016 if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
5017 && retries < 3
5018 {
5019 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5025 retries += 1;
5026 continue;
5027 }
5028 break result;
5029 }.map_err(|error| {
5030 tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
5031 AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
5032 })?;
5033 if self.recovering_pre_ack
5034 && committed.status
5035 != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5036 {
5037 return Err(plain_actor_unsupported());
5038 }
5039 let messages = session
5040 .messages
5041 .iter()
5042 .map(|message| {
5043 let mut worker = message.clone();
5044 if committed.status
5045 == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5046 && worker.id == committed.envelope.id.as_str()
5047 {
5048 worker
5049 .metadata
5050 .as_mut()
5051 .and_then(serde_json::Value::as_object_mut)
5052 .ok_or_else(|| plain_actor_unsupported())?
5053 .remove("_bamboo_owned_input_checkpoint");
5054 if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
5055 {
5056 return Err(plain_actor_unsupported());
5057 }
5058 }
5059 serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
5060 })
5061 .collect::<Result<Vec<_>, _>>()?;
5062 *session = committed.session;
5063 Ok((
5064 messages,
5065 SessionMessageDelivery {
5066 target_session_id: session.id.clone(),
5067 envelope: committed.envelope,
5068 canonical_claim_generation: committed.generation,
5069 activation_run_id: run_id.into(),
5070 activation_policy: claim.claim.activation_policy,
5071 },
5072 ))
5073 })
5074 }
5075 fn append_reply<'a>(
5076 &'a self,
5077 session: &'a mut Session,
5078 text: String,
5079 event_tx: &'a mpsc::Sender<AgentEvent>,
5080 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5081 {
5082 Box::pin(async move {
5083 let message = bamboo_agent_core::Message::assistant(text, None);
5084 let request = bamboo_storage::ActorTranscriptAppend {
5085 fence: self.fence.clone(),
5086 expected_created_at: self.created_at,
5087 expected_conversation_generation: self.conversation_generation.clone(),
5088 expected_messages: session.messages.clone(),
5089 expected_provider_transcript: self.provider_transcript.clone(),
5090 messages: vec![message.clone()],
5091 native_groups: Vec::new(),
5092 };
5093 let mut retries = 0;
5094 *session = loop {
5095 let result = self.store.append_actor_transcript(request.clone()).await;
5096 if matches!(
5097 &result,
5098 Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5099 ) && retries < 3
5100 {
5101 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5106 retries += 1;
5107 continue;
5108 }
5109 break result;
5110 }
5111 .map_err(|error| {
5112 tracing::warn!(
5113 %error,
5114 "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5115 );
5116 AgentError::LLM(format!("actor reply commit failed: {error}"))
5117 })?;
5118 let _ = event_tx
5119 .send(AgentEvent::message_appended(&session.id, &message))
5120 .await;
5121 Ok(())
5122 })
5123 }
5124 fn continue_input<'a>(
5128 &'a self,
5129 client: &'a mut dyn bamboo_subagent::ChildLink,
5130 binding: &'a SessionInboxRuntimeBinding,
5131 session: &'a mut Session,
5132 run_id: &'a str,
5133 run: Option<(&'a RunSpec, &'a AtomicU64)>,
5134 expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5135 text: String,
5136 event_tx: &'a mpsc::Sender<AgentEvent>,
5137 ) -> std::pin::Pin<
5138 Box<
5139 impl std::future::Future<
5140 Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5141 > + Send
5142 + 'a,
5143 >,
5144 > {
5145 Box::pin(async move {
5146 let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5147 return Ok(None);
5148 };
5149 let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5150 self.append_reply(session, text, event_tx).await?;
5151 let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5152 self.refresh_continuation_posture(session, run, expected_posture)?;
5154 let epoch = epochs
5155 .fetch_add(1, Ordering::Relaxed)
5156 .checked_add(1)
5157 .ok_or_else(|| plain_actor_unsupported())?;
5158 let mut next = run.clone();
5159 next.messages = messages;
5160 next.initial_session_messages = vec![delivery];
5161 next.execution_epoch = epoch;
5162 client.send(ParentFrame::Run(next)).await.map_err(|_| {
5163 AgentError::LLM(
5164 "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5165 )
5166 })?;
5167 Ok(Some((claim, epoch)))
5168 })
5169 }
5170 fn refresh_continuation_posture(
5171 &self,
5172 session: &Session,
5173 run: &RunSpec,
5174 expected: &mut Option<ExpectedPermissionPosture>,
5175 ) -> Result<(), AgentError> {
5176 let Some(expected) = expected.as_mut() else {
5177 return Err(plain_actor_unsupported());
5178 };
5179 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5180 .ok_or_else(|| plain_actor_unsupported())?;
5181 if audit.policy_revision != expected.policy_revision
5182 || audit.resolution != expected.resolution
5183 || audit.executor_mapping != expected.executor_mapping
5184 {
5185 return Err(plain_actor_unsupported());
5186 }
5187 match (&self.permission_config, &run.permission_policy) {
5188 (Some(config), Some(policy)) => {
5189 ensure_no_active_scoped_session_denies(config, &session.id)?;
5190 if config.policy_revision() != policy.revision
5191 || policy.revision != audit.policy_revision
5192 || policy.session_id != session.id
5193 || policy.workspace_path != session.workspace
5194 || policy.inherit_session_grants
5195 || serde_json::to_value(config.to_serializable())
5196 .map_err(|_| plain_actor_unsupported())?
5197 != policy.policy
5198 {
5199 return Err(plain_actor_unsupported());
5200 }
5201 }
5202 (None, None) => {}
5203 _ => return Err(plain_actor_unsupported()),
5204 }
5205 expected.expected_audit_revision = Some(audit.audit_revision);
5206 Ok(())
5207 }
5208 fn release_initial_input<'a>(
5209 &'a self,
5210 binding: &'a SessionInboxRuntimeBinding,
5211 session: &'a Session,
5212 run: &'a RunSpec,
5213 epoch: u64,
5214 claim: &'a SessionInboxOwnedClaim,
5215 request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5216 expected: &'a mut Option<ExpectedPermissionPosture>,
5217 previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5218 ) -> std::pin::Pin<
5219 Box<
5220 impl std::future::Future<
5221 Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5222 > + Send
5223 + 'a,
5224 >,
5225 > {
5226 Box::pin(async move {
5227 if !self.initial_release_required {
5228 return Err(plain_actor_unsupported());
5229 }
5230 let delivery = SessionMessageDelivery {
5231 target_session_id: session.id.clone(),
5232 envelope: claim.claim.envelope.clone(),
5233 canonical_claim_generation: claim.claim.generation,
5234 activation_run_id: self.fence.run_id.clone(),
5235 activation_policy: claim.claim.activation_policy,
5236 };
5237 let mut current_run = run.clone();
5238 current_run.execution_epoch = epoch;
5239 let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5240 ¤t_run,
5241 &delivery,
5242 request.nonce.clone(),
5243 )
5244 .map_err(|_| plain_actor_unsupported())?;
5245 if &exact != request
5246 || request.child_id != session.id
5247 || request.created_at != session.created_at
5248 || request.spawn_depth != session.spawn_depth
5249 || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5250 || request.root_id != session.root_session_id
5251 || request.project_id != project_id_for_actor_run(session)?
5252 || !binding
5253 .router
5254 .owns_run(&session.id, &self.fence.run_id)
5255 .await
5256 {
5257 return Err(plain_actor_unsupported());
5258 }
5259 let deadline = self
5260 .actor_deadline
5261 .min(self.input_deadline)
5262 .min(claim.lease.expires_at);
5263 if chrono::Utc::now() >= deadline {
5264 return Err(plain_actor_unsupported());
5265 }
5266 self.store
5267 .validate_fence(&self.fence, chrono::Utc::now())
5268 .await
5269 .map_err(|_| plain_actor_unsupported())?;
5270 let actual =
5271 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5272 .await
5273 .map_err(|_| plain_actor_unsupported())?
5274 .ok_or_else(|| plain_actor_unsupported())?;
5275 if actual.created_at != session.created_at
5276 || actual.root_session_id != session.root_session_id
5277 || actual.parent_session_id != session.parent_session_id
5278 || actual.spawn_depth != session.spawn_depth
5279 || project_id_for_actor_run(&actual)? != request.project_id
5280 || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5281 != serde_json::to_value(&session.messages)
5282 .map_err(|_| plain_actor_unsupported())?
5283 || serde_json::to_value(&actual.provider_transcript)
5284 .map_err(|_| plain_actor_unsupported())?
5285 != serde_json::to_value(&session.provider_transcript)
5286 .map_err(|_| plain_actor_unsupported())?
5287 || serde_json::to_value(actual.session_inbox_admission())
5288 .map_err(|_| plain_actor_unsupported())?
5289 != serde_json::to_value(session.session_inbox_admission())
5290 .map_err(|_| plain_actor_unsupported())?
5291 {
5292 return Err(plain_actor_unsupported());
5293 }
5294 self.refresh_continuation_posture(&actual, ¤t_run, expected)?;
5295 if let Some(previous) = previous {
5296 if !previous.permits(request, chrono::Utc::now())
5297 || previous.expires_at != deadline
5298 || !self
5299 .input_inbox
5300 .was_admitted(&session.id, &claim.claim.envelope.id)
5301 .await
5302 .map_err(|_| plain_actor_unsupported())?
5303 {
5304 return Err(plain_actor_unsupported());
5305 }
5306 } else {
5307 let checkpoint = bamboo_storage::ActorInputCheckpoint {
5310 fence: self.fence.clone(),
5311 expected_created_at: self.created_at,
5312 expected_conversation_generation: self.conversation_generation.clone(),
5313 claim: claim.clone(),
5314 expected_messages: actual.messages.clone(),
5315 expected_provider_transcript: actual.provider_transcript.clone(),
5316 expected_admission: actual.session_inbox_admission().cloned(),
5317 };
5318 let mut retries = 0;
5319 loop {
5320 let result = self
5321 .input_inbox
5322 .checkpoint_actor_input(checkpoint.clone())
5323 .await;
5324 if matches!(
5325 &result,
5326 Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5327 ) && retries < 3
5328 {
5329 tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5333 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5334 retries += 1;
5335 continue;
5336 }
5337 result.map_err(|error| {
5338 tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5339 plain_actor_unsupported()
5340 })?;
5341 break;
5342 }
5343 self.confirm_input(
5344 binding,
5345 &actual,
5346 &self.fence.run_id,
5347 claim,
5348 &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5349 target_session_id: request.child_id.clone(),
5350 envelope_id: request.envelope_id.clone(),
5351 canonical_claim_generation: request.generation,
5352 activation_run_id: request.activation_run_id.clone(),
5353 },
5354 )
5355 .await?;
5356 }
5357 if chrono::Utc::now() >= deadline
5360 || !binding
5361 .router
5362 .owns_run(&session.id, &self.fence.run_id)
5363 .await
5364 {
5365 return Err(plain_actor_unsupported());
5366 }
5367 self.store
5368 .validate_fence(&self.fence, chrono::Utc::now())
5369 .await
5370 .map_err(|_| plain_actor_unsupported())?;
5371 let final_session =
5372 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5373 .await
5374 .map_err(|_| plain_actor_unsupported())?
5375 .ok_or_else(|| plain_actor_unsupported())?;
5376 self.store
5377 .validate_fence(&self.fence, chrono::Utc::now())
5378 .await
5379 .map_err(|_| plain_actor_unsupported())?;
5380 self.refresh_continuation_posture(&final_session, ¤t_run, expected)?;
5381 if chrono::Utc::now() >= deadline {
5382 return Err(plain_actor_unsupported());
5383 }
5384 Ok(bamboo_subagent::proto::InitialInputRelease {
5385 request: request.clone(),
5386 expires_at: deadline,
5387 })
5388 })
5389 }
5390 async fn confirm_input(
5391 &self,
5392 binding: &SessionInboxRuntimeBinding,
5393 session: &Session,
5394 run_id: &str,
5395 claim: &SessionInboxOwnedClaim,
5396 confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5397 ) -> Result<(), AgentError> {
5398 if confirmation.target_session_id != session.id
5399 || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5400 || confirmation.canonical_claim_generation != claim.claim.generation
5401 || confirmation.activation_run_id != run_id
5402 || run_id != self.fence.run_id
5403 || !binding.router.owns_run(&session.id, run_id).await
5404 {
5405 return Err(plain_actor_unsupported());
5406 }
5407 self.input_inbox
5408 .ack_owned(&session.id, claim, chrono::Utc::now())
5409 .await
5410 .map_err(|error| {
5411 tracing::warn!(%error, "Actor correction ACK unresolved");
5412 AgentError::LLM(
5413 "Actor correction ACK unresolved; durable input is preserved".into(),
5414 )
5415 })
5416 }
5417 async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5418 self.finish_with_observer(outcome, None).await
5419 }
5420
5421 async fn finish_with_observer(
5422 &self,
5423 outcome: ActorActivationFinish,
5424 observer: Option<&dyn ActorEventObserver>,
5425 ) -> Result<(), AgentError> {
5426 let committed = self
5427 .store
5428 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5429 .await
5430 .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5431 let router = self.completion_router.lock().recover_poison().take();
5434 if let (Some(observer), Some(router)) = (observer, router) {
5435 match self.store.inspect_actor(&self.fence.actor_id).await {
5439 Ok(entry) => match router.into_host_completion(&entry, &committed) {
5440 Ok(event) => observer.publish(event),
5441 Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5442 "Actor completion notification superseded"),
5443 },
5444 Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5445 "Actor completion committed; notification snapshot unavailable"),
5446 }
5447 }
5448 Ok(())
5449 }
5450}
5451fn local_tool_history_unsupported() -> AgentError {
5452 AgentError::LLM("local_tool_history_unsupported".into())
5453}
5454
5455#[derive(Clone, Copy)]
5456enum LocalToolTerminal<'a> {
5457 Completed(Option<&'a str>),
5458 Cancelled,
5461}
5462
5463impl LocalToolTerminal<'_> {
5464 fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5465 match self {
5466 Self::Completed(_) => !cancel.is_cancelled(),
5467 Self::Cancelled => cancel.is_cancelled(),
5468 }
5469 }
5470}
5471
5472#[derive(Default)]
5473struct LocalToolCollector {
5474 messages: Option<Vec<bamboo_agent_core::Message>>,
5475 starts: HashMap<String, (String, serde_json::Value)>,
5476 outcomes: HashMap<String, (bool, String)>,
5477 ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5480}
5481
5482impl LocalToolCollector {
5483 fn ticket_event(
5484 &self,
5485 value: &serde_json::Value,
5486 committed: &HashMap<String, serde_json::Value>,
5487 ) -> Result<bool, AgentError> {
5488 if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5489 return Ok(true);
5490 }
5491 match serde_json::from_value::<AgentEvent>(value.clone())
5492 .map_err(|_| local_tool_history_unsupported())?
5493 {
5494 AgentEvent::ToolStart { tool_name, .. }
5495 if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5496 {
5497 Err(local_tool_history_unsupported())
5498 }
5499 AgentEvent::ToolComplete {
5500 tool_call_id,
5501 result,
5502 } if result.success => {
5503 let Some((name, args)) = self.starts.get(&tool_call_id) else {
5504 return Err(local_tool_history_unsupported());
5505 };
5506 let valid = if name == "Task" {
5507 committed.get(&tool_call_id) == Some(args)
5508 } else {
5509 committed.get(&tool_call_id).is_some_and(|proof| {
5510 proof["file_tool"] == *name
5511 && proof["arguments"] == *args
5512 && proof["result"] == result.result
5513 })
5514 };
5515 if !valid {
5516 return Err(AgentError::LLM(
5517 "LocalPlan success has no exact Host receipt".into(),
5518 ));
5519 }
5520 Ok(true)
5521 }
5522 AgentEvent::TaskListUpdated { .. } => Ok(false),
5524 _ => Ok(true),
5525 }
5526 }
5527
5528 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5529 use bamboo_subagent::proto::LocalToolMessages;
5530 if self.messages.is_some() {
5531 return Err(local_tool_history_unsupported());
5532 }
5533 if value["type"] == LocalToolMessages::TYPE {
5534 let observation: LocalToolMessages = serde_json::from_value(value.clone())
5535 .map_err(|_| local_tool_history_unsupported())?;
5536 self.messages = Some(
5537 observation
5538 .validate()
5539 .map_err(|_| local_tool_history_unsupported())?,
5540 );
5541 return Ok(false);
5542 }
5543 let event: AgentEvent =
5544 serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5545 match event {
5546 AgentEvent::ToolStart {
5547 tool_call_id,
5548 tool_name,
5549 arguments,
5550 } => {
5551 if !arguments.is_object()
5552 || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5553 || tool_call_id.is_empty()
5554 || tool_call_id.len() > 128
5555 || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5556 || self.outcomes.contains_key(&tool_call_id)
5557 || self
5558 .starts
5559 .insert(tool_call_id, (tool_name, arguments))
5560 .is_some()
5561 {
5562 return Err(local_tool_history_unsupported());
5563 }
5564 }
5565 AgentEvent::ToolComplete {
5566 tool_call_id,
5567 result,
5568 } => {
5569 if !result.images.is_empty() {
5570 return Err(local_tool_history_unsupported());
5571 }
5572 self.outcome(tool_call_id, result.success, result.result)?;
5573 }
5574 AgentEvent::ToolError {
5575 tool_call_id,
5576 error,
5577 } => {
5578 self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5579 }
5580 AgentEvent::ToolToken { tool_call_id, .. } => {
5581 if !self.starts.contains_key(&tool_call_id)
5582 || self.outcomes.contains_key(&tool_call_id)
5583 {
5584 return Err(local_tool_history_unsupported());
5585 }
5586 }
5587 AgentEvent::ToolLifecycle {
5588 tool_call_id,
5589 tool_name,
5590 is_mutating,
5591 auto_approved,
5592 phase,
5593 ..
5594 } => {
5595 if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5596 || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5597 || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5598 || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5599 {
5600 return Err(local_tool_history_unsupported());
5601 }
5602 }
5603 AgentEvent::ReasoningToken { .. } => {
5604 }
5608 AgentEvent::ContextSummarized { .. }
5609 | AgentEvent::ContextArchived { .. }
5610 | AgentEvent::ContextCompressionStatus { .. }
5611 | AgentEvent::NeedClarification { .. }
5612 | AgentEvent::SubAgentStarted { .. }
5613 | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5614 AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5616 return Ok(false)
5617 }
5618 _ => {}
5619 }
5620 Ok(true)
5621 }
5622
5623 fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5624 if id.is_empty()
5625 || id.len() > 128
5626 || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5627 || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5628 || self.outcomes.insert(id, (success, text)).is_some()
5629 {
5630 return Err(local_tool_history_unsupported());
5631 }
5632 Ok(())
5633 }
5634
5635 fn suffix(
5636 self,
5637 host: &Session,
5638 tools: &[String],
5639 read_only: bool,
5640 terminal: Option<&str>,
5641 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5642 self.suffix_for_terminal(
5643 host,
5644 tools,
5645 read_only,
5646 LocalToolTerminal::Completed(terminal),
5647 )
5648 }
5649
5650 fn suffix_for_terminal(
5651 self,
5652 host: &Session,
5653 tools: &[String],
5654 read_only: bool,
5655 terminal: LocalToolTerminal<'_>,
5656 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5657 use bamboo_subagent::proto::LocalToolMessages;
5658 let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5659 if cancelled
5660 && (!read_only
5661 || tools.is_empty()
5662 || tools
5663 .iter()
5664 .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5665 || self.ticket_yield.is_some())
5666 {
5667 return Err(local_tool_history_unsupported());
5668 }
5669 let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5670 let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5671 let prefix: Vec<_> = host
5672 .messages
5673 .iter()
5674 .filter(|m| m.role != Role::System)
5675 .collect();
5676 if !host.provider_transcript.groups().is_empty()
5677 || candidate.len() < prefix.len()
5678 || (!cancelled && candidate.len() == prefix.len())
5679 || serde_json::to_value(&candidate[..prefix.len()])
5680 .map_err(|_| local_tool_history_unsupported())?
5681 != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5682 {
5683 return Err(local_tool_history_unsupported());
5684 }
5685 let mut ids = std::collections::HashSet::new();
5686 let mut calls = std::collections::HashSet::new();
5687 for message in &host.messages {
5688 if !ids.insert(message.id.clone()) {
5689 return Err(local_tool_history_unsupported());
5690 }
5691 for call in message.tool_calls.iter().flatten() {
5692 calls.insert(call.id.clone());
5693 }
5694 if let Some(id) = &message.tool_call_id {
5695 calls.insert(id.clone());
5696 }
5697 }
5698 let suffix: Vec<_> = candidate[prefix.len()..]
5699 .iter()
5700 .map(|message| (**message).clone())
5701 .collect();
5702 let mut pending = HashMap::new();
5703 let mut used = std::collections::HashSet::new();
5704 for message in &suffix {
5705 if !ids.insert(message.id.clone()) || message.never_compress {
5706 return Err(local_tool_history_unsupported());
5707 }
5708 match message.role {
5709 Role::Assistant => {
5710 if !pending.is_empty()
5711 || message.metadata.is_some()
5712 || message.tool_call_id.is_some()
5713 || message.tool_success.is_some()
5714 {
5715 return Err(local_tool_history_unsupported());
5716 }
5717 if let Some(batch) = &message.tool_calls {
5718 if batch.is_empty() {
5719 return Err(local_tool_history_unsupported());
5720 }
5721 for call in batch {
5722 let arguments =
5723 serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5724 .ok()
5725 .filter(|arguments| arguments.is_object())
5726 .ok_or_else(local_tool_history_unsupported)?;
5727 if call.id.is_empty()
5728 || call.id.len() > 128
5729 || call.tool_type != "function"
5730 || !calls.insert(call.id.clone())
5731 || calls.len() > LocalToolMessages::MAX_MESSAGES
5732 || !matches!(
5733 call.function.name.as_str(),
5734 "Read" | "Glob" | "Write" | "Task"
5735 )
5736 || (cancelled
5737 && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5738 || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5739 name != &call.function.name || actual != &arguments
5740 })
5741 {
5742 return Err(local_tool_history_unsupported());
5743 }
5744 pending.insert(call.id.clone(), call.function.name.clone());
5745 }
5746 } else if message.content.trim().is_empty() {
5747 return Err(local_tool_history_unsupported());
5748 }
5749 }
5750 Role::Tool => {
5751 let id = message
5752 .tool_call_id
5753 .as_ref()
5754 .ok_or_else(local_tool_history_unsupported)?;
5755 let name = pending
5756 .remove(id)
5757 .ok_or_else(local_tool_history_unsupported)?;
5758 let success = message
5759 .tool_success
5760 .ok_or_else(local_tool_history_unsupported)?;
5761 if message.tool_calls.is_some()
5762 || message.phase.is_some()
5763 || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5764 || !used.insert(id.clone())
5765 || used.len() > LocalToolMessages::MAX_PAIRS
5766 {
5767 return Err(local_tool_history_unsupported());
5768 }
5769 let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5770 if denied_write {
5771 if success
5772 || !(message.content.contains("native_tool_ceiling_denied")
5773 || message
5774 .content
5775 .contains("not callable at the current conversation position"))
5776 {
5777 return Err(local_tool_history_unsupported());
5778 }
5779 } else if !tools.contains(&name)
5780 || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5781 {
5782 return Err(local_tool_history_unsupported());
5783 }
5784 if let Some(metadata) = &message.metadata {
5785 let object = metadata
5786 .as_object()
5787 .ok_or_else(local_tool_history_unsupported)?;
5788 if object.len() != 5
5789 || metadata["elapsed_ms"].as_u64().is_none()
5790 || metadata["tool_name"].as_str() != Some(name.as_str())
5791 || metadata["is_mutating"].as_bool()
5792 != Some(matches!(name.as_str(), "Write" | "Task"))
5793 || metadata["auto_approved"].as_bool()
5794 != Some(!matches!(name.as_str(), "Write" | "Task"))
5795 || metadata["success"].as_bool() != Some(success)
5796 {
5797 return Err(local_tool_history_unsupported());
5798 }
5799 }
5800 }
5801 _ => return Err(local_tool_history_unsupported()),
5802 }
5803 }
5804 let last = suffix.last();
5805 let completed_text = match terminal {
5806 LocalToolTerminal::Completed(text) => text,
5807 LocalToolTerminal::Cancelled => None,
5808 };
5809 let question_terminal =
5810 self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5811 last.is_some_and(|last| last.role == Role::Tool
5812 && last.tool_call_id.as_ref() == Some(id)
5813 && last.tool_success == Some(true)
5814 && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5815 && completed_text.is_none_or(str::is_empty)
5816 && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5817 == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5818 });
5819 let report_terminal = last.is_some_and(|last| {
5820 last.role == Role::Assistant
5821 && last.tool_calls.is_none()
5822 && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5823 && completed_text == Some(last.content.as_str())
5824 });
5825 if !pending.is_empty()
5826 || used.len() != self.outcomes.len()
5827 || self.starts.keys().any(|id| !used.contains(id))
5828 || !(cancelled || report_terminal || question_terminal)
5829 {
5830 return Err(local_tool_history_unsupported());
5831 }
5832 Ok(suffix)
5833 }
5834}
5835
5836async fn validate_local_tool_checkpoint(
5837 session: &Session,
5838 binding: &SessionInboxRuntimeBinding,
5839 run_id: &str,
5840 terminal: LocalToolTerminal<'_>,
5841 cancel: &CancellationToken,
5842) -> Result<(), AgentError> {
5843 if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5844 return Err(local_tool_history_unsupported());
5845 }
5846 if matches!(terminal, LocalToolTerminal::Cancelled) {
5847 let backlog = binding
5848 .inbox
5849 .inspect(&session.id)
5850 .await
5851 .map_err(|_| local_tool_history_unsupported())?;
5852 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5853 return Err(local_tool_history_unsupported());
5856 }
5857 }
5858 Ok(())
5859}
5860
5861async fn commit_local_tool_history(
5862 collector: LocalToolCollector,
5863 session: &mut Session,
5864 binding: &SessionInboxRuntimeBinding,
5865 run_id: &str,
5866 selection: (&[String], bool),
5867 terminal: LocalToolTerminal<'_>,
5868 cancel: &CancellationToken,
5869) -> Result<(), AgentError> {
5870 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5871 let latest = binding
5872 .storage
5873 .load_session(&session.id)
5874 .await
5875 .map_err(|_| local_tool_history_unsupported())?
5876 .ok_or_else(local_tool_history_unsupported)?;
5877 if latest.id != session.id
5878 || latest.kind != session.kind
5879 || latest.created_at != session.created_at
5880 || latest.root_session_id != session.root_session_id
5881 || latest.parent_session_id != session.parent_session_id
5882 || latest.spawn_depth != session.spawn_depth
5883 || latest.project_id_meta() != session.project_id_meta()
5884 || session.check_conversation_generation(&latest).is_err()
5885 || serde_json::to_value(&latest.provider_transcript)
5886 .map_err(|_| local_tool_history_unsupported())?
5887 != serde_json::to_value(&session.provider_transcript)
5888 .map_err(|_| local_tool_history_unsupported())?
5889 {
5890 return Err(local_tool_history_unsupported());
5891 }
5892 let suffix = match terminal {
5893 LocalToolTerminal::Completed(text) => {
5894 collector.suffix(&latest, selection.0, selection.1, text)?
5895 }
5896 LocalToolTerminal::Cancelled => {
5897 collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5898 }
5899 };
5900 let before = session.clone();
5901 let mut staged = session.clone();
5902 staged.messages = latest.messages.clone();
5903 staged.messages.extend(suffix.iter().cloned());
5904 staged.updated_at = chrono::Utc::now();
5905 let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5906 Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5907 } else {
5908 None
5909 };
5910 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5911 binding
5912 .persistence
5913 .checkpoint_runtime_session(&mut staged)
5914 .await
5915 .map_err(|_| local_tool_history_unsupported())?;
5916 let saved = binding
5917 .storage
5918 .load_session(&session.id)
5919 .await
5920 .map_err(|_| local_tool_history_unsupported())?
5921 .ok_or_else(local_tool_history_unsupported)?;
5922 if session.check_conversation_generation(&saved).is_err()
5923 || serde_json::to_value(&saved.provider_transcript)
5924 .map_err(|_| local_tool_history_unsupported())?
5925 != serde_json::to_value(&latest.provider_transcript)
5926 .map_err(|_| local_tool_history_unsupported())?
5927 {
5928 return Err(local_tool_history_unsupported());
5929 }
5930 let start = match suffix.first() {
5931 Some(first) => saved
5932 .messages
5933 .iter()
5934 .position(|message| message.id == first.id)
5935 .ok_or_else(local_tool_history_unsupported)?,
5936 None => latest.messages.len(),
5937 };
5938 if cancelled_messages.as_ref().is_some_and(|expected| {
5939 serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5940 }) {
5941 return Err(local_tool_history_unsupported());
5944 }
5945 if saved.messages.len() < latest.messages.len()
5946 || serde_json::to_value(&saved.messages[..latest.messages.len()])
5947 .map_err(|_| local_tool_history_unsupported())?
5948 != serde_json::to_value(&latest.messages)
5949 .map_err(|_| local_tool_history_unsupported())?
5950 || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5951 .map_err(|_| local_tool_history_unsupported())?
5952 != serde_json::to_value(Some(&suffix[..]))
5953 .map_err(|_| local_tool_history_unsupported())?
5954 {
5955 return Err(local_tool_history_unsupported());
5956 }
5957 let expected = latest.messages.iter().chain(&suffix);
5960 for message in expected {
5961 let matches: Vec<_> = saved
5962 .messages
5963 .iter()
5964 .filter(|current| current.id == message.id)
5965 .collect();
5966 if matches.len() != 1
5967 || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5968 != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5969 {
5970 *session = before;
5971 return Err(local_tool_history_unsupported());
5972 }
5973 }
5974 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5975 staged.messages = saved.messages;
5976 *session = staged;
5977 Ok(())
5978}
5979
5980#[derive(Default)]
5981struct ReadOnlyActorCollector {
5982 start: Option<(String, serde_json::Value)>,
5983 result: Option<String>,
5984 messages: Option<Vec<bamboo_agent_core::Message>>,
5985}
5986impl ReadOnlyActorCollector {
5987 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5988 if self.messages.is_some() {
5989 return Err(plain_actor_unsupported());
5990 }
5991 if value["type"] == "owned_readonly_transcript" {
5992 let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5993 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5994 tail.validate().map_err(|_| plain_actor_unsupported())?;
5995 let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5996 let typed: Vec<bamboo_agent_core::Message> = messages
5997 .iter()
5998 .map(|raw| {
5999 let message: bamboo_agent_core::Message =
6000 serde_json::from_value(raw.clone())
6001 .map_err(|_| plain_actor_unsupported())?;
6002 if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
6003 != *raw
6004 {
6005 return Err(plain_actor_unsupported());
6006 }
6007 Ok(message)
6008 })
6009 .collect::<Result<_, _>>()?;
6010 let (id, args) = self
6011 .start
6012 .as_ref()
6013 .ok_or_else(|| plain_actor_unsupported())?;
6014 let call = typed[0]
6015 .tool_calls
6016 .as_ref()
6017 .and_then(|calls| {
6018 if calls.len() == 1 {
6019 calls.first()
6020 } else {
6021 None
6022 }
6023 })
6024 .ok_or_else(|| plain_actor_unsupported())?;
6025 if call.id != *id
6026 || call.function.name != "Glob"
6027 || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
6028 .map_err(|_| plain_actor_unsupported())?
6029 != *args
6030 || typed[1].tool_call_id.as_ref() != Some(id)
6031 || typed[1].tool_success != Some(true)
6032 || self.result.as_ref() != Some(&typed[1].content)
6033 {
6034 return Err(plain_actor_unsupported());
6035 }
6036 self.messages = Some(typed);
6037 return Ok(false);
6038 }
6039 let event: AgentEvent =
6040 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6041 match event {
6042 AgentEvent::ToolStart {
6043 tool_call_id,
6044 tool_name,
6045 arguments,
6046 } => {
6047 if tool_name != "Glob"
6048 || tool_call_id.trim().is_empty()
6049 || self.start.is_some()
6050 || !arguments.is_object()
6051 {
6052 return Err(plain_actor_unsupported());
6053 }
6054 self.start = Some((tool_call_id, arguments));
6055 Ok(true)
6056 }
6057 AgentEvent::ToolComplete {
6058 tool_call_id,
6059 result,
6060 } => {
6061 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6062 || self.result.is_some()
6063 || !result.success
6064 || !result.images.is_empty()
6065 {
6066 return Err(plain_actor_unsupported());
6067 }
6068 self.result = Some(result.result);
6069 Ok(true)
6070 }
6071 AgentEvent::ToolToken { tool_call_id, .. } => {
6072 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6073 {
6074 return Err(plain_actor_unsupported());
6075 }
6076 Ok(true)
6077 }
6078 AgentEvent::ToolLifecycle {
6079 tool_call_id,
6080 tool_name,
6081 is_mutating,
6082 phase,
6083 error,
6084 ..
6085 } => {
6086 if tool_name != "Glob"
6087 || is_mutating
6088 || !matches!(phase.as_str(), "begin" | "finished")
6089 || error.is_some()
6090 || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6091 {
6092 return Err(plain_actor_unsupported());
6093 }
6094 Ok(true)
6095 }
6096 _ => plain_actor_event(value),
6097 }
6098 }
6099 fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6100 let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6101 if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6102 return Err(plain_actor_unsupported());
6103 }
6104 Ok(messages)
6105 }
6106}
6107
6108fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6109 let event: AgentEvent =
6110 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6111 match event {
6112 AgentEvent::ReasoningToken { .. }
6113 | AgentEvent::ToolToken { .. }
6114 | AgentEvent::ToolStart { .. }
6115 | AgentEvent::ToolComplete { .. }
6116 | AgentEvent::ToolError { .. }
6117 | AgentEvent::ToolLifecycle { .. }
6118 | AgentEvent::ContextSummarized { .. }
6119 | AgentEvent::ContextArchived { .. }
6120 | AgentEvent::ContextCompressionStatus { .. }
6121 | AgentEvent::NeedClarification { .. }
6122 | AgentEvent::SubAgentStarted { .. }
6123 | AgentEvent::SubAgentEvent { .. }
6124 | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6125 AgentEvent::MessageAppended { .. }
6128 | AgentEvent::SessionHistoryCommitted { .. }
6129 | AgentEvent::VisibleMessageStart { .. }
6130 | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6131 event if event.is_durable_change() => Ok(false),
6132 _ => Ok(true),
6133 }
6134}
6135
6136fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6142 let value = match placement {
6145 Placement::Local => return None,
6146 Placement::Remote { endpoint } => serde_json::json!({
6147 "kind": "remote",
6148 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6149 }),
6150 Placement::Schedulable { pool } => serde_json::json!({
6151 "kind": "remote",
6152 "host": host_label.unwrap_or(pool),
6153 }),
6154 };
6155 serde_json::to_string(&value).ok()
6156}
6157
6158fn host_of_endpoint(endpoint: &str) -> String {
6160 endpoint
6161 .trim()
6162 .trim_start_matches("wss://")
6163 .trim_start_matches("ws://")
6164 .split(['/', ':'])
6165 .next()
6166 .unwrap_or(endpoint)
6167 .to_string()
6168}
6169
6170async fn reconcile_already_admitted_claim(
6171 binding: &SessionInboxRuntimeBinding,
6172 session: &mut Session,
6173 claim: &SessionInboxClaim,
6174) -> crate::runtime::runner::Result<()> {
6175 let latest = binding
6176 .storage
6177 .load_session(&session.id)
6178 .await
6179 .map_err(|error| {
6180 AgentError::LLM(format!(
6181 "load canonical SessionInbox checkpoint for {}: {error}",
6182 session.id
6183 ))
6184 })?
6185 .ok_or_else(|| {
6186 AgentError::LLM(format!(
6187 "canonical SessionInbox target disappeared: {}",
6188 session.id
6189 ))
6190 })?;
6191 session
6192 .check_conversation_generation(&latest)
6193 .map_err(|error| {
6194 AgentError::LLM(format!(
6195 "canonical SessionInbox conversation changed: {error}"
6196 ))
6197 })?;
6198 let Some(message) = latest
6199 .messages
6200 .iter()
6201 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6202 .cloned()
6203 else {
6204 return Err(AgentError::LLM(format!(
6205 "canonical admitted receipt for {} exists without transcript message {}",
6206 session.id, claim.envelope.id
6207 )));
6208 };
6209 if let Some(existing) = session
6210 .messages
6211 .iter_mut()
6212 .find(|existing| existing.id == message.id)
6213 {
6214 *existing = message;
6215 } else {
6216 session.add_message(message);
6217 }
6218 bamboo_domain::merge_session_inbox_admission(session, &latest);
6219 binding
6220 .inbox
6221 .ack(&session.id, claim)
6222 .await
6223 .map_err(|error| {
6224 AgentError::LLM(format!(
6225 "ack recovered canonical SessionInbox claim {}: {error}",
6226 claim.envelope.id
6227 ))
6228 })
6229}
6230
6231async fn checkpoint_and_ack_canonical_claim(
6235 binding: &SessionInboxRuntimeBinding,
6236 session: &mut Session,
6237 claim: &SessionInboxClaim,
6238 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6239) -> crate::runtime::runner::Result<()> {
6240 if claim.envelope.target_session_id != session.id {
6241 return Err(AgentError::LLM(format!(
6242 "canonical SessionInbox claim target {} does not match active logical session {}",
6243 claim.envelope.target_session_id, session.id
6244 )));
6245 }
6246 if binding
6247 .inbox
6248 .was_admitted(&session.id, &claim.envelope.id)
6249 .await
6250 .map_err(|error| {
6251 AgentError::LLM(format!(
6252 "inspect canonical admitted receipt {}: {error}",
6253 claim.envelope.id
6254 ))
6255 })?
6256 {
6257 return reconcile_already_admitted_claim(binding, session, claim).await;
6258 }
6259
6260 if let Some((store, fence)) = fenced {
6261 let committed = store
6262 .confirm_actor_claim_context(
6263 concrete_file_inbox(binding)?,
6264 bamboo_storage::ActorClaimContextSeed {
6265 fence: fence.clone(),
6266 expected_created_at: session.created_at,
6267 expected_conversation_generation: session.conversation_generation_observation(),
6268 expected_messages: session.messages.clone(),
6269 expected_provider_transcript: session.provider_transcript.clone(),
6270 expected_admission: session.session_inbox_admission().cloned(),
6271 claims: vec![claim.clone()],
6272 },
6273 )
6274 .await;
6275 let committed = match committed {
6276 Ok(committed) => committed,
6277 Err(error) => {
6278 adopt_latest_actor_context(store, session).await;
6282 return Err(AgentError::LLM(format!(
6283 "confirm fenced Actor claim context unconfirmed: {error}"
6284 )));
6285 }
6286 };
6287 bamboo_domain::merge_session_inbox_admission(session, &committed);
6288 session.messages = committed.messages;
6289 session.provider_transcript = committed.provider_transcript;
6290 session.updated_at = committed.updated_at;
6291 if !session
6292 .messages
6293 .iter()
6294 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6295 || !session
6296 .session_inbox_admission()
6297 .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6298 {
6299 return Err(AgentError::LLM(
6300 "fenced Actor claim confirmation lost durable proof".into(),
6301 ));
6302 }
6303 return binding
6304 .inbox
6305 .ack(&session.id, claim)
6306 .await
6307 .map_err(|error| {
6308 AgentError::LLM(format!(
6309 "ack fenced Actor claim {} after confirmation: {error}",
6310 claim.envelope.id
6311 ))
6312 });
6313 }
6314
6315 let transcript_has_id = session
6316 .messages
6317 .iter()
6318 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6319 if session
6320 .messages
6321 .iter()
6322 .any(|message| message.id == claim.envelope.id.as_str())
6323 && !transcript_has_id
6324 {
6325 return Err(AgentError::LLM(format!(
6326 "canonical SessionInbox id {} collides with a non-matching transcript message",
6327 claim.envelope.id
6328 )));
6329 }
6330 let cursor_has_id = session
6331 .session_inbox_admission()
6332 .is_some_and(|state| state.contains(&claim.envelope.id));
6333 if cursor_has_id && !transcript_has_id {
6334 return Err(AgentError::LLM(format!(
6335 "canonical SessionInbox cursor exists without transcript message {}",
6336 claim.envelope.id
6337 )));
6338 }
6339 let before = session.clone();
6340 if !transcript_has_id {
6341 let message = claim.envelope.to_provider_message().map_err(|error| {
6342 AgentError::LLM(format!(
6343 "translate canonical SessionInbox envelope {}: {error}",
6344 claim.envelope.id
6345 ))
6346 })?;
6347 session.add_message(message);
6348 }
6349 session
6350 .session_inbox_admission_mut()
6351 .record(claim.envelope.id.clone(), claim.generation);
6352 session.updated_at = chrono::Utc::now();
6353
6354 if let Err(error) = binding
6355 .persistence
6356 .checkpoint_runtime_session(session)
6357 .await
6358 {
6359 *session = before;
6360 return Err(AgentError::LLM(format!(
6361 "checkpoint canonical SessionInbox claim {}: {error}",
6362 claim.envelope.id
6363 )));
6364 }
6365 if !session
6366 .messages
6367 .iter()
6368 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6369 {
6370 *session = before;
6371 return Err(AgentError::LLM(format!(
6372 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6373 claim.envelope.id
6374 )));
6375 }
6376 binding
6377 .inbox
6378 .ack(&session.id, claim)
6379 .await
6380 .map_err(|error| {
6381 AgentError::LLM(format!(
6382 "ack canonical SessionInbox claim {} after checkpoint: {error}",
6383 claim.envelope.id
6384 ))
6385 })
6386}
6387
6388fn concrete_file_inbox(
6389 binding: &SessionInboxRuntimeBinding,
6390) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6391 binding
6392 .inbox
6393 .as_any()
6394 .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6395 .cloned()
6396 .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6397}
6398
6399async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6400 if let Ok(Some(latest)) = store.load_session(&session.id).await {
6401 if latest.created_at == session.created_at
6402 && session.check_conversation_generation(&latest).is_ok()
6403 {
6404 bamboo_domain::merge_session_inbox_admission(session, &latest);
6405 session.messages = latest.messages;
6406 session.provider_transcript = latest.provider_transcript;
6407 session.updated_at = latest.updated_at;
6408 }
6409 }
6410}
6411
6412async fn checkpoint_claim_context_before_dispatch(
6423 binding: &SessionInboxRuntimeBinding,
6424 session: &mut Session,
6425 claims: &[SessionInboxClaim],
6426 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6427) -> crate::runtime::runner::Result<()> {
6428 if claims.is_empty() {
6429 return Ok(());
6430 }
6431 let before = session.clone();
6432 let mut additions = Vec::new();
6433 for claim in claims {
6434 if claim.envelope.target_session_id != session.id {
6435 return Err(AgentError::LLM(format!(
6436 "canonical SessionInbox claim target {} does not match actor session {}",
6437 claim.envelope.target_session_id, session.id
6438 )));
6439 }
6440 let matching = session
6441 .messages
6442 .iter()
6443 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6444 if session
6445 .messages
6446 .iter()
6447 .any(|message| message.id == claim.envelope.id.as_str())
6448 && !matching
6449 {
6450 return Err(AgentError::LLM(format!(
6451 "canonical SessionInbox id {} collides before actor dispatch",
6452 claim.envelope.id
6453 )));
6454 }
6455 if session
6456 .session_inbox_admission()
6457 .is_some_and(|state| state.contains(&claim.envelope.id))
6458 && !matching
6459 {
6460 return Err(AgentError::LLM(format!(
6461 "canonical SessionInbox cursor exists without transcript proof for {}",
6462 claim.envelope.id
6463 )));
6464 }
6465 if !matching {
6466 let message = claim.envelope.to_provider_message().map_err(|error| {
6467 AgentError::LLM(format!(
6468 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6469 claim.envelope.id
6470 ))
6471 })?;
6472 additions.push(message);
6473 }
6474 }
6475 let fenced_seed = fenced.is_some();
6476 if let Some((store, fence)) = fenced {
6477 let committed = store
6478 .seed_actor_claim_context(
6479 concrete_file_inbox(binding)?,
6480 bamboo_storage::ActorClaimContextSeed {
6481 fence: fence.clone(),
6482 expected_created_at: session.created_at,
6483 expected_conversation_generation: session.conversation_generation_observation(),
6484 expected_messages: session.messages.clone(),
6485 expected_provider_transcript: session.provider_transcript.clone(),
6486 expected_admission: session.session_inbox_admission().cloned(),
6487 claims: claims.to_vec(),
6488 },
6489 )
6490 .await;
6491 let committed = match committed {
6492 Ok(committed) => committed,
6493 Err(error) => {
6494 adopt_latest_actor_context(store, session).await;
6498 return Err(AgentError::LLM(format!(
6499 "seed fenced Actor claim context unconfirmed: {error}"
6500 )));
6501 }
6502 };
6503 bamboo_domain::merge_session_inbox_admission(session, &committed);
6504 session.messages = committed.messages;
6505 session.provider_transcript = committed.provider_transcript;
6506 session.updated_at = committed.updated_at;
6507 } else {
6508 for message in additions {
6509 session.add_message(message);
6510 }
6511 session.updated_at = chrono::Utc::now();
6512 if let Err(error) = binding
6513 .persistence
6514 .checkpoint_runtime_session(session)
6515 .await
6516 {
6517 *session = before;
6518 return Err(AgentError::LLM(format!(
6519 "checkpoint canonical SessionInbox actor context: {error}"
6520 )));
6521 }
6522 }
6523 for claim in claims {
6524 if !session
6525 .messages
6526 .iter()
6527 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6528 {
6529 if !fenced_seed {
6530 *session = before;
6531 }
6532 return Err(AgentError::LLM(format!(
6533 "actor context checkpoint lost typed transcript proof for {}",
6534 claim.envelope.id
6535 )));
6536 }
6537 }
6538 Ok(())
6539}
6540
6541async fn claim_canonical_deliveries(
6542 binding: &SessionInboxRuntimeBinding,
6543 session: &mut Session,
6544 activation_run_id: &str,
6545 limit: usize,
6546 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6547) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6548 let claims = binding
6549 .inbox
6550 .claim_for_turn(&session.id, limit, Some(activation_run_id))
6551 .await
6552 .map_err(|error| {
6553 AgentError::LLM(format!(
6554 "claim canonical SessionInbox for active actor {}: {error}",
6555 session.id
6556 ))
6557 })?;
6558 if claims.is_empty() {
6559 return Ok(Vec::new());
6560 }
6561 let mut unconfirmed = Vec::with_capacity(claims.len());
6562 for claim in claims {
6563 if binding
6564 .inbox
6565 .was_admitted(&session.id, &claim.envelope.id)
6566 .await
6567 .map_err(|error| {
6568 AgentError::LLM(format!(
6569 "inspect canonical SessionInbox claim {}: {error}",
6570 claim.envelope.id
6571 ))
6572 })?
6573 {
6574 reconcile_already_admitted_claim(binding, session, &claim).await?;
6575 continue;
6576 }
6577 unconfirmed.push(claim);
6578 }
6579 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6580
6581 let mut deliveries = Vec::with_capacity(unconfirmed.len());
6582 for claim in unconfirmed {
6583 if session
6588 .session_inbox_admission()
6589 .is_some_and(|state| state.contains(&claim.envelope.id))
6590 {
6591 binding
6592 .inbox
6593 .ack(&session.id, &claim)
6594 .await
6595 .map_err(|error| {
6596 AgentError::LLM(format!(
6597 "finish confirmed canonical SessionInbox ack {}: {error}",
6598 claim.envelope.id
6599 ))
6600 })?;
6601 continue;
6602 }
6603 let delivery = SessionMessageDelivery {
6604 target_session_id: session.id.clone(),
6605 envelope: claim.envelope.clone(),
6606 canonical_claim_generation: claim.generation,
6607 activation_run_id: activation_run_id.to_string(),
6608 activation_policy: claim.activation_policy,
6609 };
6610 deliveries.push((claim, delivery));
6611 }
6612 Ok(deliveries)
6613}
6614
6615async fn forward_next_canonical_claim(
6616 client: &mut dyn bamboo_subagent::ChildLink,
6617 binding: &SessionInboxRuntimeBinding,
6618 session: &mut Session,
6619 activation_run_id: &str,
6620 inflight: &mut VecDeque<SessionInboxClaim>,
6621 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6622) -> crate::runtime::runner::Result<()> {
6623 if !inflight.is_empty() {
6624 return Ok(());
6625 }
6626 let Some((claim, delivery)) =
6627 claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6628 .await?
6629 .pop()
6630 else {
6631 return Ok(());
6632 };
6633 client
6634 .send(ParentFrame::SessionMessage { delivery })
6635 .await
6636 .map_err(|error| {
6637 AgentError::LLM(format!(
6638 "forward canonical SessionInbox claim {} to active actor: {error}",
6639 claim.envelope.id
6640 ))
6641 })?;
6642 inflight.push_back(claim);
6643 Ok(())
6644}
6645
6646struct ActorDriveContext<'a> {
6648 client: &'a mut dyn bamboo_subagent::ChildLink,
6649 parent_session_id: &'a str,
6650 child_session_id: &'a str,
6651 child_attempt: u32,
6652 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6653 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6654 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6655 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6656 event_tx: &'a mpsc::Sender<AgentEvent>,
6657 cancel_token: &'a CancellationToken,
6658 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6659 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6660 logical_session: &'a mut Session,
6661 expected_permission_posture: Option<ExpectedPermissionPosture>,
6662 expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6663 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6664 actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6665 canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6666 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6667 activation_run_id: Option<&'a str>,
6668 execution_epoch: u64,
6669 expected_source_actor_id: &'a str,
6670 initial_inflight_claims: VecDeque<SessionInboxClaim>,
6671 plain_actor: bool,
6672 remote_environment_lease: bool,
6673 readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6674 local_history_tools: Option<&'a [String]>,
6675 local_history_read_only: bool,
6676 strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6677 plain_input: Option<&'a PlainActorActivation>,
6678 canonical_activation: Option<&'a ActorActivationFence>,
6679 canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6680 actor_event_observer: Option<&'a dyn ActorEventObserver>,
6681 plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6682 first_frame_timeout: Option<Duration>,
6683}
6684
6685#[derive(Debug, Clone, PartialEq, Eq)]
6686struct ExpectedPermissionPosture {
6687 policy_revision: u64,
6688 resolution: bamboo_domain::PermissionModeResolution,
6689 expected_audit_revision: Option<u64>,
6690 executor_mapping: String,
6691}
6692
6693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6694enum PermissionPostureHandshake {
6695 NotRequired,
6697 Awaiting,
6700 Confirmed,
6702}
6703
6704impl PermissionPostureHandshake {
6705 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6706 if expected.is_some() {
6707 Self::Awaiting
6708 } else {
6709 Self::NotRequired
6710 }
6711 }
6712
6713 fn is_awaiting(self) -> bool {
6714 self == Self::Awaiting
6715 }
6716
6717 fn posture_was_confirmed(self) -> bool {
6718 self == Self::Confirmed
6719 }
6720}
6721
6722fn permission_posture_seed_from_event(
6723 session: &Session,
6724 event: &AgentEvent,
6725) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6726 let AgentEvent::PermissionPostureActivated {
6727 session_id,
6728 policy_revision,
6729 requested_mode,
6730 effective_mode,
6731 executor_mapping,
6732 } = event
6733 else {
6734 return Ok(None);
6735 };
6736 if session_id != &session.id {
6737 return Err("permission posture event targets a different logical session".to_string());
6738 }
6739 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6740 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6741 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6742 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6743 let resolution = bamboo_domain::PermissionModeResolution {
6744 requested,
6745 effective,
6746 };
6747 if !resolution.is_consistent() {
6748 return Err("permission posture event has an inconsistent mode pair".to_string());
6749 }
6750 let current_requested = session
6751 .agent_runtime_state
6752 .as_ref()
6753 .map(|state| state.effective_permission_mode())
6754 .unwrap_or_default();
6755 if current_requested != requested {
6756 return Err("permission posture event is stale for the host typed mode".to_string());
6757 }
6758 let mapping_chars = executor_mapping.chars().count();
6759 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6760 return Err("permission posture event has an invalid executor mapping".to_string());
6761 }
6762 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6763 *policy_revision,
6764 resolution,
6765 executor_mapping,
6766 )))
6767}
6768
6769fn actor_event_logical_identity(
6770 session: &Session,
6771 parent_session_id: &str,
6772 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6773) -> LogicalSessionIdentity {
6774 LogicalSessionIdentity {
6775 creation: creation.cloned(),
6776 session_id: session.id.clone(),
6777 parent_session_id: session
6778 .parent_session_id
6779 .clone()
6780 .or_else(|| Some(parent_session_id.to_string())),
6781 root_session_id: if session.root_session_id.trim().is_empty() {
6782 parent_session_id.to_string()
6783 } else {
6784 session.root_session_id.clone()
6785 },
6786 }
6787}
6788
6789fn validate_strict_history_terminal(
6790 watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6791 session: &Session,
6792 parent_session_id: &str,
6793 activation_run_id: Option<&str>,
6794 execution_epoch: u64,
6795 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6796 consumed_seq: u64,
6797) -> Result<(), AgentError> {
6798 let identity = actor_event_logical_identity(session, parent_session_id, creation);
6799 if watermark
6800 .zip(activation_run_id)
6801 .is_some_and(|(watermark, run_id)| {
6802 watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6803 })
6804 {
6805 Ok(())
6806 } else {
6807 Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6808 }
6809}
6810
6811fn validate_actor_event_batch(
6812 batch: &ActorEventBatch,
6813 logical_session: &Session,
6814 parent_session_id: &str,
6815 activation_run_id: Option<&str>,
6816 execution_epoch: u64,
6817 expected_source_actor_id: &str,
6818 expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6819) -> Result<(), AgentError> {
6820 batch
6821 .validate()
6822 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6823 let identity = batch.logical_session.as_ref().ok_or_else(|| {
6824 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6825 })?;
6826 let expected_identity =
6831 actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6832 if identity != &expected_identity {
6833 return Err(AgentError::LLM(
6834 "actor event batch targets a different logical session".to_string(),
6835 ));
6836 }
6837 if batch.activation_id.as_deref() != activation_run_id {
6838 return Err(AgentError::LLM(
6839 "actor event batch belongs to a stale activation".to_string(),
6840 ));
6841 }
6842 if batch.execution_epoch != execution_epoch {
6843 return Err(AgentError::LLM(
6844 "actor event batch belongs to a stale execution epoch".to_string(),
6845 ));
6846 }
6847 if batch
6848 .source_actor_id
6849 .as_deref()
6850 .is_some_and(|source| source != expected_source_actor_id)
6851 {
6852 return Err(AgentError::LLM(
6853 "actor event batch declares a different physical actor".to_string(),
6854 ));
6855 }
6856 Ok(())
6857}
6858
6859async fn validate_directory_actor_event(
6863 store: &bamboo_storage::SessionStoreV2,
6864 fence: &ActorActivationFence,
6865 session: &Session,
6866 run_id: Option<&str>,
6867) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6868 store
6869 .validate_fence(fence, chrono::Utc::now())
6870 .await
6871 .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6872 let entry = store
6873 .inspect_actor(&fence.actor_id)
6874 .await
6875 .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6876 if !entry.actor.matches_session(session)
6877 || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6878 || !entry.activation.as_ref().is_some_and(|current| {
6879 current.matches_fence(fence)
6880 && current.status == bamboo_domain::ActorActivationStatus::Running
6881 })
6882 || run_id.is_some_and(|id| id != fence.run_id)
6883 {
6884 return Err(AgentError::LLM("actor event authority changed".into()));
6885 }
6886 Ok(entry)
6887}
6888
6889async fn inspect_active_owned_tree(
6892 store: &bamboo_storage::SessionStoreV2,
6893 binding: &SessionInboxRuntimeBinding,
6894 run_id: &str,
6895 child_id: &str,
6896 parent_id: &str,
6897 logical_session: &Session,
6898 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6899 plain_input: Option<&PlainActorActivation>,
6900 cursor: Option<&str>,
6901) -> Option<serde_json::Value> {
6902 use bamboo_agent_core::storage::Storage;
6903 let current = match store.load_session(child_id).await {
6904 Ok(Some(current)) => current,
6905 _ => {
6906 tracing::warn!("owned tree denied: canonical caller unavailable");
6907 return None;
6908 }
6909 };
6910 let current_project = match project_id_for_actor_run(¤t) {
6911 Ok(project) => project,
6912 Err(_) => {
6913 tracing::warn!("owned tree denied: invalid canonical Project");
6914 return None;
6915 }
6916 };
6917 let logical_project = match project_id_for_actor_run(logical_session) {
6918 Ok(project) => project,
6919 Err(_) => {
6920 tracing::warn!("owned tree denied: invalid logical Project");
6921 return None;
6922 }
6923 };
6924 if current.id != child_id
6925 || current.kind != bamboo_domain::SessionKind::Child
6926 || current.parent_session_id.as_deref() != Some(parent_id)
6927 || current.parent_session_id != logical_session.parent_session_id
6928 || current.root_session_id != logical_session.root_session_id
6929 || current.created_at != creation.created_at
6930 || current.created_at != logical_session.created_at
6931 || current.spawn_depth != creation.spawn_depth
6932 || current.spawn_depth != logical_session.spawn_depth
6933 || current_project != logical_project
6934 {
6935 tracing::warn!("owned tree denied: caller lifetime or Project changed");
6936 return None;
6937 }
6938 if let Some(activation) = plain_input {
6939 validate_directory_actor_event(
6940 activation.store.as_ref(),
6941 &activation.fence,
6942 ¤t,
6943 Some(run_id),
6944 )
6945 .await
6946 .ok()?;
6947 } else if !binding.router.owns_run(child_id, run_id).await {
6948 tracing::warn!("owned tree denied: activation owner changed");
6949 return None;
6950 }
6951 let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6952 store, child_id, cursor,
6953 )
6954 .await
6955 {
6956 Ok(page) => page,
6957 Err(error) => {
6958 tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6959 return None;
6960 }
6961 };
6962 let again = match store.load_session(child_id).await {
6963 Ok(Some(again)) => again,
6964 _ => {
6965 tracing::warn!("owned tree denied: canonical caller disappeared");
6966 return None;
6967 }
6968 };
6969 if again.created_at != current.created_at
6970 || again.parent_session_id != current.parent_session_id
6971 || again.root_session_id != current.root_session_id
6972 || again.spawn_depth != current.spawn_depth
6973 || project_id_for_actor_run(&again).ok()? != current_project
6974 {
6975 tracing::warn!("owned tree denied: caller changed during inspection");
6976 return None;
6977 }
6978 if let Some(activation) = plain_input {
6979 validate_directory_actor_event(
6980 activation.store.as_ref(),
6981 &activation.fence,
6982 &again,
6983 Some(run_id),
6984 )
6985 .await
6986 .ok()?;
6987 } else if !binding.router.owns_run(child_id, run_id).await {
6988 tracing::warn!("owned tree denied: activation owner changed after inspection");
6989 return None;
6990 }
6991 serde_json::to_vec(&page)
6992 .ok()
6993 .filter(|bytes| bytes.len() <= 8192)?;
6994 Some(page)
6995}
6996
6997async fn load_active_subagent_caller(
7001 store: &bamboo_storage::SessionStoreV2,
7002 binding: &SessionInboxRuntimeBinding,
7003 run_id: &str,
7004 child_id: &str,
7005 parent_id: &str,
7006 logical_session: &Session,
7007 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7008 plain_input: Option<&PlainActorActivation>,
7009) -> Option<Session> {
7010 use bamboo_agent_core::storage::Storage;
7011 let current = store.load_session(child_id).await.ok().flatten()?;
7012 let current_project = project_id_for_actor_run(¤t).ok()?;
7013 let logical_project = project_id_for_actor_run(logical_session).ok()?;
7014 if current.id != child_id
7015 || current.kind != bamboo_domain::SessionKind::Child
7016 || current.parent_session_id.as_deref() != Some(parent_id)
7017 || current.parent_session_id != logical_session.parent_session_id
7018 || current.root_session_id != logical_session.root_session_id
7019 || current.created_at != creation.created_at
7020 || current.created_at != logical_session.created_at
7021 || current.spawn_depth != creation.spawn_depth
7022 || current.spawn_depth != logical_session.spawn_depth
7023 || current_project != logical_project
7024 || bamboo_domain::ActorSession::from_session(¤t).is_err()
7025 {
7026 return None;
7027 }
7028 if let Some(activation) = plain_input {
7029 validate_directory_actor_event(
7030 activation.store.as_ref(),
7031 &activation.fence,
7032 ¤t,
7033 Some(run_id),
7034 )
7035 .await
7036 .ok()?;
7037 } else if !binding.router.owns_run(child_id, run_id).await {
7038 return None;
7039 }
7040 Some(current)
7041}
7042
7043async fn invoke_active_subagent(
7044 tool: &dyn bamboo_agent_core::tools::Tool,
7045 caller: &Session,
7046 args: serde_json::Value,
7047 tool_call_id: &str,
7048) -> serde_json::Value {
7049 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7050 let mut ctx = ToolCtx::none(tool_call_id.to_owned());
7051 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7052 let output_cap = caller
7053 .effective_token_budget()
7054 .map(|budget| budget.max_tool_output_tokens);
7055 let outcome = bamboo_agent_core::tools::scope_tool_output_cap(
7056 &caller.id,
7057 tool_call_id,
7058 output_cap,
7059 tool.invoke(args, ctx),
7060 )
7061 .await;
7062 match outcome {
7063 Ok(ToolOutcome::Completed(result))
7064 if result.images.is_empty()
7065 && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
7066 {
7067 serde_json::json!({"result":result})
7068 }
7069 _ => serde_json::json!({
7070 "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
7071 }),
7072 }
7073}
7074
7075#[allow(clippy::too_many_arguments)]
7078async fn checkpoint_active_parent_question(
7079 store: &bamboo_storage::SessionStoreV2,
7080 binding: &SessionInboxRuntimeBinding,
7081 run_id: &str,
7082 child_id: &str,
7083 parent_id: &str,
7084 logical_session: &mut Session,
7085 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7086 plain_input: Option<&PlainActorActivation>,
7087 observation: ParentQuestionCheckpointV1,
7088) -> Option<String> {
7089 use bamboo_agent_core::storage::Storage;
7090 if plain_input.is_some() || observation.validate_shape().is_err() {
7091 return None;
7092 }
7093 let _session_guard = binding
7097 .parent_question_lock
7098 .as_ref()?
7099 .acquire_lock(child_id)
7100 .await;
7101 let caller = load_active_subagent_caller(
7102 store,
7103 binding,
7104 run_id,
7105 child_id,
7106 parent_id,
7107 logical_session,
7108 creation,
7109 plain_input,
7110 )
7111 .await?;
7112 let parent = store.load_session(parent_id).await.ok().flatten()?;
7113 if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7114 return None;
7115 }
7116 let (saved, question) = store
7117 .checkpoint_parent_question(&caller, &parent, &observation)
7118 .await
7119 .ok()
7120 .flatten()?;
7121 logical_session.messages = saved.messages;
7124 logical_session.pending_question = saved.pending_question;
7125 if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7126 logical_session
7127 .metadata
7128 .insert("runtime.suspend_reason".into(), reason.clone());
7129 }
7130 if let Some(request) = saved
7131 .metadata
7132 .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7133 {
7134 logical_session.metadata.insert(
7135 bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7136 request.clone(),
7137 );
7138 }
7139 logical_session.metadata.insert(
7140 "runtime.actor_parent_question_handoff".into(),
7141 "true".into(),
7142 );
7143 Some(question.id.as_str().to_owned())
7144}
7145
7146async fn ensure_active_nested_wait(
7151 tool: &dyn bamboo_agent_core::tools::Tool,
7152 store: &bamboo_storage::SessionStoreV2,
7153 binding: &SessionInboxRuntimeBinding,
7154 run_id: &str,
7155 child_id: &str,
7156 parent_id: &str,
7157 logical_session: &Session,
7158 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7159 plain_input: Option<&PlainActorActivation>,
7160) -> Result<bool, AgentError> {
7161 fn is_terminal_status(status: &str) -> bool {
7162 matches!(
7163 status,
7164 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7165 )
7166 }
7167 use bamboo_agent_core::storage::Storage;
7168 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7169
7170 let caller = load_active_subagent_caller(
7171 store,
7172 binding,
7173 run_id,
7174 child_id,
7175 parent_id,
7176 logical_session,
7177 creation,
7178 plain_input,
7179 )
7180 .await
7181 .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7182 if caller
7183 .agent_runtime_state
7184 .as_ref()
7185 .and_then(|state| state.waiting_for_children.as_ref())
7186 .is_some()
7187 {
7188 return Ok(true);
7189 }
7190
7191 let statuses = store
7192 .list_child_run_statuses(child_id)
7193 .await
7194 .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?;
7195 let mut active = Vec::new();
7196 for (id, status) in statuses {
7197 if !status.as_deref().is_some_and(is_terminal_status)
7198 && !store
7199 .is_supervisor_observer_child(&caller, &id)
7200 .await
7201 .map_err(|error| {
7202 AgentError::LLM(format!("nested Observer classification failed: {error}"))
7203 })?
7204 {
7205 active.push(id);
7206 }
7207 }
7208 if active.is_empty() {
7209 return Ok(false);
7210 }
7211
7212 let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7213 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7214 let outcome = tokio::time::timeout(
7215 Duration::from_secs(30),
7216 tool.invoke(
7217 serde_json::json!({"action":"wait", "child_session_ids":active}),
7218 ctx,
7219 ),
7220 )
7221 .await
7222 .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7223 .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7224 let wait_registered = match outcome {
7225 ToolOutcome::Completed(result) if result.success => {
7226 let payload: serde_json::Value = serde_json::from_str(&result.result)
7227 .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7228 payload["status"] == "waiting"
7229 }
7230 _ => {
7231 return Err(AgentError::LLM(
7232 "nested wait registration was not completed".into(),
7233 ))
7234 }
7235 };
7236
7237 let latest = load_active_subagent_caller(
7240 store,
7241 binding,
7242 run_id,
7243 child_id,
7244 parent_id,
7245 logical_session,
7246 creation,
7247 plain_input,
7248 )
7249 .await
7250 .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7251 if latest
7252 .agent_runtime_state
7253 .as_ref()
7254 .and_then(|state| state.waiting_for_children.as_ref())
7255 .is_some()
7256 {
7257 return Ok(true);
7258 }
7259 let statuses = store
7260 .list_child_run_statuses(child_id)
7261 .await
7262 .map_err(|error| {
7263 AgentError::LLM(format!("nested child wait verification failed: {error}"))
7264 })?;
7265 let mut still_active = false;
7266 for (id, status) in statuses {
7267 if !status.as_deref().is_some_and(is_terminal_status)
7268 && !store
7269 .is_supervisor_observer_child(&latest, &id)
7270 .await
7271 .map_err(|error| {
7272 AgentError::LLM(format!("nested Observer classification failed: {error}"))
7273 })?
7274 {
7275 still_active = true;
7276 break;
7277 }
7278 }
7279 if still_active {
7280 return Err(AgentError::LLM(
7281 "nested child remains active without a durable parent wait".into(),
7282 ));
7283 }
7284 Ok(wait_registered)
7289}
7290
7291fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7292 let message = match error {
7293 ActorEventRouteError::SequenceGap { .. } => {
7294 "actor event sequence gap; authoritative session snapshot is required"
7295 }
7296 ActorEventRouteError::LifecycleMissing => {
7297 "actor emitted live content before its activation lifecycle event"
7298 }
7299 ActorEventRouteError::ForgedSession => {
7300 "actor event payload targets a different canonical session"
7301 }
7302 ActorEventRouteError::ConflictingReplay => {
7303 "actor replay reused an event sequence with different content"
7304 }
7305 ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7306 ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7307 };
7308 AgentError::LLM(message.into())
7309}
7310
7311const MAX_DISPLAY_CALLS: usize = 4_096;
7312const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7313const MAX_DISPLAY_ID_BYTES: usize = 256;
7314const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7315
7316fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7317 if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7318 return None;
7319 }
7320 Some(match result {
7321 HookResult::Continue => HookResult::Continue,
7322 HookResult::Mutated => HookResult::Mutated,
7323 HookResult::Allow => HookResult::Allow,
7324 HookResult::Ask => HookResult::Ask,
7325 HookResult::Deny { .. } => HookResult::Deny {
7326 reason: "Hook denied".into(),
7327 },
7328 HookResult::InjectContext { .. } => HookResult::InjectContext {
7329 text: "Hook context hidden".into(),
7330 },
7331 HookResult::WithContext { result, .. } => HookResult::WithContext {
7332 result: Box::new(hook_result_for_display(*result, depth + 1)?),
7333 text: "Hook context hidden".into(),
7334 },
7335 HookResult::Suspend { .. } => HookResult::Suspend {
7336 reason: "Hook suspended".into(),
7337 },
7338 HookResult::Abort { .. } => HookResult::Abort {
7339 reason: "Hook aborted".into(),
7340 },
7341 })
7342}
7343
7344#[derive(Clone, Copy, PartialEq, Eq)]
7345enum DisplayCallKind {
7346 Ordinary,
7347 PrivateBrowser,
7348 Reused,
7349}
7350
7351#[derive(Clone, Copy)]
7352struct DisplayCall {
7353 kind: DisplayCallKind,
7354 started: bool,
7355}
7356
7357#[derive(Default)]
7358struct ActorEventDisplay {
7359 calls: HashMap<String, DisplayCall>,
7360 hook_names: HashMap<String, usize>,
7361 children: HashMap<(String, String), Box<ActorEventDisplay>>,
7362 identities: Arc<AtomicUsize>,
7365 overflowed: bool,
7366 nested_overflowed: bool,
7367}
7368
7369fn is_browser_tool_name(tool_name: &str) -> bool {
7370 tool_name
7371 .trim()
7372 .rsplit("::")
7373 .next()
7374 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7375}
7376
7377fn known_browser_action(args: &serde_json::Value) -> bool {
7378 matches!(
7379 args.get("action").and_then(serde_json::Value::as_str),
7380 Some(
7381 "tabs"
7382 | "new_tab"
7383 | "activate_tab"
7384 | "close_tab"
7385 | "navigate"
7386 | "history"
7387 | "viewport"
7388 | "snapshot"
7389 | "click"
7390 | "click_at"
7391 | "hover"
7392 | "drag"
7393 | "fill"
7394 | "select_option"
7395 | "set_file_input"
7396 | "type"
7397 | "press"
7398 | "key"
7399 | "scroll"
7400 | "download"
7401 | "screenshot"
7402 | "dialog_respond"
7403 )
7404 )
7405}
7406
7407fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7408 is_browser_tool_name(tool_name)
7409 && (!known_browser_action(args)
7410 || args
7411 .get("action")
7412 .and_then(serde_json::Value::as_str)
7413 .is_some_and(|action| {
7414 action.eq_ignore_ascii_case("download") || action == "set_file_input"
7415 })
7416 || args.get("data_base64").is_some())
7417}
7418
7419fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7420 if is_browser_tool_name(tool_name) {
7421 if !known_browser_action(args) {
7424 return serde_json::json!({"details":"[redacted]"});
7425 }
7426 }
7427 tool_start_arguments_for_display(tool_name, args)
7428}
7429
7430impl ActorEventDisplay {
7431 fn fail_closed(&mut self) {
7432 self.identities
7433 .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7434 self.overflowed = true;
7435 self.calls.clear();
7436 self.hook_names.clear();
7437 self.nested_overflowed = true;
7438 self.children.clear();
7439 }
7440
7441 fn reserve_identity(&mut self) -> bool {
7442 if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7443 true
7444 } else {
7445 self.fail_closed();
7446 false
7447 }
7448 }
7449
7450 fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7451 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7452 return DisplayCallKind::Reused;
7453 }
7454 let kind = if private {
7455 DisplayCallKind::PrivateBrowser
7456 } else {
7457 DisplayCallKind::Ordinary
7458 };
7459 if let Some(call) = self.calls.get_mut(id) {
7460 if call.started || call.kind != kind {
7461 call.kind = DisplayCallKind::Reused;
7462 }
7463 call.started = true;
7464 return call.kind;
7465 }
7466 if !self.reserve_identity() {
7467 return DisplayCallKind::Reused;
7468 }
7469 self.calls.insert(
7470 id.to_string(),
7471 DisplayCall {
7472 kind,
7473 started: true,
7474 },
7475 );
7476 kind
7477 }
7478
7479 fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7480 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7481 return DisplayCallKind::Reused;
7482 }
7483 let kind = if private {
7484 DisplayCallKind::PrivateBrowser
7485 } else {
7486 DisplayCallKind::Ordinary
7487 };
7488 if let Some(call) = self.calls.get_mut(id) {
7489 if call.kind != kind {
7490 call.kind = DisplayCallKind::Reused;
7491 }
7492 return call.kind;
7493 }
7494 if !self.reserve_identity() {
7495 return DisplayCallKind::Reused;
7496 }
7497 self.calls.insert(
7498 id.to_string(),
7499 DisplayCall {
7500 kind,
7501 started: false,
7502 },
7503 );
7504 kind
7505 }
7506
7507 fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7508 if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7509 return None;
7510 }
7511 self.calls
7512 .get(id)
7513 .filter(|call| call.started)
7514 .map(|call| call.kind)
7515 }
7516
7517 fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7518 if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7519 self.fail_closed();
7520 return None;
7521 }
7522 if let Some(index) = self.hook_names.get(hook_name) {
7523 return Some(format!("actor-hook-{index}"));
7524 }
7525 if !self.reserve_identity() {
7526 return None;
7527 }
7528 let index = self.hook_names.len() + 1;
7529 self.hook_names.insert(hook_name.to_string(), index);
7530 Some(format!("actor-hook-{index}"))
7531 }
7532
7533 fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7534 let call_id = match &event {
7535 AgentEvent::ToolStart { tool_call_id, .. }
7536 | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7537 | AgentEvent::ToolToken { tool_call_id, .. }
7538 | AgentEvent::ToolComplete { tool_call_id, .. }
7539 | AgentEvent::ToolError { tool_call_id, .. }
7540 | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7541 _ => None,
7542 };
7543 if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7544 self.fail_closed();
7545 return None;
7546 }
7547 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7548 self.fail_closed();
7549 }
7550 Some(match event {
7551 AgentEvent::ToolStart {
7552 tool_call_id,
7553 tool_name,
7554 arguments,
7555 } => {
7556 let private = is_private_browser_call(&tool_name, &arguments);
7557 let kind = self.remember_start(&tool_call_id, private);
7558 let arguments = if kind == DisplayCallKind::Reused {
7559 serde_json::json!({"details":"[redacted]"})
7560 } else {
7561 actor_arguments_for_display(&tool_name, &arguments)
7562 };
7563 AgentEvent::ToolStart {
7564 tool_call_id,
7565 tool_name: match kind {
7566 DisplayCallKind::Reused => "unknown".into(),
7567 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7568 _ => tool_name,
7569 },
7570 arguments,
7571 }
7572 }
7573 AgentEvent::ToolApprovalRequested {
7574 tool_call_id,
7575 tool_name,
7576 parameters,
7577 } => {
7578 let private = is_private_browser_call(&tool_name, ¶meters);
7579 let kind = self.remember_approval(&tool_call_id, private);
7580 let parameters = if kind == DisplayCallKind::Reused {
7581 serde_json::json!({"details":"[redacted]"})
7582 } else {
7583 actor_arguments_for_display(&tool_name, ¶meters)
7584 };
7585 AgentEvent::ToolApprovalRequested {
7586 tool_call_id,
7587 tool_name: match kind {
7588 DisplayCallKind::Reused => "unknown".into(),
7589 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7590 _ => tool_name,
7591 },
7592 parameters,
7593 }
7594 }
7595 AgentEvent::ToolToken {
7596 tool_call_id,
7597 content,
7598 } => {
7599 let content = match self.known_kind(&tool_call_id) {
7600 Some(DisplayCallKind::Ordinary) => content,
7601 Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7602 _ => "Tool output hidden".into(),
7603 };
7604 AgentEvent::ToolToken {
7605 tool_call_id,
7606 content,
7607 }
7608 }
7609 AgentEvent::ToolComplete {
7610 tool_call_id,
7611 result,
7612 } => {
7613 let result = match self.known_kind(&tool_call_id) {
7614 Some(DisplayCallKind::Ordinary) => result,
7615 Some(DisplayCallKind::PrivateBrowser) => {
7616 bamboo_agent_core::tools::ToolResult::text(
7617 result.success,
7618 "Browser tool result hidden",
7619 )
7620 }
7621 _ => bamboo_agent_core::tools::ToolResult::text(
7622 result.success,
7623 "Tool result hidden",
7624 ),
7625 };
7626 AgentEvent::ToolComplete {
7627 tool_call_id,
7628 result,
7629 }
7630 }
7631 AgentEvent::ToolError {
7632 tool_call_id,
7633 error,
7634 } => {
7635 let error = match self.known_kind(&tool_call_id) {
7636 Some(DisplayCallKind::Ordinary) => error,
7637 Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7638 _ => "Tool failed".into(),
7639 };
7640 AgentEvent::ToolError {
7641 tool_call_id,
7642 error,
7643 }
7644 }
7645 AgentEvent::ToolLifecycle {
7646 tool_call_id,
7647 tool_name,
7648 phase,
7649 elapsed_ms,
7650 is_mutating,
7651 auto_approved,
7652 summary,
7653 error,
7654 } => {
7655 let kind = self.known_kind(&tool_call_id);
7656 let (summary, error) = match kind {
7657 Some(DisplayCallKind::Ordinary) => (summary, error),
7658 Some(DisplayCallKind::PrivateBrowser) => (
7659 summary.map(|_| "Browser tool activity hidden".into()),
7660 error.map(|_| "Browser tool failed".into()),
7661 ),
7662 _ => (
7663 summary.map(|_| "Tool activity hidden".into()),
7664 error.map(|_| "Tool failed".into()),
7665 ),
7666 };
7667 AgentEvent::ToolLifecycle {
7668 tool_call_id,
7669 tool_name: match kind {
7670 Some(DisplayCallKind::Ordinary) => tool_name,
7671 Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7672 _ => "unknown".into(),
7673 },
7674 phase: if kind == Some(DisplayCallKind::Ordinary)
7675 || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7676 {
7677 phase
7678 } else {
7679 "event".into()
7680 },
7681 elapsed_ms,
7682 is_mutating,
7683 auto_approved,
7684 summary,
7685 error,
7686 }
7687 }
7688 AgentEvent::HookLifecycle {
7689 hook_name,
7690 point,
7691 phase,
7692 duration_ms,
7693 decision,
7694 } => {
7695 let Some(decision) = hook_result_for_display(decision, 0) else {
7696 self.fail_closed();
7697 return None;
7698 };
7699 let hook_name = self.display_hook_name(&hook_name)?;
7700 let phase = match phase.as_str() {
7701 "completed" | "started" | "error" => phase,
7702 _ => "event".into(),
7703 };
7704 AgentEvent::HookLifecycle {
7705 hook_name,
7706 point,
7707 phase,
7708 duration_ms,
7709 decision,
7710 }
7711 }
7712 AgentEvent::ChildApprovalRequested {
7713 child_session_id,
7714 request_id,
7715 tool_name,
7716 permission,
7717 resource,
7718 } => {
7719 let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7720 (
7721 "browser".into(),
7722 "Browser interaction approval".into(),
7723 "[redacted]".into(),
7724 )
7725 } else {
7726 (tool_name, permission, resource)
7727 };
7728 AgentEvent::ChildApprovalRequested {
7729 child_session_id,
7730 request_id,
7731 tool_name,
7732 permission,
7733 resource,
7734 }
7735 }
7736 AgentEvent::ChildApprovalChanged { tool_name, .. }
7737 if is_browser_tool_name(&tool_name) =>
7738 {
7739 return None;
7743 }
7744 AgentEvent::SubAgentEvent {
7745 parent_session_id,
7746 child_session_id,
7747 event,
7748 } => {
7749 if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7750 return None;
7751 }
7752 if parent_session_id.is_empty()
7753 || child_session_id.is_empty()
7754 || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7755 || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7756 {
7757 self.fail_closed();
7758 return None;
7759 }
7760 let key = (parent_session_id.clone(), child_session_id.clone());
7761 if !self.children.contains_key(&key) && !self.reserve_identity() {
7762 return None;
7763 }
7764 let identities = self.identities.clone();
7765 let child = self.children.entry(key).or_insert_with(|| {
7766 Box::new(ActorEventDisplay {
7767 identities,
7768 ..Default::default()
7769 })
7770 });
7771 let event = child.project(*event, depth + 1)?;
7772 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7773 self.fail_closed();
7774 return None;
7775 }
7776 AgentEvent::SubAgentEvent {
7777 parent_session_id,
7778 child_session_id,
7779 event: Box::new(event),
7780 }
7781 }
7782 other => other,
7783 })
7784 }
7785}
7786
7787#[allow(clippy::too_many_arguments)]
7788async fn process_actor_event(
7789 event: serde_json::Value,
7790 strict_permission_events: bool,
7791 permission_handshake: &mut PermissionPostureHandshake,
7792 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7793 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7794 logical_session: &mut Session,
7795 event_tx: &mpsc::Sender<AgentEvent>,
7796 display: &mut ActorEventDisplay,
7797) -> crate::runtime::runner::Result<()> {
7798 let event = match serde_json::from_value::<AgentEvent>(event) {
7799 Ok(event) => event,
7800 Err(_) if strict_permission_events => {
7801 return Err(AgentError::LLM(
7805 "actor emitted malformed AgentEvent under a typed permission posture contract"
7806 .to_string(),
7807 ));
7808 }
7809 Err(_) => {
7810 display.fail_closed();
7814 return Ok(());
7815 }
7816 };
7817 process_typed_actor_event(
7818 event,
7819 permission_handshake,
7820 expected_permission_posture,
7821 session_inbox_runtime,
7822 logical_session,
7823 event_tx,
7824 display,
7825 )
7826 .await
7827}
7828
7829#[allow(clippy::too_many_arguments)]
7830async fn process_typed_actor_event(
7831 event: AgentEvent,
7832 permission_handshake: &mut PermissionPostureHandshake,
7833 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7834 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7835 logical_session: &mut Session,
7836 event_tx: &mpsc::Sender<AgentEvent>,
7837 display: &mut ActorEventDisplay,
7838) -> crate::runtime::runner::Result<()> {
7839 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7840 if permission_handshake.posture_was_confirmed() {
7841 return Err(AgentError::LLM(
7842 "actor emitted a duplicate permission posture activation".to_string(),
7843 ));
7844 }
7845 let seed = permission_posture_seed_from_event(logical_session, &event)
7846 .map_err(AgentError::LLM)?
7847 .ok_or_else(|| {
7848 AgentError::LLM(
7849 "actor permission posture event did not decode as a posture".to_string(),
7850 )
7851 })?;
7852 if let Some(expected) = expected_permission_posture {
7853 if seed.policy_revision != expected.policy_revision
7854 || seed.resolution != expected.resolution
7855 {
7856 return Err(AgentError::LLM(
7857 "permission posture event does not match the host-dispatched policy"
7858 .to_string(),
7859 ));
7860 }
7861 if seed.executor_mapping() != expected.executor_mapping {
7862 return Err(AgentError::LLM(
7863 "permission posture event does not match the host-dispatched executor mapping"
7864 .to_string(),
7865 ));
7866 }
7867 }
7868 if let Some(binding) = session_inbox_runtime {
7869 let saved = binding
7870 .persistence
7871 .record_permission_posture_activation(
7872 &logical_session.id,
7873 expected_permission_posture
7874 .and_then(|expected| expected.expected_audit_revision),
7875 &seed,
7876 )
7877 .await
7878 .map_err(|error| {
7879 AgentError::LLM(format!(
7880 "persist child permission posture bootstrap: {error}"
7881 ))
7882 })?
7883 .ok_or_else(|| {
7884 AgentError::LLM(
7885 "persist child permission posture bootstrap: session not found".to_string(),
7886 )
7887 })?;
7888 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7889 .ok_or_else(|| {
7890 AgentError::LLM(
7891 "persisted child permission posture audit is incomplete".to_string(),
7892 )
7893 })?;
7894 snapshot.write_to(&mut logical_session.metadata);
7895 } else {
7896 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7899 .map_err(|error| {
7900 AgentError::LLM(format!(
7901 "record in-memory child permission posture: {error}"
7902 ))
7903 })?;
7904 }
7905 *permission_handshake = PermissionPostureHandshake::Confirmed;
7908 } else if permission_handshake.is_awaiting() {
7909 return Err(AgentError::LLM(
7910 "actor emitted an execution event before permission posture confirmation".to_string(),
7911 ));
7912 }
7913 if let Some(event) = display.project(event, 0) {
7914 let _ = event_tx.send(event).await;
7915 }
7916 Ok(())
7917}
7918
7919async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7930 let ActorDriveContext {
7931 client,
7932 parent_session_id,
7933 child_session_id,
7934 child_attempt,
7935 approval_registry,
7936 approval_decider,
7937 approval_reviewer,
7938 escalation_bridge,
7939 event_tx,
7940 cancel_token,
7941 live_rx,
7942 delivery_rx,
7943 logical_session,
7944 mut expected_permission_posture,
7945 expected_creation,
7946 session_inbox_runtime,
7947 actor_directory_store,
7948 canonical_subagent_tool,
7949 ticket_service,
7950 activation_run_id,
7951 execution_epoch,
7952 expected_source_actor_id,
7953 initial_inflight_claims,
7954 first_frame_timeout,
7955 plain_actor,
7956 remote_environment_lease,
7957 mut readonly_output,
7958 local_history_tools,
7959 local_history_read_only,
7960 mut strict_history_output,
7961 plain_input,
7962 canonical_activation,
7963 canonical_placement_ref,
7964 actor_event_observer,
7965 plain_run,
7966 } = context;
7967
7968 logical_session
7972 .metadata
7973 .remove("runtime.actor_nested_wait_handoff");
7974
7975 let mut got_first_frame = false;
7982 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7983 let mut inflight_claims = initial_inflight_claims;
7984 let mut owned_inputs = plain_input
7985 .map(|activation| VecDeque::from(activation.initial_inputs.clone()))
7986 .unwrap_or_default();
7987 let continuation_limit = if owned_inputs.is_empty() { 2 } else { 1 };
7990 let mut continuations = usize::from(!owned_inputs.is_empty());
7991 let mut released_inputs: Vec<(
7992 SessionInboxOwnedClaim,
7993 bamboo_subagent::proto::InitialInputRelease,
7994 )> = Vec::new();
7995 let mut current_epoch = execution_epoch;
7996 let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7997 let approval_cancel = cancel_token.child_token();
7998 let _approval_lifetime = approval_cancel.clone().drop_guard();
7999 let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
8000 HashMap::new();
8001 let mut owned_tree_requests = 0usize;
8002 let mut canonical_subagent_requests = 0usize;
8003 let mut committed_plan_calls = HashMap::new();
8004 let mut nested_wait_registered_this_run = false;
8008 let mut parent_question_checkpoint_id: Option<String> = None;
8009 let strict_permission_events = expected_permission_posture.is_some();
8010 let mut permission_handshake =
8011 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8012 let mut next_actor_event_seq = 1u64;
8013 let mut canonical_router: Option<ActorEventRouter> = None;
8014 let canonical_fence = plain_input
8015 .map(|activation| &activation.fence)
8016 .or(canonical_activation);
8017 let canonical_store = plain_input
8018 .map(|activation| activation.store.as_ref())
8019 .or(actor_directory_store);
8020 let remote_claim_fence = actor_directory_store.zip(canonical_activation);
8021 let workflow_usage_requested = logical_session
8022 .metadata
8023 .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
8024 .is_some_and(|v| v == "true");
8025 if workflow_usage_requested {
8026 logical_session
8027 .metadata
8028 .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
8029 logical_session
8030 .metadata
8031 .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
8032 }
8033 let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
8034 let mut display = ActorEventDisplay::default();
8035 let mut readonly = readonly_output
8036 .as_ref()
8037 .map(|_| ReadOnlyActorCollector::default());
8038 let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
8039 loop {
8040 tokio::select! {
8041 _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
8042 if remote_claim_fence.is_some() || workflow_usage_requested {
8043 let _ = client.send(ParentFrame::Cancel).await;
8049 remote_cancel_deadline = Some(
8050 tokio::time::Instant::now() + Duration::from_secs(10)
8051 );
8052 continue;
8053 }
8054 break;
8056 }
8057 _ = async {
8058 if let Some(deadline) = remote_cancel_deadline {
8059 tokio::time::sleep_until(deadline).await;
8060 } else {
8061 std::future::pending::<()>().await;
8062 }
8063 } => {
8064 return Err(AgentError::Cancelled);
8065 }
8066 _ = async {
8067 match first_frame_watch.as_mut() {
8068 Some(s) => s.as_mut().await,
8069 None => std::future::pending::<()>().await,
8070 }
8071 }, if !got_first_frame => {
8072 return Err(AgentError::WorkerUnresponsive(format!(
8073 "child {child_session_id} produced no frame within {:?}",
8074 first_frame_timeout.unwrap_or_default()
8075 )));
8076 }
8077 Some(_generation) = delivery_rx.recv(),
8078 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
8079 {
8080 if plain_input.is_some() {
8081 continue;
8084 }
8085 forward_next_canonical_claim(
8086 client,
8087 session_inbox_runtime.expect("guarded"),
8088 logical_session,
8089 activation_run_id.expect("guarded"),
8090 &mut inflight_claims,
8091 remote_claim_fence,
8092 )
8093 .await?;
8094 }
8095 Some(frame) = live_rx.recv() => {
8096 if plain_actor { return Err(plain_actor_unsupported()); }
8097 if client.send(frame).await.is_err() {
8099 tracing::warn!("live steering frame could not be sent; connection failing");
8100 }
8101 }
8102 frame = client.next_frame() => {
8103 got_first_frame = true;
8106 first_frame_watch = None;
8107 match frame {
8108 Ok(Some(ChildFrame::Event { event })) => {
8109 if canonical_activation.is_some() {
8110 return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8111 }
8112 if local_history.is_some() {
8113 return Err(local_tool_history_unsupported());
8114 }
8115 if event.get("initial_input_control").is_some() {
8116 let bamboo_subagent::proto::InitialInputControl::Request { request } =
8117 bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8118 else { return Err(plain_actor_unsupported()); };
8119 if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8120 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8121 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8122 let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8123 let previous = released_inputs.iter()
8127 .find(|(_, release)| release.request == request);
8128 let (claim, previous) = match previous {
8129 Some((claim, release)) => (claim.clone(), Some(release.clone())),
8130 None => (owned_inputs.front().cloned()
8131 .ok_or_else(|| plain_actor_unsupported())?, None),
8132 };
8133 let release = activation.release_initial_input(binding, logical_session, run, current_epoch, &claim,
8134 &request, &mut expected_permission_posture, previous.as_ref()).await?;
8135 if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8136 client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8137 .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8138 if previous.is_none() {
8139 let _ = owned_inputs.pop_front();
8140 released_inputs.push((claim, release));
8141 }
8142 continue;
8143 }
8144 if expected_creation.is_some() {
8145 return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8146 }
8147 if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8148 if event["type"] == "owned_readonly_transcript" {
8149 if plain_actor { return Err(plain_actor_unsupported()); }
8150 continue;
8151 }
8152 if plain_actor && !plain_actor_event(&event)? { continue; }
8153 if let Some(activation) = plain_input {
8154 validate_directory_actor_event(
8155 activation.store.as_ref(),
8156 &activation.fence,
8157 logical_session,
8158 None,
8159 ).await?;
8160 }
8161 process_actor_event(
8165 event,
8166 strict_permission_events,
8167 &mut permission_handshake,
8168 expected_permission_posture.as_ref(),
8169 session_inbox_runtime,
8170 logical_session,
8171 event_tx,
8172 &mut display,
8173 )
8174 .await?;
8175 }
8176 Ok(Some(ChildFrame::EventBatch { batch })) => {
8177 validate_actor_event_batch(
8178 &batch,
8179 logical_session,
8180 parent_session_id,
8181 activation_run_id,
8182 current_epoch,
8183 expected_source_actor_id,
8184 expected_creation,
8185 )?;
8186 let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8187 let entry = validate_directory_actor_event(
8188 store, fence, logical_session, batch.activation_id.as_deref(),
8189 ).await?;
8190 if canonical_placement_ref.is_some_and(|expected| {
8191 entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8192 }) {
8193 return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8194 }
8195 Some(entry)
8196 } else { None };
8197 if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8198 if canonical_router.is_none() {
8199 canonical_router = Some(
8200 ActorEventRouter::new(
8201 entry,
8202 fence,
8203 current_epoch,
8204 strict_permission_events && permission_handshake.is_awaiting(),
8205 )
8206 .map_err(actor_event_route_error)?,
8207 );
8208 }
8209 }
8210 if local_history_tools.is_some()
8211 && (batch.first_seq != next_actor_event_seq
8212 || batch.qos != bamboo_subagent::ActorEventQos::Durable
8213 || batch.last_seq == u64::MAX) {
8214 return Err(local_tool_history_unsupported());
8215 }
8216 if batch.last_seq < next_actor_event_seq {
8217 continue;
8218 }
8219 if batch.first_seq > next_actor_event_seq {
8220 if plain_actor {
8221 return Err(AgentError::LLM(
8222 "actor event sequence gap; authoritative session snapshot is required".into(),
8223 ));
8224 }
8225 tracing::warn!(
8226 child_session_id,
8227 expected_seq = next_actor_event_seq,
8228 received_seq = batch.first_seq,
8229 "actor event sequence gap; authoritative session snapshot is required"
8230 );
8231 display.fail_closed();
8235 }
8236 let skip = next_actor_event_seq
8237 .saturating_sub(batch.first_seq)
8238 .min(batch.events.len() as u64) as usize;
8239 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8240 let seq = batch.first_seq + offset as u64;
8241 let mut publish = true;
8242 if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8243 if workflow_usage_requested {
8244 if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8245 let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8246 let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8247 if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8248 if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8249 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8250 }
8251 publish = false;
8252 }
8253 if publish { if let Some(collector) = local_history.as_mut() {
8254 if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8255 return Err(local_tool_history_unsupported());
8256 }
8257 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8258 publish = collector.ticket_event(&event, &committed_plan_calls)?;
8259 }
8260 publish &= collector.event(&event)?;
8261 } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8262 publish = false;
8264 }}
8265 if publish { publish = if let Some(collector) = readonly.as_mut() {
8266 if permission_handshake.is_awaiting()
8267 && event["type"] != "permission_posture_activated" {
8268 return Err(plain_actor_unsupported());
8269 }
8270 collector.event(&event)?
8271 } else if event["type"] == "owned_readonly_transcript" {
8272 if plain_actor { return Err(plain_actor_unsupported()); }
8273 false } else if plain_actor { plain_actor_event(&event)? } else { true };
8275 }
8276 if let (Some(router), Some(entry), Some(fence)) =
8277 (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8278 {
8279 let typed = if publish {
8280 Some(serde_json::from_value::<AgentEvent>(event.clone())
8281 .map_err(|_| plain_actor_unsupported())?)
8282 } else {
8283 None
8284 };
8285 match router.route(entry, fence, current_epoch, seq, typed)
8286 .map_err(actor_event_route_error)?
8287 {
8288 ActorEventRoute::Publish(envelope) => {
8289 let public_event = actor_event_observer
8290 .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8291 .map(|_| PublicActorEvent::from(envelope.as_ref()));
8292 tracing::trace!(
8293 actor_id = %envelope.actor_id,
8294 event_id = %envelope.event_id,
8295 class = ?envelope.class,
8296 "publishing canonical Actor event"
8297 );
8298 process_typed_actor_event(
8299 envelope.payload,
8300 &mut permission_handshake,
8301 expected_permission_posture.as_ref(),
8302 session_inbox_runtime,
8303 logical_session,
8304 event_tx,
8305 &mut display,
8306 ).await?;
8307 if let (Some(observer), Some(public_event)) =
8308 (actor_event_observer, public_event)
8309 {
8310 observer.publish(public_event);
8311 }
8312 }
8313 ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8314 }
8315 next_actor_event_seq = seq.saturating_add(1);
8316 continue;
8317 }
8318 if !publish {
8319 next_actor_event_seq = seq.saturating_add(1);
8320 continue;
8321 }
8322 process_actor_event(
8323 event,
8324 strict_permission_events,
8325 &mut permission_handshake,
8326 expected_permission_posture.as_ref(),
8327 session_inbox_runtime,
8328 logical_session,
8329 event_tx,
8330 &mut display,
8331 )
8332 .await?;
8333 next_actor_event_seq = seq.saturating_add(1);
8334 }
8335 }
8336 Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8337 owned_tree_requests += 1;
8338 if owned_tree_requests > 16 {
8339 return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8340 }
8341 let page = if id.len() <= 128
8342 && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8343 && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8344 && !cancel_token.is_cancelled()
8345 {
8346 match (actor_directory_store, session_inbox_runtime,
8347 activation_run_id, expected_creation) {
8348 (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8349 tokio::time::timeout(
8350 Duration::from_secs(8),
8351 inspect_active_owned_tree(
8352 store, binding, run_id, child_session_id,
8353 parent_session_id, logical_session, creation,
8354 plain_input, cursor.as_deref(),
8355 ),
8356 ).await.ok().flatten()
8357 }
8358 _ => {
8359 tracing::warn!("owned tree denied: canonical Host scope unavailable");
8360 None
8361 },
8362 }
8363 } else {
8364 tracing::warn!("owned tree denied: request bounds or cancellation");
8365 None
8366 };
8367 if cancel_token.is_cancelled() {
8368 return Err(AgentError::Cancelled);
8369 }
8370 client.send(ParentFrame::OwnedTreeReply { id, page }).await
8371 .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8372 }
8373 Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8374 canonical_subagent_requests += 1;
8375 if canonical_subagent_requests > 64 {
8376 return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8377 }
8378 let valid_shape = !id.is_empty() && id.len() <= 128
8379 && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8380 && args.is_object()
8381 && serde_json::to_vec(&args)
8382 .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8383 let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8384 Some("wait"))
8385 || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8386 && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8387 let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8388 let ticket_bootstrap_pending = valid_shape
8389 && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8390 && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8391 && permission_handshake.is_awaiting()
8392 && !cancel_token.is_cancelled();
8393 let result = if ticket_bootstrap_pending {
8394 serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8398 } else if valid_shape
8399 && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8400 || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8401 || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8402 && !cancel_token.is_cancelled()
8403 && !permission_handshake.is_awaiting()
8404 {
8405 if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8406 match (ticket_service.as_deref(), actor_directory_store,
8407 session_inbox_runtime, activation_run_id, expected_creation) {
8408 (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8409 if let Some(caller) = load_active_subagent_caller(
8410 store, binding, run_id, child_session_id,
8411 parent_session_id, logical_session, creation, plain_input,
8412 ).await {
8413 match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8414 Ok(projection) => {
8415 logical_session.task_list = projection.task_list.clone();
8416 logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8417 if let Some(question) = &projection.question {
8418 let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8419 logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8420 if let Some(collector) = local_history.as_mut() {
8421 collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8422 }
8423 }
8424 if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8425 committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8426 if let Some(task_list) = projection.task_list.clone() {
8427 let _ = event_tx.send(AgentEvent::TaskListUpdated {
8428 task_list,
8429 version: Some(projection.plan_revision),
8430 }).await;
8431 }
8432 }
8433 serde_json::json!({"result":projection})
8434 }
8435 Err(error) => serde_json::json!({"error":error.to_string()}),
8436 }
8437 } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8438 }
8439 _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8440 }
8441 } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8442 match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8443 activation_run_id, expected_creation, local_history_tools) {
8444 (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8445 if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8446 match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8447 Ok(reply)=>{
8448 let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8449 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())?}));
8450 serde_json::json!({"result":reply})
8451 },
8452 Err(error)=>serde_json::json!({"error":error.to_string()}),
8453 }
8454 } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8455 },
8456 _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8457 }
8458 } else if let Some(payload) = checkpoint_payload {
8459 let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8460 .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8461 .flatten()
8462 .filter(|observation| observation.tool_call_id == tool_call_id);
8463 match (parsed, actor_directory_store, session_inbox_runtime,
8464 activation_run_id, expected_creation) {
8465 (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8466 let receipt = tokio::time::timeout(Duration::from_secs(25),
8467 checkpoint_active_parent_question(
8468 store, binding, run_id, child_session_id,
8469 parent_session_id, logical_session, creation,
8470 plain_input, observation,
8471 )).await.ok().flatten();
8472 if let Some(request_id) = receipt {
8473 parent_question_checkpoint_id = Some(request_id.clone());
8474 serde_json::json!({"result":{"request_id":request_id}})
8475 } else {
8476 serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8477 }
8478 }
8479 _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8480 }
8481 } else {
8482 match (canonical_subagent_tool.as_deref(), actor_directory_store,
8483 session_inbox_runtime, activation_run_id, expected_creation) {
8484 (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8485 if let Some(caller) = load_active_subagent_caller(
8486 store, binding, run_id, child_session_id,
8487 parent_session_id, logical_session, creation,
8488 plain_input,
8489 ).await {
8490 tokio::time::timeout(
8491 Duration::from_secs(30),
8492 invoke_active_subagent(tool, &caller, args, &tool_call_id),
8493 ).await.unwrap_or_else(|_| serde_json::json!({
8494 "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8495 }))
8496 } else {
8497 serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8498 }
8499 }
8500 _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8501 }
8502 }
8503 } else {
8504 serde_json::json!({"error":"canonical SubAgent request rejected"})
8505 };
8506 if registers_wait
8507 && result["result"]["success"] == true
8508 && result["result"]["result"]
8509 .as_str()
8510 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8511 .is_some_and(|value| value["status"] == "waiting")
8512 {
8513 nested_wait_registered_this_run = true;
8514 }
8515 if cancel_token.is_cancelled() {
8516 return Err(AgentError::Cancelled);
8517 }
8518 client.send(ParentFrame::SubAgentReply { id, result }).await
8519 .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8520 }
8521 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8522 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8523 return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8524 }
8525 if plain_actor { return Err(plain_actor_unsupported()); }
8526 if permission_handshake.is_awaiting() {
8527 return Err(AgentError::LLM(
8528 "actor requested approval before permission posture confirmation"
8529 .to_string(),
8530 ));
8531 }
8532 if let Some(reviewer) = approval_reviewer
8538 .cloned()
8539 .or_else(child_approval_reviewer)
8540 {
8541 let child = child_session_id.to_string();
8549 let parent = parent_session_id.to_string();
8550 let req_id = id.clone();
8551 let body = body.clone();
8552 let registry = approval_registry.cloned();
8553 let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8554 tokio::spawn(async move {
8557 let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8558 reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8559 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8560 child_attempt, &req_id, approved);
8561 });
8562 continue;
8563 };
8564 let generation = body.pointer("/permission_request/request_generation")
8565 .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8566 let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8567 if original != &body || scope.stamp()["reply"] != req_id {
8568 return Err(AgentError::LLM("approval generation changed in live scope".into()));
8569 }
8570 scope.clone()
8571 } else {
8572 if approval_scopes.len() >= 64 {
8573 return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8574 }
8575 let scope = ChildApprovalScope::new(&parent, &child,
8576 (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8577 approval_cancel.clone(), approval_epoch.clone());
8578 approval_scopes.insert(generation, (body.clone(), scope.clone()));
8579 scope
8580 };
8581 tokio::spawn(async move {
8582 let result = tokio::time::timeout(
8583 CHILD_APPROVAL_TIMEOUT,
8584 reviewer.review_scoped(&parent, &child, &body, &scope),
8585 ).await.unwrap_or(ChildApprovalReview::Reply(false));
8586 if let ChildApprovalReview::Reply(approved) = result {
8587 if scope.is_current(&parent, &child).await {
8588 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8589 child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8590 }
8591 }
8592 });
8593 } else if approval_decider.is_some() {
8594 let approved =
8598 decide_child_approval(approval_decider, child_session_id, &body)
8599 .await;
8600 if client
8601 .send(ParentFrame::ApprovalReply { id, approved })
8602 .await
8603 .is_err()
8604 {
8605 tracing::warn!(
8606 "failed to answer approval_request; connection failing"
8607 );
8608 }
8609 } else if let Some(host) = escalation_bridge.clone() {
8610 let child = child_session_id.to_string();
8617 let req_id = id.clone();
8618 let body = body.clone();
8619 let registry = approval_registry.cloned();
8620 tokio::spawn(async move {
8621 let approved = match tokio::time::timeout(
8622 CHILD_APPROVAL_TIMEOUT,
8623 host.approval_call(body),
8624 )
8625 .await
8626 {
8627 Ok(Ok(reply)) => reply
8628 .get("approved")
8629 .and_then(|v| v.as_bool())
8630 .unwrap_or(false),
8631 _ => false,
8633 };
8634 super::live::deliver_approval_scoped(
8635 registry.as_ref(),
8636 &child,
8637 child_attempt,
8638 &req_id,
8639 approved,
8640 );
8641 });
8642 } else {
8643 tracing::warn!(
8647 parent_session_id,
8648 child_session_id,
8649 request_id = %id,
8650 "forced-ask request has no parent-agent reviewer; denying"
8651 );
8652 if client
8653 .send(ParentFrame::ApprovalReply {
8654 id,
8655 approved: false,
8656 })
8657 .await
8658 .is_err()
8659 {
8660 tracing::warn!(
8661 "failed to send fail-closed approval reply; connection failing"
8662 );
8663 }
8664 }
8665 }
8666 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8667 if let Some(activation) = plain_input {
8668 if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8669 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8670 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8671 let claim = owned_inputs.front().ok_or_else(|| plain_actor_unsupported())?;
8672 activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8673 let _ = owned_inputs.pop_front();
8674 continue;
8675 }
8676 let Some(binding) = session_inbox_runtime else {
8677 tracing::warn!(
8678 child_session_id,
8679 "ignoring SessionInbox confirmation without a runtime binding"
8680 );
8681 continue;
8682 };
8683 let Some(bound_run_id) = activation_run_id else {
8684 tracing::warn!(
8685 child_session_id,
8686 "ignoring SessionInbox confirmation without an activation owner"
8687 );
8688 continue;
8689 };
8690 let Some(claim) = inflight_claims.front() else {
8691 tracing::warn!(
8692 child_session_id,
8693 envelope_id = %confirmation.envelope_id,
8694 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8695 );
8696 continue;
8697 };
8698 let exact = confirmation.target_session_id == logical_session.id
8699 && confirmation.envelope_id == claim.envelope.id.as_str()
8700 && confirmation.canonical_claim_generation == claim.generation
8701 && confirmation.activation_run_id == bound_run_id;
8702 if !exact
8703 || !binding
8704 .router
8705 .owns_run(&logical_session.id, bound_run_id)
8706 .await
8707 {
8708 tracing::warn!(
8709 child_session_id,
8710 expected_target = %logical_session.id,
8711 received_target = %confirmation.target_session_id,
8712 expected_envelope_id = %claim.envelope.id,
8713 received_envelope_id = %confirmation.envelope_id,
8714 expected_generation = claim.generation,
8715 received_generation = confirmation.canonical_claim_generation,
8716 expected_run_id = bound_run_id,
8717 received_run_id = %confirmation.activation_run_id,
8718 "rejecting stale or mismatched SessionInbox admission confirmation"
8719 );
8720 continue;
8721 }
8722 let claim = inflight_claims
8723 .pop_front()
8724 .expect("validated in-flight canonical claim");
8725 checkpoint_and_ack_canonical_claim(
8728 binding,
8729 logical_session,
8730 &claim,
8731 remote_claim_fence,
8732 )
8733 .await?;
8734 if inflight_claims.is_empty() {
8738 forward_next_canonical_claim(
8739 client,
8740 binding,
8741 logical_session,
8742 bound_run_id,
8743 &mut inflight_claims,
8744 remote_claim_fence,
8745 )
8746 .await?;
8747 }
8748 }
8749 Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8750 if local_history_tools.is_some() {
8751 validate_strict_history_terminal(
8754 final_event_watermark.as_ref(), logical_session, parent_session_id,
8755 activation_run_id, current_epoch, expected_creation,
8756 next_actor_event_seq - 1,
8757 )?;
8758 }
8759 if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8760 if let Some(tools) = local_history_tools {
8761 if permission_handshake.is_awaiting()
8765 || !owned_inputs.is_empty()
8766 || !inflight_claims.is_empty()
8767 || parent_question_checkpoint_id.is_some()
8768 || nested_wait_registered_this_run
8769 || strict_history_output.is_none()
8770 {
8771 return Err(local_tool_history_unsupported());
8772 }
8773 commit_local_tool_history(
8774 local_history.take().ok_or_else(local_tool_history_unsupported)?,
8775 logical_session,
8776 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8777 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8778 (tools, local_history_read_only),
8779 LocalToolTerminal::Cancelled,
8780 cancel_token,
8781 ).await?;
8782 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8783 logical_session,
8784 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8785 current_epoch,
8786 next_actor_event_seq - 1,
8787 ).map_err(|_| local_tool_history_unsupported())?;
8788 *strict_history_output.as_deref_mut()
8789 .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8790 proof,
8791 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8792 });
8793 }
8794 client.accept_durable_terminal(status);
8800 if workflow_usage_requested {
8801 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8802 }
8803 return Err(AgentError::Cancelled);
8804 }
8805 if parent_question_checkpoint_id.is_some()
8806 && status != TerminalStatus::Suspended
8807 {
8808 return Err(AgentError::LLM(
8809 "Child question checkpoint must end in a suspended Run".into(),
8810 ));
8811 }
8812 if permission_handshake.is_awaiting() {
8813 if remote_environment_lease && status == TerminalStatus::Error {
8814 if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8815 client.accept_durable_terminal(status);
8822 if workflow_usage_requested {
8823 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8824 }
8825 return Err(AgentError::LLM(code.to_owned()));
8826 }
8827 }
8828 return Err(AgentError::LLM(
8829 "actor terminated before permission posture confirmation"
8830 .to_string(),
8831 ));
8832 }
8833 if !owned_inputs.is_empty() {
8834 return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8835 }
8836 if let Some(claim) = inflight_claims.front() {
8837 return Err(AgentError::LLM(format!(
8838 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8839 claim.envelope.id
8840 )));
8841 }
8842 if status == TerminalStatus::Completed {
8843 if let Some(collector) = local_history.take() {
8844 commit_local_tool_history(
8845 collector, logical_session,
8846 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8847 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8848 (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8849 LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8850 ).await?;
8851 }
8852 }
8853 if let Some(collector) = readonly.take() {
8854 if status == TerminalStatus::Completed {
8855 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8856 let backlog = activation.input_inbox.inspect(&logical_session.id).await
8857 .map_err(|_| plain_actor_unsupported())?;
8858 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8859 return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8860 }
8861 *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8862 collector.finish(result.as_deref())?;
8863 }
8864 } else if let Some(activation) = plain_input {
8865 if status == TerminalStatus::Completed {
8866 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8867 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8868 if continuations < continuation_limit {
8869 if let Some((claim, epoch)) = activation.continue_input(
8870 client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8871 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8872 ).await? {
8873 owned_inputs = VecDeque::from([claim]);
8874 released_inputs.clear();
8875 continuations += 1;
8876 current_epoch = epoch;
8877 approval_epoch.store(epoch, Ordering::Release);
8878 next_actor_event_seq = 1;
8881 canonical_router = None;
8882 permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8883 display = ActorEventDisplay::default();
8884 continue;
8885 }
8886 } else if activation.input_inbox.inspect(&logical_session.id).await
8887 .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8888 if continuation_limit == 1 {
8889 return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8890 }
8891 activation.append_reply(logical_session,
8896 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8897 event_tx).await?;
8898 return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8899 }
8900 }
8901 }
8902 if status == TerminalStatus::Completed {
8903 if let (Some(tool), Some(creation)) =
8908 (canonical_subagent_tool.as_deref(), expected_creation)
8909 {
8910 let (Some(store), Some(binding), Some(run_id)) =
8911 (actor_directory_store, session_inbox_runtime,
8912 activation_run_id)
8913 else {
8914 return Err(AgentError::LLM(
8915 "nested SubAgent Host authority unavailable at Child terminal"
8916 .into(),
8917 ));
8918 };
8919 let must_suspend = ensure_active_nested_wait(
8920 tool, store, binding, run_id, child_session_id,
8921 parent_session_id, logical_session, creation, plain_input,
8922 ).await?;
8923 if must_suspend || nested_wait_registered_this_run {
8924 logical_session.metadata.insert(
8931 "runtime.actor_nested_wait_handoff".into(),
8932 "true".into(),
8933 );
8934 }
8935 }
8936 }
8937 let terminal_result = match status {
8938 TerminalStatus::Completed => Ok(result),
8939 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8940 TerminalStatus::Error => Err(AgentError::LLM(
8941 error.unwrap_or_else(|| "actor child errored".to_string()),
8942 )),
8943 TerminalStatus::Suspended => {
8944 use bamboo_agent_core::storage::Storage;
8945 let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8946 (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8947 session_inbox_runtime, activation_run_id, expected_creation)
8948 else {
8949 return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8950 };
8951 if load_active_subagent_caller(
8952 store, binding, run_id, child_session_id,
8953 parent_session_id, logical_session, creation, plain_input,
8954 ).await.is_none() {
8955 return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8956 }
8957 let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8958 .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8959 let child = store.load_session(child_session_id).await
8960 .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8961 .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8962 let parent = store.load_session(parent_session_id).await
8963 .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8964 .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8965 let pending = ParentQuestion::for_pending(&parent, &child)
8966 .is_some_and(|question| question.id == id);
8967 let answered = bamboo_domain::ParentQuestionResolution::from_child(
8968 &parent, &child, &id,
8969 ).is_some();
8970 if !pending && !answered {
8971 return Err(AgentError::LLM("canonical Child question proof changed".into()));
8972 }
8973 logical_session.metadata.insert(
8974 "runtime.actor_parent_question_handoff".into(), "true".into(),
8975 );
8976 Ok(None)
8977 }
8978 };
8979 if local_history_tools.is_some() {
8980 if status != TerminalStatus::Completed {
8984 return terminal_result;
8985 }
8986 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8987 logical_session,
8988 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8989 current_epoch,
8990 next_actor_event_seq - 1,
8991 ).map_err(|_| local_tool_history_unsupported())?;
8992 let history = StrictTerminalHistory {
8993 proof,
8994 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8995 };
8996 if let Some(output) = strict_history_output.as_deref_mut() {
8997 *output = Some(history);
8998 }
8999 }
9000 client.accept_durable_terminal(status);
9007 if workflow_usage_requested {
9008 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
9009 }
9010 if status == TerminalStatus::Completed {
9011 if let Some(activation) = plain_input {
9012 *activation.completion_router.lock().recover_poison() =
9015 canonical_router.take();
9016 }
9017 }
9018 return terminal_result;
9019 }
9020 Ok(None) => {
9021 return Err(AgentError::LLM(
9022 "actor child closed before terminal".to_string(),
9023 ));
9024 }
9025 Err(e) => {
9026 return Err(AgentError::LLM(format!("actor transport error: {e}")));
9027 }
9028 }
9029 }
9030 }
9031 }
9032
9033 let _ = client.send(ParentFrame::Cancel).await;
9035 Err(AgentError::Cancelled)
9036}
9037
9038fn project_id_for_actor_run(
9040 session: &Session,
9041) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
9042 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
9043 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
9044 Ok(Some(project_id))
9045 }
9046 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
9047 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
9048 Err(AgentError::LLM(format!(
9049 "child session carries an invalid Project identity '{raw}': {message}"
9050 )))
9051 }
9052 }
9053}
9054
9055fn remote_broker_unavailable() -> AgentError {
9056 AgentError::LLM("remote_broker_activation_unavailable".into())
9057}
9058
9059fn remote_environment_error_code(value: &str) -> Option<&'static str> {
9063 const CODES: &[&str] = &[
9064 "remote_environment_lease_invalid",
9065 "remote_environment_snapshot_mismatch",
9066 "remote_environment_checkout_not_clean",
9067 "remote_environment_checkout_unsupported",
9068 "remote_environment_workspace_unavailable",
9069 "remote_environment_workspace_outside_git_root",
9070 "remote_environment_path_unsupported",
9071 "remote_environment_git_unavailable",
9072 "remote_environment_git_inspection_failed",
9073 "remote_environment_git_inspection_timed_out",
9074 "remote_environment_git_output_too_large",
9075 "remote_environment_snapshot_too_large",
9076 "remote_environment_snapshot_changed",
9077 "remote_environment_index_invalid",
9078 "remote_environment_index_unsupported",
9079 "remote_environment_host_path_or_grants_rejected",
9080 "remote_environment_worker_workspace_missing",
9081 "remote_environment_runspec_invalid",
9082 "remote_environment_lease_missing_or_host_path_present",
9083 ];
9084 CODES.iter().copied().find(|code| *code == value)
9085}
9086
9087async fn remote_canonical_lineage(
9090 binding: &SessionInboxRuntimeBinding,
9091 session: &Session,
9092) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
9093 let mut current = bamboo_domain::ActorSession::from_session(session)
9094 .map_err(|_| remote_broker_unavailable())?;
9095 let mut chain = Vec::new();
9096 loop {
9097 if chain.len() > session.spawn_depth as usize {
9098 return Err(remote_broker_unavailable());
9099 }
9100 let saved = binding
9101 .storage
9102 .load_session(¤t.actor_id)
9103 .await
9104 .map_err(|_| remote_broker_unavailable())?
9105 .ok_or_else(remote_broker_unavailable)?;
9106 let observed = bamboo_domain::ActorSession::from_session(&saved)
9107 .map_err(|_| remote_broker_unavailable())?;
9108 if observed.actor_id != current.actor_id
9109 || observed.session_created_at != current.session_created_at
9110 || observed.parent_actor_id != current.parent_actor_id
9111 || observed.root_actor_id != current.root_actor_id
9112 || observed.spawn_depth != current.spawn_depth
9113 || observed.project_id != current.project_id
9114 {
9115 return Err(remote_broker_unavailable());
9116 }
9117 chain.push(observed.clone());
9118 let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9119 if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9120 return Err(remote_broker_unavailable());
9121 }
9122 return Ok(chain);
9123 };
9124 let parent = binding
9125 .storage
9126 .load_session(parent_id)
9127 .await
9128 .map_err(|_| remote_broker_unavailable())?
9129 .ok_or_else(remote_broker_unavailable)?;
9130 let parent = bamboo_domain::ActorSession::from_session(&parent)
9131 .map_err(|_| remote_broker_unavailable())?;
9132 if parent.root_actor_id != observed.root_actor_id
9133 || parent.project_id != observed.project_id
9134 || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9135 || parent.session_created_at > observed.session_created_at
9136 {
9137 return Err(remote_broker_unavailable());
9138 }
9139 current = parent;
9140 }
9141}
9142
9143async fn canonical_child_creation(
9144 binding: &SessionInboxRuntimeBinding,
9145 session: &Session,
9146 job: &SpawnJob,
9147) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9148 let conflict = || {
9149 AgentError::LLM(
9150 "child creation identity unavailable or changed in canonical host Storage".into(),
9151 )
9152 };
9153 let current = binding
9154 .storage
9155 .load_session(&job.child_session_id)
9156 .await
9157 .map_err(|_| conflict())?
9158 .ok_or_else(conflict)?;
9159 if bamboo_domain::ActorSession::from_session(¤t).is_err()
9160 || bamboo_domain::ActorSession::from_session(session).is_err()
9161 || current.kind != bamboo_domain::SessionKind::Child
9162 || session.kind != current.kind
9163 || current.id != session.id
9164 || current.id != job.child_session_id
9165 || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9166 || current.parent_session_id != session.parent_session_id
9167 || current.root_session_id.trim().is_empty()
9168 || current.root_session_id != session.root_session_id
9169 || current.created_at != session.created_at
9170 || current.spawn_depth != session.spawn_depth
9171 || current.spawn_depth == 0
9172 || project_id_for_actor_run(¤t)? != project_id_for_actor_run(session)?
9173 {
9174 return Err(conflict());
9175 }
9176 Ok(bamboo_subagent::proto::ChildCreationIdentity {
9177 created_at: current.created_at,
9178 spawn_depth: current.spawn_depth,
9179 })
9180}
9181
9182fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9183 LogicalSessionIdentity {
9184 creation: None,
9185 session_id: session.id.clone(),
9186 parent_session_id: session
9187 .parent_session_id
9188 .clone()
9189 .or_else(|| Some(job.parent_session_id.clone())),
9190 root_session_id: if session.root_session_id.trim().is_empty() {
9191 job.parent_session_id.clone()
9192 } else {
9193 session.root_session_id.clone()
9194 },
9195 }
9196}
9197
9198fn extract_assignment(session: &Session) -> String {
9199 session
9200 .messages
9201 .iter()
9202 .rev()
9203 .find(|m| matches!(m.role, Role::User))
9204 .map(|m| m.content.clone())
9205 .unwrap_or_else(|| {
9206 session
9207 .metadata
9208 .get("title")
9209 .cloned()
9210 .unwrap_or_else(|| "Execute task".to_string())
9211 })
9212}
9213
9214#[cfg(test)]
9215mod tests {
9216 use super::*;
9217 use crate::SessionActivationRouter;
9218 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9219
9220 include!("expired_plain_owner_tests.rs");
9221
9222 include!("owned_cold_input_tests.rs");
9223
9224 struct SubAgentCapProbe;
9225
9226 #[async_trait]
9227 impl bamboo_agent_core::tools::Tool for SubAgentCapProbe {
9228 fn name(&self) -> &str {
9229 "SubAgent"
9230 }
9231
9232 fn description(&self) -> &str {
9233 "Capture the Host's output-cap observation"
9234 }
9235
9236 fn parameters_schema(&self) -> serde_json::Value {
9237 serde_json::json!({"type":"object"})
9238 }
9239
9240 async fn invoke(
9241 &self,
9242 _args: serde_json::Value,
9243 ctx: bamboo_agent_core::tools::ToolCtx,
9244 ) -> Result<bamboo_agent_core::tools::ToolOutcome, bamboo_agent_core::tools::ToolError>
9245 {
9246 let cap = bamboo_agent_core::tools::observed_tool_output_cap(&ctx);
9247 Ok(bamboo_agent_core::tools::ToolOutcome::Completed(
9248 bamboo_agent_core::tools::ToolResult::text(
9249 true,
9250 serde_json::json!(cap).to_string(),
9251 ),
9252 ))
9253 }
9254 }
9255
9256 #[tokio::test]
9257 async fn host_subagent_dispatch_scopes_the_callers_current_output_cap() {
9258 for cap in [None, Some(0), Some(512)] {
9259 let mut caller = Session::new_child("cap-child", "cap-parent", "model", "Child");
9260 caller.token_budget = cap.map(|max_tool_output_tokens| bamboo_domain::TokenBudget {
9261 max_tool_output_tokens,
9262 ..Default::default()
9263 });
9264 let response = invoke_active_subagent(
9265 &SubAgentCapProbe,
9266 &caller,
9267 serde_json::json!({}),
9268 "cap-call",
9269 )
9270 .await;
9271 let result: bamboo_agent_core::tools::ToolResult =
9272 serde_json::from_value(response["result"].clone()).unwrap();
9273 assert_eq!(
9274 serde_json::from_str::<Option<u32>>(&result.result).unwrap(),
9275 cap
9276 );
9277 }
9278 }
9279
9280 #[test]
9281 fn plain_rejection_logs_branch_and_run_without_changing_error() {
9282 let capture_interest_guard = tracing::Dispatch::new(
9284 tracing_subscriber::fmt()
9285 .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9286 .with_writer(std::io::sink)
9287 .finish(),
9288 );
9289 let output = tempfile::NamedTempFile::new().unwrap();
9290 let subscriber = tracing_subscriber::fmt()
9291 .with_ansi(false)
9292 .without_time()
9293 .with_writer(output.reopen().unwrap())
9294 .finish();
9295 let branch_line = std::cell::Cell::new(0);
9296 let error = tracing::subscriber::with_default(subscriber, || {
9297 std::thread::spawn(|| drop(plain_actor_unsupported()))
9299 .join()
9300 .expect("unscoped rejection warning thread panicked");
9301 let span = tracing::warn_span!(
9302 "actor_child_run",
9303 child_session_id = %"diagnostic-child",
9304 activation_run_id = tracing::field::Empty
9305 );
9306 let _entered = span.enter();
9307 span.record("activation_run_id", "diagnostic-run");
9308 None::<()>
9309 .ok_or_else(|| {
9310 let caller_line = line!() + 1;
9311 let error = plain_actor_unsupported();
9312 branch_line.set(caller_line);
9313 error
9314 })
9315 .unwrap_err()
9316 });
9317 assert!(matches!(&error, AgentError::LLM(message)
9318 if message.starts_with("This Actor Child supports a fresh plain activation,")
9319 && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9320 let log = std::fs::read_to_string(output.path()).unwrap();
9321 assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9322 assert!(
9323 log.contains("activation_run_id=\"diagnostic-run\""),
9324 "{log}"
9325 );
9326 assert!(
9327 log.contains(&format!("process_id={}", std::process::id())),
9328 "{log}"
9329 );
9330 assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9331 assert!(
9332 log.contains(&format!("source_line={}", branch_line.get())),
9333 "{log}"
9334 );
9335 assert!(!error.to_string().contains("diagnostic-run"));
9336 drop(capture_interest_guard);
9337 }
9338
9339 #[tokio::test]
9340 async fn completed_plain_continuation_requires_bounded_inputs_and_current_identity() {
9341 for case in [
9342 "valid",
9343 "over-batch-limit",
9344 "tool-history",
9345 "stale-lineage",
9346 "no-release",
9347 ] {
9348 let temp = tempfile::tempdir().unwrap();
9349 let store = Arc::new(
9350 bamboo_storage::SessionStoreV2::new(temp.path().into())
9351 .await
9352 .unwrap(),
9353 );
9354 let parent = Session::new("continuation-parent", "model");
9355 store.save_session(&parent).await.unwrap();
9356 let mut child =
9357 Session::new_child_of("continuation-child", &parent, "model", "first task");
9358 child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9359 if case == "tool-history" {
9360 let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9361 tool.role = Role::Tool;
9362 child.add_message(tool);
9363 }
9364 child.set_last_run_status("completed");
9365 store.save_session(&child).await.unwrap();
9366 store.ensure_actor(&child.id).await.unwrap();
9367 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9368 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9369 let now = chrono::Utc::now();
9370 let old = store
9371 .claim_activation(&ActorActivationClaim {
9372 actor_id: child.id.clone(),
9373 run_id: "first-run".into(),
9374 lease_owner: "first-owner".into(),
9375 lease_expires_at: now + chrono::Duration::minutes(1),
9376 inbox_generation: 0,
9377 placement_ref: None,
9378 now,
9379 })
9380 .await
9381 .unwrap();
9382 store.start_activation(&old.fence(), now).await.unwrap();
9383 store
9384 .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9385 .await
9386 .unwrap();
9387 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9388 store.clone(),
9389 Default::default(),
9390 ));
9391 for slot in 0..if case == "over-batch-limit" {
9392 bamboo_domain::SessionInboxLimits::default().max_claim_batch + 1
9393 } else {
9394 1
9395 } {
9396 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9397 &child.id,
9398 format!("new task {slot}"),
9399 );
9400 let receipt = inbox.deliver(&envelope).await.unwrap();
9401 inbox
9402 .mark_activation_eligible(
9403 &child.id,
9404 receipt.generation,
9405 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9406 )
9407 .await
9408 .unwrap();
9409 }
9410 let storage: Arc<dyn Storage> = store.clone();
9411 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9412 let binding = actor_binding(store.clone(), inbox, persistence);
9413 let _registration = binding
9414 .router
9415 .register_run(&child.id, "second-run")
9416 .await
9417 .unwrap();
9418 if case == "stale-lineage" {
9419 child.parent_session_id = Some("foreign-parent".into());
9420 }
9421 let result = PlainActorActivation::start(
9422 store.clone(),
9423 &child,
9424 &binding,
9425 Some("second-run"),
9426 Some(policy),
9427 true,
9428 (case != "no-release").then_some("second-worker"),
9429 )
9430 .await;
9431 if case == "valid" {
9432 let activation = result.expect("one new input on a completed plain Child");
9433 assert_eq!(activation.fence.attempt, old.attempt + 1);
9434 assert_eq!(activation.fence.actor_id, child.id);
9435 assert_eq!(activation.inbox_generation, 1);
9436 activation
9437 .finish(ActorActivationFinish::Failed)
9438 .await
9439 .unwrap();
9440 } else {
9441 assert!(
9442 result.is_err(),
9443 "unsupported continuation {case} must fail before claim"
9444 );
9445 assert_eq!(
9446 store
9447 .inspect_actor(&child.id)
9448 .await
9449 .unwrap()
9450 .actor
9451 .current_attempt,
9452 old.attempt
9453 );
9454 }
9455 let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9456 assert_eq!(
9457 canonical.parent_session_id.as_deref(),
9458 Some(parent.id.as_str())
9459 );
9460 assert!(canonical
9461 .messages
9462 .iter()
9463 .any(|message| message.content == "first result"));
9464 }
9465 }
9466
9467 #[tokio::test]
9468 async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9469 let temp = tempfile::tempdir().unwrap();
9470 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9471 .await
9472 .unwrap();
9473 let parent = Session::new("placement-parent", "model");
9474 let child = Session::new_child_of("placement-child", &parent, "model", "task");
9475 store.save_session(&parent).await.unwrap();
9476 store.save_session(&child).await.unwrap();
9477 store.ensure_actor(&child.id).await.unwrap();
9478 store
9479 .bind_control_plane(
9480 &child.id,
9481 bamboo_domain::ActorControlPlaneBinding {
9482 policy_revision: None,
9483 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9484 pool_id: "remote-worker".into(),
9485 }),
9486 },
9487 )
9488 .await
9489 .unwrap();
9490
9491 let claim = |run_id: &str| ActorActivationClaim {
9492 actor_id: child.id.clone(),
9493 run_id: run_id.into(),
9494 lease_owner: format!("host-{run_id}"),
9495 lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9496 inbox_generation: 0,
9497 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9498 class: bamboo_domain::ActorPlacementClass::Remote,
9499 lease_id: format!("slot-{run_id}"),
9500 slot_epoch: Some(1),
9501 }),
9502 now: chrono::Utc::now(),
9503 };
9504 let first = store.claim_activation(&claim("first")).await.unwrap();
9505 store
9506 .start_activation(&first.fence(), chrono::Utc::now())
9507 .await
9508 .unwrap();
9509 assert!(store.claim_activation(&claim("retry")).await.is_err());
9510
9511 fail_pre_dispatch_activation(&store, &first.fence())
9514 .await
9515 .unwrap();
9516 let failed = store.inspect_actor(&child.id).await.unwrap();
9517 assert_eq!(
9518 failed.activation.unwrap().status,
9519 bamboo_domain::ActorActivationStatus::Failed
9520 );
9521 let retry = store.claim_activation(&claim("retry")).await.unwrap();
9522 assert_eq!(retry.attempt, first.attempt + 1);
9523 assert!(fail_pre_dispatch_activation(&store, &first.fence())
9524 .await
9525 .is_err());
9526 assert_eq!(
9527 store.inspect_actor(&child.id).await.unwrap().activation,
9528 Some(retry)
9529 );
9530 }
9531
9532 #[tokio::test]
9533 async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9534 struct AcceptedWithoutReceipt {
9535 published: bool,
9536 }
9537
9538 #[async_trait]
9539 impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9540 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9541 assert!(matches!(frame, ParentFrame::Run(_)));
9542 self.published = true;
9543 Err(bamboo_subagent::TransportError::Protocol(
9544 "broker delivery receipt lost after publication".into(),
9545 ))
9546 }
9547
9548 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9549 std::future::pending().await
9550 }
9551 }
9552
9553 let temp = tempfile::tempdir().unwrap();
9554 let store = Arc::new(
9555 bamboo_storage::SessionStoreV2::new(temp.path().into())
9556 .await
9557 .unwrap(),
9558 );
9559 let parent = Session::new("dispatch-parent", "model");
9560 let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9561 store.save_session(&parent).await.unwrap();
9562 store.save_session(&child).await.unwrap();
9563 store.ensure_actor(&child.id).await.unwrap();
9564 let now = chrono::Utc::now();
9565 let claim = ActorActivationClaim {
9566 actor_id: child.id.clone(),
9567 run_id: "dispatch-run".into(),
9568 lease_owner: "dispatch-host".into(),
9569 lease_expires_at: now + chrono::Duration::seconds(80),
9570 inbox_generation: 0,
9571 placement_ref: None,
9572 now,
9573 };
9574 let activation = store.claim_activation(&claim).await.unwrap();
9575 store
9576 .start_activation(&activation.fence(), chrono::Utc::now())
9577 .await
9578 .unwrap();
9579 let mut link = AcceptedWithoutReceipt { published: false };
9580 let mut send_invoked = false;
9581 let run = RunSpec {
9582 assignment: "task".into(),
9583 logical_session: None,
9584 project_id: None,
9585 reasoning_effort: None,
9586 permission_policy: None,
9587 messages: Vec::new(),
9588 activation_run_id: Some(claim.run_id.clone()),
9589 execution_epoch: 1,
9590 initial_session_messages: Vec::new(),
9591 secrets: Default::default(),
9592 };
9593 assert!(send_actor_run(&mut link, run, &mut send_invoked)
9594 .await
9595 .is_err());
9596 assert!(send_invoked && link.published);
9597 let slot = WorkerSlotLease {
9598 lease_id: "dispatch-slot".into(),
9599 host_ref: "dispatch-worker".into(),
9600 connection_generation: "dispatch-generation".into(),
9601 slot: 0,
9602 epoch: 1,
9603 actor_id: child.id.clone(),
9604 run_id: claim.run_id.clone(),
9605 expires_at: claim.lease_expires_at,
9606 };
9607 let mut lease = ActivePlacementLease {
9608 registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9609 .await
9610 .unwrap(),
9611 actor_store: store.clone(),
9612 route: ScopedPlacementRoute {
9613 endpoint: String::new(),
9614 token: String::new(),
9615 ca_cert_file: PathBuf::new(),
9616 parent: bamboo_subagent::AgentRef {
9617 session_id: "dispatch-parent".into(),
9618 role: Some("host".into()),
9619 },
9620 worker_role: "worker".into(),
9621 pinned_mailbox: None,
9622 requirements: Default::default(),
9623 },
9624 mailbox: "dispatch-worker".into(),
9625 host_ref: "dispatch-worker".into(),
9626 fence: activation.fence(),
9627 placement_ref: bamboo_domain::ActorPlacementRef {
9628 class: bamboo_domain::ActorPlacementClass::Remote,
9629 lease_id: slot.lease_id.clone(),
9630 slot_epoch: Some(slot.epoch),
9631 },
9632 lease: Arc::new(tokio::sync::Mutex::new(slot)),
9633 heartbeat: tokio::spawn(std::future::pending()),
9634 run_cancel: CancellationToken::new(),
9635 lost: Arc::new(AtomicBool::new(false)),
9636 release_on_drop: AtomicBool::new(false),
9637 };
9638 assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9639 assert_eq!(
9640 store
9641 .inspect_actor(&child.id)
9642 .await
9643 .unwrap()
9644 .activation
9645 .unwrap()
9646 .status,
9647 bamboo_domain::ActorActivationStatus::Running
9648 );
9649 let mut retry = claim.clone();
9650 retry.run_id = "dispatch-retry".into();
9651 retry.now = chrono::Utc::now();
9652 retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9653 assert!(matches!(
9654 store.claim_activation(&retry).await,
9655 Err(bamboo_domain::ActorDirectoryError::Busy)
9656 ));
9657 }
9658
9659 #[test]
9660 fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9661 let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9662 assert_eq!(
9663 remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9664 ActorActivationFinish::Cancelled
9665 );
9666 assert_eq!(
9667 remote_activation_finish(&cancelled, None),
9668 ActorActivationFinish::Failed
9669 );
9670 assert_eq!(
9671 remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9672 ActorActivationFinish::Failed
9673 );
9674 }
9675
9676 #[tokio::test]
9677 async fn remote_reply_is_fenced_before_terminal_status_save() {
9678 let temp = tempfile::tempdir().unwrap();
9679 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9680 .await
9681 .unwrap();
9682 let parent = Session::new("remote-reply-parent", "model");
9683 let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9684 child.add_message(bamboo_agent_core::Message::user("task"));
9685 store.save_session(&parent).await.unwrap();
9686 store.save_session(&child).await.unwrap();
9687 store.ensure_actor(&child.id).await.unwrap();
9688 store
9689 .bind_control_plane(
9690 &child.id,
9691 bamboo_domain::ActorControlPlaneBinding {
9692 policy_revision: None,
9693 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9694 pool_id: "remote-worker".into(),
9695 }),
9696 },
9697 )
9698 .await
9699 .unwrap();
9700 let now = chrono::Utc::now();
9701 let activation = store
9702 .claim_activation(&ActorActivationClaim {
9703 actor_id: child.id.clone(),
9704 run_id: "remote-reply-run".into(),
9705 lease_owner: "remote-worker".into(),
9706 lease_expires_at: now + chrono::Duration::seconds(80),
9707 inbox_generation: 0,
9708 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9709 class: bamboo_domain::ActorPlacementClass::Remote,
9710 lease_id: "remote-slot".into(),
9711 slot_epoch: Some(1),
9712 }),
9713 now,
9714 })
9715 .await
9716 .unwrap();
9717 let fence = activation.fence();
9718 store.start_activation(&fence, now).await.unwrap();
9719
9720 append_fenced_remote_reply(&store, &fence, &mut child, "done")
9721 .await
9722 .unwrap();
9723 store
9724 .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9725 .await
9726 .unwrap();
9727 child.set_last_run_status("completed");
9728 store.save_session(&child).await.unwrap();
9729 let saved = store.load_session(&child.id).await.unwrap().unwrap();
9730 assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9731 assert_eq!(saved.messages.len(), 2);
9732 assert_eq!(saved.messages.last().unwrap().content, "done");
9733 assert!(
9734 append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9735 .await
9736 .is_err()
9737 );
9738 assert_eq!(
9739 store
9740 .load_session(&child.id)
9741 .await
9742 .unwrap()
9743 .unwrap()
9744 .messages
9745 .len(),
9746 2
9747 );
9748 }
9749
9750 async fn start_bus() -> (String, tempfile::TempDir) {
9751 let dir = tempfile::tempdir().unwrap();
9752 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9753 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9754 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9755 let addr = listener.local_addr().unwrap();
9756 tokio::spawn(async move {
9757 let _ = server.serve(listener).await;
9758 });
9759 (format!("ws://{addr}"), dir)
9760 }
9761
9762 struct DurableAckProbe {
9763 calls: Arc<AtomicUsize>,
9764 fail: bool,
9765 terminal_status: TerminalStatus,
9766 parent_mailbox: String,
9767 }
9768
9769 #[async_trait]
9770 impl bamboo_subagent::ChildLink for DurableAckProbe {
9771 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9772 Ok(())
9773 }
9774
9775 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9776 Ok(None)
9777 }
9778
9779 fn has_pending_durable_terminal(&self) -> bool {
9780 true
9781 }
9782
9783 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9784 Some(bamboo_subagent::DurableChildDeliveryReceipt {
9785 broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9786 parent_mailbox: self.parent_mailbox.clone(),
9787 correlation_id: "broker-run-1".into(),
9788 message_ids: vec!["broker-outcome-1".into()],
9789 terminal_status: self.terminal_status,
9790 })
9791 }
9792
9793 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9794 self.calls.fetch_add(1, Ordering::SeqCst);
9795 if self.fail {
9796 Err(bamboo_subagent::TransportError::Protocol(
9797 "injected ACK delivery failure".into(),
9798 ))
9799 } else {
9800 Ok(())
9801 }
9802 }
9803 }
9804
9805 async fn insert_ack_probe(
9806 runner: &ActorChildRunner,
9807 session: &Session,
9808 calls: &Arc<AtomicUsize>,
9809 terminal_status: TerminalStatus,
9810 ) {
9811 insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9812 }
9813
9814 async fn insert_ack_probe_with_nested_wait(
9815 runner: &ActorChildRunner,
9816 session: &Session,
9817 calls: &Arc<AtomicUsize>,
9818 terminal_status: TerminalStatus,
9819 host_nested_wait_handoff: bool,
9820 ) {
9821 runner.pending_durable_links.lock().await.insert(
9822 (session.id.clone(), session.created_at, "run-1".into()),
9823 PendingDurableChildLink {
9824 link: Box::new(DurableAckProbe {
9825 calls: calls.clone(),
9826 fail: false,
9827 terminal_status,
9828 parent_mailbox: format!("p-{}", session.id),
9829 }),
9830 host_nested_wait_handoff,
9831 strict_history: None,
9832 strict_history_required: false,
9833 strict_history_committed: false,
9834 },
9835 );
9836 }
9837
9838 #[tokio::test]
9839 async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9840 let (endpoint, broker_dir) = start_bus().await;
9841 let home = tempfile::tempdir().unwrap();
9842 let store = Arc::new(
9843 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9844 .await
9845 .unwrap(),
9846 );
9847 let root = Session::new("receipt-root", "model");
9848 store.save_session(&root).await.unwrap();
9849 let mut sender = bamboo_broker::BrokerClient::connect(
9850 &endpoint,
9851 bamboo_subagent::AgentRef {
9852 session_id: "receipt-worker".into(),
9853 role: None,
9854 },
9855 "t",
9856 )
9857 .await
9858 .unwrap();
9859 let broker_identity = sender.durable_broker_identity().await.unwrap();
9860 let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9861 "00000000-0000-4000-8000-000000000002"
9862 } else {
9863 "00000000-0000-4000-8000-000000000001"
9864 };
9865
9866 for (id, receipt_mailbox, receipt_broker) in [
9867 ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9868 (
9869 "receipt-wrong-mailbox",
9870 "p-another-child",
9871 broker_identity.as_str(),
9872 ),
9873 (
9874 "receipt-wrong-broker",
9875 "p-receipt-wrong-broker",
9876 wrong_broker_identity,
9877 ),
9878 (
9879 "receipt-local-role-changed",
9880 "p-receipt-local-role-changed",
9881 broker_identity.as_str(),
9882 ),
9883 (
9884 "receipt-remote-unproven",
9885 "remote-parent-unproven",
9886 broker_identity.as_str(),
9887 ),
9888 ] {
9889 let mut child = Session::new_child_of(id, &root, "model", "task");
9890 child
9891 .metadata
9892 .insert("runtime.kind".into(), "external".into());
9893 child
9894 .metadata
9895 .insert("external.protocol".into(), "actor".into());
9896 child.metadata.insert(
9897 "external.agent_id".into(),
9898 crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9899 );
9900 if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9901 child
9902 .metadata
9903 .insert("subagent_type".into(), "remote-worker".into());
9904 }
9905 child.add_message(bamboo_agent_core::Message::user("work"));
9906 store.save_session(&child).await.unwrap();
9907 let message_id = bamboo_subagent::MsgId::new();
9908 sender
9909 .deliver(
9910 &format!("p-{id}"),
9911 bamboo_subagent::InboxMessage {
9912 id: message_id.clone(),
9913 from: bamboo_subagent::AgentRef {
9914 session_id: "receipt-worker".into(),
9915 role: None,
9916 },
9917 kind: bamboo_subagent::InboxKind::Outcome,
9918 body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9919 "done",
9920 ))
9921 .unwrap(),
9922 created_at: chrono::Utc::now(),
9923 correlation_id: Some(bamboo_subagent::MsgId::new()),
9924 },
9925 )
9926 .await
9927 .unwrap();
9928 let mailbox =
9931 bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9932 assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9933 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9934 child.set_last_run_status("completed");
9935 if id == "receipt-good" {
9936 let prior_ids = child.messages[..child.messages.len() - 1]
9937 .iter()
9938 .map(|message| message.id.clone())
9939 .collect();
9940 crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9941 assert!(
9942 crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9943 &mut child, "run-1"
9944 )
9945 .unwrap()
9946 );
9947 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9948 &child, "run-1", 7, 1,
9949 )
9950 .unwrap();
9951 store
9952 .prepare_broker_terminal_receipt_with_completeness(
9953 &child,
9954 bamboo_storage::v2::BrokerTerminalRoute {
9955 activation_run_id: "run-1",
9956 broker_identity: receipt_broker,
9957 parent_mailbox: receipt_mailbox,
9958 broker_correlation_id: "correlation-1",
9959 message_ids: &[message_id.as_str().to_owned()],
9960 },
9961 &proof,
9962 )
9963 .await
9964 .unwrap();
9965 let mut staged = child.clone();
9966 staged.set_last_run_status("running");
9967 staged.clear_last_run_error();
9968 store.save_session(&staged).await.unwrap();
9969 store
9970 .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9971 .await
9972 .unwrap();
9973 } else {
9974 store
9975 .prepare_broker_terminal_receipt(
9976 &child,
9977 "run-1",
9978 receipt_broker,
9979 receipt_mailbox,
9980 "correlation-1",
9981 &[message_id.as_str().to_owned()],
9982 )
9983 .await
9984 .unwrap();
9985 store.save_session(&child).await.unwrap();
9986 }
9987 }
9988
9989 let mut config = bamboo_config::Config::default();
9990 config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9991 endpoint,
9992 token: "t".into(),
9993 ..Default::default()
9994 });
9995 config
9996 .subagents_mut()
9997 .remote_placements
9998 .push(bamboo_config::RemoteActorPlacement {
9999 role: "remote-worker".into(),
10000 endpoint: "ws://unscoped.example.invalid".into(),
10001 token_env: None,
10002 ca_cert_file: None,
10003 broker_peer: None,
10004 placement_requirements: None,
10005 });
10006 let cold_store = Arc::new(
10009 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10010 .await
10011 .unwrap(),
10012 );
10013 let before = cold_store
10014 .load_session("receipt-good")
10015 .await
10016 .unwrap()
10017 .unwrap();
10018 assert!(crate::execution::ChildCompletionSource::has_source_record(
10019 &before
10020 ));
10021 assert_eq!(before.last_run_status().as_deref(), Some("running"));
10022 assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
10023 let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
10024 let report = repair.reconcile_once().await;
10025 assert_eq!(report.candidates, 5);
10026 assert_eq!(report.repaired, 2);
10027 assert_eq!(report.blocked, 3);
10028 let after = cold_store
10029 .load_session("receipt-good")
10030 .await
10031 .unwrap()
10032 .unwrap();
10033 assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
10034 assert_eq!(
10035 serde_json::to_value(before.messages).unwrap(),
10036 serde_json::to_value(after.messages).unwrap()
10037 );
10038 let retry = repair.reconcile_once().await;
10039 assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
10040
10041 let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
10042 let wrong = bamboo_subagent::Mailbox::at(
10043 broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
10044 );
10045 let wrong_broker = bamboo_subagent::Mailbox::at(
10046 broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
10047 );
10048 let local_role_changed = bamboo_subagent::Mailbox::at(
10049 broker_dir
10050 .path()
10051 .join("mailboxes/p-receipt-local-role-changed"),
10052 );
10053 let remote_unproven = bamboo_subagent::Mailbox::at(
10054 broker_dir
10055 .path()
10056 .join("mailboxes/p-receipt-remote-unproven"),
10057 );
10058 assert_eq!(good.pending_count().await.unwrap(), 0);
10059 assert_eq!(wrong.pending_count().await.unwrap(), 1);
10060 assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
10061 assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
10062 assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
10063 let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
10064 let wrong_child = store
10065 .load_session("receipt-wrong-mailbox")
10066 .await
10067 .unwrap()
10068 .unwrap();
10069 let wrong_broker_child = store
10070 .load_session("receipt-wrong-broker")
10071 .await
10072 .unwrap()
10073 .unwrap();
10074 let local_role_changed_child = store
10075 .load_session("receipt-local-role-changed")
10076 .await
10077 .unwrap()
10078 .unwrap();
10079 let remote_unproven_child = store
10080 .load_session("receipt-remote-unproven")
10081 .await
10082 .unwrap()
10083 .unwrap();
10084 assert!(store
10085 .recover_broker_terminal_receipts(&good_child)
10086 .await
10087 .unwrap()
10088 .is_empty());
10089 assert_eq!(
10090 store
10091 .recover_broker_terminal_receipts(&wrong_child)
10092 .await
10093 .unwrap()
10094 .len(),
10095 1
10096 );
10097 assert_eq!(
10098 store
10099 .recover_broker_terminal_receipts(&wrong_broker_child)
10100 .await
10101 .unwrap()
10102 .len(),
10103 1
10104 );
10105 assert!(store
10106 .recover_broker_terminal_receipts(&local_role_changed_child)
10107 .await
10108 .unwrap()
10109 .is_empty());
10110 assert_eq!(
10111 store
10112 .recover_broker_terminal_receipts(&remote_unproven_child)
10113 .await
10114 .unwrap()
10115 .len(),
10116 1
10117 );
10118 }
10119
10120 #[tokio::test]
10121 async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
10122 let runner = ActorChildRunner::new(
10123 "test".into(),
10124 PathBuf::new(),
10125 vec![],
10126 PathBuf::new(),
10127 ExecutorSpec::BambooRuntime,
10128 vec![],
10129 "test".into(),
10130 1,
10131 );
10132 let home = tempfile::tempdir().unwrap();
10133 let store = Arc::new(
10134 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10135 .await
10136 .unwrap(),
10137 );
10138 let root = Session::new("ack-parent", "model");
10139 store.save_session(&root).await.unwrap();
10140 let mut child = Session::new_child_of("ack-child", &root, "model", "task");
10141 child.add_message(bamboo_agent_core::Message::user("work"));
10142 store.save_session(&child).await.unwrap();
10143 child.add_message(bamboo_agent_core::Message::assistant("done", None));
10144 child.set_last_run_status("completed");
10145 store
10146 .prepare_broker_terminal_receipt(
10147 &child,
10148 "run-1",
10149 "00000000-0000-4000-8000-000000000001",
10150 "p-ack-child",
10151 "broker-run-1",
10152 &["broker-outcome-1".into()],
10153 )
10154 .await
10155 .unwrap();
10156 store.save_session(&child).await.unwrap();
10157 runner.set_actor_directory_store(Some(store));
10158 let calls = Arc::new(AtomicUsize::new(0));
10159 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10160 runner
10161 .confirm_durable_child_delivery(&child, "wrong-run", true)
10162 .await
10163 .unwrap();
10164 let mut wrong_birth = child.clone();
10165 wrong_birth.created_at += chrono::Duration::seconds(1);
10166 runner
10167 .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
10168 .await
10169 .unwrap();
10170 assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
10171 child.set_last_run_status("completed");
10172 runner
10173 .confirm_durable_child_delivery(&child, "run-1", false)
10174 .await
10175 .unwrap();
10176 assert_eq!(calls.load(Ordering::SeqCst), 0);
10177
10178 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10179 child.set_last_run_status("error");
10180 runner
10181 .confirm_durable_child_delivery(&child, "run-1", true)
10182 .await
10183 .unwrap();
10184 assert_eq!(calls.load(Ordering::SeqCst), 0);
10185
10186 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10187 child.set_last_run_status("completed");
10188 runner
10189 .confirm_durable_child_delivery(&child, "run-1", true)
10190 .await
10191 .unwrap();
10192 assert_eq!(calls.load(Ordering::SeqCst), 1);
10193 }
10194
10195 #[tokio::test]
10196 async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10197 for commit in [false, true] {
10198 let runner = ActorChildRunner::new(
10199 "test".into(),
10200 PathBuf::new(),
10201 vec![],
10202 PathBuf::new(),
10203 ExecutorSpec::BambooRuntime,
10204 vec![],
10205 "test".into(),
10206 1,
10207 );
10208 let home = tempfile::tempdir().unwrap();
10209 let store = Arc::new(
10210 bamboo_storage::SessionStoreV2::new(home.path().into())
10211 .await
10212 .unwrap(),
10213 );
10214 let root = Session::new("strict-ack-parent", "model");
10215 store.save_session(&root).await.unwrap();
10216 let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10217 child.add_message(bamboo_agent_core::Message::user("work"));
10218 store.save_session(&child).await.unwrap();
10219 child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10220 child.set_last_run_status("completed");
10221 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10222 &child, "run-1", 7, 2,
10223 )
10224 .unwrap();
10225 let creation = bamboo_subagent::proto::ChildCreationIdentity {
10226 created_at: child.created_at,
10227 spawn_depth: child.spawn_depth,
10228 };
10229 let history = StrictTerminalHistory {
10230 proof: proof.clone(),
10231 watermark: bamboo_subagent::ActorEventWatermark {
10232 version: 1,
10233 logical_session: Some(actor_event_logical_identity(
10234 &child,
10235 &root.id,
10236 Some(&creation),
10237 )),
10238 activation_id: Some("run-1".into()),
10239 execution_epoch: 7,
10240 final_seq: 2,
10241 },
10242 };
10243 let parent_mailbox = "p-strict-ack-child";
10244 store
10245 .prepare_broker_terminal_receipt_with_completeness(
10246 &child,
10247 bamboo_storage::v2::BrokerTerminalRoute {
10248 activation_run_id: "run-1",
10249 broker_identity: "00000000-0000-4000-8000-000000000001",
10250 parent_mailbox,
10251 broker_correlation_id: "broker-run-1",
10252 message_ids: &["broker-outcome-1".into()],
10253 },
10254 &proof,
10255 )
10256 .await
10257 .unwrap();
10258 store.save_session(&child).await.unwrap();
10259 runner.set_actor_directory_store(Some(store.clone()));
10260 let calls = Arc::new(AtomicUsize::new(0));
10261 runner.pending_durable_links.lock().await.insert(
10262 (child.id.clone(), child.created_at, "run-1".into()),
10263 PendingDurableChildLink {
10264 link: Box::new(DurableAckProbe {
10265 calls: calls.clone(),
10266 fail: true,
10267 terminal_status: TerminalStatus::Completed,
10268 parent_mailbox: parent_mailbox.into(),
10269 }),
10270 host_nested_wait_handoff: false,
10271 strict_history: Some(history),
10272 strict_history_required: true,
10273 strict_history_committed: false,
10274 },
10275 );
10276 if commit {
10277 runner
10278 .commit_durable_child_delivery(&child, "run-1")
10279 .await
10280 .unwrap();
10281 assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10282 }
10283 assert!(runner
10284 .confirm_durable_child_delivery(&child, "run-1", true)
10285 .await
10286 .is_err());
10287 assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10288 let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10289 .await
10290 .unwrap();
10291 let recovered = cold.recover_broker_terminal_receipts(&child).await;
10292 if commit {
10293 let receipts = recovered.unwrap();
10294 assert_eq!(receipts.len(), 1);
10295 assert_eq!(
10296 receipts[0]
10297 .terminal_completeness
10298 .as_ref()
10299 .unwrap()
10300 .contiguous_applied_seq,
10301 2
10302 );
10303 assert_eq!(
10304 cold.load_session(&child.id)
10305 .await
10306 .unwrap()
10307 .unwrap()
10308 .last_run_status()
10309 .as_deref(),
10310 Some("completed")
10311 );
10312 } else {
10313 assert!(
10314 recovered.is_err(),
10315 "matching canonical history is not proof commitment"
10316 );
10317 }
10318 assert_eq!(
10319 serde_json::to_value(
10320 cold.load_session(&child.id)
10321 .await
10322 .unwrap()
10323 .unwrap()
10324 .messages
10325 )
10326 .unwrap(),
10327 serde_json::to_value(&child.messages).unwrap(),
10328 );
10329 }
10330 }
10331
10332 #[tokio::test]
10333 async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10334 for (terminal_status, status) in [
10335 (TerminalStatus::Error, "error"),
10336 (TerminalStatus::Cancelled, "cancelled"),
10337 ] {
10338 let runner = ActorChildRunner::new(
10339 "test".into(),
10340 PathBuf::new(),
10341 vec![],
10342 PathBuf::new(),
10343 ExecutorSpec::BambooRuntime,
10344 vec![],
10345 "test".into(),
10346 1,
10347 );
10348 let home = tempfile::tempdir().unwrap();
10349 let store = Arc::new(
10350 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10351 .await
10352 .unwrap(),
10353 );
10354 let root = Session::new("failed-ack-parent", "model");
10355 store.save_session(&root).await.unwrap();
10356 let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10357 child.add_message(bamboo_agent_core::Message::user("work"));
10358 store.save_session(&child).await.unwrap();
10359 child.set_last_run_status(status);
10360 child.set_last_run_error(format!("worker {status}"));
10361 store
10362 .prepare_broker_terminal_receipt(
10363 &child,
10364 "run-1",
10365 "00000000-0000-4000-8000-000000000001",
10366 "p-failed-ack-child",
10367 "broker-run-1",
10368 &["broker-outcome-1".into()],
10369 )
10370 .await
10371 .unwrap();
10372 store.save_session(&child).await.unwrap();
10373 runner.set_actor_directory_store(Some(store));
10374 let calls = Arc::new(AtomicUsize::new(0));
10375 insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10376 runner
10377 .confirm_durable_child_delivery(&child, "run-1", true)
10378 .await
10379 .unwrap();
10380 assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10381 }
10382 }
10383
10384 #[tokio::test]
10385 async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10386 assert_eq!(
10387 effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10388 "suspended"
10389 );
10390 for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10391 assert!(effective_broker_terminal_status(raw, true).is_err());
10392 }
10393 let runner = ActorChildRunner::new(
10394 "test".into(),
10395 PathBuf::new(),
10396 vec![],
10397 PathBuf::new(),
10398 ExecutorSpec::BambooRuntime,
10399 vec![],
10400 "test".into(),
10401 1,
10402 );
10403 let home = tempfile::tempdir().unwrap();
10404 let store = Arc::new(
10405 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10406 .await
10407 .unwrap(),
10408 );
10409 let root = Session::new("nested-ack-parent", "model");
10410 store.save_session(&root).await.unwrap();
10411 let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10412 child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10413 store.save_session(&child).await.unwrap();
10414 child.set_last_run_status("suspended");
10415 store
10416 .prepare_broker_terminal_receipt(
10417 &child,
10418 "run-1",
10419 "00000000-0000-4000-8000-000000000001",
10420 "p-nested-ack-child",
10421 "broker-run-1",
10422 &["broker-outcome-1".into()],
10423 )
10424 .await
10425 .unwrap();
10426 store.save_session(&child).await.unwrap();
10427 runner.set_actor_directory_store(Some(store.clone()));
10428 let calls = Arc::new(AtomicUsize::new(0));
10429
10430 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10431 runner
10432 .confirm_durable_child_delivery(&child, "run-1", true)
10433 .await
10434 .unwrap();
10435 assert_eq!(calls.load(Ordering::SeqCst), 0);
10436
10437 insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10438 .await;
10439 runner
10440 .confirm_durable_child_delivery(&child, "run-1", true)
10441 .await
10442 .unwrap();
10443 assert_eq!(calls.load(Ordering::SeqCst), 1);
10444 assert_eq!(
10445 store
10446 .load_session(&child.id)
10447 .await
10448 .unwrap()
10449 .unwrap()
10450 .last_run_status()
10451 .as_deref(),
10452 Some("suspended")
10453 );
10454 assert!(store
10455 .recover_broker_terminal_receipts(&child)
10456 .await
10457 .unwrap()
10458 .is_empty());
10459 }
10460
10461 #[tokio::test]
10462 async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10463 struct CommittedObserver {
10464 authority: PathBuf,
10465 events: std::sync::Mutex<Vec<PublicActorEvent>>,
10466 }
10467 impl ActorEventObserver for CommittedObserver {
10468 fn publish(&self, event: PublicActorEvent) {
10469 let entry: bamboo_domain::ActorDirectoryEntry =
10471 serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10472 let committed = entry.activation.unwrap();
10473 assert_eq!(
10474 committed.status,
10475 bamboo_domain::ActorActivationStatus::Succeeded
10476 );
10477 assert_eq!(committed.activation_id, event.activation_id);
10478 assert!(committed.finished_at.is_some());
10479 self.events.lock().unwrap().push(event);
10480 }
10481 }
10482 struct CompletionLink(VecDeque<ChildFrame>);
10483 #[async_trait]
10484 impl bamboo_subagent::ChildLink for CompletionLink {
10485 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10486 Ok(())
10487 }
10488 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10489 Ok(self.0.pop_front())
10490 }
10491 }
10492 for cut in [
10493 "success",
10494 "no_terminal",
10495 "invalid_terminal",
10496 "stale",
10497 "storage_failure",
10498 "append_failure",
10499 ] {
10500 let temp = tempfile::tempdir().unwrap();
10501 let store = Arc::new(
10502 bamboo_storage::SessionStoreV2::new(temp.path().into())
10503 .await
10504 .unwrap(),
10505 );
10506 let root = Session::new("notify-root", "model");
10507 store.save_session(&root).await.unwrap();
10508 let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10509 store.save_session(&child).await.unwrap();
10510 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10511 store.clone(),
10512 bamboo_domain::SessionInboxLimits::default(),
10513 ));
10514 let binding = actor_binding(
10515 store.clone(),
10516 inbox,
10517 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10518 );
10519 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10520 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10521 let activation = PlainActorActivation::start(
10522 store.clone(),
10523 &child,
10524 &binding,
10525 Some("notify-run"),
10526 Some(policy),
10527 false,
10528 None,
10529 )
10530 .await
10531 .unwrap();
10532 let _owner = binding
10533 .router
10534 .register_run(&child.id, "notify-run")
10535 .await
10536 .unwrap();
10537 let batch = ActorEventBatch {
10538 logical_session: Some(LogicalSessionIdentity {
10539 session_id: child.id.clone(),
10540 parent_session_id: child.parent_session_id.clone(),
10541 root_session_id: child.root_session_id.clone(),
10542 creation: None,
10543 }),
10544 activation_id: Some("notify-run".into()),
10545 execution_epoch: 9,
10546 source_node_id: None,
10547 source_actor_id: Some("notify-worker".into()),
10548 first_seq: 1,
10549 last_seq: 2,
10550 qos: bamboo_subagent::ActorEventQos::Durable,
10551 events: vec![
10552 serde_json::to_value(AgentEvent::Token {
10553 content: "PRIVATE_PROVIDER_TOKEN".into(),
10554 })
10555 .unwrap(),
10556 serde_json::to_value(AgentEvent::message_appended(
10557 &child.id,
10558 &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10559 ))
10560 .unwrap(),
10561 ],
10562 };
10563 let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10564 if cut != "no_terminal" {
10565 let mut terminal = completed_actor_frame();
10566 if cut == "invalid_terminal" {
10567 if let ChildFrame::Terminal { status, .. } = &mut terminal {
10568 *status = TerminalStatus::Suspended;
10569 }
10570 }
10571 frames.push_back(terminal);
10572 }
10573 let mut link = CompletionLink(frames);
10574 let (tx, mut rx) = mpsc::channel(8);
10575 let (_live, mut live_rx) = mpsc::unbounded_channel();
10576 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10577 let cancel = CancellationToken::new();
10578 let outcome = drive(ActorDriveContext {
10579 client: &mut link,
10580 parent_session_id: "notify-root",
10581 child_session_id: "notify-child",
10582 child_attempt: 0,
10583 approval_registry: None,
10584 approval_decider: None,
10585 approval_reviewer: None,
10586 escalation_bridge: None,
10587 event_tx: &tx,
10588 cancel_token: &cancel,
10589 live_rx: &mut live_rx,
10590 delivery_rx: &mut delivery_rx,
10591 logical_session: &mut child,
10592 expected_permission_posture: None,
10593 expected_creation: None,
10594 session_inbox_runtime: Some(&binding),
10595 actor_directory_store: Some(store.as_ref()),
10596 canonical_subagent_tool: None,
10597 ticket_service: None,
10598 activation_run_id: Some("notify-run"),
10599 execution_epoch: 9,
10600 expected_source_actor_id: "notify-worker",
10601 initial_inflight_claims: VecDeque::new(),
10602 plain_actor: true,
10603 remote_environment_lease: false,
10604 readonly_output: None,
10605 local_history_tools: None,
10606 local_history_read_only: false,
10607 strict_history_output: None,
10608 plain_input: Some(&activation),
10609 canonical_activation: None,
10610 canonical_placement_ref: None,
10611 actor_event_observer: None,
10612 plain_run: None,
10613 first_frame_timeout: Some(Duration::from_secs(1)),
10614 })
10615 .await;
10616 let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10617 assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10618 assert_eq!(
10619 activation.completion_router.lock().unwrap().is_some(),
10620 admitted,
10621 "{cut}"
10622 );
10623 while rx.try_recv().is_ok() {}
10624 let authority = temp
10625 .path()
10626 .join(store.resolve_rel_path(&child.id).await.unwrap())
10627 .join("actor-authority.json");
10628 let observer = CommittedObserver {
10629 authority: authority.clone(),
10630 events: Default::default(),
10631 };
10632 if cut == "append_failure" {
10633 child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10634 assert!(activation
10635 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10636 .await
10637 .is_err());
10638 activation
10639 .finish(ActorActivationFinish::Failed)
10640 .await
10641 .unwrap();
10642 assert!(observer.events.lock().unwrap().is_empty());
10643 continue;
10644 }
10645 activation
10646 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10647 .await
10648 .unwrap();
10649 assert!(
10650 rx.try_recv().is_ok(),
10651 "legacy content notification retained"
10652 );
10653 assert!(
10654 observer.events.lock().unwrap().is_empty(),
10655 "reply alone must not publish"
10656 );
10657 if cut == "stale" {
10658 store
10659 .retire_actor(&child.id, chrono::Utc::now())
10660 .await
10661 .unwrap();
10662 } else if cut == "storage_failure" {
10663 std::fs::remove_file(&authority).unwrap();
10666 std::fs::create_dir(&authority).unwrap();
10667 }
10668 let result = activation
10669 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10670 .await;
10671 assert_eq!(
10672 result.is_ok(),
10673 matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10674 "{cut}"
10675 );
10676 let events = observer.events.lock().unwrap().clone();
10677 assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10678 if let Some(event) = events.first() {
10679 assert_eq!(event.actor_id, child.id);
10680 assert_eq!(event.root_actor_id, root.id);
10681 assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10682 assert_eq!(event.source_order.sequence, 3);
10683 let json = serde_json::to_string(event).unwrap();
10684 assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10685 let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10686 assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10687 }
10688 assert!(activation
10689 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10690 .await
10691 .is_err());
10692 assert_eq!(
10693 observer.events.lock().unwrap().len(),
10694 events.len(),
10695 "repeat finish cannot publish"
10696 );
10697 }
10698 }
10699
10700 async fn bind_local_control_plane(
10701 store: &bamboo_storage::SessionStoreV2,
10702 actor_id: &str,
10703 policy: &bamboo_tools::permission::PermissionConfig,
10704 ) {
10705 store
10706 .bind_control_plane(
10707 actor_id,
10708 bamboo_domain::ActorControlPlaneBinding {
10709 policy_revision: Some(policy.policy_revision()),
10710 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10711 },
10712 )
10713 .await
10714 .unwrap();
10715 }
10716
10717 fn local_tool_sample(
10718 host: &Session,
10719 denied_write: bool,
10720 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10721 local_tool_sample_with_reasoning(host, denied_write, None)
10722 }
10723
10724 fn local_tool_sample_with_reasoning(
10725 host: &Session,
10726 denied_write: bool,
10727 reasoning: Option<&str>,
10728 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10729 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10730 let calls = ["Read", "Write"]
10731 .map(|name| ToolCall {
10732 id: format!("local-{name}"),
10733 tool_type: "function".into(),
10734 function: FunctionCall {
10735 name: name.into(),
10736 arguments: "{\"file_path\":\"answer.txt\"}".into(),
10737 },
10738 })
10739 .to_vec();
10740 let mut rows = vec![Message::assistant("checking", Some(calls))];
10741 rows[0].reasoning = reasoning.map(str::to_owned);
10742 let mut collector = LocalToolCollector::default();
10743 if let Some(reasoning) = reasoning {
10744 assert!(collector
10745 .event(
10746 &serde_json::to_value(AgentEvent::ReasoningToken {
10747 content: reasoning.into(),
10748 })
10749 .unwrap()
10750 )
10751 .unwrap());
10752 }
10753 for name in ["Read", "Write"] {
10754 let denied = name == "Write" && denied_write;
10755 let id = format!("local-{name}");
10756 if !denied {
10757 collector
10758 .event(
10759 &serde_json::to_value(AgentEvent::ToolStart {
10760 tool_call_id: id.clone(),
10761 tool_name: name.into(),
10762 arguments: serde_json::json!({"file_path":"answer.txt"}),
10763 })
10764 .unwrap(),
10765 )
10766 .unwrap();
10767 }
10768 let text = if denied {
10769 "Error: native_tool_ceiling_denied: Write"
10770 } else {
10771 "actual file result"
10772 };
10773 let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10774 message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10775 "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10776 rows.push(message);
10777 let event = if denied {
10778 AgentEvent::ToolError {
10779 tool_call_id: id,
10780 error: "native_tool_ceiling_denied: Write".into(),
10781 }
10782 } else {
10783 AgentEvent::ToolComplete {
10784 tool_call_id: id,
10785 result: bamboo_agent_core::tools::ToolResult::text(true, text),
10786 }
10787 };
10788 collector
10789 .event(&serde_json::to_value(event).unwrap())
10790 .unwrap();
10791 }
10792 rows.push(Message::assistant("complete exact report", None));
10793 rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10794 if let Some(reasoning) = reasoning {
10795 assert!(collector
10796 .event(
10797 &serde_json::to_value(AgentEvent::ReasoningToken {
10798 content: reasoning.into(),
10799 })
10800 .unwrap()
10801 )
10802 .unwrap());
10803 }
10804 let mut messages = host.messages.clone();
10805 if let Some(system) = messages
10806 .iter_mut()
10807 .find(|message| message.role == Role::System)
10808 {
10809 system.content = "worker-private system never imported".into();
10810 }
10811 messages.extend(rows.iter().cloned());
10812 let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10813 version: 1,
10814 messages: messages
10815 .iter()
10816 .map(|message| serde_json::to_value(message).unwrap())
10817 .collect(),
10818 };
10819 assert!(!collector
10820 .event(&serde_json::to_value(data).unwrap())
10821 .unwrap());
10822 (collector, rows)
10823 }
10824
10825 #[test]
10826 fn ticket_task_event_requires_exact_host_callback_receipt() {
10827 let mut collector = LocalToolCollector::default();
10828 let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10829 let start = serde_json::to_value(AgentEvent::ToolStart {
10830 tool_call_id: "task-call".into(),
10831 tool_name: "Task".into(),
10832 arguments: args.clone(),
10833 })
10834 .unwrap();
10835 let mut receipts = HashMap::new();
10836 assert!(collector.ticket_event(&start, &receipts).unwrap());
10837 collector.event(&start).unwrap();
10838 let complete = serde_json::to_value(AgentEvent::ToolComplete {
10839 tool_call_id: "task-call".into(),
10840 result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10841 })
10842 .unwrap();
10843 assert!(collector.ticket_event(&complete, &receipts).is_err());
10844 receipts.insert("other-call".into(), args.clone());
10845 assert!(collector.ticket_event(&complete, &receipts).is_err());
10846 receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10847 assert!(collector.ticket_event(&complete, &receipts).is_err());
10848 receipts.insert("task-call".into(), args);
10849 assert!(collector.ticket_event(&complete, &receipts).unwrap());
10850 let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10851 task_list: bamboo_tools::TaskTool::task_list_from_args(
10852 &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10853 "root",
10854 )
10855 .unwrap(),
10856 version: Some(999),
10857 })
10858 .unwrap();
10859 assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10860 }
10861
10862 #[test]
10863 fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10864 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10865 let mut host = Session::new("question-child", "model");
10866 host.messages.push(Message::user("own assignment"));
10867 let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10868 "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10869 "generation":1, "contract_revision":1, "prompt_revision":1,
10870 "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10871 "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10872 })).unwrap();
10873 let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10874 for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10875 let mut returned =
10876 serde_json::json!({"status":"waiting_for_answer", "request":question});
10877 if case == "forged_request" {
10878 returned["request"]["id"] = serde_json::json!("sibling-question");
10879 }
10880 let text = returned.to_string();
10881 let rows = [
10882 Message::assistant(
10883 "",
10884 Some(vec![ToolCall {
10885 id: "own-call".into(),
10886 tool_type: "function".into(),
10887 function: FunctionCall {
10888 name: "Task".into(),
10889 arguments: args.to_string(),
10890 },
10891 }]),
10892 ),
10893 Message::tool_result_with_status("own-call", &text, true),
10894 ];
10895 let collector = LocalToolCollector {
10896 messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10897 starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10898 outcomes: HashMap::from([("own-call".into(), (true, text))]),
10899 ticket_yield: (case != "no_host_receipt")
10900 .then(|| ("own-call".into(), question.clone())),
10901 };
10902 let terminal = if case == "fake_report" {
10903 Some("fake delivery")
10904 } else {
10905 Some("")
10906 };
10907 assert_eq!(
10908 collector
10909 .suffix(&host, &["Task".into()], false, terminal)
10910 .is_ok(),
10911 case == "exact",
10912 "{case}"
10913 );
10914 }
10915 }
10916
10917 #[test]
10918 fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10919 let mut host = Session::new("tool-history-prefix", "model");
10920 host.messages
10921 .push(bamboo_agent_core::Message::user("assignment"));
10922 let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10923 confirmed.metadata = Some(
10924 serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10925 );
10926 host.messages.push(confirmed);
10927 let tools = vec!["Read".into(), "Write".into()];
10928 let (good, rows) = local_tool_sample(&host, false);
10929 assert_eq!(
10930 serde_json::to_value(
10931 good.suffix(&host, &tools, false, Some("complete exact report"))
10932 .unwrap()
10933 )
10934 .unwrap(),
10935 serde_json::to_value(&rows).unwrap()
10936 );
10937 for case in [
10938 "missing",
10939 "changed",
10940 "metadata",
10941 "system",
10942 "user",
10943 "collision",
10944 "dangling",
10945 "result",
10946 "terminal",
10947 "tool_metadata",
10948 "grant",
10949 "start_arguments",
10950 "ledger",
10951 ] {
10952 let (mut collector, _) = local_tool_sample(&host, false);
10953 let mut current = host.clone();
10954 let messages = collector.messages.as_mut().unwrap();
10955 match case {
10956 "missing" => {
10957 messages.remove(host.messages.len() - 1);
10958 }
10959 "changed" => messages[0].content.push('!'),
10960 "metadata" => messages[1].metadata = None,
10961 "system" => messages.last_mut().unwrap().role = Role::System,
10962 "user" => messages.last_mut().unwrap().role = Role::User,
10963 "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10964 "dangling" => {
10965 messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10966 }
10967 "result" => messages[host.messages.len() + 1].content.push('!'),
10968 "tool_metadata" => {
10969 messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10970 true.into()
10971 }
10972 "grant" => {
10973 messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10974 .function
10975 .name = "Bash".into()
10976 }
10977 "start_arguments" => {
10978 collector.starts.get_mut("local-Read").unwrap().1 =
10979 serde_json::json!({"file_path":"different.txt"})
10980 }
10981 "ledger" => {
10982 current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10983 bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10984 bamboo_domain::ProviderFamily::OpenAi,
10985 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10986 bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10987 bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10988 serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10989 ).unwrap(),
10990 ]).unwrap();
10991 }
10992 "terminal" => {}
10993 _ => unreachable!(),
10994 }
10995 let terminal = if case == "terminal" {
10996 "different"
10997 } else {
10998 "complete exact report"
10999 };
11000 assert!(
11001 collector
11002 .suffix(¤t, &tools, false, Some(terminal))
11003 .is_err(),
11004 "{case}"
11005 );
11006 }
11007 let (denied, _) = local_tool_sample(&host, true);
11008 assert!(denied
11009 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11010 .is_ok());
11011 let (mut contradicted, _) = local_tool_sample(&host, true);
11012 contradicted.starts.insert(
11013 "local-Write".into(),
11014 ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
11015 );
11016 assert!(contradicted
11017 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11018 .is_err());
11019 let (successful, _) = local_tool_sample(&host, false);
11020 assert!(successful
11021 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11022 .is_err());
11023 let mut empty = LocalToolCollector::default();
11024 assert!(empty
11025 .event(
11026 &serde_json::to_value(AgentEvent::ReasoningToken {
11027 content: "private".into()
11028 })
11029 .unwrap()
11030 )
11031 .unwrap());
11032 assert!(
11033 empty
11034 .suffix(&host, &tools, false, Some("complete exact report"))
11035 .is_err(),
11036 "stream observations alone cannot commit history"
11037 );
11038 let (mut completed, _) = local_tool_sample(&host, false);
11039 assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
11040 }
11041
11042 struct LocalToolConcurrentCheckpoint {
11043 real: SessionInboxRuntimeBinding,
11044 claim: SessionInboxClaim,
11045 }
11046 #[async_trait]
11047 impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
11048 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11049 self.real.persistence.save_runtime_session(session).await
11050 }
11051 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11052 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11055 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
11056 .await
11057 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11058 self.real
11059 .persistence
11060 .checkpoint_runtime_session(session)
11061 .await
11062 }
11063 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11064 self.real.storage.load_session(id).await
11065 }
11066 }
11067
11068 #[tokio::test]
11069 async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
11070 for case in [
11071 "success",
11072 "reasoning",
11073 "concurrent_input",
11074 "checkpoint_error",
11075 "stale_prefix",
11076 "cancelled",
11077 "lost_owner",
11078 ] {
11079 let temp = tempfile::tempdir().unwrap();
11080 let store = Arc::new(
11081 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11082 .await
11083 .unwrap(),
11084 );
11085 let root = Session::new("local-tool-root", "model");
11086 store.save_session(&root).await.unwrap();
11087 let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
11088 let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
11089 system.role = Role::System;
11090 child.messages.push(system);
11091 child
11092 .messages
11093 .push(bamboo_agent_core::Message::user("assignment"));
11094 store.save_session(&child).await.unwrap();
11095 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11096 store.clone(),
11097 bamboo_domain::SessionInboxLimits::default(),
11098 ));
11099 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11100 let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11101 let mut owner = Some(
11102 binding
11103 .router
11104 .register_run(&child.id, "current-run")
11105 .await
11106 .unwrap(),
11107 );
11108 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
11109 &child.id,
11110 "current confirmed User",
11111 );
11112 let receipt = inbox.deliver(&envelope).await.unwrap();
11113 inbox
11114 .mark_activation_eligible(
11115 &child.id,
11116 receipt.generation,
11117 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11118 )
11119 .await
11120 .unwrap();
11121 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11122 checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
11123 .await
11124 .unwrap();
11125 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
11126 let before = serde_json::to_value(&child).unwrap();
11127 let reasoning = (case == "reasoning")
11128 .then_some("Complete plain reasoning before Read and the final reply 🪷.");
11129 let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
11130 let mut binding = binding;
11131 let late = bamboo_domain::SessionMessageEnvelope::user_input(
11132 &child.id,
11133 "concurrent confirmed User",
11134 );
11135 if case == "concurrent_input" {
11136 let receipt = inbox.deliver(&late).await.unwrap();
11137 inbox
11138 .mark_activation_eligible(
11139 &child.id,
11140 receipt.generation,
11141 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11142 )
11143 .await
11144 .unwrap();
11145 let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11146 binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
11147 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11148 claim: late_claim,
11149 });
11150 }
11151 if case == "checkpoint_error" {
11152 binding.persistence = Arc::new(ActorFaultingPersistence {
11153 inner: locked.clone(),
11154 fail_checkpoint_once: AtomicBool::new(true),
11155 });
11156 }
11157 if case == "stale_prefix" {
11158 let mut latest = child.clone();
11159 latest
11160 .messages
11161 .push(bamboo_agent_core::Message::user("new confirmed prefix"));
11162 store.save_session(&latest).await.unwrap();
11163 }
11164 let cancel = CancellationToken::new();
11165 if case == "cancelled" {
11166 cancel.cancel();
11167 }
11168 if case == "lost_owner" {
11169 owner.take().unwrap().abandon().await;
11170 }
11171 let durable_before =
11172 serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11173 let result = commit_local_tool_history(
11174 collector,
11175 &mut child,
11176 &binding,
11177 "current-run",
11178 (&["Read".into(), "Write".into()], false),
11179 LocalToolTerminal::Completed(Some("complete exact report")),
11180 &cancel,
11181 )
11182 .await;
11183 let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11184 .await
11185 .unwrap()
11186 .load_session(&child.id)
11187 .await
11188 .unwrap()
11189 .unwrap();
11190 if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11191 assert!(result.is_err(), "{case}");
11192 assert_eq!(
11193 serde_json::to_value(&child).unwrap(),
11194 before,
11195 "{case}: no partial mutable success"
11196 );
11197 assert_eq!(
11198 serde_json::to_value(Some(&cold)).unwrap(),
11199 durable_before,
11200 "{case}: no write"
11201 );
11202 continue;
11203 }
11204 result.unwrap();
11205 assert_eq!(
11206 serde_json::to_value(&cold.messages[..3]).unwrap(),
11207 before["messages"]
11208 );
11209 let suffix_start = if case == "concurrent_input" {
11210 assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11211 assert_eq!(
11212 serde_json::to_value(&cold.messages[3]).unwrap(),
11213 serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11214 );
11215 assert!(child
11216 .messages
11217 .iter()
11218 .any(|message| message.id == late.id.as_str()));
11219 4
11220 } else {
11221 3
11222 };
11223 assert_eq!(
11224 serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11225 serde_json::to_value(rows).unwrap()
11226 );
11227 assert_eq!(
11228 cold.messages
11229 .iter()
11230 .filter(|message| message.id == envelope.id.as_str())
11231 .count(),
11232 1
11233 );
11234 assert_eq!(
11235 cold.messages
11236 .iter()
11237 .filter(|message| message.content == "complete exact report")
11238 .count(),
11239 1
11240 );
11241 assert_eq!(cold.metadata, child.metadata);
11242 assert!(cold.provider_transcript.is_empty());
11243 }
11244 }
11245
11246 #[tokio::test]
11247 async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11248 for case in [
11249 "legacy",
11250 "missing",
11251 "gap",
11252 "duplicate",
11253 "epoch",
11254 "source",
11255 "birth",
11256 "qos",
11257 "ticket_forged_success",
11258 ] {
11259 let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11260 session
11261 .messages
11262 .push(bamboo_agent_core::Message::user("original"));
11263 if case == "ticket_forged_success" {
11264 session.metadata.insert(
11265 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11266 "assignment".into(),
11267 );
11268 }
11269 let before = serde_json::to_value(&session).unwrap();
11270 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11271 created_at: session.created_at,
11272 spawn_depth: session.spawn_depth,
11273 };
11274 let mut messages = session.messages.clone();
11275 messages.push(bamboo_agent_core::Message::assistant("complete", None));
11276 let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11277 version: 1,
11278 messages: messages
11279 .iter()
11280 .map(|message| serde_json::to_value(message).unwrap())
11281 .collect(),
11282 })
11283 .unwrap();
11284 let mut batch = ActorEventBatch {
11285 logical_session: Some(LogicalSessionIdentity {
11286 session_id: session.id.clone(),
11287 parent_session_id: session.parent_session_id.clone(),
11288 root_session_id: session.root_session_id.clone(),
11289 creation: Some(creation.clone()),
11290 }),
11291 activation_id: Some("current".into()),
11292 execution_epoch: 7,
11293 source_node_id: None,
11294 source_actor_id: Some("selected".into()),
11295 first_seq: 1,
11296 last_seq: 1,
11297 qos: bamboo_subagent::proto::ActorEventQos::Durable,
11298 events: vec![event.clone()],
11299 };
11300 match case {
11301 "gap" => {
11302 batch.first_seq = 2;
11303 batch.last_seq = 2;
11304 }
11305 "epoch" => batch.execution_epoch = 6,
11306 "source" => batch.source_actor_id = Some("replaced".into()),
11307 "birth" => {
11308 batch
11309 .logical_session
11310 .as_mut()
11311 .unwrap()
11312 .creation
11313 .as_mut()
11314 .unwrap()
11315 .created_at += chrono::Duration::nanoseconds(1)
11316 }
11317 "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11318 "ticket_forged_success" => {
11319 batch.last_seq = 2;
11320 batch.events = vec![
11321 serde_json::to_value(AgentEvent::ToolStart {
11322 tool_call_id: "forged".into(), tool_name: "Task".into(),
11323 arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11324 }).unwrap(),
11325 serde_json::to_value(AgentEvent::ToolComplete {
11326 tool_call_id: "forged".into(),
11327 result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11328 }).unwrap(),
11329 ];
11330 }
11331 _ => {}
11332 }
11333 let terminal = ChildFrame::Terminal {
11334 status: TerminalStatus::Completed,
11335 result: Some("complete".into()),
11336 error: None,
11337 final_event_watermark: None,
11338 transcript: vec![],
11339 };
11340 let frames = match case {
11341 "legacy" => vec![ChildFrame::Event { event }, terminal],
11342 "missing" => vec![terminal],
11343 "duplicate" => vec![
11344 ChildFrame::EventBatch {
11345 batch: batch.clone(),
11346 },
11347 ChildFrame::EventBatch { batch },
11348 terminal,
11349 ],
11350 _ => vec![ChildFrame::EventBatch { batch }, terminal],
11351 };
11352 let mut link = ConfirmationSequenceLink {
11353 frames: frames.into(),
11354 sent: vec![],
11355 };
11356 let (tx, mut rx) = mpsc::channel(32);
11357 let (_live, mut live_rx) = mpsc::unbounded_channel();
11358 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11359 let cancel = CancellationToken::new();
11360 let tools = vec![if case == "ticket_forged_success" {
11361 "Task"
11362 } else {
11363 "Read"
11364 }
11365 .into()];
11366 let outcome = drive(ActorDriveContext {
11367 client: &mut link,
11368 parent_session_id: "frame-root",
11369 child_session_id: "frame-child",
11370 child_attempt: 0,
11371 approval_registry: None,
11372 approval_decider: None,
11373 approval_reviewer: None,
11374 escalation_bridge: None,
11375 event_tx: &tx,
11376 cancel_token: &cancel,
11377 live_rx: &mut live_rx,
11378 delivery_rx: &mut delivery_rx,
11379 logical_session: &mut session,
11380 expected_permission_posture: None,
11381 expected_creation: Some(&creation),
11382 session_inbox_runtime: None,
11383 actor_directory_store: None,
11384 canonical_subagent_tool: None,
11385 ticket_service: None,
11386 activation_run_id: Some("current"),
11387 execution_epoch: 7,
11388 expected_source_actor_id: "selected",
11389 initial_inflight_claims: vec![].into(),
11390 plain_actor: false,
11391 remote_environment_lease: false,
11392 readonly_output: None,
11393 local_history_tools: Some(&tools),
11394 local_history_read_only: true,
11395 strict_history_output: None,
11396 plain_input: None,
11397 canonical_activation: None,
11398 canonical_placement_ref: None,
11399 actor_event_observer: None,
11400 plain_run: None,
11401 first_frame_timeout: Some(Duration::from_secs(1)),
11402 })
11403 .await;
11404 assert!(outcome.is_err(), "{case}: no last-text success fallback");
11405 if case == "ticket_forged_success" {
11406 assert!(outcome
11407 .unwrap_err()
11408 .to_string()
11409 .contains("no exact Host receipt"));
11410 assert!(matches!(
11411 rx.try_recv().unwrap(),
11412 AgentEvent::ToolStart { .. }
11413 ));
11414 }
11415 assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11416 assert!(link.sent.is_empty());
11417 assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11418 }
11419 }
11420
11421 struct HistoryTerminalProbe {
11422 frames: VecDeque<ChildFrame>,
11423 accepted: Option<TerminalStatus>,
11424 acks: usize,
11425 }
11426
11427 #[async_trait]
11428 impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11429 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11430 Ok(())
11431 }
11432 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11433 Ok(self.frames.pop_front())
11434 }
11435 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11436 self.accepted = Some(status);
11437 }
11438 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11439 Some(bamboo_subagent::DurableChildDeliveryReceipt {
11440 broker_identity: "fixture".into(),
11441 parent_mailbox: "parent".into(),
11442 correlation_id: "current".into(),
11443 message_ids: vec!["tail".into(), "terminal".into()],
11444 terminal_status: self.accepted?,
11445 })
11446 }
11447 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11448 self.acks += 1;
11449 Ok(())
11450 }
11451 }
11452
11453 struct CancelConcurrentInputCheckpoint {
11454 real: SessionInboxRuntimeBinding,
11455 envelope: bamboo_domain::SessionMessageEnvelope,
11456 }
11457 #[async_trait]
11458 impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11459 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11460 self.real.persistence.save_runtime_session(session).await
11461 }
11462 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11463 let receipt = self
11464 .real
11465 .inbox
11466 .deliver(&self.envelope)
11467 .await
11468 .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11469 self.real
11470 .inbox
11471 .mark_activation_eligible(
11472 &session.id,
11473 receipt.generation,
11474 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11475 )
11476 .await
11477 .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11478 let claim = self
11479 .real
11480 .inbox
11481 .claim(&session.id, 1)
11482 .await
11483 .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11484 .remove(0);
11485 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11486 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11487 .await
11488 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11489 self.real
11490 .persistence
11491 .checkpoint_runtime_session(session)
11492 .await
11493 }
11494 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11495 self.real.storage.load_session(id).await
11496 }
11497 }
11498
11499 struct CancelHistoryProbe {
11500 inner: HistoryTerminalProbe,
11501 cancel_requested: bool,
11502 }
11503
11504 #[async_trait]
11505 impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11506 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11507 self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11508 Ok(())
11509 }
11510 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11511 if !self.cancel_requested {
11512 std::future::pending().await
11513 } else {
11514 self.inner.next_frame().await
11515 }
11516 }
11517 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11518 self.inner.accept_durable_terminal(status);
11519 }
11520 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11521 self.inner.durable_delivery_receipt()
11522 }
11523 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11524 self.inner.acknowledge_durable_frames().await
11525 }
11526 }
11527
11528 #[tokio::test]
11529 async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11530 use bamboo_subagent::ChildLink;
11531 for case in [
11532 "success",
11533 "empty",
11534 "missing",
11535 "unclosed",
11536 "unobserved",
11537 "prefix",
11538 "metadata",
11539 "write",
11540 "not_read_only",
11541 "watermark",
11542 "epoch",
11543 "birth",
11544 "permission",
11545 "inbox",
11546 "pending",
11547 "pending_eligible",
11548 "claimed",
11549 "inspect_error",
11550 "checkpoint_error",
11551 "lost_owner",
11552 "concurrent_input",
11553 ] {
11554 let temp = tempfile::tempdir().unwrap();
11555 let store = Arc::new(
11556 bamboo_storage::SessionStoreV2::new(temp.path().into())
11557 .await
11558 .unwrap(),
11559 );
11560 let root = Session::new("cancel-history-root", "model");
11561 store.save_session(&root).await.unwrap();
11562 let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11563 child
11564 .messages
11565 .push(bamboo_agent_core::Message::user("exact assignment"));
11566 child.metadata.insert(
11567 bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11568 "true".into(),
11569 );
11570 store.save_session(&child).await.unwrap();
11571 let before = serde_json::to_value(&child.messages).unwrap();
11572 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11573 store.clone(),
11574 bamboo_domain::SessionInboxLimits::default(),
11575 ));
11576 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11577 let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11578 let mut owner = Some(
11579 binding
11580 .router
11581 .register_run(&child.id, "current")
11582 .await
11583 .unwrap(),
11584 );
11585 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11586 created_at: child.created_at,
11587 spawn_depth: child.spawn_depth,
11588 };
11589 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11590 let tool = if case == "write" { "Write" } else { "Read" };
11591 let arguments = serde_json::json!({"file_path":"assigned.txt"});
11592 let call = bamboo_agent_core::ToolCall {
11593 id: "cancel-read".into(),
11594 tool_type: "function".into(),
11595 function: bamboo_agent_core::FunctionCall {
11596 name: tool.into(),
11597 arguments: arguments.to_string(),
11598 },
11599 };
11600 let mut rows = child.messages.clone();
11601 let mut events = Vec::new();
11602 if case != "empty" {
11603 rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11604 events.push(
11605 serde_json::to_value(AgentEvent::ToolStart {
11606 tool_call_id: "cancel-read".into(),
11607 tool_name: tool.into(),
11608 arguments,
11609 })
11610 .unwrap(),
11611 );
11612 if case != "unclosed" {
11613 let mut result = bamboo_agent_core::Message::tool_result_with_status(
11614 "cancel-read",
11615 "exact Read result",
11616 true,
11617 );
11618 if case == "metadata" {
11619 result.metadata = Some(serde_json::json!({"permission":true}));
11620 }
11621 rows.push(result);
11622 events.push(
11623 serde_json::to_value(AgentEvent::ToolComplete {
11624 tool_call_id: "cancel-read".into(),
11625 result: bamboo_agent_core::tools::ToolResult::text(
11626 true,
11627 "exact Read result",
11628 ),
11629 })
11630 .unwrap(),
11631 );
11632 }
11633 }
11634 if case == "unobserved" {
11635 events.clear();
11636 }
11637 if case == "prefix" {
11638 rows[0].content.push('!');
11639 }
11640 events.push(
11641 serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11642 version: 1,
11643 messages: rows
11644 .iter()
11645 .map(|row| serde_json::to_value(row).unwrap())
11646 .collect(),
11647 })
11648 .unwrap(),
11649 );
11650 if case == "missing" {
11651 events.clear();
11652 }
11653 let final_seq = events.len() as u64;
11654 let mut watermark = bamboo_subagent::ActorEventWatermark {
11655 version: 1,
11656 logical_session: Some(identity.clone()),
11657 activation_id: Some("current".into()),
11658 execution_epoch: 7,
11659 final_seq,
11660 };
11661 match case {
11662 "watermark" => watermark.final_seq += 1,
11663 "epoch" => watermark.execution_epoch -= 1,
11664 "birth" => {
11665 watermark
11666 .logical_session
11667 .as_mut()
11668 .unwrap()
11669 .creation
11670 .as_mut()
11671 .unwrap()
11672 .created_at += chrono::Duration::nanoseconds(1)
11673 }
11674 _ => {}
11675 }
11676 let mut frames = VecDeque::new();
11677 if !events.is_empty() {
11678 frames.push_back(ChildFrame::EventBatch {
11679 batch: ActorEventBatch {
11680 logical_session: Some(identity),
11681 activation_id: Some("current".into()),
11682 execution_epoch: 7,
11683 source_node_id: None,
11684 source_actor_id: Some("selected".into()),
11685 first_seq: 1,
11686 last_seq: final_seq,
11687 qos: bamboo_subagent::ActorEventQos::Durable,
11688 events,
11689 },
11690 });
11691 }
11692 frames.push_back(ChildFrame::Terminal {
11693 status: TerminalStatus::Cancelled,
11694 result: None,
11695 error: None,
11696 transcript: vec![],
11697 final_event_watermark: Some(watermark),
11698 });
11699 let mut claims = VecDeque::new();
11700 let pending_input =
11701 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11702 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11703 let receipt = inbox.deliver(&pending_input).await.unwrap();
11704 if case != "pending" {
11705 inbox
11706 .mark_activation_eligible(
11707 &child.id,
11708 receipt.generation,
11709 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11710 )
11711 .await
11712 .unwrap();
11713 }
11714 if matches!(case, "inbox" | "claimed") {
11715 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11716 if case == "inbox" {
11717 claims.push_back(claim);
11718 }
11719 }
11720 }
11721 if case == "concurrent_input" {
11722 binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11723 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11724 envelope: pending_input.clone(),
11725 });
11726 }
11727 if case == "inspect_error" {
11728 let rel = store.resolve_rel_path(&child.id).await.unwrap();
11729 let dir = store.bamboo_home_dir().join(rel).join("inbox");
11730 std::fs::create_dir_all(&dir).unwrap();
11731 std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11732 assert!(inbox.inspect(&child.id).await.is_err());
11733 }
11734 let backlog_before =
11735 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11736 Some(inbox.inspect(&child.id).await.unwrap())
11737 } else {
11738 None
11739 };
11740 if case == "checkpoint_error" {
11741 binding.persistence = Arc::new(ActorFaultingPersistence {
11742 inner: locked.clone(),
11743 fail_checkpoint_once: AtomicBool::new(true),
11744 });
11745 }
11746 if case == "lost_owner" {
11747 owner.take().unwrap().abandon().await;
11748 }
11749 let mut link = CancelHistoryProbe {
11750 inner: HistoryTerminalProbe {
11751 frames,
11752 accepted: None,
11753 acks: 0,
11754 },
11755 cancel_requested: false,
11756 };
11757 let (tx, mut rx) = mpsc::channel(32);
11758 let (_live, mut live_rx) = mpsc::unbounded_channel();
11759 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11760 let cancel = CancellationToken::new();
11761 cancel.cancel();
11762 let tools = vec!["Read".into(), "Glob".into()];
11763 let mut strict_history = None;
11764 let outcome = drive(ActorDriveContext {
11765 client: &mut link,
11766 parent_session_id: &root.id,
11767 child_session_id: &child.id.clone(),
11768 child_attempt: 0,
11769 approval_registry: None,
11770 approval_decider: None,
11771 approval_reviewer: None,
11772 escalation_bridge: None,
11773 event_tx: &tx,
11774 cancel_token: &cancel,
11775 live_rx: &mut live_rx,
11776 delivery_rx: &mut delivery_rx,
11777 logical_session: &mut child,
11778 expected_permission_posture: (case == "permission")
11779 .then(|| expected_default_permission_posture(7)),
11780 expected_creation: Some(&creation),
11781 session_inbox_runtime: Some(&binding),
11782 actor_directory_store: None,
11783 canonical_subagent_tool: None,
11784 ticket_service: None,
11785 activation_run_id: Some("current"),
11786 execution_epoch: 7,
11787 expected_source_actor_id: "selected",
11788 initial_inflight_claims: claims,
11789 plain_actor: false,
11790 remote_environment_lease: false,
11791 readonly_output: None,
11792 local_history_tools: Some(&tools),
11793 local_history_read_only: case != "not_read_only",
11794 strict_history_output: Some(&mut strict_history),
11795 plain_input: None,
11796 canonical_activation: None,
11797 canonical_placement_ref: None,
11798 actor_event_observer: None,
11799 plain_run: None,
11800 first_frame_timeout: None,
11801 })
11802 .await;
11803 let proven = matches!(case, "success" | "empty");
11804 assert_eq!(strict_history.is_some(), proven, "{case}");
11805 assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11806 assert_eq!(
11807 link.inner.acks, 0,
11808 "{case}: pump cannot ACK before SDK save/proof"
11809 );
11810 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11811 .await
11812 .unwrap()
11813 .load_session(&child.id)
11814 .await
11815 .unwrap()
11816 .unwrap();
11817 if proven {
11818 assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11819 assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11820 let history = strict_history.unwrap();
11821 assert_eq!(history.proof.execution_epoch(), 7);
11822 assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11823 assert_eq!(
11824 serde_json::to_value(&cold.messages).unwrap(),
11825 serde_json::to_value(&rows).unwrap()
11826 );
11827 } else {
11828 assert!(outcome.is_err(), "{case}");
11829 if case == "concurrent_input" {
11830 assert!(
11831 cold.messages
11832 .iter()
11833 .any(|message| message.id == pending_input.id.as_str()),
11834 "new input must survive refusal"
11835 );
11836 } else {
11837 assert_eq!(
11838 serde_json::to_value(&cold.messages).unwrap(),
11839 before,
11840 "{case}: no unvalidated history write"
11841 );
11842 }
11843 }
11844 if let Some(backlog) = backlog_before {
11845 assert!(!inbox
11846 .was_admitted(&child.id, &pending_input.id)
11847 .await
11848 .unwrap());
11849 assert_eq!(
11850 inbox.inspect(&child.id).await.unwrap(),
11851 backlog,
11852 "{case}: proof refusal must neither claim nor ACK the input"
11853 );
11854 }
11855 while let Ok(event) = rx.try_recv() {
11856 assert!(
11857 !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11858 "private DATA is not a canonical public barrier"
11859 );
11860 }
11861 }
11862 }
11863
11864 #[tokio::test]
11865 async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11866 use bamboo_subagent::ChildLink;
11867 for case in [
11868 "success",
11869 "tail",
11870 "short",
11871 "missing",
11872 "version",
11873 "activation",
11874 "epoch",
11875 "birth",
11876 "duplicate",
11877 "overlap",
11878 "reorder",
11879 "midgap",
11880 "qos",
11881 "zero_missing",
11882 "zero_valid",
11883 "error_tail",
11884 "cancelled_tail",
11885 "cancelled_valid",
11886 "cancelled_zero_valid",
11887 "error_valid",
11888 "error_zero_valid",
11889 "ordinary_lossy",
11890 ] {
11891 let temp = tempfile::tempdir().unwrap();
11892 let store = Arc::new(
11893 bamboo_storage::SessionStoreV2::new(temp.path().into())
11894 .await
11895 .unwrap(),
11896 );
11897 let root = Session::new("watermark-root", "model");
11898 store.save_session(&root).await.unwrap();
11899 let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11900 child
11901 .messages
11902 .push(bamboo_agent_core::Message::user("assignment"));
11903 store.save_session(&child).await.unwrap();
11904 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11905 store.clone(),
11906 bamboo_domain::SessionInboxLimits::default(),
11907 ));
11908 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11909 let binding = actor_binding(store.clone(), inbox, locked);
11910 let _owner = binding
11911 .router
11912 .register_run(&child.id, "current")
11913 .await
11914 .unwrap();
11915 let before = serde_json::to_value(&child).unwrap();
11916 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11917 created_at: child.created_at,
11918 spawn_depth: child.spawn_depth,
11919 };
11920 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11921 let mut rows = child.messages.clone();
11922 rows.push(bamboo_agent_core::Message::assistant("complete", None));
11923 let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11924 version: 1,
11925 messages: rows
11926 .iter()
11927 .map(|row| serde_json::to_value(row).unwrap())
11928 .collect(),
11929 })
11930 .unwrap();
11931 let batch = |seq, event, qos| ActorEventBatch {
11932 logical_session: Some(identity.clone()),
11933 activation_id: Some("current".into()),
11934 execution_epoch: 7,
11935 source_node_id: None,
11936 source_actor_id: Some("selected".into()),
11937 first_seq: seq,
11938 last_seq: seq,
11939 qos,
11940 events: vec![event],
11941 };
11942 let durable = bamboo_subagent::ActorEventQos::Durable;
11943 let token = serde_json::json!({"type":"token", "content":"observation"});
11944 let mut frames = vec![ChildFrame::EventBatch {
11945 batch: batch(1, data.clone(), durable),
11946 }];
11947 let mut marker = bamboo_subagent::ActorEventWatermark {
11948 version: 1,
11949 logical_session: Some(identity.clone()),
11950 activation_id: Some("current".into()),
11951 execution_epoch: 7,
11952 final_seq: 1,
11953 };
11954 match case {
11955 "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11956 "short" => marker.final_seq = 0,
11957 "version" => marker.version = 99,
11958 "activation" => marker.activation_id = Some("old".into()),
11959 "epoch" => marker.execution_epoch = 6,
11960 "birth" => {
11961 marker
11962 .logical_session
11963 .as_mut()
11964 .unwrap()
11965 .creation
11966 .as_mut()
11967 .unwrap()
11968 .created_at += chrono::Duration::nanoseconds(1)
11969 }
11970 "duplicate" => frames.push(frames[0].clone()),
11971 "overlap" | "midgap" | "qos" => {
11972 frames = vec![
11973 ChildFrame::EventBatch {
11974 batch: batch(
11975 1,
11976 token.clone(),
11977 if case == "qos" {
11978 bamboo_subagent::ActorEventQos::Ephemeral
11979 } else {
11980 durable
11981 },
11982 ),
11983 },
11984 ChildFrame::EventBatch {
11985 batch: batch(
11986 if case == "overlap" {
11987 1
11988 } else if case == "midgap" {
11989 3
11990 } else {
11991 2
11992 },
11993 data,
11994 durable,
11995 ),
11996 },
11997 ];
11998 marker.final_seq = 2;
11999 }
12000 "reorder" => {
12001 frames = vec![
12002 ChildFrame::EventBatch {
12003 batch: batch(2, data, durable),
12004 },
12005 ChildFrame::EventBatch {
12006 batch: batch(1, token, durable),
12007 },
12008 ]
12009 }
12010 "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
12011 frames.clear();
12012 marker.final_seq = 0;
12013 }
12014 "ordinary_lossy" => {
12015 frames = vec![ChildFrame::EventBatch {
12016 batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
12017 }]
12018 }
12019 _ => {}
12020 }
12021 let status = match case {
12022 "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
12023 "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
12024 TerminalStatus::Cancelled
12025 }
12026 _ => TerminalStatus::Completed,
12027 };
12028 frames.push(ChildFrame::Terminal {
12029 status,
12030 result: Some("complete".into()),
12031 error: Some("worker failed".into()),
12032 transcript: vec![],
12033 final_event_watermark: (!matches!(
12034 case,
12035 "missing" | "zero_missing" | "ordinary_lossy"
12036 ))
12037 .then_some(marker),
12038 });
12039 let mut link = HistoryTerminalProbe {
12040 frames: frames.into(),
12041 accepted: None,
12042 acks: 0,
12043 };
12044 let (tx, mut rx) = mpsc::channel(32);
12045 let (_live, mut live_rx) = mpsc::unbounded_channel();
12046 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
12047 let cancel = CancellationToken::new();
12048 let tools = vec!["Read".into()];
12049 let mut strict_history = None;
12050 let outcome = drive(ActorDriveContext {
12051 client: &mut link,
12052 parent_session_id: &root.id,
12053 child_session_id: "watermark-child",
12054 child_attempt: 0,
12055 approval_registry: None,
12056 approval_decider: None,
12057 approval_reviewer: None,
12058 escalation_bridge: None,
12059 event_tx: &tx,
12060 cancel_token: &cancel,
12061 live_rx: &mut live_rx,
12062 delivery_rx: &mut delivery_rx,
12063 logical_session: &mut child,
12064 expected_permission_posture: None,
12065 expected_creation: Some(&creation),
12066 session_inbox_runtime: Some(&binding),
12067 actor_directory_store: None,
12068 canonical_subagent_tool: None,
12069 ticket_service: None,
12070 activation_run_id: Some("current"),
12071 execution_epoch: 7,
12072 expected_source_actor_id: "selected",
12073 initial_inflight_claims: vec![].into(),
12074 plain_actor: false,
12075 remote_environment_lease: false,
12076 readonly_output: None,
12077 local_history_tools: (case != "ordinary_lossy").then_some(&tools),
12078 local_history_read_only: true,
12079 strict_history_output: Some(&mut strict_history),
12080 plain_input: None,
12081 canonical_activation: None,
12082 canonical_placement_ref: None,
12083 actor_event_observer: None,
12084 plain_run: None,
12085 first_frame_timeout: Some(Duration::from_secs(1)),
12086 })
12087 .await;
12088 assert_eq!(strict_history.is_some(), case == "success", "{case}");
12089 if let Some(history) = strict_history {
12090 assert_eq!(history.proof.execution_epoch(), 7);
12091 assert_eq!(history.proof.contiguous_applied_seq(), 1);
12092 assert_eq!(history.watermark.final_seq, 1);
12093 }
12094 let cold = store.load_session(&child.id).await.unwrap().unwrap();
12095 if matches!(case, "success" | "ordinary_lossy") {
12096 assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
12097 } else {
12098 let error = outcome.unwrap_err();
12099 if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
12100 assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
12101 }
12102 let error = error.to_string();
12103 if !matches!(
12104 case,
12105 "duplicate"
12106 | "overlap"
12107 | "reorder"
12108 | "midgap"
12109 | "qos"
12110 | "zero_valid"
12111 | "error_valid"
12112 | "error_zero_valid"
12113 | "cancelled_valid"
12114 | "cancelled_zero_valid"
12115 ) {
12116 assert!(
12117 error.contains("actor_history_tail_incomplete"),
12118 "{case}: {error}"
12119 );
12120 }
12121 }
12122 if case == "success" {
12123 assert_eq!(
12124 serde_json::to_value(&cold.messages).unwrap(),
12125 serde_json::to_value(rows).unwrap()
12126 );
12127 } else {
12128 assert_eq!(
12129 serde_json::to_value(&cold).unwrap(),
12130 before,
12131 "{case}: no cold history commit"
12132 );
12133 assert_eq!(
12134 serde_json::to_value(&child).unwrap(),
12135 before,
12136 "{case}: no mutable history commit"
12137 );
12138 }
12139 let accepted = matches!(case, "success" | "ordinary_lossy");
12140 assert_eq!(link.accepted.is_some(), accepted, "{case}");
12141 assert_eq!(
12142 link.durable_delivery_receipt().is_some(),
12143 accepted,
12144 "{case}"
12145 );
12146 assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
12147 while let Ok(event) = rx.try_recv() {
12148 assert!(
12149 matches!(event, AgentEvent::Token { .. }),
12150 "typed DATA cannot leak"
12151 );
12152 }
12153 }
12154 }
12155
12156 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
12157 PooledWorker {
12158 worker: SpawnedChild::remote(AgentRecord {
12159 agent_id: mailbox_id.to_string(),
12160 role: "test".to_string(),
12161 labels: Vec::new(),
12162 endpoint: "ws://127.0.0.1:1".to_string(),
12163 pid: 0,
12164 version: String::new(),
12165 started_at: chrono::Utc::now(),
12166 lease_expires_at: chrono::Utc::now(),
12167 }),
12168 mailbox_id: mailbox_id.to_string(),
12169 parked_at: None,
12170 }
12171 }
12172
12173 #[test]
12174 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12175 let now = Instant::now();
12176 let timeout = Duration::from_secs(300);
12177 assert!(!parked_worker_expired(None, now, timeout));
12178 assert!(!parked_worker_expired(
12179 Some(now - timeout + Duration::from_millis(1)),
12180 now,
12181 timeout
12182 ));
12183 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12184 }
12185
12186 #[test]
12187 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12188 let mut pool = WorkerPool::new();
12189 pool.insert(
12190 "fingerprint-a".to_string(),
12191 vec![processless_pool_worker("a")],
12192 );
12193 pool.insert(
12194 "fingerprint-b".to_string(),
12195 vec![processless_pool_worker("b")],
12196 );
12197
12198 assert_eq!(
12199 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12200 Some("pool_global_limit")
12201 );
12202 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12203 assert_eq!(
12204 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12205 Some("pool_per_key_limit")
12206 );
12207 }
12208
12209 #[test]
12210 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12211 let mut pool = WorkerPool::new();
12212 pool.insert(
12213 "fingerprint-a".to_string(),
12214 vec![processless_pool_worker("dead")],
12215 );
12216
12217 let reaped = take_reapable_workers(
12218 &mut pool,
12219 Instant::now(),
12220 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12221 );
12222
12223 assert!(pool.is_empty());
12224 assert_eq!(reaped.len(), 1);
12225 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12226 }
12227
12228 #[test]
12229 fn actor_preflight_counts_only_current_session_scoped_denies() {
12230 let config = bamboo_tools::permission::PermissionConfig::new();
12231 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12232 config.deny_scoped_session_permission(
12233 "target-session",
12234 bamboo_tools::permission::PermissionType::ExecuteCommand,
12235 secret_matcher,
12236 );
12237 config.deny_scoped_session_permission(
12238 "other-session",
12239 bamboo_tools::permission::PermissionType::WriteFile,
12240 "/other/**",
12241 );
12242
12243 assert_eq!(
12244 active_scoped_session_deny_count(&config, "target-session"),
12245 1
12246 );
12247 assert_eq!(
12248 active_scoped_session_deny_count(&config, "clean-session"),
12249 0
12250 );
12251 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12252 .unwrap_err()
12253 .to_string();
12254 assert!(!error.contains(secret_matcher));
12255 ensure_no_active_scoped_session_denies(&config, "clean-session")
12256 .expect("another session's deny must not block this activation");
12257 }
12258
12259 #[test]
12260 fn permission_posture_event_rejects_oversized_executor_mapping() {
12261 let session = Session::new("mapping-bound", "model");
12262 let event = AgentEvent::PermissionPostureActivated {
12263 session_id: session.id.clone(),
12264 policy_revision: 1,
12265 requested_mode: "default".to_string(),
12266 effective_mode: "default".to_string(),
12267 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12268 };
12269
12270 assert!(permission_posture_seed_from_event(&session, &event)
12271 .unwrap_err()
12272 .contains("executor mapping"));
12273 }
12274
12275 #[test]
12276 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12277 let mut session = Session::new("host-resigns-audit", "model");
12278 let hostile_timestamp = "9".repeat(1024);
12279 let event: AgentEvent = serde_json::from_value(serde_json::json!({
12280 "type": "permission_posture_activated",
12281 "session_id": session.id.clone(),
12282 "policy_revision": 31,
12283 "requested_mode": "default",
12284 "effective_mode": "default",
12285 "executor_mapping": "codex_exec:approval_policy=never",
12286 "audit_revision": u64::MAX,
12287 "transitioned_at": hostile_timestamp,
12288 }))
12289 .expect("unknown remote audit fields are ignored by the typed event");
12290 let seed = permission_posture_seed_from_event(&session, &event)
12291 .unwrap()
12292 .expect("permission event");
12293
12294 let host_revision =
12295 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12296 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12297 .expect("host-generated complete audit");
12298 assert_eq!(host_audit.audit_revision, host_revision);
12299 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12300 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12301 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12302 }
12303
12304 struct ActorFaultingPersistence {
12305 inner: Arc<bamboo_storage::LockedSessionStore>,
12306 fail_checkpoint_once: std::sync::atomic::AtomicBool,
12307 }
12308
12309 #[async_trait]
12310 impl RuntimeSessionPersistence for ActorFaultingPersistence {
12311 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12312 self.inner.merge_save_runtime(session).await
12313 }
12314
12315 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12316 if self
12317 .fail_checkpoint_once
12318 .swap(false, std::sync::atomic::Ordering::SeqCst)
12319 {
12320 return Err(std::io::Error::other("injected actor checkpoint failure"));
12321 }
12322 self.inner.checkpoint_runtime_session(session).await
12323 }
12324
12325 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12326 self.inner.storage().load_session(session_id).await
12327 }
12328 }
12329
12330 struct ActorFailBeforeAckInbox {
12331 inner: Arc<dyn SessionInboxPort>,
12332 fail_once: std::sync::atomic::AtomicBool,
12333 }
12334
12335 #[async_trait]
12336 impl SessionInboxPort for ActorFailBeforeAckInbox {
12337 async fn deliver(
12338 &self,
12339 envelope: &bamboo_domain::SessionMessageEnvelope,
12340 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12341 self.inner.deliver(envelope).await
12342 }
12343
12344 async fn mark_activation_eligible(
12345 &self,
12346 target_session_id: &str,
12347 generation: u64,
12348 policy: bamboo_domain::SessionActivationPolicy,
12349 ) -> Result<(), bamboo_domain::SessionInboxError> {
12350 self.inner
12351 .mark_activation_eligible(target_session_id, generation, policy)
12352 .await
12353 }
12354
12355 async fn claim(
12356 &self,
12357 target_session_id: &str,
12358 limit: usize,
12359 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12360 self.inner.claim(target_session_id, limit).await
12361 }
12362
12363 async fn was_admitted(
12364 &self,
12365 target_session_id: &str,
12366 id: &bamboo_domain::SessionMessageId,
12367 ) -> Result<bool, bamboo_domain::SessionInboxError> {
12368 self.inner.was_admitted(target_session_id, id).await
12369 }
12370
12371 async fn ack(
12372 &self,
12373 target_session_id: &str,
12374 claim: &SessionInboxClaim,
12375 ) -> Result<(), bamboo_domain::SessionInboxError> {
12376 if self
12377 .fail_once
12378 .swap(false, std::sync::atomic::Ordering::SeqCst)
12379 {
12380 return Err(bamboo_domain::SessionInboxError::Storage(
12381 "injected actor pre-ack failure".to_string(),
12382 ));
12383 }
12384 self.inner.ack(target_session_id, claim).await
12385 }
12386
12387 async fn inspect(
12388 &self,
12389 target_session_id: &str,
12390 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12391 self.inner.inspect(target_session_id).await
12392 }
12393 }
12394
12395 async fn actor_inbox_fixture(
12396 session_id: &str,
12397 ) -> (
12398 tempfile::TempDir,
12399 Arc<bamboo_storage::SessionStoreV2>,
12400 Arc<bamboo_storage::LockedSessionStore>,
12401 Arc<dyn SessionInboxPort>,
12402 Session,
12403 SessionInboxClaim,
12404 ) {
12405 let temp = tempfile::tempdir().unwrap();
12406 let store = Arc::new(
12407 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12408 .await
12409 .unwrap(),
12410 );
12411 let storage: Arc<dyn Storage> = store.clone();
12412 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12413 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12414 store.clone(),
12415 bamboo_domain::SessionInboxLimits::default(),
12416 ));
12417 let session = Session::new(session_id, "model");
12418 store.save_session(&session).await.unwrap();
12419 let mut envelope =
12420 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12421 envelope.id =
12422 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12423 let receipt = inbox.deliver(&envelope).await.unwrap();
12424 inbox
12425 .mark_activation_eligible(
12426 session_id,
12427 receipt.generation,
12428 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12429 )
12430 .await
12431 .unwrap();
12432 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12433 (temp, store, locked, inbox, session, claim)
12434 }
12435
12436 fn actor_binding(
12437 store: Arc<bamboo_storage::SessionStoreV2>,
12438 inbox: Arc<dyn SessionInboxPort>,
12439 persistence: Arc<dyn RuntimeSessionPersistence>,
12440 ) -> SessionInboxRuntimeBinding {
12441 let storage: Arc<dyn Storage> = store;
12442 SessionInboxRuntimeBinding {
12443 router: SessionActivationRouter::new(),
12444 inbox,
12445 storage,
12446 persistence,
12447 parent_question_lock: None,
12448 }
12449 }
12450
12451 #[tokio::test]
12452 async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12453 let (_temp, store, locked, inbox, mut session, claim) =
12454 actor_inbox_fixture("unsupported-remote-inbox").await;
12455 let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12456 inner: inbox.clone(),
12457 fail_once: std::sync::atomic::AtomicBool::new(false),
12458 });
12459 let binding = actor_binding(store.clone(), wrapped, locked);
12460 let fence = ActorActivationFence {
12461 schema_version: 1,
12462 actor_id: session.id.clone(),
12463 activation_id: "unsupported-inbox-activation".into(),
12464 attempt: 1,
12465 run_id: "unsupported-inbox-run".into(),
12466 lease_owner: "unsupported-inbox-host".into(),
12467 lease_epoch: 1,
12468 };
12469 let before = session.clone();
12470
12471 let error = checkpoint_claim_context_before_dispatch(
12472 &binding,
12473 &mut session,
12474 &[claim],
12475 Some((store.as_ref(), &fence)),
12476 )
12477 .await
12478 .unwrap_err();
12479 assert!(error
12480 .to_string()
12481 .contains("requires canonical FileSessionInbox"));
12482 assert_eq!(
12483 serde_json::to_value(&session.messages).unwrap(),
12484 serde_json::to_value(&before.messages).unwrap()
12485 );
12486 assert_eq!(
12487 serde_json::to_value(
12488 &store
12489 .load_session(&session.id)
12490 .await
12491 .unwrap()
12492 .unwrap()
12493 .messages
12494 )
12495 .unwrap(),
12496 serde_json::to_value(&before.messages).unwrap()
12497 );
12498 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12499 }
12500
12501 #[tokio::test]
12502 async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12503 let (_temp, store, locked, inbox, parent, _claim) =
12504 actor_inbox_fixture("remote-parent").await;
12505 let child = Session::new_child_of("remote-child", &parent, "model", "child");
12506 store.save_session(&child).await.unwrap();
12507 let binding = actor_binding(store.clone(), inbox, locked);
12508 let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12509 assert_eq!(first.len(), 2);
12510 let mut changed = child.clone();
12511 changed.set_project_id_meta("different-project");
12512 assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12513 store.delete_session(&parent.id).await.unwrap();
12514 assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12515 }
12516
12517 #[tokio::test]
12518 async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12519 let (_temp, store, locked, inbox, root, _claim) =
12520 actor_inbox_fixture("deep-remote-root").await;
12521 let mut parent = root;
12522 for depth in 1..=6 {
12523 let child =
12524 Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12525 store.save_session(&child).await.unwrap();
12526 parent = child;
12527 }
12528 let binding = actor_binding(store, inbox, locked);
12529 let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12530 assert_eq!(chain.len(), 7);
12531 assert_eq!(chain.last().unwrap().spawn_depth, 0);
12532 }
12533
12534 #[tokio::test]
12535 async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12536 let (_temp, store, locked, inbox, parent, _claim) =
12537 actor_inbox_fixture("creation-parent").await;
12538 let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12539 child.set_project_id_meta("creation-project");
12540 store.save_session(&child).await.unwrap();
12541 let binding = actor_binding(store.clone(), inbox, locked);
12542 let job = SpawnJob {
12543 parent_session_id: parent.id.clone(),
12544 child_session_id: child.id.clone(),
12545 model: "model".into(),
12546 disabled_tools: None,
12547 };
12548 let creation = canonical_child_creation(&binding, &child, &job)
12549 .await
12550 .unwrap();
12551 assert_eq!(creation.created_at, child.created_at);
12552 assert_eq!(creation.spawn_depth, child.spawn_depth);
12553 for damage in 0..7 {
12554 let mut held = child.clone();
12555 match damage {
12556 0 => held.created_at += chrono::Duration::nanoseconds(1),
12557 1 => held.parent_session_id = Some("foreign-parent".into()),
12558 2 => held.root_session_id = "foreign-root".into(),
12559 3 => held.spawn_depth += 1,
12560 4 => held.set_project_id_meta("foreign-project"),
12561 5 => held.kind = bamboo_domain::SessionKind::Root,
12562 _ => held.id = " creation-child ".into(),
12563 }
12564 assert!(
12565 canonical_child_creation(&binding, &held, &job)
12566 .await
12567 .is_err(),
12568 "damage {damage}"
12569 );
12570 }
12571 store.delete_session(&child.id).await.unwrap();
12572 assert!(canonical_child_creation(&binding, &child, &job)
12573 .await
12574 .is_err());
12575 let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12576 replacement.set_project_id_meta("creation-project");
12577 assert_ne!(replacement.created_at, child.created_at);
12578 store.save_session(&replacement).await.unwrap();
12579 assert!(
12580 canonical_child_creation(&binding, &child, &job)
12581 .await
12582 .is_err(),
12583 "old held spec cannot adopt replacement birth"
12584 );
12585 canonical_child_creation(&binding, &replacement, &job)
12586 .await
12587 .unwrap();
12588 }
12589
12590 #[tokio::test]
12591 async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12592 use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12593 for policies in [
12594 [
12595 SessionActivationPolicy::RespectSpecificWait,
12596 SessionActivationPolicy::InterruptSpecificWait,
12597 ],
12598 [
12599 SessionActivationPolicy::InterruptSpecificWait,
12600 SessionActivationPolicy::RespectSpecificWait,
12601 ],
12602 ] {
12603 let temp = tempfile::tempdir().unwrap();
12604 let store = Arc::new(
12605 bamboo_storage::SessionStoreV2::new(temp.path().into())
12606 .await
12607 .unwrap(),
12608 );
12609 let session_id = "actor-mixed-intent-policy";
12610 store
12611 .save_session(&Session::new(session_id, "model"))
12612 .await
12613 .unwrap();
12614 let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12615 let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12616 let staged_receipt = inbox.deliver(&staged).await.unwrap();
12617 let mut sent = Vec::new();
12618 for policy in policies {
12619 let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12620 let receipt = inbox
12621 .deliver_with_activation_intent(&envelope, policy, None)
12622 .await
12623 .unwrap();
12624 sent.push((envelope, policy, receipt));
12625 }
12626 let restarted = Arc::new(
12627 bamboo_storage::SessionStoreV2::new(temp.path().into())
12628 .await
12629 .unwrap(),
12630 );
12631 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12632 restarted.clone(),
12633 SessionInboxLimits::default(),
12634 ));
12635 let binding = actor_binding(
12636 restarted.clone(),
12637 inbox.clone(),
12638 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12639 );
12640 let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12641 let pairs =
12642 claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12643 .await
12644 .unwrap();
12645 assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12646 for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12647 assert_eq!(claim.envelope.id, envelope.id);
12648 assert_eq!(claim.generation, receipt.generation);
12649 assert_eq!(claim.activation_policy, *policy);
12650 assert_eq!(delivery.activation_policy, *policy);
12651 checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12652 .await
12653 .unwrap();
12654 }
12655 let restarted = Arc::new(
12656 bamboo_storage::SessionStoreV2::new(temp.path().into())
12657 .await
12658 .unwrap(),
12659 );
12660 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12661 restarted.clone(),
12662 SessionInboxLimits::default(),
12663 ));
12664 let binding = actor_binding(
12665 restarted.clone(),
12666 inbox.clone(),
12667 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12668 );
12669 session = restarted.load_session(session_id).await.unwrap().unwrap();
12670 for (envelope, policy, receipt) in &sent {
12671 assert_eq!(
12672 inbox
12673 .deliver_with_activation_intent(envelope, *policy, None)
12674 .await
12675 .unwrap(),
12676 *receipt
12677 );
12678 }
12679 assert!(
12680 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12681 .await
12682 .unwrap()
12683 .is_empty()
12684 );
12685 inbox
12686 .mark_activation_eligible(
12687 session_id,
12688 staged_receipt.generation,
12689 SessionActivationPolicy::RespectSpecificWait,
12690 )
12691 .await
12692 .unwrap();
12693 let pairs =
12694 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12695 .await
12696 .unwrap();
12697 assert_eq!(pairs.len(), 1);
12698 assert_eq!(pairs[0].0.envelope.id, staged.id);
12699 assert_eq!(
12700 pairs[0].1.activation_policy,
12701 SessionActivationPolicy::RespectSpecificWait
12702 );
12703 }
12704 }
12705
12706 #[tokio::test]
12707 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12708 let (_temp, store, locked, inbox, mut session, claim) =
12709 actor_inbox_fixture("actor-live-id-collision").await;
12710 let mut forged = claim.envelope.to_provider_message().unwrap();
12711 forged.metadata = Some(serde_json::json!({
12712 "session_message": {
12713 "id": claim.envelope.id,
12714 "target_session_id": "different-session"
12715 }
12716 }));
12717 session.add_message(forged);
12718 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12719 let binding = actor_binding(store, inbox.clone(), persistence);
12720
12721 assert!(
12722 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12723 .await
12724 .is_err()
12725 );
12726 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12727 assert!(!inbox
12728 .was_admitted(&session.id, &claim.envelope.id)
12729 .await
12730 .unwrap());
12731 }
12732
12733 #[tokio::test]
12734 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12735 let (_temp, store, locked, inbox, mut session, claim) =
12736 actor_inbox_fixture("actor-durable-id-collision").await;
12737 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12738 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12739 forged.id = claim.envelope.id.to_string();
12740 concurrent.add_message(forged);
12741 store.save_session(&concurrent).await.unwrap();
12742 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12743 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12744
12745 assert!(
12746 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12747 .await
12748 .is_err()
12749 );
12750 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12751 assert!(!inbox
12752 .was_admitted(&session.id, &claim.envelope.id)
12753 .await
12754 .unwrap());
12755 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12756 assert!(!durable.messages.iter().any(|message| {
12757 bamboo_domain::is_matching_session_message(message, &claim.envelope)
12758 }));
12759 }
12760
12761 #[tokio::test]
12762 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12763 let (_temp, store, locked, inbox, mut session, claim) =
12764 actor_inbox_fixture("actor-durable-typed-body-collision").await;
12765 let mut different = claim.envelope.clone();
12766 different.body = bamboo_domain::SessionMessageBody::Content(
12767 bamboo_domain::SessionMessageContent::text("forged actor body"),
12768 );
12769 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12770 concurrent.add_message(different.to_provider_message().unwrap());
12771 store.save_session(&concurrent).await.unwrap();
12772 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12773 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12774
12775 assert!(
12776 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12777 .await
12778 .is_err()
12779 );
12780 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12781 assert!(!inbox
12782 .was_admitted(&session.id, &claim.envelope.id)
12783 .await
12784 .unwrap());
12785 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12786 assert!(!durable
12787 .messages
12788 .iter()
12789 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12790 }
12791
12792 #[tokio::test]
12793 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12794 let (_temp, store, locked, inbox, mut session, claim) =
12795 actor_inbox_fixture("actor-checkpoint-failure").await;
12796 let envelope_id = claim.envelope.id.clone();
12797 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12798 inner: locked.clone(),
12799 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12800 });
12801 let binding = actor_binding(store.clone(), inbox.clone(), fault);
12802
12803 assert!(
12804 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12805 .await
12806 .is_err()
12807 );
12808 assert!(!session
12809 .messages
12810 .iter()
12811 .any(|message| message.id == envelope_id.as_str()));
12812 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12813 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12814
12815 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12816 store.clone(),
12817 bamboo_domain::SessionInboxLimits::default(),
12818 ));
12819 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12820 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12821 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12822 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12823 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12824 .await
12825 .unwrap();
12826 assert_eq!(
12827 restarted
12828 .messages
12829 .iter()
12830 .filter(|message| message.id == envelope_id.as_str())
12831 .count(),
12832 1
12833 );
12834 assert!(reopened
12835 .was_admitted(&session.id, &envelope_id)
12836 .await
12837 .unwrap());
12838 let backlog = reopened.inspect(&session.id).await.unwrap();
12839 assert_eq!(backlog.pending + backlog.claimed, 0);
12840 }
12841
12842 #[tokio::test]
12843 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12844 let (_temp, store, locked, real_inbox, mut session, claim) =
12845 actor_inbox_fixture("actor-pre-ack-failure").await;
12846 let envelope_id = claim.envelope.id.clone();
12847 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12848 inner: real_inbox.clone(),
12849 fail_once: std::sync::atomic::AtomicBool::new(true),
12850 });
12851 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12852 let binding = actor_binding(store.clone(), faulted, persistence);
12853
12854 assert!(
12855 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12856 .await
12857 .is_err()
12858 );
12859 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12860 assert_eq!(
12861 durable
12862 .messages
12863 .iter()
12864 .filter(|message| message.id == envelope_id.as_str())
12865 .count(),
12866 1
12867 );
12868 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12869 assert!(!real_inbox
12870 .was_admitted(&session.id, &envelope_id)
12871 .await
12872 .unwrap());
12873
12874 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12875 store.clone(),
12876 bamboo_domain::SessionInboxLimits::default(),
12877 ));
12878 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12879 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12880 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12881 let mut restarted = durable;
12882 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12883 .await
12884 .unwrap();
12885 assert_eq!(
12886 restarted
12887 .messages
12888 .iter()
12889 .filter(|message| message.id == envelope_id.as_str())
12890 .count(),
12891 1
12892 );
12893 assert!(reopened
12894 .was_admitted(&session.id, &envelope_id)
12895 .await
12896 .unwrap());
12897 let backlog = reopened.inspect(&session.id).await.unwrap();
12898 assert_eq!(backlog.pending + backlog.claimed, 0);
12899 }
12900
12901 struct ConfirmationSequenceLink {
12902 frames: VecDeque<ChildFrame>,
12903 sent: Vec<ParentFrame>,
12904 }
12905
12906 #[async_trait]
12907 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12908 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12909 self.sent.push(frame);
12910 Ok(())
12911 }
12912
12913 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12914 match self.frames.pop_front() {
12915 Some(frame) => Ok(Some(frame)),
12916 None => std::future::pending().await,
12917 }
12918 }
12919 }
12920
12921 #[tokio::test]
12922 async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12923 for case in [
12924 "accepted",
12925 "readonly-refusal",
12926 "prefix-refusal",
12927 "old-claim",
12928 "old-checkpoint",
12929 "succeeded",
12930 "changed-birth",
12931 "oversize",
12932 ] {
12933 let temp = tempfile::tempdir().unwrap();
12934 let store = Arc::new(
12935 bamboo_storage::SessionStoreV2::new(temp.path().into())
12936 .await
12937 .unwrap(),
12938 );
12939 let parent = Session::new("retry-parent", "model");
12940 store.save_session(&parent).await.unwrap();
12941 let mut child =
12942 Session::new_child_of("retry-child", &parent, "model", "original assignment");
12943 child.add_message(bamboo_agent_core::Message::user("original assignment"));
12944 store.save_session(&child).await.unwrap();
12945 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12946 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12947 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12948 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12949 store.clone(),
12950 bamboo_domain::SessionInboxLimits::default(),
12951 ));
12952 let binding = actor_binding(store.clone(), inbox, locked);
12953 let registration = binding
12954 .router
12955 .register_run(&child.id, "first-run")
12956 .await
12957 .unwrap();
12958 let first = PlainActorActivation::start(
12959 store.clone(),
12960 &child,
12961 &binding,
12962 Some("first-run"),
12963 Some(policy.clone()),
12964 true,
12965 None,
12966 )
12967 .await
12968 .unwrap();
12969 if matches!(case, "old-claim" | "old-checkpoint") {
12970 let old = bamboo_domain::SessionMessageEnvelope::user_input(
12971 &child.id,
12972 "unconfirmed old input",
12973 );
12974 let receipt = first.input_inbox.deliver(&old).await.unwrap();
12975 first
12976 .input_inbox
12977 .mark_activation_eligible(
12978 &child.id,
12979 receipt.generation,
12980 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12981 )
12982 .await
12983 .unwrap();
12984 let claim = first
12985 .claim_input(&binding, &child, "first-run")
12986 .await
12987 .unwrap()
12988 .unwrap();
12989 if case == "old-checkpoint" {
12990 first
12991 .checkpoint_input(&mut child, "first-run", &claim)
12992 .await
12993 .unwrap();
12994 }
12995 }
12996 first
12997 .finish(if case == "succeeded" {
12998 ActorActivationFinish::Succeeded
12999 } else {
13000 ActorActivationFinish::Failed
13001 })
13002 .await
13003 .unwrap();
13004 registration.abandon().await;
13006 assert!(
13007 !binding.router.owns_run(&child.id, "first-run").await,
13008 "{case}: abandoned first run must release its owner before retry"
13009 );
13010 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
13011 &child.id,
13012 if case == "oversize" {
13013 "x".repeat(8193)
13014 } else {
13015 "NEW_CORRECTION".into()
13016 },
13017 );
13018 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
13019 first
13020 .input_inbox
13021 .mark_activation_eligible(
13022 &child.id,
13023 receipt.generation,
13024 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13025 )
13026 .await
13027 .unwrap();
13028 let before = store.load_session(&child.id).await.unwrap().unwrap();
13029 let expected_messages = serde_json::to_value(&before.messages).unwrap();
13030 child = before.clone();
13031 if case == "prefix-refusal" {
13032 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
13033 }
13034 if case == "changed-birth" {
13035 child.created_at += chrono::Duration::milliseconds(1);
13036 }
13037 let _retry_registration = binding
13038 .router
13039 .register_run(&child.id, "retry-run")
13040 .await
13041 .unwrap();
13042 let result = PlainActorActivation::start_and_prepare(
13043 store.clone(),
13044 &mut child,
13045 &binding,
13046 Some("retry-run"),
13047 Some(policy.clone()),
13048 case != "readonly-refusal",
13049 None,
13050 )
13051 .await;
13052 if case != "accepted" {
13053 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
13054 let actual = store.load_session(&before.id).await.unwrap().unwrap();
13055 assert_eq!(
13056 serde_json::to_value(&actual.messages).unwrap(),
13057 expected_messages,
13058 "{case}: failed preparation preserves Main history"
13059 );
13060 assert!(
13061 !first
13062 .input_inbox
13063 .was_admitted(&before.id, &envelope.id)
13064 .await
13065 .unwrap(),
13066 "{case}: no ACK"
13067 );
13068 if case == "readonly-refusal" {
13069 let entry = store.inspect_actor(&before.id).await.unwrap();
13070 assert_eq!(entry.actor.current_attempt, 1);
13071 assert_eq!(
13072 entry.activation.unwrap().activation_id,
13073 first.fence.activation_id
13074 );
13075 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
13076 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
13077 }
13078 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
13079 assert_eq!(
13080 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
13081 1
13082 );
13083 assert_eq!(
13084 store
13085 .inspect_actor(&before.id)
13086 .await
13087 .unwrap()
13088 .activation
13089 .unwrap()
13090 .status,
13091 bamboo_domain::ActorActivationStatus::Failed
13092 );
13093 }
13094 if case == "oversize" {
13095 let reopened = Arc::new(
13096 bamboo_storage::SessionStoreV2::new(temp.path().into())
13097 .await
13098 .unwrap(),
13099 );
13100 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13101 reopened,
13102 bamboo_domain::SessionInboxLimits::default(),
13103 );
13104 let now = chrono::Utc::now();
13105 let leases = cold_inbox
13106 .inspect_owned_leases(&before.id, 2, now)
13107 .await
13108 .unwrap();
13109 assert_eq!(leases.len(), 1);
13110 assert_eq!(leases[0].failure_count, 1);
13111 assert_eq!(
13112 leases[0].last_error_code.as_deref(),
13113 Some("unsupported_actor_input")
13114 );
13115 let retry_after = leases[0].retry_after.unwrap();
13116 assert!(retry_after > now);
13117 let readiness = cold_inbox
13118 .inspect_wake_readiness(&before.id, now)
13119 .await
13120 .unwrap();
13121 assert!(
13122 readiness.ready.is_none(),
13123 "failed correction must not hot-loop"
13124 );
13125 assert_eq!(readiness.next_due_at, Some(retry_after));
13126 }
13127 continue;
13128 }
13129 let (retry, prepared) = result.unwrap();
13130 let (prefix, mut deliveries) = prepared.unwrap();
13131 assert_eq!(deliveries.len(), 1);
13132 let delivery = deliveries.pop().unwrap();
13133 assert_eq!(
13134 prefix,
13135 serde_json::to_value(&before.messages)
13136 .unwrap()
13137 .as_array()
13138 .unwrap()
13139 .clone()
13140 );
13141 assert_eq!(delivery.envelope, envelope);
13142 assert_eq!(delivery.activation_run_id, "retry-run");
13143 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13144 assert!(store
13145 .validate_fence(&first.fence, chrono::Utc::now())
13146 .await
13147 .is_err());
13148 let running = store.inspect_actor(&child.id).await.unwrap();
13149 assert_eq!(running.actor.current_attempt, 2);
13150 assert_eq!(
13151 running.activation.as_ref().unwrap().status,
13152 bamboo_domain::ActorActivationStatus::Running
13153 );
13154 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13155 let claim = retry.initial_inputs.first().unwrap();
13156 assert!(child
13157 .session_inbox_admission()
13158 .unwrap()
13159 .contains(&envelope.id));
13160 assert!(
13161 !retry
13162 .input_inbox
13163 .was_admitted(&child.id, &envelope.id)
13164 .await
13165 .unwrap(),
13166 "checkpoint is not startup confirmation"
13167 );
13168 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13169 confirmation.activation_run_id = "first-run".into();
13170 assert!(retry
13171 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13172 .await
13173 .is_err());
13174 assert!(!retry
13175 .input_inbox
13176 .was_admitted(&child.id, &envelope.id)
13177 .await
13178 .unwrap());
13179 confirmation.activation_run_id = "retry-run".into();
13180 retry
13181 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13182 .await
13183 .unwrap();
13184 let (tx, mut rx) = mpsc::channel(1);
13185 retry
13186 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13187 .await
13188 .unwrap();
13189 rx.recv().await.unwrap();
13190 retry
13191 .finish(ActorActivationFinish::Succeeded)
13192 .await
13193 .unwrap();
13194 let reopened = Arc::new(
13195 bamboo_storage::SessionStoreV2::new(temp.path().into())
13196 .await
13197 .unwrap(),
13198 );
13199 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13200 assert_eq!(cold.created_at, before.created_at);
13201 assert_eq!(cold.messages.len(), before.messages.len() + 2);
13202 assert_eq!(
13203 cold.messages[before.messages.len()].id,
13204 envelope.id.as_str()
13205 );
13206 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13207 assert_eq!(
13208 cold.messages
13209 .iter()
13210 .filter(|m| m.id == envelope.id.as_str())
13211 .count(),
13212 1
13213 );
13214 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13215 reopened,
13216 bamboo_domain::SessionInboxLimits::default(),
13217 );
13218 assert!(cold_inbox
13219 .was_admitted(&cold.id, &envelope.id)
13220 .await
13221 .unwrap());
13222 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13223 }
13224 }
13225
13226 #[tokio::test]
13227 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13228 for case in [
13229 "accepted",
13230 "actor-live",
13231 "claim-live",
13232 "legacy",
13233 "ack",
13234 "readonly",
13235 "prefix",
13236 "duplicate",
13237 "missing-marker",
13238 "mixed",
13239 "project",
13240 "birth",
13241 ] {
13242 let temp = tempfile::tempdir().unwrap();
13243 let store = Arc::new(
13244 bamboo_storage::SessionStoreV2::new(temp.path().into())
13245 .await
13246 .unwrap(),
13247 );
13248 let parent = Session::new("recovery-parent", "model");
13249 store.save_session(&parent).await.unwrap();
13250 let mut child =
13251 Session::new_child_of("recovery-child", &parent, "model", "original task");
13252 child.add_message(bamboo_agent_core::Message::user("original task"));
13253 store.save_session(&child).await.unwrap();
13254 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13255 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13256 let inbox = bamboo_storage::FileSessionInbox::new(
13257 store.clone(),
13258 bamboo_domain::SessionInboxLimits::default(),
13259 );
13260 let envelope =
13262 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13263 let receipt = inbox.deliver(&envelope).await.unwrap();
13264 inbox
13265 .mark_activation_eligible(
13266 &child.id,
13267 receipt.generation,
13268 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13269 )
13270 .await
13271 .unwrap();
13272 let now = chrono::Utc::now();
13273 let cutoff = now + chrono::Duration::seconds(10);
13274 let consumer = SessionInboxConsumerId::new();
13275 let old = store
13276 .claim_activation(&ActorActivationClaim {
13277 actor_id: child.id.clone(),
13278 run_id: "old-run".into(),
13279 lease_owner: consumer.as_str().into(),
13280 lease_expires_at: if case == "actor-live" {
13281 now + chrono::Duration::minutes(5)
13282 } else {
13283 cutoff
13284 },
13285 inbox_generation: 0,
13286 placement_ref: if case == "legacy" {
13287 None
13288 } else {
13289 Some(bamboo_domain::ActorPlacementRef {
13290 class: bamboo_domain::ActorPlacementClass::Local,
13291 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13292 slot_epoch: None,
13293 })
13294 },
13295 now,
13296 })
13297 .await
13298 .unwrap();
13299 store
13300 .start_activation(&old.fence(), chrono::Utc::now())
13301 .await
13302 .unwrap();
13303 let claim_now = chrono::Utc::now();
13304 let claim = inbox
13305 .claim_owned(
13306 &child.id,
13307 1,
13308 Some("old-run"),
13309 &SessionInboxLeaseRequest {
13310 consumer,
13311 now: claim_now,
13312 duration: if case == "claim-live" {
13313 chrono::Duration::minutes(5)
13314 } else {
13315 cutoff - claim_now
13316 },
13317 },
13318 )
13319 .await
13320 .unwrap()
13321 .pop()
13322 .unwrap();
13323 child = inbox
13324 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13325 fence: old.fence(),
13326 expected_created_at: child.created_at,
13327 expected_conversation_generation: child.conversation_generation_observation(),
13328 claim: claim.clone(),
13329 expected_messages: child.messages.clone(),
13330 expected_provider_transcript: child.provider_transcript.clone(),
13331 expected_admission: None,
13332 })
13333 .await
13334 .unwrap()
13335 .session;
13336 store
13338 .validate_fence(&old.fence(), chrono::Utc::now())
13339 .await
13340 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13341 let setup_leases = inbox
13342 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13343 .await
13344 .unwrap();
13345 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13346 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13347 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13348 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13349 let setup_at = chrono::Utc::now();
13350 assert!(
13351 setup_at < old.lease_expires_at,
13352 "{case}: setup Actor expired"
13353 );
13354 assert!(
13355 setup_at < claim.lease.expires_at,
13356 "{case}: setup claim expired"
13357 );
13358 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13359 if case == "ack" {
13360 inbox
13361 .ack_owned(&child.id, &claim, chrono::Utc::now())
13362 .await
13363 .unwrap();
13364 }
13365 if case == "mixed" {
13366 inbox
13367 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13368 &child.id,
13369 "different input",
13370 ))
13371 .await
13372 .unwrap();
13373 }
13374 let directory = store
13375 .bamboo_home_dir()
13376 .join(store.resolve_rel_path(&child.id).await.unwrap());
13377 let main = std::fs::read(directory.join("session.json")).unwrap();
13378 let canonical = child.clone();
13379 match case {
13380 "prefix" => child.messages[0].content.push_str(" stale"),
13381 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13382 "missing-marker" => {
13383 child
13384 .messages
13385 .last_mut()
13386 .unwrap()
13387 .metadata
13388 .as_mut()
13389 .unwrap()
13390 .as_object_mut()
13391 .unwrap()
13392 .remove("_bamboo_owned_input_checkpoint");
13393 }
13394 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13395 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13396 _ => {}
13397 }
13398 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13401 tokio::time::sleep(delay + Duration::from_millis(10)).await;
13402 let recovery_at = chrono::Utc::now();
13403 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13404 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13405 let original = before_recovery.activation.unwrap();
13406 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13407 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13408 assert_eq!(
13409 store
13410 .validate_fence(&old.fence(), recovery_at)
13411 .await
13412 .is_ok(),
13413 case == "actor-live",
13414 "{case}: Actor expiry control"
13415 );
13416 let recovery_leases = inbox
13417 .inspect_owned_leases(&child.id, 2, recovery_at)
13418 .await
13419 .unwrap();
13420 if case == "ack" {
13421 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13422 } else {
13423 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13424 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13425 assert_eq!(
13426 recovery_leases[0].expires_at, claim.lease.expires_at,
13427 "{case}"
13428 );
13429 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13430 }
13431 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13432 let second = Arc::new(
13433 bamboo_storage::SessionStoreV2::new(temp.path().into())
13434 .await
13435 .unwrap(),
13436 );
13437 let binding = actor_binding(
13438 second.clone(),
13439 Arc::new(bamboo_storage::FileSessionInbox::new(
13440 second.clone(),
13441 bamboo_domain::SessionInboxLimits::default(),
13442 )),
13443 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13444 );
13445 let _owner = binding
13446 .router
13447 .register_run(&child.id, "replacement-run")
13448 .await
13449 .unwrap();
13450 let prepared = PlainActorActivation::start_and_prepare(
13451 second.clone(),
13452 &mut child,
13453 &binding,
13454 Some("replacement-run"),
13455 Some(policy.clone()),
13456 case != "readonly",
13457 Some("probed-new-worker"),
13458 )
13459 .await;
13460 assert_eq!(
13461 std::fs::read(directory.join("session.json")).unwrap(),
13462 main,
13463 "{case}: Already or refusal never rewrites Main"
13464 );
13465 if case != "accepted" {
13466 assert!(prepared.is_err(), "{case}");
13467 assert_eq!(
13468 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13469 case == "ack"
13470 );
13471 continue;
13472 }
13473 let (replacement, delivery) = prepared.unwrap();
13474 assert!(replacement.recovering_pre_ack);
13475 let (messages, mut deliveries) = delivery.unwrap();
13476 assert_eq!(deliveries.len(), 1);
13477 let delivery = deliveries.pop().unwrap();
13478 let new_claim = replacement.initial_inputs.first().unwrap();
13479 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13480 assert_eq!(new_claim.claim.generation, claim.claim.generation);
13481 assert_eq!(
13482 new_claim.claim.activation_policy,
13483 claim.claim.activation_policy
13484 );
13485 assert!(new_claim.lease.epoch > claim.lease.epoch);
13486 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13487 assert_eq!(
13488 replacement.inbox_generation, 1,
13489 "old Actor generation was zero"
13490 );
13491 assert_eq!(
13492 second
13493 .inspect_actor(&child.id)
13494 .await
13495 .unwrap()
13496 .actor
13497 .current_attempt,
13498 2
13499 );
13500 assert!(second
13501 .validate_fence(&old.fence(), chrono::Utc::now())
13502 .await
13503 .is_err());
13504 assert!(inbox
13505 .ack_owned(&child.id, &claim, chrono::Utc::now())
13506 .await
13507 .is_err());
13508 let authority = second.inspect_actor(&child.id).await.unwrap();
13512 let leases = inbox
13513 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13514 .await
13515 .unwrap();
13516 let delayed = inbox
13517 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13518 fence: old.fence(),
13519 expected_created_at: child.created_at,
13520 expected_conversation_generation: child.conversation_generation_observation(),
13521 claim: claim.clone(),
13522 expected_messages: child.messages.clone(),
13523 expected_provider_transcript: child.provider_transcript.clone(),
13524 expected_admission: child.session_inbox_admission().cloned(),
13525 })
13526 .await;
13527 assert!(matches!(
13528 delayed,
13529 Err(bamboo_storage::ActorInputCheckpointError::Actor(
13530 bamboo_domain::ActorDirectoryError::StaleFence
13531 ))
13532 ));
13533 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13534 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13535 assert_eq!(
13536 inbox
13537 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13538 .await
13539 .unwrap(),
13540 leases
13541 );
13542 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13543 assert_eq!(
13544 serde_json::to_value(&child.messages).unwrap(),
13545 serde_json::to_value(&canonical.messages).unwrap()
13546 );
13547 let mut worker: Vec<bamboo_agent_core::Message> = messages
13548 .iter()
13549 .cloned()
13550 .map(|v| serde_json::from_value(v).unwrap())
13551 .collect();
13552 let target = worker.last_mut().unwrap();
13553 assert!(target
13554 .metadata
13555 .as_ref()
13556 .unwrap()
13557 .get("_bamboo_owned_input_checkpoint")
13558 .is_none());
13559 assert!(bamboo_domain::is_matching_session_message(
13560 target, &envelope
13561 ));
13562 let mut wanted = canonical.messages.last().unwrap().clone();
13563 wanted
13564 .metadata
13565 .as_mut()
13566 .unwrap()
13567 .as_object_mut()
13568 .unwrap()
13569 .remove("_bamboo_owned_input_checkpoint");
13570 assert_eq!(
13571 serde_json::to_value(target).unwrap(),
13572 serde_json::to_value(wanted).unwrap()
13573 );
13574 let run = RunSpec {
13575 assignment: "original task".into(),
13576 logical_session: Some(LogicalSessionIdentity {
13577 session_id: child.id.clone(),
13578 parent_session_id: child.parent_session_id.clone(),
13579 root_session_id: child.root_session_id.clone(),
13580 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13581 created_at: child.created_at,
13582 spawn_depth: child.spawn_depth,
13583 }),
13584 }),
13585 project_id: None,
13586 reasoning_effort: None,
13587 permission_policy: Some(PermissionPolicyContext {
13588 revision: policy.policy_revision(),
13589 requested_mode: "default".into(),
13590 effective_mode: "default".into(),
13591 bypass_permissions: false,
13592 auto_approve_permissions: false,
13593 session_id: child.id.clone(),
13594 workspace_path: child.workspace.clone(),
13595 environment_lease: None,
13596 inherit_session_grants: false,
13597 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13598 }),
13599 messages,
13600 activation_run_id: Some("replacement-run".into()),
13601 execution_epoch: 2,
13602 initial_session_messages: vec![delivery.clone()],
13603 secrets: Default::default(),
13604 };
13605 let policy_revision = policy.policy_revision();
13606 let mut expected = Some(expected_default_permission_posture(policy_revision));
13607 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13608 else {
13609 unreachable!()
13610 };
13611 let (events, mut rx) = mpsc::channel(1);
13612 process_actor_event(
13613 event,
13614 true,
13615 &mut PermissionPostureHandshake::Awaiting,
13616 expected.as_ref(),
13617 Some(&binding),
13618 &mut child,
13619 &events,
13620 &mut ActorEventDisplay::default(),
13621 )
13622 .await
13623 .unwrap();
13624 rx.recv().await.unwrap();
13625 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13626 &run,
13627 &delivery,
13628 uuid::Uuid::new_v4().to_string(),
13629 )
13630 .unwrap();
13631 replacement
13632 .release_initial_input(
13633 &binding,
13634 &child,
13635 &run,
13636 2,
13637 new_claim,
13638 &request,
13639 &mut expected,
13640 None,
13641 )
13642 .await
13643 .unwrap();
13644 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13645 replacement
13646 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13647 .await
13648 .unwrap();
13649 rx.recv().await.unwrap();
13650 replacement
13651 .finish(ActorActivationFinish::Succeeded)
13652 .await
13653 .unwrap();
13654 let cold = second.load_session(&child.id).await.unwrap().unwrap();
13655 assert_eq!(cold.created_at, canonical.created_at);
13656 assert_eq!(
13657 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13658 serde_json::to_value(&canonical.messages).unwrap()
13659 );
13660 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13661 assert_eq!(
13662 cold.messages
13663 .iter()
13664 .filter(|m| m.id == envelope.id.as_str())
13665 .count(),
13666 1
13667 );
13668 }
13669 }
13670
13671 #[cfg(unix)]
13672 #[tokio::test]
13673 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13674 use bamboo_subagent::provision::*;
13675 use std::os::unix::fs::PermissionsExt;
13676 let temp = tempfile::tempdir().unwrap();
13677 let worker = temp.path().join("mixed-worker");
13678 let spawned = temp.path().join("spawned");
13679 let run = temp.path().join("run");
13680 let probes = temp.path().join("probes");
13681 let fabric = temp.path().join("fabric");
13682 let mut report = WorkerCapabilityReport::current();
13683 report
13684 .capabilities
13685 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13686 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13687 let report = serde_json::to_string(&report).unwrap();
13688 std::fs::write(&worker, format!(
13689 "#!/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"
13690 )).unwrap();
13691 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13692 let args = vec![
13693 spawned.to_str().unwrap().into(),
13694 run.to_str().unwrap().into(),
13695 probes.to_str().unwrap().into(),
13696 ];
13697 for capability in [
13699 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13700 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13701 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13702 TYPED_READ_ONLY_WORKER_CAPABILITY,
13703 ] {
13704 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13705 .await
13706 .unwrap();
13707 }
13708 let runner = ActorChildRunner::new(
13709 "mixed".into(),
13710 worker,
13711 args,
13712 fabric.clone(),
13713 ExecutorSpec::BambooRuntime,
13714 vec![],
13715 "openai".into(),
13716 1,
13717 );
13718 let error = runner
13720 .require_initial_input_release_worker()
13721 .await
13722 .unwrap_err();
13723 assert!(error
13724 .to_string()
13725 .contains("owned initial release worker capability unconfirmed"));
13726 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13727 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13728 assert!(runner.pool.lock().await.is_empty());
13729 }
13730
13731 #[tokio::test]
13732 async fn ordinary_glob_only_observes_own_initialization_signals() {
13733 let temp = tempfile::tempdir().unwrap();
13734 let home = temp.path().canonicalize().unwrap();
13735 let store = bamboo_storage::SessionStoreV2::new(home.clone())
13736 .await
13737 .unwrap();
13738 let parent = Session::new("glob-standard-parent", "model");
13739 store.save_session(&parent).await.unwrap();
13740 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13741 store.save_session(&child).await.unwrap();
13742 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13743 let unrelated = directory.parent().unwrap().join("unrelated");
13746 std::fs::create_dir(&unrelated).unwrap();
13747 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13748 require_unowned_glob(&store, &child).await.unwrap();
13749 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13750 let path = directory.join(name);
13751 std::fs::write(&path, b"invalid prior signal").unwrap();
13752 assert!(require_unowned_glob(&store, &child).await.is_err());
13753 std::fs::remove_file(&path).unwrap();
13754 }
13755 #[cfg(unix)]
13756 {
13757 let marker = directory.join("actor-authority.initialized.json");
13758 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13759 assert!(require_unowned_glob(&store, &child).await.is_err());
13760 }
13761 }
13762
13763 #[test]
13764 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13765 let call = bamboo_domain::ToolCall {
13766 id: "glob".into(),
13767 tool_type: "function".into(),
13768 function: bamboo_domain::FunctionCall {
13769 name: "Glob".into(),
13770 arguments: r#"{"pattern":"marker.txt"}"#.into(),
13771 },
13772 };
13773 let messages = [
13774 bamboo_agent_core::Message::assistant("", Some(vec![call])),
13775 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13776 bamboo_agent_core::Message::assistant("done", None),
13777 ];
13778 let start = serde_json::to_value(AgentEvent::ToolStart {
13779 tool_call_id: "glob".into(),
13780 tool_name: "Glob".into(),
13781 arguments: serde_json::json!({"pattern":"marker.txt"}),
13782 })
13783 .unwrap();
13784 let complete = serde_json::to_value(AgentEvent::ToolComplete {
13785 tool_call_id: "glob".into(),
13786 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13787 })
13788 .unwrap();
13789 let tail =
13790 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13791 messages: messages
13792 .iter()
13793 .map(serde_json::to_value)
13794 .collect::<Result<_, _>>()
13795 .unwrap(),
13796 })
13797 .unwrap();
13798 let mut ordered = ReadOnlyActorCollector::default();
13799 assert!(ordered.event(&start).unwrap());
13800 assert!(ordered.event(&complete).unwrap());
13801 assert!(!ordered.event(&tail).unwrap());
13802 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13803 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13804 assert!(ReadOnlyActorCollector::default()
13805 .finish(Some("done"))
13806 .is_err());
13807 let mut unfinished = ReadOnlyActorCollector::default();
13808 unfinished.event(&start).unwrap();
13809 unfinished.event(&complete).unwrap();
13810 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13811 let mut closed = tail.clone();
13812 closed["grant"] = true.into();
13813 assert!(
13814 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13815 .is_err()
13816 );
13817 let mut oversized = tail.clone();
13818 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13819 assert!(
13820 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13821 .unwrap()
13822 .validate()
13823 .is_err()
13824 );
13825 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13826 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13827 let mut bad = start.clone();
13828 bad["tool_name"] = "Bash".into();
13829 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13830 let mut duplicate = ReadOnlyActorCollector::default();
13831 duplicate.event(&start).unwrap();
13832 assert!(duplicate.event(&start).is_err());
13833 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13834 content: "hidden".into(),
13835 })
13836 .unwrap();
13837 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13838 let mut mismatched = ReadOnlyActorCollector::default();
13839 mismatched.event(&start).unwrap();
13840 let mut bad = complete.clone();
13841 bad["tool_call_id"] = "foreign".into();
13842 assert!(mismatched.event(&bad).is_err());
13843 mismatched.event(&complete).unwrap();
13844 let mut changed = tail.clone();
13845 changed["messages"][1]["content"] = "fabricated".into();
13846 assert!(mismatched.event(&changed).is_err());
13847 }
13848
13849 #[cfg(unix)]
13850 #[tokio::test]
13851 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13852 let temp = tempfile::tempdir().unwrap();
13853 let store = Arc::new(
13854 bamboo_storage::SessionStoreV2::new(temp.path().into())
13855 .await
13856 .unwrap(),
13857 );
13858 let parent = Session::new("release-host-parent", "model");
13859 store.save_session(&parent).await.unwrap();
13860 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13861 store.save_session(&child).await.unwrap();
13862 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13863 store.clone(),
13864 bamboo_domain::SessionInboxLimits::default(),
13865 ));
13866 let binding = actor_binding(
13867 store.clone(),
13868 inbox,
13869 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13870 );
13871 let _owner = binding
13872 .router
13873 .register_run(&child.id, "release-host-run")
13874 .await
13875 .unwrap();
13876 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13877 let revision = policy.policy_revision();
13878 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13879 let activation = PlainActorActivation::start(
13880 store.clone(),
13881 &child,
13882 &binding,
13883 Some("release-host-run"),
13884 Some(policy.clone()),
13885 true,
13886 Some("probed-physical-worker"),
13887 )
13888 .await
13889 .unwrap();
13890 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13891 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13892 activation
13893 .input_inbox
13894 .mark_activation_eligible(
13895 &child.id,
13896 receipt.generation,
13897 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13898 )
13899 .await
13900 .unwrap();
13901 let claim = activation
13902 .claim_input(&binding, &child, "release-host-run")
13903 .await
13904 .unwrap()
13905 .unwrap();
13906 let (prefix, delivery) = activation
13907 .checkpoint_input(&mut child, "release-host-run", &claim)
13908 .await
13909 .unwrap();
13910 let mut expected = Some(expected_default_permission_posture(revision));
13911 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13912 unreachable!()
13913 };
13914 let (events, mut events_rx) = mpsc::channel(1);
13915 process_actor_event(
13916 event,
13917 true,
13918 &mut PermissionPostureHandshake::Awaiting,
13919 expected.as_ref(),
13920 Some(&binding),
13921 &mut child,
13922 &events,
13923 &mut ActorEventDisplay::default(),
13924 )
13925 .await
13926 .unwrap();
13927 events_rx.recv().await.unwrap();
13928 let run = RunSpec {
13929 assignment: "assignment".into(),
13930 logical_session: Some(LogicalSessionIdentity {
13931 session_id: child.id.clone(),
13932 parent_session_id: child.parent_session_id.clone(),
13933 root_session_id: child.root_session_id.clone(),
13934 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13935 created_at: child.created_at,
13936 spawn_depth: child.spawn_depth,
13937 }),
13938 }),
13939 project_id: None,
13940 reasoning_effort: None,
13941 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13942 revision,
13943 requested_mode: "default".into(),
13944 effective_mode: "default".into(),
13945 bypass_permissions: false,
13946 auto_approve_permissions: false,
13947 session_id: child.id.clone(),
13948 workspace_path: child.workspace.clone(),
13949 environment_lease: None,
13950 inherit_session_grants: false,
13951 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13952 }),
13953 messages: prefix,
13954 activation_run_id: Some("release-host-run".into()),
13955 execution_epoch: 9,
13956 initial_session_messages: vec![delivery.clone()],
13957 secrets: Default::default(),
13958 };
13959 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13960 &run,
13961 &delivery,
13962 uuid::Uuid::new_v4().to_string(),
13963 )
13964 .unwrap();
13965 let mut stale = child.clone();
13966 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13967 assert!(activation
13968 .release_initial_input(
13969 &binding,
13970 &stale,
13971 &run,
13972 9,
13973 &claim,
13974 &request,
13975 &mut expected,
13976 None
13977 )
13978 .await
13979 .is_err());
13980 assert!(!activation
13981 .input_inbox
13982 .was_admitted(&child.id, &envelope.id)
13983 .await
13984 .unwrap());
13985 use std::os::unix::fs::PermissionsExt;
13988 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13989 impl Drop for RestorePermissions {
13990 fn drop(&mut self) {
13991 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13992 }
13993 }
13994 let admitted = store
13995 .bamboo_home_dir()
13996 .join(store.resolve_rel_path(&child.id).await.unwrap())
13997 .join("inbox/admitted");
13998 std::fs::create_dir_all(&admitted).unwrap();
13999 let restore = RestorePermissions(
14000 admitted.clone(),
14001 std::fs::metadata(&admitted).unwrap().permissions(),
14002 );
14003 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
14004 let verified = activation
14005 .input_inbox
14006 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
14007 fence: activation.fence.clone(),
14008 expected_created_at: child.created_at,
14009 expected_conversation_generation: child.conversation_generation_observation(),
14010 claim: claim.clone(),
14011 expected_messages: child.messages.clone(),
14012 expected_provider_transcript: child.provider_transcript.clone(),
14013 expected_admission: child.session_inbox_admission().cloned(),
14014 })
14015 .await
14016 .unwrap();
14017 assert_eq!(
14018 verified.status,
14019 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
14020 );
14021 let error = activation
14022 .release_initial_input(
14023 &binding,
14024 &child,
14025 &run,
14026 9,
14027 &claim,
14028 &request,
14029 &mut expected,
14030 None,
14031 )
14032 .await
14033 .unwrap_err();
14034 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
14035 drop(restore);
14036 assert!(
14037 error.to_string().contains("ACK unresolved"),
14038 "actual release error (<=512B): {}",
14039 error.to_string().chars().take(128).collect::<String>()
14040 );
14041 let release = activation
14042 .release_initial_input(
14043 &binding,
14044 &child,
14045 &run,
14046 9,
14047 &claim,
14048 &request,
14049 &mut expected,
14050 None,
14051 )
14052 .await
14053 .unwrap();
14054 assert!(activation
14055 .input_inbox
14056 .was_admitted(&child.id, &envelope.id)
14057 .await
14058 .unwrap());
14059 assert_eq!(
14060 release.expires_at,
14061 activation
14062 .actor_deadline
14063 .min(claim.lease.expires_at)
14064 .min(activation.input_deadline)
14065 );
14066 assert_eq!(
14067 activation
14068 .release_initial_input(
14069 &binding,
14070 &child,
14071 &run,
14072 9,
14073 &claim,
14074 &request,
14075 &mut expected,
14076 Some(&release)
14077 )
14078 .await
14079 .unwrap(),
14080 release
14081 );
14082 let mut wrong = request.clone();
14083 wrong.nonce = uuid::Uuid::new_v4().to_string();
14084 assert!(activation
14085 .release_initial_input(
14086 &binding,
14087 &child,
14088 &run,
14089 9,
14090 &claim,
14091 &wrong,
14092 &mut expected,
14093 Some(&release)
14094 )
14095 .await
14096 .is_err());
14097 assert!(activation
14098 .release_initial_input(
14099 &binding,
14100 &child,
14101 &run,
14102 10,
14103 &claim,
14104 &request,
14105 &mut expected,
14106 Some(&release)
14107 )
14108 .await
14109 .is_err());
14110 let deadlines = (
14112 activation.fence.clone(),
14113 activation.actor_deadline,
14114 activation.input_deadline,
14115 );
14116 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14117 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14118 activation
14119 .input_inbox
14120 .mark_activation_eligible(
14121 &child.id,
14122 receipt.generation,
14123 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14124 )
14125 .await
14126 .unwrap();
14127 let mut link = ConfirmationSequenceLink {
14128 frames: VecDeque::new(),
14129 sent: Vec::new(),
14130 };
14131 let epochs = AtomicU64::new(9);
14132 let (claim2, epoch2) = activation
14133 .continue_input(
14134 &mut link,
14135 &binding,
14136 &mut child,
14137 "release-host-run",
14138 Some((&run, &epochs)),
14139 &mut expected,
14140 "SECOND_REPLY".into(),
14141 &events,
14142 )
14143 .await
14144 .unwrap()
14145 .unwrap();
14146 events_rx.recv().await.unwrap();
14147 assert_eq!(epoch2, 10);
14148 let ParentFrame::Run(next) = &link.sent[0] else {
14149 panic!("second correlated Run")
14150 };
14151 assert_eq!(next.logical_session, run.logical_session);
14152 assert_eq!(next.activation_run_id, run.activation_run_id);
14153 assert_eq!(next.initial_session_messages.len(), 1);
14154 assert!(next
14155 .messages
14156 .iter()
14157 .any(|m| m["id"] == envelope.id.as_str()));
14158 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14159 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14160 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14161 next,
14162 &next.initial_session_messages[0],
14163 uuid::Uuid::new_v4().to_string(),
14164 )
14165 .unwrap();
14166 assert_ne!(request2.nonce, request.nonce);
14167 macro_rules! release_second {
14168 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14169 activation
14170 .release_initial_input(
14171 &binding,
14172 $candidate,
14173 next,
14174 $epoch,
14175 &claim2,
14176 $request,
14177 &mut expected,
14178 $previous,
14179 )
14180 .await
14181 };
14182 }
14183 assert!(release_second!(&child, &request, epoch2, None).is_err());
14184 assert!(release_second!(&child, &request2, 9, None).is_err());
14185 let mut stale2 = child.clone();
14186 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14187 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14188 assert!(!activation
14189 .input_inbox
14190 .was_admitted(&child.id, &second.id)
14191 .await
14192 .unwrap());
14193 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14194 assert_eq!(
14195 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14196 release2
14197 );
14198 let mut wrong2 = request2.clone();
14199 wrong2.nonce = request.nonce.clone();
14200 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14201 assert!(activation
14202 .input_inbox
14203 .was_admitted(&child.id, &second.id)
14204 .await
14205 .unwrap());
14206 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14207 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14208 activation
14209 .input_inbox
14210 .mark_activation_eligible(
14211 &child.id,
14212 receipt.generation,
14213 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14214 )
14215 .await
14216 .unwrap();
14217 let now = chrono::Utc::now();
14218 let expired_claim = activation
14219 .input_inbox
14220 .claim_owned(
14221 &child.id,
14222 1,
14223 Some("release-host-run"),
14224 &SessionInboxLeaseRequest {
14225 consumer: activation.input_consumer.clone(),
14226 now,
14227 duration: chrono::Duration::seconds(1),
14228 },
14229 )
14230 .await
14231 .unwrap()
14232 .pop()
14233 .unwrap();
14234 let (prefix, delivery3) = activation
14235 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14236 .await
14237 .unwrap();
14238 let mut expired_run = next.clone();
14239 expired_run.execution_epoch = 11;
14240 expired_run.messages = prefix;
14241 expired_run.initial_session_messages = vec![delivery3.clone()];
14242 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14243 &expired_run,
14244 &delivery3,
14245 uuid::Uuid::new_v4().to_string(),
14246 )
14247 .unwrap();
14248 tokio::time::sleep(
14249 (expired_claim.lease.expires_at - chrono::Utc::now())
14250 .to_std()
14251 .unwrap_or_default(),
14252 )
14253 .await;
14254 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14255 assert!(activation
14256 .release_initial_input(
14257 &binding,
14258 &child,
14259 &expired_run,
14260 11,
14261 &expired_claim,
14262 &expired_request,
14263 &mut expected,
14264 None
14265 )
14266 .await
14267 .is_err());
14268 assert!(!activation
14269 .input_inbox
14270 .was_admitted(&child.id, &expired.id)
14271 .await
14272 .unwrap());
14273 assert_eq!(
14274 (
14275 activation.fence.clone(),
14276 activation.actor_deadline,
14277 activation.input_deadline
14278 ),
14279 deadlines
14280 );
14281 assert_eq!(
14282 link.sent.len(),
14283 1,
14284 "no dispatch from rejected release checks"
14285 );
14286 let cold = store.load_session(&child.id).await.unwrap().unwrap();
14287 for id in [&envelope.id, &second.id] {
14288 assert_eq!(
14289 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14290 1
14291 );
14292 assert!(cold.session_inbox_admission().unwrap().contains(id));
14293 }
14294 policy.deny_scoped_session_permission(
14296 &child.id,
14297 bamboo_tools::permission::PermissionType::ExecuteCommand,
14298 "blocked-action",
14299 );
14300 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14301 assert!(activation
14302 .input_inbox
14303 .was_admitted(&child.id, &second.id)
14304 .await
14305 .unwrap());
14306 activation
14307 .finish(ActorActivationFinish::Failed)
14308 .await
14309 .unwrap();
14310 activation
14312 .input_inbox
14313 .ack_owned(&child.id, &claim, chrono::Utc::now())
14314 .await
14315 .unwrap();
14316 assert!(activation
14317 .release_initial_input(
14318 &binding,
14319 &child,
14320 &run,
14321 9,
14322 &claim,
14323 &request,
14324 &mut expected,
14325 Some(&release)
14326 )
14327 .await
14328 .is_err());
14329 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14330 .await
14331 .unwrap()
14332 .load_session(&child.id)
14333 .await
14334 .unwrap()
14335 .unwrap();
14336 assert_eq!(
14337 cold.messages
14338 .iter()
14339 .filter(|m| m.id == envelope.id.as_str())
14340 .count(),
14341 1
14342 );
14343 }
14344
14345 #[tokio::test]
14346 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14347 for reject_checkpoint in [false, true] {
14348 let temp = tempfile::tempdir().unwrap();
14349 let store = Arc::new(
14350 bamboo_storage::SessionStoreV2::new(temp.path().into())
14351 .await
14352 .unwrap(),
14353 );
14354 let parent = Session::new("owned-input-parent", "model");
14355 store.save_session(&parent).await.unwrap();
14356 let mut child =
14357 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14358 store.save_session(&child).await.unwrap();
14359 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14360 let raw_storage: Arc<dyn Storage> = store.clone();
14361 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14362 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14363 store.clone(),
14364 bamboo_domain::SessionInboxLimits::default(),
14365 ));
14366 let binding = actor_binding(store.clone(), inbox, locked);
14367 let run_id = "owned-input-run";
14368 let _registration = binding
14369 .router
14370 .register_run(&child.id, run_id)
14371 .await
14372 .unwrap();
14373 assert!(PlainActorActivation::start(
14374 store.clone(),
14375 &child,
14376 &binding,
14377 Some(run_id),
14378 None,
14379 true,
14380 None,
14381 )
14382 .await
14383 .is_err());
14384 assert!(PlainActorActivation::start(
14385 store.clone(),
14386 &child,
14387 &binding,
14388 Some(run_id),
14389 Some(policy.clone()),
14390 true,
14391 None,
14392 )
14393 .await
14394 .is_err());
14395 assert_eq!(
14396 store
14397 .inspect_actor(&child.id)
14398 .await
14399 .unwrap()
14400 .actor
14401 .current_attempt,
14402 0
14403 );
14404 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14405 let activation = PlainActorActivation::start(
14406 store.clone(),
14407 &child,
14408 &binding,
14409 Some(run_id),
14410 Some(policy.clone()),
14411 true,
14412 None,
14413 )
14414 .await
14415 .unwrap();
14416 let envelope =
14417 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14418 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14419 activation
14420 .input_inbox
14421 .mark_activation_eligible(
14422 &child.id,
14423 receipt.generation,
14424 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14425 )
14426 .await
14427 .unwrap();
14428 let mut link = ConfirmationSequenceLink {
14429 frames: VecDeque::new(),
14430 sent: Vec::new(),
14431 };
14432 let policy_revision = policy.policy_revision();
14433 let run = RunSpec {
14434 assignment: "one plain task".into(),
14435 logical_session: None,
14436 project_id: None,
14437 reasoning_effort: None,
14438 permission_policy: Some(PermissionPolicyContext {
14439 revision: policy_revision,
14440 requested_mode: "default".into(),
14441 effective_mode: "default".into(),
14442 bypass_permissions: false,
14443 auto_approve_permissions: false,
14444 session_id: child.id.clone(),
14445 workspace_path: child.workspace.clone(),
14446 environment_lease: None,
14447 inherit_session_grants: false,
14448 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14449 }),
14450 messages: vec![],
14451 activation_run_id: Some(run_id.into()),
14452 execution_epoch: 1,
14453 initial_session_messages: vec![],
14454 secrets: Default::default(),
14455 };
14456 let epochs = AtomicU64::new(1);
14457 let (tx, mut rx) = mpsc::channel(1);
14458 let mut expected = Some(expected_default_permission_posture(policy_revision));
14459 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14460 else {
14461 unreachable!()
14462 };
14463 process_actor_event(
14464 event,
14465 true,
14466 &mut PermissionPostureHandshake::Awaiting,
14467 expected.as_ref(),
14468 Some(&binding),
14469 &mut child,
14470 &tx,
14471 &mut ActorEventDisplay::default(),
14472 )
14473 .await
14474 .unwrap();
14475 rx.recv().await.unwrap();
14476 let first_audit =
14477 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14478 .unwrap()
14479 .audit_revision;
14480 if reject_checkpoint {
14481 tx.send(AgentEvent::Complete {
14484 usage: Default::default(),
14485 })
14486 .await
14487 .unwrap();
14488 }
14489 let mut operation = activation.continue_input(
14490 &mut link,
14491 &binding,
14492 &mut child,
14493 run_id,
14494 Some((&run, &epochs)),
14495 &mut expected,
14496 "FIRST_PLAIN_REPLY".into(),
14497 &tx,
14498 );
14499 if reject_checkpoint {
14500 tokio::select! {
14501 result = &mut operation => panic!("publication barrier released early: {result:?}"),
14502 _ = async {
14503 tokio::time::timeout(Duration::from_secs(5), async {
14504 loop {
14505 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14506 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14507 tokio::task::yield_now().await;
14508 }
14509 }).await.unwrap();
14510 activation.finish(ActorActivationFinish::Failed).await.unwrap();
14511 rx.recv().await.unwrap();
14512 } => {}
14513 }
14514 }
14515 let result = operation.await;
14516 if reject_checkpoint {
14517 assert!(result
14518 .unwrap_err()
14519 .to_string()
14520 .contains("checkpoint unconfirmed"));
14521 assert!(
14522 link.sent.is_empty(),
14523 "zero worker/provider correction dispatch"
14524 );
14525 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14526 assert!(durable
14527 .messages
14528 .iter()
14529 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14530 assert!(!durable
14531 .messages
14532 .iter()
14533 .any(|m| m.id == envelope.id.as_str()));
14534 assert!(!activation
14535 .input_inbox
14536 .was_admitted(&child.id, &envelope.id)
14537 .await
14538 .unwrap());
14539 assert_eq!(
14540 activation
14541 .input_inbox
14542 .inspect(&child.id)
14543 .await
14544 .unwrap()
14545 .claimed,
14546 1
14547 );
14548 continue;
14549 }
14550 let (claim, epoch) = result.unwrap().unwrap();
14551 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
14553 assert_eq!(link.sent.len(), 1);
14554 let ParentFrame::Run(next) = &link.sent[0] else {
14555 panic!("correlated continuation Run")
14556 };
14557 assert_eq!(next.execution_epoch, 2);
14558 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14559 assert!(next
14560 .messages
14561 .iter()
14562 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14563 assert!(!next
14564 .messages
14565 .iter()
14566 .any(|m| m["id"] == envelope.id.as_str()));
14567 let delivery = &next.initial_session_messages[0];
14568 assert_eq!(delivery.envelope, envelope);
14569 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14570 assert!(durable
14571 .session_inbox_admission()
14572 .unwrap()
14573 .contains(&envelope.id));
14574 assert_eq!(
14575 durable
14576 .messages
14577 .iter()
14578 .filter(|m| m.id == envelope.id.as_str())
14579 .count(),
14580 1
14581 );
14582 assert!(
14583 !activation
14584 .input_inbox
14585 .was_admitted(&child.id, &envelope.id)
14586 .await
14587 .unwrap(),
14588 "checkpoint is not a worker admission receipt"
14589 );
14590 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14591 confirmation.canonical_claim_generation += 1;
14592 assert!(activation
14593 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14594 .await
14595 .is_err());
14596 assert!(!activation
14597 .input_inbox
14598 .was_admitted(&child.id, &envelope.id)
14599 .await
14600 .unwrap());
14601 confirmation.canonical_claim_generation = claim.claim.generation;
14602 activation
14603 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14604 .await
14605 .unwrap();
14606 assert!(activation
14607 .input_inbox
14608 .was_admitted(&child.id, &envelope.id)
14609 .await
14610 .unwrap());
14611 assert_eq!(
14612 expected.as_ref().unwrap().expected_audit_revision,
14613 Some(first_audit)
14614 );
14615 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14616 else {
14617 unreachable!()
14618 };
14619 process_actor_event(
14620 event,
14621 true,
14622 &mut PermissionPostureHandshake::Awaiting,
14623 expected.as_ref(),
14624 Some(&binding),
14625 &mut child,
14626 &tx,
14627 &mut ActorEventDisplay::default(),
14628 )
14629 .await
14630 .unwrap();
14631 rx.recv().await.unwrap();
14632 assert!(
14633 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14634 .unwrap()
14635 .audit_revision
14636 > first_audit
14637 );
14638 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14639 .await
14640 .unwrap();
14641 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14642 assert_eq!(
14643 cold.messages
14644 .iter()
14645 .filter(|m| m.id == envelope.id.as_str())
14646 .count(),
14647 1
14648 );
14649 assert!(cold
14650 .session_inbox_admission()
14651 .unwrap()
14652 .contains(&envelope.id));
14653 }
14654 }
14655
14656 fn admission_confirmation(
14657 session_id: &str,
14658 claim: &SessionInboxClaim,
14659 run_id: &str,
14660 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14661 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14662 target_session_id: session_id.to_string(),
14663 envelope_id: claim.envelope.id.to_string(),
14664 canonical_claim_generation: claim.generation,
14665 activation_run_id: run_id.to_string(),
14666 }
14667 }
14668
14669 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14670 ExpectedPermissionPosture {
14671 policy_revision,
14672 resolution: bamboo_domain::PermissionModeResolution {
14673 requested: bamboo_domain::SessionPermissionMode::Default,
14674 effective: bamboo_domain::PermissionMode::Default,
14675 },
14676 expected_audit_revision: None,
14677 executor_mapping: "test_actor:permission_mode=default".to_string(),
14678 }
14679 }
14680
14681 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14682 ChildFrame::Event {
14683 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14684 session_id: session_id.to_string(),
14685 policy_revision,
14686 requested_mode: "default".to_string(),
14687 effective_mode: "default".to_string(),
14688 executor_mapping: "test_actor:permission_mode=default".to_string(),
14689 })
14690 .expect("serialize permission posture event"),
14691 }
14692 }
14693
14694 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14695 ChildFrame::Event {
14696 event: serde_json::to_value(event).expect("serialize actor event"),
14697 }
14698 }
14699
14700 #[test]
14701 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14702 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14703 let mut batch = ActorEventBatch {
14704 logical_session: Some(LogicalSessionIdentity {
14705 creation: None,
14706 session_id: session.id.clone(),
14707 parent_session_id: session.parent_session_id.clone(),
14708 root_session_id: session.root_session_id.clone(),
14709 }),
14710 activation_id: Some("activation-7".into()),
14711 execution_epoch: 11,
14712 source_node_id: Some("node-a".into()),
14713 source_actor_id: Some("worker-a".into()),
14714 first_seq: 1,
14715 last_seq: 1,
14716 qos: bamboo_subagent::ActorEventQos::Ephemeral,
14717 events: vec![serde_json::json!({"type":"token","content":"ok"})],
14718 };
14719 validate_actor_event_batch(
14720 &batch,
14721 &session,
14722 "logical-parent",
14723 Some("activation-7"),
14724 11,
14725 "worker-a",
14726 None,
14727 )
14728 .unwrap();
14729
14730 let creation = bamboo_subagent::proto::ChildCreationIdentity {
14731 created_at: session.created_at,
14732 spawn_depth: session.spawn_depth,
14733 };
14734 assert!(validate_actor_event_batch(
14735 &batch,
14736 &session,
14737 "logical-parent",
14738 Some("activation-7"),
14739 11,
14740 "worker-a",
14741 Some(&creation)
14742 )
14743 .is_err());
14744 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14745 validate_actor_event_batch(
14746 &batch,
14747 &session,
14748 "logical-parent",
14749 Some("activation-7"),
14750 11,
14751 "worker-a",
14752 Some(&creation),
14753 )
14754 .unwrap();
14755 batch
14756 .logical_session
14757 .as_mut()
14758 .unwrap()
14759 .creation
14760 .as_mut()
14761 .unwrap()
14762 .created_at += chrono::Duration::nanoseconds(1);
14763 assert!(validate_actor_event_batch(
14764 &batch,
14765 &session,
14766 "logical-parent",
14767 Some("activation-7"),
14768 11,
14769 "worker-a",
14770 Some(&creation)
14771 )
14772 .is_err());
14773 batch.logical_session.as_mut().unwrap().creation = None;
14774
14775 batch.execution_epoch = 10;
14776 assert!(validate_actor_event_batch(
14777 &batch,
14778 &session,
14779 "logical-parent",
14780 Some("activation-7"),
14781 11,
14782 "worker-a",
14783 None,
14784 )
14785 .unwrap_err()
14786 .to_string()
14787 .contains("execution epoch"));
14788 batch.execution_epoch = 11;
14789 batch.activation_id = Some("replaced-activation".into());
14790 assert!(validate_actor_event_batch(
14791 &batch,
14792 &session,
14793 "logical-parent",
14794 Some("activation-7"),
14795 11,
14796 "worker-a",
14797 None,
14798 )
14799 .unwrap_err()
14800 .to_string()
14801 .contains("stale activation"));
14802 batch.activation_id = Some("activation-7".into());
14803 batch
14804 .logical_session
14805 .as_mut()
14806 .expect("logical identity")
14807 .session_id = "foreign-child".into();
14808 assert!(validate_actor_event_batch(
14809 &batch,
14810 &session,
14811 "logical-parent",
14812 Some("activation-7"),
14813 11,
14814 "worker-a",
14815 None,
14816 )
14817 .unwrap_err()
14818 .to_string()
14819 .contains("different logical session"));
14820 batch
14821 .logical_session
14822 .as_mut()
14823 .expect("logical identity")
14824 .session_id = session.id.clone();
14825 batch.source_actor_id = Some("worker-b".into());
14826 assert!(validate_actor_event_batch(
14827 &batch,
14828 &session,
14829 "logical-parent",
14830 Some("activation-7"),
14831 11,
14832 "worker-a",
14833 None,
14834 )
14835 .unwrap_err()
14836 .to_string()
14837 .contains("different physical actor"));
14838 }
14839
14840 #[tokio::test]
14841 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14842 use bamboo_domain::Storage;
14843
14844 let temp = tempfile::tempdir().unwrap();
14845 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14846 .await
14847 .unwrap();
14848 let parent = Session::new("event-parent", "model");
14849 let child = Session::new_child_of("event-child", &parent, "model", "task");
14850 store.save_session(&parent).await.unwrap();
14851 store.save_session(&child).await.unwrap();
14852 store.ensure_actor(&child.id).await.unwrap();
14853 let now = chrono::Utc::now();
14854 let claim = |run_id: &str| ActorActivationClaim {
14855 actor_id: child.id.clone(),
14856 run_id: run_id.into(),
14857 lease_owner: "event-owner".into(),
14858 lease_expires_at: now + chrono::Duration::minutes(2),
14859 inbox_generation: 0,
14860 placement_ref: None,
14861 now,
14862 };
14863 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14864 store.start_activation(&first.fence(), now).await.unwrap();
14865 let first_fence = first.fence();
14866 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14867 .await
14868 .unwrap();
14869 validate_directory_actor_event(&store, &first_fence, &child, None)
14870 .await
14871 .unwrap(); assert!(
14873 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14874 .await
14875 .is_err()
14876 );
14877 let mut forged = child.clone();
14878 forged.root_session_id = "foreign-root".into();
14879 assert!(
14880 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14881 .await
14882 .is_err()
14883 );
14884
14885 store
14886 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14887 .await
14888 .unwrap();
14889 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14890 store.start_activation(&second.fence(), now).await.unwrap();
14891 assert!(
14892 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14893 .await
14894 .is_err()
14895 );
14896 assert!(
14897 validate_directory_actor_event(&store, &first_fence, &child, None)
14898 .await
14899 .is_err()
14900 );
14901 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14902 .await
14903 .unwrap();
14904 }
14905
14906 fn completed_actor_frame() -> ChildFrame {
14907 ChildFrame::Terminal {
14908 status: TerminalStatus::Completed,
14909 result: Some("done".to_string()),
14910 error: None,
14911 final_event_watermark: None,
14912 transcript: Vec::new(),
14913 }
14914 }
14915
14916 async fn drive_permission_handshake_frames(
14917 session_id: &str,
14918 frames: impl IntoIterator<Item = ChildFrame>,
14919 expected: ExpectedPermissionPosture,
14920 ) -> (
14921 crate::runtime::runner::Result<Option<String>>,
14922 Session,
14923 Vec<AgentEvent>,
14924 Vec<ParentFrame>,
14925 ) {
14926 let mut link = ConfirmationSequenceLink {
14927 frames: frames.into_iter().collect(),
14928 sent: Vec::new(),
14929 };
14930 let (event_tx, mut event_rx) = mpsc::channel(128);
14931 let cancel = CancellationToken::new();
14932 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14933 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14934 let mut session = Session::new(session_id, "model");
14935 let result = drive(ActorDriveContext {
14936 expected_creation: None,
14937 client: &mut link,
14938 parent_session_id: "permission-parent",
14939 child_session_id: session_id,
14940 child_attempt: 0,
14941 approval_registry: None,
14942 approval_decider: None,
14943 approval_reviewer: None,
14944 escalation_bridge: None,
14945 event_tx: &event_tx,
14946 cancel_token: &cancel,
14947 live_rx: &mut live_rx,
14948 delivery_rx: &mut delivery_rx,
14949 logical_session: &mut session,
14950 expected_permission_posture: Some(expected),
14951 session_inbox_runtime: None,
14952 actor_directory_store: None,
14953 canonical_subagent_tool: None,
14954 ticket_service: None,
14955 activation_run_id: None,
14956 execution_epoch: 0,
14957 expected_source_actor_id: session_id,
14958 initial_inflight_claims: VecDeque::new(),
14959 plain_actor: false,
14960 remote_environment_lease: false,
14961 readonly_output: None,
14962 local_history_tools: None,
14963 local_history_read_only: false,
14964 strict_history_output: None,
14965 plain_input: None,
14966 canonical_activation: None,
14967 canonical_placement_ref: None,
14968 actor_event_observer: None,
14969 plain_run: None,
14970 first_frame_timeout: Some(Duration::from_secs(1)),
14971 })
14972 .await;
14973 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14974 (result, session, events, link.sent)
14975 }
14976
14977 #[tokio::test]
14978 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14979 let session_id = "permission-missing";
14980 let (result, session, events, _) = drive_permission_handshake_frames(
14981 session_id,
14982 [completed_actor_frame()],
14983 expected_default_permission_posture(7),
14984 )
14985 .await;
14986
14987 assert!(result
14988 .unwrap_err()
14989 .to_string()
14990 .contains("terminated before permission posture confirmation"));
14991 assert!(events.is_empty());
14992 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14993 }
14994
14995 #[tokio::test]
14996 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14997 struct TerminalProofProbe {
14998 frame: Option<ChildFrame>,
14999 accepted: bool,
15000 }
15001
15002 #[async_trait]
15003 impl bamboo_subagent::ChildLink for TerminalProofProbe {
15004 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15005 Ok(())
15006 }
15007
15008 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15009 Ok(self.frame.take())
15010 }
15011
15012 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15013 self.accepted = status == TerminalStatus::Error;
15014 }
15015
15016 fn has_pending_durable_terminal(&self) -> bool {
15017 self.accepted
15018 }
15019 }
15020
15021 for (status, error, expected_proof) in [
15022 (
15023 TerminalStatus::Error,
15024 Some("remote_environment_checkout_not_clean"),
15025 true,
15026 ),
15027 (TerminalStatus::Error, Some("untrusted worker error"), false),
15028 (
15029 TerminalStatus::Cancelled,
15030 Some("remote_environment_checkout_not_clean"),
15031 false,
15032 ),
15033 ] {
15034 let mut link = TerminalProofProbe {
15035 frame: Some(ChildFrame::Terminal {
15036 status,
15037 result: None,
15038 error: error.map(str::to_owned),
15039 final_event_watermark: None,
15040 transcript: Vec::new(),
15041 }),
15042 accepted: false,
15043 };
15044 let (event_tx, _event_rx) = mpsc::channel(8);
15045 let cancel = CancellationToken::new();
15046 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15047 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15048 let mut session = Session::new("remote-terminal-proof", "model");
15049 let result = drive(ActorDriveContext {
15050 client: &mut link,
15051 parent_session_id: "proof-parent",
15052 child_session_id: "remote-terminal-proof",
15053 child_attempt: 0,
15054 approval_registry: None,
15055 approval_decider: None,
15056 approval_reviewer: None,
15057 escalation_bridge: None,
15058 event_tx: &event_tx,
15059 cancel_token: &cancel,
15060 live_rx: &mut live_rx,
15061 delivery_rx: &mut delivery_rx,
15062 logical_session: &mut session,
15063 expected_permission_posture: Some(expected_default_permission_posture(7)),
15064 expected_creation: None,
15065 session_inbox_runtime: None,
15066 actor_directory_store: None,
15067 canonical_subagent_tool: None,
15068 ticket_service: None,
15069 activation_run_id: None,
15070 execution_epoch: 0,
15071 expected_source_actor_id: "remote-terminal-proof",
15072 initial_inflight_claims: VecDeque::new(),
15073 plain_actor: false,
15074 remote_environment_lease: true,
15075 readonly_output: None,
15076 local_history_tools: None,
15077 local_history_read_only: false,
15078 strict_history_output: None,
15079 plain_input: None,
15080 canonical_activation: None,
15081 canonical_placement_ref: None,
15082 actor_event_observer: None,
15083 plain_run: None,
15084 first_frame_timeout: Some(Duration::from_secs(1)),
15085 })
15086 .await;
15087 assert!(result.is_err());
15088 assert_eq!(
15089 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15090 expected_proof
15091 );
15092 }
15093 }
15094
15095 #[tokio::test(start_paused = true)]
15096 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
15097 struct CancelOutcomeLink {
15098 cancel_sent: bool,
15099 settle: bool,
15100 accepted: bool,
15101 }
15102
15103 #[async_trait]
15104 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
15105 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15106 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
15107 Ok(())
15108 }
15109
15110 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15111 if !self.cancel_sent || !self.settle {
15112 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15113 .await
15114 } else {
15115 self.cancel_sent = false;
15116 Ok(Some(ChildFrame::Terminal {
15117 status: TerminalStatus::Cancelled,
15118 result: None,
15119 error: None,
15120 final_event_watermark: None,
15121 transcript: Vec::new(),
15122 }))
15123 }
15124 }
15125
15126 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15127 self.accepted = status == TerminalStatus::Cancelled;
15128 }
15129
15130 fn has_pending_durable_terminal(&self) -> bool {
15131 self.accepted
15132 }
15133 }
15134
15135 let temp = tempfile::tempdir().unwrap();
15136 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15137 .await
15138 .unwrap();
15139 let fence = ActorActivationFence {
15140 schema_version: 2,
15141 actor_id: "cancelled-remote-child".into(),
15142 activation_id: "cancelled-remote-activation".into(),
15143 attempt: 1,
15144 run_id: "cancelled-remote-run".into(),
15145 lease_owner: "cancelled-remote-host".into(),
15146 lease_epoch: 1,
15147 };
15148 for settle in [true, false] {
15149 let mut link = CancelOutcomeLink {
15150 cancel_sent: false,
15151 settle,
15152 accepted: false,
15153 };
15154 let (event_tx, _event_rx) = mpsc::channel(8);
15155 let cancel = CancellationToken::new();
15156 cancel.cancel();
15157 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15158 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15159 let mut session = Session::new("cancelled-remote-child", "model");
15160 let result = drive(ActorDriveContext {
15161 expected_creation: None,
15162 client: &mut link,
15163 parent_session_id: "cancelled-remote-parent",
15164 child_session_id: "cancelled-remote-child",
15165 child_attempt: 1,
15166 approval_registry: None,
15167 approval_decider: None,
15168 approval_reviewer: None,
15169 escalation_bridge: None,
15170 event_tx: &event_tx,
15171 cancel_token: &cancel,
15172 live_rx: &mut live_rx,
15173 delivery_rx: &mut delivery_rx,
15174 logical_session: &mut session,
15175 expected_permission_posture: Some(expected_default_permission_posture(7)),
15176 session_inbox_runtime: None,
15177 actor_directory_store: Some(&store),
15178 canonical_subagent_tool: None,
15179 ticket_service: None,
15180 activation_run_id: Some("cancelled-remote-run"),
15181 execution_epoch: 0,
15182 expected_source_actor_id: "cancelled-remote-worker",
15183 initial_inflight_claims: VecDeque::new(),
15184 plain_actor: false,
15185 remote_environment_lease: true,
15186 readonly_output: None,
15187 local_history_tools: None,
15188 local_history_read_only: false,
15189 strict_history_output: None,
15190 plain_input: None,
15191 canonical_activation: Some(&fence),
15192 canonical_placement_ref: None,
15193 actor_event_observer: None,
15194 plain_run: None,
15195 first_frame_timeout: None,
15196 })
15197 .await;
15198 assert!(matches!(result, Err(AgentError::Cancelled)));
15199 assert_eq!(
15200 link.accepted, settle,
15201 "only a consumed exact terminal can release the remote fence"
15202 );
15203 assert_eq!(
15204 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15205 settle
15206 );
15207 }
15208 }
15209
15210 #[tokio::test]
15211 async fn actor_permission_handshake_rejects_malformed_agent_event() {
15212 let session_id = "permission-malformed";
15213 let (result, session, events, _) = drive_permission_handshake_frames(
15214 session_id,
15215 [ChildFrame::Event {
15216 event: serde_json::json!({"type": "token", "content": 42}),
15217 }],
15218 expected_default_permission_posture(7),
15219 )
15220 .await;
15221
15222 assert!(result
15223 .unwrap_err()
15224 .to_string()
15225 .contains("malformed AgentEvent"));
15226 assert!(events.is_empty());
15227 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15228
15229 let (result, _, events, _) = drive_permission_handshake_frames(
15230 session_id,
15231 [
15232 permission_posture_frame(session_id, 7),
15233 ChildFrame::Event {
15234 event: serde_json::json!({"type":"private-download-secret"}),
15235 },
15236 ],
15237 expected_default_permission_posture(7),
15238 )
15239 .await;
15240 assert!(!result
15241 .unwrap_err()
15242 .to_string()
15243 .contains("private-download-secret"));
15244 assert_eq!(
15245 events.len(),
15246 1,
15247 "only the confirmed posture may be forwarded"
15248 );
15249 }
15250
15251 #[tokio::test]
15252 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15253 let session_id = "permission-early-event";
15254 let early_events = [
15255 (
15256 "progress",
15257 AgentEvent::RunnerProgress {
15258 session_id: session_id.to_string(),
15259 round_count: 1,
15260 },
15261 ),
15262 (
15263 "token",
15264 AgentEvent::Token {
15265 content: "must-not-forward".to_string(),
15266 },
15267 ),
15268 (
15269 "tool",
15270 AgentEvent::ToolStart {
15271 tool_call_id: "early-tool".to_string(),
15272 tool_name: "Read".to_string(),
15273 arguments: serde_json::json!({"file_path": "README.md"}),
15274 },
15275 ),
15276 ];
15277 for (kind, event) in early_events {
15278 let (result, session, events, _) = drive_permission_handshake_frames(
15279 session_id,
15280 [
15281 actor_event_frame(event),
15282 permission_posture_frame(session_id, 7),
15283 completed_actor_frame(),
15284 ],
15285 expected_default_permission_posture(7),
15286 )
15287 .await;
15288
15289 assert!(
15290 result
15291 .unwrap_err()
15292 .to_string()
15293 .contains("execution event before permission posture confirmation"),
15294 "{kind} must fail closed before posture"
15295 );
15296 assert!(events.is_empty(), "{kind} must not be forwarded");
15297 assert!(
15298 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15299 "{kind} must not advance the permission audit"
15300 );
15301 }
15302 }
15303
15304 #[tokio::test]
15305 async fn actor_permission_handshake_rejects_approval_before_posture() {
15306 let session_id = "permission-early-approval";
15307 let (result, session, events, sent) = drive_permission_handshake_frames(
15308 session_id,
15309 [ChildFrame::ApprovalRequest {
15310 id: "approval-before-posture".to_string(),
15311 body: serde_json::json!({
15312 "tool_name": "Bash",
15313 "permission": "execute",
15314 "resource": "echo must-not-run"
15315 }),
15316 }],
15317 expected_default_permission_posture(7),
15318 )
15319 .await;
15320
15321 assert!(result
15322 .unwrap_err()
15323 .to_string()
15324 .contains("requested approval before permission posture confirmation"));
15325 assert!(events.is_empty());
15326 assert!(
15327 sent.is_empty(),
15328 "an unconfirmed actor must receive no approval reply"
15329 );
15330 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15331 }
15332
15333 #[tokio::test]
15334 async fn actor_permission_handshake_rejects_mismatched_posture() {
15335 let session_id = "permission-mismatch";
15336 let (result, session, events, _) = drive_permission_handshake_frames(
15337 session_id,
15338 [permission_posture_frame(session_id, 8)],
15339 expected_default_permission_posture(7),
15340 )
15341 .await;
15342
15343 assert!(result
15344 .unwrap_err()
15345 .to_string()
15346 .contains("does not match the host-dispatched policy"));
15347 assert!(events.is_empty());
15348 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15349 }
15350
15351 #[tokio::test]
15352 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15353 let session_id = "permission-hostile-mapping";
15354 for hostile_mapping in [
15355 "wrong_executor:permission_mode=default",
15356 "test_actor:permission_mode=default;credential=must-not-persist",
15357 ] {
15358 let frame = ChildFrame::Event {
15359 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15360 session_id: session_id.to_string(),
15361 policy_revision: 7,
15362 requested_mode: "default".to_string(),
15363 effective_mode: "default".to_string(),
15364 executor_mapping: hostile_mapping.to_string(),
15365 })
15366 .expect("serialize hostile posture fixture"),
15367 };
15368 let (result, session, events, _) = drive_permission_handshake_frames(
15369 session_id,
15370 [frame],
15371 expected_default_permission_posture(7),
15372 )
15373 .await;
15374
15375 let error = result.unwrap_err().to_string();
15376 assert!(error.contains("host-dispatched executor mapping"));
15377 assert!(
15378 !error.contains(hostile_mapping),
15379 "untrusted mapping must not be reflected in host errors"
15380 );
15381 assert!(events.is_empty());
15382 assert!(
15383 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15384 "untrusted mapping must not reach durable or in-memory audit state"
15385 );
15386 }
15387 }
15388
15389 #[tokio::test]
15390 async fn actor_permission_handshake_rejects_duplicate_posture() {
15391 let session_id = "permission-duplicate";
15392 let (result, session, events, _) = drive_permission_handshake_frames(
15393 session_id,
15394 [
15395 permission_posture_frame(session_id, 7),
15396 permission_posture_frame(session_id, 7),
15397 completed_actor_frame(),
15398 ],
15399 expected_default_permission_posture(7),
15400 )
15401 .await;
15402
15403 assert!(result
15404 .unwrap_err()
15405 .to_string()
15406 .contains("duplicate permission posture activation"));
15407 assert_eq!(
15408 events.len(),
15409 1,
15410 "only the confirmed posture may be forwarded"
15411 );
15412 assert!(matches!(
15413 events[0],
15414 AgentEvent::PermissionPostureActivated { .. }
15415 ));
15416 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15417 .expect("the first matching posture must be recorded");
15418 assert_eq!(audit.policy_revision, 7);
15419 }
15420
15421 #[tokio::test]
15422 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15423 let session_id = "permission-happy";
15424 let (result, session, events, _) = drive_permission_handshake_frames(
15425 session_id,
15426 [
15427 permission_posture_frame(session_id, 7),
15428 actor_event_frame(AgentEvent::RunnerProgress {
15429 session_id: session_id.to_string(),
15430 round_count: 1,
15431 }),
15432 actor_event_frame(AgentEvent::Token {
15433 content: "working".to_string(),
15434 }),
15435 actor_event_frame(AgentEvent::ToolStart {
15436 tool_call_id: "tool-1".to_string(),
15437 tool_name: "Read".to_string(),
15438 arguments: serde_json::json!({"file_path": "README.md"}),
15439 }),
15440 completed_actor_frame(),
15441 ],
15442 expected_default_permission_posture(7),
15443 )
15444 .await;
15445
15446 assert_eq!(result.unwrap().as_deref(), Some("done"));
15447 assert!(matches!(
15448 events.as_slice(),
15449 [
15450 AgentEvent::PermissionPostureActivated { .. },
15451 AgentEvent::RunnerProgress { .. },
15452 AgentEvent::Token { .. },
15453 AgentEvent::ToolStart { .. }
15454 ]
15455 ));
15456 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15457 .expect("matching posture must be recorded before execution events are accepted");
15458 assert_eq!(audit.policy_revision, 7);
15459 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15460 }
15461
15462 #[tokio::test]
15463 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15464 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15465 .into_iter()
15466 .enumerate()
15467 {
15468 let session_id = format!("actor-download-{index}");
15469 let args = serde_json::json!({
15470 "action":"download",
15471 "selector":"a[data-secret='private-selector']",
15472 "expected_epoch":17,
15473 "data_base64":"private-extra-bytes",
15474 "extra":{"url":"https://private.test/file"},
15475 });
15476 let original = args.clone();
15477 let read_args = serde_json::json!({"file_path":"README.md"});
15478 let (result, _session, events, _) = drive_permission_handshake_frames(
15479 &session_id,
15480 [
15481 permission_posture_frame(&session_id, 7),
15482 actor_event_frame(AgentEvent::ToolStart {
15483 tool_call_id: "download-call".to_string(),
15484 tool_name: tool_name.to_string(),
15485 arguments: args.clone(),
15486 }),
15487 actor_event_frame(AgentEvent::ToolStart {
15488 tool_call_id: "read-call".to_string(),
15489 tool_name: "Read".to_string(),
15490 arguments: read_args.clone(),
15491 }),
15492 completed_actor_frame(),
15493 ],
15494 expected_default_permission_posture(7),
15495 )
15496 .await;
15497 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15498 assert_eq!(args, original);
15499 let starts: Vec<_> = events
15500 .iter()
15501 .filter_map(|event| match event {
15502 AgentEvent::ToolStart {
15503 tool_name,
15504 arguments,
15505 ..
15506 } => Some((tool_name.as_str(), arguments)),
15507 _ => None,
15508 })
15509 .collect();
15510 assert_eq!(starts.len(), 2);
15511 assert_eq!(starts[0].0, "browser");
15512 assert_eq!(
15513 starts[0].1,
15514 &serde_json::json!({"action":"download","expected_epoch":17})
15515 );
15516 assert_eq!(starts[1], ("Read", &read_args));
15517 assert!(!format!("{events:?}").contains("private"));
15518 }
15519 }
15520
15521 #[tokio::test]
15522 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15523 let session_id = "actor-hook-display";
15524 let private = "private-selector-url-filename-response";
15525 let hook_name = format!("audit-{private}");
15526 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15527 hook_name: hook_name.clone(),
15528 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15529 phase: phase.into(),
15530 duration_ms,
15531 decision,
15532 };
15533 let decisions = [
15534 HookResult::Deny {
15535 reason: private.into(),
15536 },
15537 HookResult::InjectContext {
15538 text: private.into(),
15539 },
15540 HookResult::WithContext {
15541 result: Box::new(HookResult::WithContext {
15542 result: Box::new(HookResult::Deny {
15543 reason: private.into(),
15544 }),
15545 text: private.into(),
15546 }),
15547 text: private.into(),
15548 },
15549 HookResult::Suspend {
15550 reason: private.into(),
15551 },
15552 HookResult::Abort {
15553 reason: private.into(),
15554 },
15555 ];
15556 let originals = decisions.to_vec();
15557 let batch_events = [
15558 hook(2, "completed", decisions[1].clone()),
15559 hook(3, "completed", decisions[2].clone()),
15560 hook(4, "completed", decisions[3].clone()),
15561 ]
15562 .into_iter()
15563 .map(|event| serde_json::to_value(event).unwrap())
15564 .collect::<Vec<_>>();
15565 let batch = ActorEventBatch {
15566 logical_session: Some(LogicalSessionIdentity {
15567 creation: None,
15568 session_id: session_id.into(),
15569 parent_session_id: Some("permission-parent".into()),
15570 root_session_id: session_id.into(),
15571 }),
15572 activation_id: None,
15573 execution_epoch: 0,
15574 source_node_id: None,
15575 source_actor_id: Some(session_id.into()),
15576 first_seq: 1,
15577 last_seq: batch_events.len() as u64,
15578 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15579 events: batch_events,
15580 };
15581 let nested = AgentEvent::SubAgentEvent {
15582 parent_session_id: "parent".into(),
15583 child_session_id: "child".into(),
15584 event: Box::new(hook(5, "completed", decisions[4].clone())),
15585 };
15586 let raw = serde_json::to_string(&originals).unwrap();
15587 assert!(raw.contains(private));
15588
15589 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15590 session_id,
15591 [
15592 permission_posture_frame(session_id, 7),
15593 actor_event_frame(hook(1, private, decisions[0].clone())),
15594 ChildFrame::EventBatch { batch },
15595 actor_event_frame(nested),
15596 completed_actor_frame(),
15597 ],
15598 expected_default_permission_posture(7),
15599 )
15600 .await;
15601 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15602 let wire = serde_json::to_string(&forwarded).unwrap();
15603 assert!(
15604 !wire.contains(private),
15605 "private hook text reached SSE: {wire}"
15606 );
15607
15608 let hooks = forwarded
15609 .iter()
15610 .filter_map(|event| match event {
15611 AgentEvent::HookLifecycle {
15612 hook_name,
15613 point,
15614 phase,
15615 duration_ms,
15616 decision,
15617 } => Some((hook_name, point, phase, duration_ms, decision)),
15618 _ => None,
15619 })
15620 .collect::<Vec<_>>();
15621 assert_eq!(hooks.len(), 4);
15622 assert!(hooks.iter().all(|(name, point, _, _, _)| {
15623 name == &&"actor-hook-1".to_string()
15624 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15625 }));
15626 assert_eq!(hooks[0].2, "event");
15627 assert_eq!(hooks[1].2, "completed");
15628 assert_eq!(*hooks[0].3, 1);
15629 assert_eq!(*hooks[3].3, 4);
15630 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15631 assert!(
15632 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15633 );
15634 assert!(
15635 matches!(hooks[2].4, HookResult::WithContext { result, text }
15636 if text == "Hook context hidden" && matches!(result.as_ref(),
15637 HookResult::WithContext { result, text }
15638 if text == "Hook context hidden" && matches!(result.as_ref(),
15639 HookResult::Deny { reason } if reason == "Hook denied")))
15640 );
15641 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15642 assert!(forwarded.iter().any(|event| matches!(event,
15643 AgentEvent::SubAgentEvent { event, .. }
15644 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15645 hook_name, duration_ms: 5,
15646 decision: HookResult::Abort { reason }, ..
15647 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15648 )));
15649 }
15650
15651 #[test]
15652 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15653 let mut display = ActorEventDisplay::default();
15654 let mut deep = HookResult::Allow;
15655 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15656 deep = HookResult::WithContext {
15657 result: Box::new(deep),
15658 text: "private-download-bytes".into(),
15659 };
15660 }
15661 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15662 hook_name,
15663 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15664 phase: "completed".into(),
15665 duration_ms: 7,
15666 decision,
15667 };
15668 assert!(display.project(event("deep".into(), deep), 0).is_none());
15669 assert!(display.overflowed);
15670 assert!(display
15671 .project(
15672 event(
15673 "private-download-bytes".into(),
15674 HookResult::Deny {
15675 reason: "private-download-bytes".into(),
15676 },
15677 ),
15678 0,
15679 )
15680 .is_none());
15681
15682 let mut oversized = ActorEventDisplay::default();
15683 assert!(oversized
15684 .project(
15685 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15686 0,
15687 )
15688 .is_none());
15689 assert!(oversized.overflowed);
15690
15691 let mut reconnected = ActorEventDisplay::default();
15695 let private = "private-download-bytes";
15696 for _ in 0..2 {
15697 let projected = reconnected
15698 .project(event(private.into(), HookResult::Allow), 0)
15699 .unwrap();
15700 assert!(
15701 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15702 if hook_name == "actor-hook-1")
15703 );
15704 }
15705 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15706 for index in 1..MAX_DISPLAY_CALLS {
15707 assert!(reconnected
15708 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15709 .is_some());
15710 }
15711 assert!(reconnected
15712 .project(event("overflow-private".into(), HookResult::Allow), 0)
15713 .is_none());
15714 assert!(reconnected.overflowed);
15715 assert!(reconnected.hook_names.is_empty());
15716 }
15717
15718 #[test]
15719 fn actor_hook_display_preserves_nontext_decision_kinds() {
15720 for result in [
15721 HookResult::Continue,
15722 HookResult::Mutated,
15723 HookResult::Allow,
15724 HookResult::Ask,
15725 ] {
15726 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15727 }
15728 }
15729
15730 #[tokio::test]
15731 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15732 let session_id = "actor-malformed-hook";
15733 let private = "private-download-selector";
15734 let original = AgentEvent::HookLifecycle {
15735 hook_name: "audit".into(),
15736 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15737 phase: "completed".into(),
15738 duration_ms: 9,
15739 decision: HookResult::Deny {
15740 reason: private.into(),
15741 },
15742 };
15743 let mut invalid = serde_json::to_value(&original).unwrap();
15744 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15745 let nested = AgentEvent::SubAgentEvent {
15746 parent_session_id: "parent".into(),
15747 child_session_id: "child".into(),
15748 event: Box::new(original),
15749 };
15750 let mut invalid_nested = serde_json::to_value(nested).unwrap();
15751 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15752 let batch = ActorEventBatch {
15753 logical_session: Some(LogicalSessionIdentity {
15754 creation: None,
15755 session_id: session_id.into(),
15756 parent_session_id: Some("permission-parent".into()),
15757 root_session_id: session_id.into(),
15758 }),
15759 activation_id: None,
15760 execution_epoch: 0,
15761 source_node_id: None,
15762 source_actor_id: Some(session_id.into()),
15763 first_seq: 1,
15764 last_seq: 1,
15765 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15766 events: vec![invalid.clone()],
15767 };
15768 for frame in [
15769 ChildFrame::Event {
15770 event: invalid.clone(),
15771 },
15772 ChildFrame::EventBatch { batch },
15773 ChildFrame::Event {
15774 event: invalid_nested,
15775 },
15776 ] {
15777 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15778 session_id,
15779 [permission_posture_frame(session_id, 7), frame],
15780 expected_default_permission_posture(7),
15781 )
15782 .await;
15783 let error = outcome.unwrap_err().to_string();
15784 assert!(error.contains("malformed AgentEvent"), "{error}");
15785 assert!(!error.contains(private));
15786 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15787 }
15788
15789 let (event_tx, mut event_rx) = mpsc::channel(8);
15790 let mut session = Session::new("legacy-hook", "model");
15791 let mut handshake = PermissionPostureHandshake::NotRequired;
15792 let mut display = ActorEventDisplay::default();
15793 process_actor_event(
15794 invalid,
15795 false,
15796 &mut handshake,
15797 None,
15798 None,
15799 &mut session,
15800 &event_tx,
15801 &mut display,
15802 )
15803 .await
15804 .unwrap();
15805 assert!(display.overflowed);
15806 assert!(event_rx.try_recv().is_err());
15807 }
15808
15809 #[tokio::test]
15810 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15811 let session_id = "actor-download-batch";
15812 let private = "private-download-bytes-and-url";
15813 let events = vec![
15814 AgentEvent::ToolStart {
15815 tool_call_id: "download-call".into(),
15816 tool_name: "default::browser".into(),
15817 arguments: serde_json::json!({
15818 "action":"download", "selector": private, "expected_epoch": 17,
15819 }),
15820 },
15821 AgentEvent::ToolApprovalRequested {
15822 tool_call_id: "download-call".into(),
15823 tool_name: format!("{private}::browser"),
15824 parameters: serde_json::json!({
15825 "action":"download", "selector": private, "expected_epoch": 17,
15826 }),
15827 },
15828 AgentEvent::ToolStart {
15829 tool_call_id: "read-call".into(),
15830 tool_name: "Read".into(),
15831 arguments: serde_json::json!({"file_path":"README.md"}),
15832 },
15833 ];
15834 let batch = ActorEventBatch {
15835 logical_session: Some(LogicalSessionIdentity {
15836 creation: None,
15837 session_id: session_id.into(),
15838 parent_session_id: Some("permission-parent".into()),
15839 root_session_id: session_id.into(),
15840 }),
15841 activation_id: None,
15842 execution_epoch: 0,
15843 source_node_id: None,
15844 source_actor_id: Some(session_id.into()),
15845 first_seq: 1,
15846 last_seq: events.len() as u64,
15847 qos: bamboo_subagent::ActorEventQos::Durable,
15848 events: events
15849 .into_iter()
15850 .map(|event| serde_json::to_value(event).unwrap())
15851 .collect(),
15852 };
15853 let result = bamboo_agent_core::tools::ToolResult {
15854 success: true,
15855 result: format!("{{\"data_base64\":\"{private}\"}}"),
15856 display_preference: Some(private.into()),
15857 images: vec![bamboo_agent_core::tools::ToolResultImage {
15858 mime_type: "image/png".into(),
15859 data: private.into(),
15860 }],
15861 };
15862 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15863 session_id,
15864 [
15865 permission_posture_frame(session_id, 7),
15866 ChildFrame::EventBatch { batch },
15867 actor_event_frame(AgentEvent::ToolToken {
15868 tool_call_id: "download-call".into(),
15869 content: private.into(),
15870 }),
15871 actor_event_frame(AgentEvent::ToolToken {
15872 tool_call_id: "read-call".into(),
15873 content: "public live output".into(),
15874 }),
15875 actor_event_frame(AgentEvent::ToolComplete {
15876 tool_call_id: "download-call".into(),
15877 result,
15878 }),
15879 actor_event_frame(AgentEvent::ToolLifecycle {
15880 tool_call_id: "download-call".into(),
15881 tool_name: "browser".into(),
15882 phase: "finished".into(),
15883 elapsed_ms: Some(1),
15884 is_mutating: false,
15885 auto_approved: false,
15886 summary: Some(private.into()),
15887 error: Some(private.into()),
15888 }),
15889 actor_event_frame(AgentEvent::ToolError {
15890 tool_call_id: "download-call".into(),
15891 error: private.into(),
15892 }),
15893 actor_event_frame(AgentEvent::ToolComplete {
15894 tool_call_id: "read-call".into(),
15895 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15896 }),
15897 completed_actor_frame(),
15898 ],
15899 expected_default_permission_posture(7),
15900 )
15901 .await;
15902 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15903 let wire = serde_json::to_string(&forwarded).unwrap();
15904 assert!(
15905 !wire.contains(private),
15906 "private content reached actor SSE: {wire}"
15907 );
15908 assert!(wire.contains("public README text"));
15909 assert!(wire.contains("public live output"));
15910 assert!(forwarded.iter().any(|event| matches!(event,
15911 AgentEvent::ToolComplete { tool_call_id, result }
15912 if tool_call_id == "download-call"
15913 && result.result == "Browser tool result hidden"
15914 && result.images.is_empty() && result.display_preference.is_none()
15915 )));
15916 }
15917
15918 #[tokio::test]
15919 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15920 let private = "private-download-content";
15921 let nested = |event| AgentEvent::SubAgentEvent {
15922 parent_session_id: "parent".into(),
15923 child_session_id: "nested-child".into(),
15924 event: Box::new(event),
15925 };
15926 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15927 "actor-reorder",
15928 [
15929 permission_posture_frame("actor-reorder", 7),
15930 actor_event_frame(AgentEvent::ToolComplete {
15931 tool_call_id: "missing".into(),
15932 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15933 }),
15934 actor_event_frame(AgentEvent::ToolLifecycle {
15935 tool_call_id: "missing".into(),
15936 tool_name: "browser".into(),
15937 phase: private.into(),
15938 elapsed_ms: None,
15939 is_mutating: false,
15940 auto_approved: false,
15941 summary: Some(private.into()),
15942 error: Some(private.into()),
15943 }),
15944 actor_event_frame(AgentEvent::ToolApprovalRequested {
15945 tool_call_id: "approval-only".into(),
15946 tool_name: "browser".into(),
15947 parameters: serde_json::json!({"action":"download","selector":private}),
15948 }),
15949 actor_event_frame(AgentEvent::ToolToken {
15950 tool_call_id: "approval-only".into(),
15951 content: private.into(),
15952 }),
15953 actor_event_frame(AgentEvent::ToolStart {
15954 tool_call_id: "malformed-browser".into(),
15955 tool_name: format!("{private}::browser"),
15956 arguments: serde_json::json!({"selector":private}),
15957 }),
15958 actor_event_frame(AgentEvent::ToolComplete {
15959 tool_call_id: "malformed-browser".into(),
15960 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15961 }),
15962 actor_event_frame(AgentEvent::ToolStart {
15963 tool_call_id: "reused".into(),
15964 tool_name: "Read".into(),
15965 arguments: serde_json::json!({"file_path":"README.md"}),
15966 }),
15967 actor_event_frame(AgentEvent::ToolStart {
15968 tool_call_id: "reused".into(),
15969 tool_name: "browser".into(),
15970 arguments: serde_json::json!({"action":"download","selector":private}),
15971 }),
15972 actor_event_frame(AgentEvent::ToolToken {
15973 tool_call_id: "reused".into(),
15974 content: private.into(),
15975 }),
15976 actor_event_frame(nested(AgentEvent::ToolStart {
15977 tool_call_id: "nested".into(),
15978 tool_name: "browser".into(),
15979 arguments: serde_json::json!({"action":"download","selector":private}),
15980 })),
15981 actor_event_frame(nested(AgentEvent::ToolComplete {
15982 tool_call_id: "nested".into(),
15983 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15984 })),
15985 actor_event_frame(AgentEvent::ChildApprovalRequested {
15986 child_session_id: "nested-child".into(),
15987 request_id: "approval".into(),
15988 tool_name: format!("{private}::browser"),
15989 permission: private.into(),
15990 resource: private.into(),
15991 }),
15992 actor_event_frame(AgentEvent::ChildApprovalChanged {
15993 parent_session_id: "parent".into(),
15994 child_session_id: "nested-child".into(),
15995 child_attempt: 1,
15996 request_id: "approval".into(),
15997 version: 1,
15998 status: "pending".into(),
15999 reason: Some(private.into()),
16000 tool_name: "browser".into(),
16001 permission: private.into(),
16002 resource: private.into(),
16003 created_at: "2026-01-01T00:00:00Z".into(),
16004 resolved_at: None,
16005 }),
16006 completed_actor_frame(),
16007 ],
16008 expected_default_permission_posture(7),
16009 )
16010 .await;
16011 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16012 let wire = serde_json::to_string(&forwarded).unwrap();
16013 assert!(
16014 !wire.contains(private),
16015 "private content reached actor SSE: {wire}"
16016 );
16017 assert!(wire.contains("Tool result hidden"));
16018 assert!(wire.contains("Browser tool result hidden"));
16019 assert!(wire.contains("Browser interaction approval"));
16020 assert!(!forwarded
16021 .iter()
16022 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
16023 }
16024
16025 #[test]
16026 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
16027 let mut display = ActorEventDisplay::default();
16028 display
16029 .project(
16030 AgentEvent::ToolStart {
16031 tool_call_id: "private-call".into(),
16032 tool_name: "browser".into(),
16033 arguments: serde_json::json!({"action":"download","selector":"private"}),
16034 },
16035 0,
16036 )
16037 .unwrap();
16038 for index in 0..MAX_DISPLAY_CALLS {
16039 display
16040 .project(
16041 AgentEvent::ToolStart {
16042 tool_call_id: format!("ordinary-{index}"),
16043 tool_name: "Read".into(),
16044 arguments: serde_json::json!({"file_path":"README.md"}),
16045 },
16046 0,
16047 )
16048 .unwrap();
16049 }
16050 assert!(display.overflowed);
16051 let forwarded = display
16052 .project(
16053 AgentEvent::ToolComplete {
16054 tool_call_id: "private-call".into(),
16055 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
16056 },
16057 0,
16058 )
16059 .unwrap();
16060 assert!(!serde_json::to_string(&forwarded)
16061 .unwrap()
16062 .contains("private-bytes"));
16063 }
16064
16065 #[test]
16066 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
16067 let mut display = ActorEventDisplay::default();
16068 let nested = |index: usize| AgentEvent::SubAgentEvent {
16069 parent_session_id: "parent".into(),
16070 child_session_id: format!("child-{index}"),
16071 event: Box::new(AgentEvent::ToolStart {
16072 tool_call_id: format!("read-{index}"),
16073 tool_name: "Read".into(),
16074 arguments: serde_json::json!({"file_path":"README.md"}),
16075 }),
16076 };
16077 for index in 0..(MAX_DISPLAY_CALLS / 2) {
16078 assert!(display.project(nested(index), 0).is_some());
16079 }
16080 assert_eq!(
16081 display.identities.load(Ordering::Relaxed),
16082 MAX_DISPLAY_CALLS
16083 );
16084 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
16085 assert!(display.overflowed);
16086 assert!(display.children.is_empty());
16087
16088 let mut oversized = ActorEventDisplay::default();
16089 assert!(oversized
16090 .project(
16091 AgentEvent::SubAgentEvent {
16092 parent_session_id: "parent".into(),
16093 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16094 event: Box::new(AgentEvent::ToolComplete {
16095 tool_call_id: "call".into(),
16096 result: bamboo_agent_core::tools::ToolResult::text(
16097 true,
16098 "private-download-bytes",
16099 ),
16100 }),
16101 },
16102 0,
16103 )
16104 .is_none());
16105 assert!(oversized.overflowed);
16106 assert!(oversized
16107 .project(
16108 AgentEvent::ToolStart {
16109 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16110 tool_name: "Read".into(),
16111 arguments: serde_json::json!({"file_path":"README.md"}),
16112 },
16113 0,
16114 )
16115 .is_none());
16116 }
16117
16118 #[tokio::test]
16119 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16120 let session_id = "actor-gap";
16121 let batch = |seq, event: AgentEvent| {
16122 let event = serde_json::to_value(event).unwrap();
16123 ChildFrame::EventBatch {
16124 batch: ActorEventBatch {
16125 logical_session: Some(LogicalSessionIdentity {
16126 creation: None,
16127 session_id: session_id.into(),
16128 parent_session_id: Some("permission-parent".into()),
16129 root_session_id: session_id.into(),
16130 }),
16131 activation_id: None,
16132 execution_epoch: 0,
16133 source_node_id: None,
16134 source_actor_id: Some(session_id.into()),
16135 first_seq: seq,
16136 last_seq: seq,
16137 qos: bamboo_subagent::ActorEventQos::classify(&event),
16138 events: vec![event],
16139 },
16140 }
16141 };
16142 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16143 session_id,
16144 [
16145 permission_posture_frame(session_id, 7),
16146 batch(
16147 1,
16148 AgentEvent::ToolStart {
16149 tool_call_id: "reused-call".into(),
16150 tool_name: "Read".into(),
16151 arguments: serde_json::json!({"file_path":"README.md"}),
16152 },
16153 ),
16154 batch(
16157 3,
16158 AgentEvent::ToolComplete {
16159 tool_call_id: "reused-call".into(),
16160 result: bamboo_agent_core::tools::ToolResult::text(
16161 true,
16162 "private-download-bytes",
16163 ),
16164 },
16165 ),
16166 completed_actor_frame(),
16167 ],
16168 expected_default_permission_posture(7),
16169 )
16170 .await;
16171 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16172 let wire = serde_json::to_string(&forwarded).unwrap();
16173 assert!(!wire.contains("private-download-bytes"), "{wire}");
16174 assert!(wire.contains("Tool result hidden"));
16175 }
16176
16177 #[tokio::test]
16178 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16179 let (event_tx, mut event_rx) = mpsc::channel(8);
16180 let mut session = Session::new("legacy-malformed-private", "model");
16181 let mut handshake = PermissionPostureHandshake::NotRequired;
16182 let mut display = ActorEventDisplay::default();
16183 let read_start = serde_json::to_value(AgentEvent::ToolStart {
16184 tool_call_id: "reused-call".into(),
16185 tool_name: "Read".into(),
16186 arguments: serde_json::json!({"file_path":"README.md"}),
16187 })
16188 .unwrap();
16189 process_actor_event(
16190 read_start,
16191 false,
16192 &mut handshake,
16193 None,
16194 None,
16195 &mut session,
16196 &event_tx,
16197 &mut display,
16198 )
16199 .await
16200 .unwrap();
16201
16202 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16205 tool_call_id: "reused-call".into(),
16206 tool_name: "browser".into(),
16207 arguments: serde_json::json!({
16208 "action":"download", "selector":"private-download-selector"
16209 }),
16210 })
16211 .unwrap();
16212 malformed["tool_name"] = serde_json::json!(7);
16213 process_actor_event(
16214 malformed,
16215 false,
16216 &mut handshake,
16217 None,
16218 None,
16219 &mut session,
16220 &event_tx,
16221 &mut display,
16222 )
16223 .await
16224 .unwrap();
16225 process_actor_event(
16226 serde_json::to_value(AgentEvent::ToolComplete {
16227 tool_call_id: "reused-call".into(),
16228 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16229 })
16230 .unwrap(),
16231 false,
16232 &mut handshake,
16233 None,
16234 None,
16235 &mut session,
16236 &event_tx,
16237 &mut display,
16238 )
16239 .await
16240 .unwrap();
16241 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16242 assert_eq!(forwarded.len(), 2);
16243 let wire = serde_json::to_string(&forwarded).unwrap();
16244 assert!(!wire.contains("private-download"), "{wire}");
16245 assert!(wire.contains("Tool result hidden"));
16246 }
16247
16248 #[tokio::test]
16249 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16250 let temp = tempfile::tempdir().unwrap();
16251 let store = Arc::new(
16252 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16253 .await
16254 .unwrap(),
16255 );
16256 let storage: Arc<dyn Storage> = store.clone();
16257 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16258 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16259 store.clone(),
16260 bamboo_domain::SessionInboxLimits::default(),
16261 ));
16262 let session_id = "actor-confirmation-order";
16263 let run_id = "actor-run-current";
16264 let mut session = Session::new(session_id, "model");
16265 store.save_session(&session).await.unwrap();
16266 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16267 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16268 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16269 inbox.deliver(&envelope).await.unwrap();
16270 }
16271 inbox
16272 .mark_activation_eligible(
16273 session_id,
16274 2,
16275 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16276 )
16277 .await
16278 .unwrap();
16279 let router = SessionActivationRouter::new();
16280 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16281 let binding = SessionInboxRuntimeBinding {
16282 router,
16283 inbox: inbox.clone(),
16284 storage,
16285 persistence: locked,
16286 parent_question_lock: None,
16287 };
16288 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16289 .await
16290 .unwrap();
16291 assert_eq!(
16292 pairs
16293 .iter()
16294 .map(|(claim, _)| claim.envelope.id.as_str())
16295 .collect::<Vec<_>>(),
16296 vec!["actor-first", "actor-second"]
16297 );
16298 let seeded = store.load_session(session_id).await.unwrap().unwrap();
16299 assert_eq!(
16300 seeded
16301 .messages
16302 .iter()
16303 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16304 .map(|message| message.id.as_str())
16305 .collect::<Vec<_>>(),
16306 vec!["actor-first", "actor-second"],
16307 "host context must be durable before actor dispatch"
16308 );
16309 for (claim, _) in &pairs {
16310 assert_eq!(
16311 seeded
16312 .messages
16313 .iter()
16314 .filter(|message| bamboo_domain::is_matching_session_message(
16315 message,
16316 &claim.envelope
16317 ))
16318 .count(),
16319 1,
16320 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16321 claim.envelope.id
16322 );
16323 }
16324 assert!(
16325 seeded.session_inbox_admission().is_none_or(|cursor| {
16326 !cursor.contains(&pairs[0].0.envelope.id)
16327 && !cursor.contains(&pairs[1].0.envelope.id)
16328 }),
16329 "pre-dispatch transcript seeding must not forge worker confirmation"
16330 );
16331 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16332 let claims = pairs
16333 .into_iter()
16334 .map(|(claim, _)| claim)
16335 .collect::<VecDeque<_>>();
16336 let first = claims[0].clone();
16337 let second = claims[1].clone();
16338 let mut stale = admission_confirmation(session_id, &first, "stale-run");
16339 stale.canonical_claim_generation = second.generation;
16340 let mut link = ConfirmationSequenceLink {
16341 frames: VecDeque::from([
16342 ChildFrame::SessionMessageAdmitted {
16343 confirmation: stale,
16344 },
16345 ChildFrame::SessionMessageAdmitted {
16346 confirmation: admission_confirmation(session_id, &first, run_id),
16347 },
16348 ChildFrame::SessionMessageAdmitted {
16349 confirmation: admission_confirmation(session_id, &second, run_id),
16350 },
16351 ChildFrame::Terminal {
16352 status: TerminalStatus::Completed,
16353 result: Some("done".to_string()),
16354 error: None,
16355 final_event_watermark: None,
16356 transcript: Vec::new(),
16357 },
16358 ]),
16359 sent: Vec::new(),
16360 };
16361 let (event_tx, _event_rx) = mpsc::channel(8);
16362 let cancel = CancellationToken::new();
16363 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16364 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16365 let result = drive(ActorDriveContext {
16366 expected_creation: None,
16367 client: &mut link,
16368 parent_session_id: "parent",
16369 child_session_id: session_id,
16370 child_attempt: 0,
16371 approval_registry: None,
16372 approval_decider: None,
16373 approval_reviewer: None,
16374 escalation_bridge: None,
16375 event_tx: &event_tx,
16376 cancel_token: &cancel,
16377 live_rx: &mut live_rx,
16378 delivery_rx: &mut delivery_rx,
16379 logical_session: &mut session,
16380 expected_permission_posture: None,
16381 session_inbox_runtime: Some(&binding),
16382 actor_directory_store: None,
16383 canonical_subagent_tool: None,
16384 ticket_service: None,
16385 activation_run_id: Some(run_id),
16386 execution_epoch: 0,
16387 expected_source_actor_id: session_id,
16388 initial_inflight_claims: claims,
16389 plain_actor: false,
16390 remote_environment_lease: false,
16391 readonly_output: None,
16392 local_history_tools: None,
16393 local_history_read_only: false,
16394 strict_history_output: None,
16395 plain_input: None,
16396 canonical_activation: None,
16397 canonical_placement_ref: None,
16398 actor_event_observer: None,
16399 plain_run: None,
16400 first_frame_timeout: Some(Duration::from_secs(1)),
16401 })
16402 .await
16403 .unwrap();
16404 assert_eq!(result.as_deref(), Some("done"));
16405 assert_eq!(
16406 session
16407 .messages
16408 .iter()
16409 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16410 .map(|message| message.id.as_str())
16411 .collect::<Vec<_>>(),
16412 vec!["actor-first", "actor-second"]
16413 );
16414 let backlog = inbox.inspect(session_id).await.unwrap();
16415 assert_eq!(backlog.pending + backlog.claimed, 0);
16416 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16417 let cursor = confirmed
16418 .session_inbox_admission()
16419 .expect("exact worker confirmation must checkpoint the admission cursor");
16420 assert!(cursor.contains(&first.envelope.id));
16421 assert!(cursor.contains(&second.envelope.id));
16422 for claim in [&first, &second] {
16423 assert_eq!(
16424 confirmed
16425 .messages
16426 .iter()
16427 .filter(|message| bamboo_domain::is_matching_session_message(
16428 message,
16429 &claim.envelope
16430 ))
16431 .count(),
16432 1,
16433 "confirmation must retain one exact canonical marker for {}",
16434 claim.envelope.id
16435 );
16436 }
16437 assert!(inbox
16438 .was_admitted(session_id, &first.envelope.id)
16439 .await
16440 .unwrap());
16441 assert!(inbox
16442 .was_admitted(session_id, &second.envelope.id)
16443 .await
16444 .unwrap());
16445 owner_registration.begin_finalization().await;
16446 owner_registration.finish(2).await.unwrap();
16447 }
16448
16449 #[derive(Default)]
16450 struct RecordingCodexTokenAuthority {
16451 issued_for: std::sync::Mutex<Vec<String>>,
16452 revoked: std::sync::Mutex<Vec<String>>,
16453 }
16454
16455 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16456 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16457 self.issued_for
16458 .lock()
16459 .expect("issued fixture lock")
16460 .push(session_id.to_string());
16461 Ok(IssuedCodexRunToken {
16462 token_id: format!("id-{session_id}"),
16463 token: format!("bcx1_secret-{session_id}"),
16464 })
16465 }
16466
16467 fn revoke(&self, token_id: &str) {
16468 self.revoked
16469 .lock()
16470 .expect("revoked fixture lock")
16471 .push(token_id.to_string());
16472 }
16473 }
16474
16475 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16476 let bamboo_mode = auth_mode == Some("bamboo")
16477 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16478 ExecutorSpec::Codex {
16479 binary: None,
16480 model: None,
16481 mode: None,
16482 sandbox: None,
16483 inherit_user_config,
16484 auth_mode: auth_mode.map(str::to_string),
16485 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16486 wire_api: Some("responses".to_string()),
16487 provider_key_ref: None,
16488 forward_env: None,
16489 approval_policy: None,
16490 network_access: None,
16491 allow_danger_bypass: None,
16492 permission_profile: None,
16493 workspace_owned: None,
16494 }
16495 }
16496
16497 fn permission_resolution(
16498 requested: bamboo_domain::SessionPermissionMode,
16499 effective: bamboo_domain::PermissionMode,
16500 ) -> bamboo_domain::PermissionModeResolution {
16501 bamboo_domain::PermissionModeResolution {
16502 requested,
16503 effective,
16504 }
16505 }
16506
16507 #[test]
16508 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16509 use bamboo_domain::{PermissionMode, SessionPermissionMode};
16510
16511 let default =
16512 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16513 assert_eq!(
16514 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16515 .unwrap()
16516 .as_deref(),
16517 Some("bamboo_runtime:default")
16518 );
16519 assert_eq!(
16520 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16521 None,
16522 "transport-only Echo must not claim the typed permission contract"
16523 );
16524 assert_eq!(
16525 expected_permission_executor_mapping(
16526 &ExecutorSpec::CliAdapter {
16527 command: "must-not-appear-in-contract".to_string(),
16528 args: vec!["credential-like-argument".to_string()],
16529 },
16530 default,
16531 false,
16532 )
16533 .unwrap(),
16534 None,
16535 "unimplemented CliAdapter must not leak command data into a contract"
16536 );
16537
16538 let claude = ExecutorSpec::ClaudeCode {
16539 binary: None,
16540 model: None,
16541 permission_mode: Some("default".to_string()),
16542 inherit_user_config: None,
16543 forward_env: None,
16544 };
16545 for (resolution, mapping) in [
16546 (
16547 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16548 "claude_code:permission_mode=plan",
16549 ),
16550 (
16551 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16552 "claude_code:permission_mode=bypassPermissions",
16553 ),
16554 (
16555 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16556 "claude_code:permission_mode=acceptEdits",
16557 ),
16558 (
16559 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16560 "claude_code:permission_mode=dontAsk",
16561 ),
16562 (
16563 permission_resolution(
16564 SessionPermissionMode::Bypass,
16565 PermissionMode::BypassPermissions,
16566 ),
16567 "claude_code:permission_mode=default",
16568 ),
16569 ] {
16570 assert_eq!(
16571 expected_permission_executor_mapping(&claude, resolution, false)
16572 .unwrap()
16573 .as_deref(),
16574 Some(mapping)
16575 );
16576 }
16577 assert_eq!(
16578 expected_permission_executor_mapping(&claude, default, true)
16579 .unwrap()
16580 .as_deref(),
16581 Some("claude_code:blocked_explicit_deny")
16582 );
16583
16584 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16585 if let ExecutorSpec::Codex {
16586 approval_policy, ..
16587 } = &mut codex_exec
16588 {
16589 *approval_policy = Some("on-failure".to_string());
16590 }
16591 assert_eq!(
16592 expected_permission_executor_mapping(&codex_exec, default, false)
16593 .unwrap()
16594 .as_deref(),
16595 Some("codex_exec:approval_policy=on-failure")
16596 );
16597 assert_eq!(
16598 expected_permission_executor_mapping(
16599 &codex_exec,
16600 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16601 false,
16602 )
16603 .unwrap()
16604 .as_deref(),
16605 Some("codex_exec:approval_policy=never")
16606 );
16607 assert_eq!(
16608 expected_permission_executor_mapping(&codex_exec, default, true)
16609 .unwrap()
16610 .as_deref(),
16611 Some("codex_exec:blocked_explicit_deny")
16612 );
16613
16614 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16615 if let ExecutorSpec::Codex {
16616 mode,
16617 approval_policy,
16618 ..
16619 } = &mut codex_app_server
16620 {
16621 *mode = Some("app_server".to_string());
16622 *approval_policy = Some("on-request".to_string());
16623 }
16624 assert_eq!(
16625 expected_permission_executor_mapping(&codex_app_server, default, false)
16626 .unwrap()
16627 .as_deref(),
16628 Some("codex_app_server:approvalPolicy=on-request")
16629 );
16630 assert_eq!(
16631 expected_permission_executor_mapping(
16632 &codex_app_server,
16633 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16634 false,
16635 )
16636 .unwrap()
16637 .as_deref(),
16638 Some("codex_app_server:approvalPolicy=never")
16639 );
16640 assert_eq!(
16641 expected_permission_executor_mapping(&codex_app_server, default, true)
16642 .unwrap()
16643 .as_deref(),
16644 Some("codex_app_server:blocked_explicit_deny")
16645 );
16646 }
16647
16648 #[test]
16649 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16650 let project = tempfile::tempdir().unwrap();
16651 let managed = project.path().join(".bamboo/worktree/child-571");
16652 std::fs::create_dir_all(&managed).unwrap();
16653 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16654 let marker = project
16655 .path()
16656 .join(".bamboo/worktree/.bamboo-owned/child-571");
16657 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16658 std::fs::write(&marker, "bamboo/child-571").unwrap();
16659 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16660 let nested = managed.join("nested/path");
16661 std::fs::create_dir_all(&nested).unwrap();
16662 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16663
16664 let arbitrary = tempfile::tempdir().unwrap();
16665 assert!(!workspace_is_bamboo_owned(
16666 arbitrary.path().to_str().unwrap()
16667 ));
16668 }
16669
16670 #[test]
16671 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16672 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16673 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16674
16675 let (secrets, guard) = build_codex_run_secrets(
16676 &codex_executor(Some("bamboo"), None),
16677 Some(authority_dyn),
16678 "child-570",
16679 )
16680 .unwrap();
16681
16682 let token = secrets
16683 .codex_provider_token
16684 .as_ref()
16685 .expect("bamboo mode mints a token");
16686 assert_eq!(token.expose(), "bcx1_secret-child-570");
16687 assert!(!format!("{token:?}").contains("secret-child-570"));
16688 assert_eq!(
16689 authority.issued_for.lock().unwrap().as_slice(),
16690 ["child-570"]
16691 );
16692 assert!(authority.revoked.lock().unwrap().is_empty());
16693
16694 drop(guard);
16695 assert_eq!(
16696 authority.revoked.lock().unwrap().as_slice(),
16697 ["id-child-570"]
16698 );
16699 }
16700
16701 #[test]
16702 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16703 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16704 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16705 let (secrets, guard) = build_codex_run_secrets(
16706 &codex_executor(Some("custom"), None),
16707 Some(authority_dyn),
16708 "child-custom",
16709 )
16710 .unwrap();
16711 assert!(secrets.codex_provider_token.is_none());
16712 assert!(guard.is_none());
16713 assert!(authority.issued_for.lock().unwrap().is_empty());
16714
16715 let error = build_codex_run_secrets(
16716 &codex_executor(Some("bamboo"), None),
16717 None,
16718 "child-no-authority",
16719 )
16720 .err()
16721 .expect("bamboo mode without an authority must fail closed");
16722 assert!(error.to_string().contains("per-run token authority"));
16723 }
16724
16725 #[test]
16726 fn codex_provisioning_never_leaks_the_session_provider_credential() {
16727 let credentials = vec![ScopedCredential {
16728 provider: "openai".to_string(),
16729 api_key: "upstream-secret-must-not-cross".to_string(),
16730 base_url: None,
16731 provider_type: Some("openai".to_string()),
16732 credential_ref: Some("provider.openai.api_key".to_string()),
16733 }];
16734
16735 for (mode, label) in [
16736 (Some("inherit"), "inherit"),
16737 (Some("api_key"), "api_key"),
16738 (Some("bamboo"), "bamboo"),
16739 (None, "default-bamboo"),
16740 ] {
16741 let runner = ActorChildRunner::new(
16742 format!("codex-{label}-test"),
16743 PathBuf::from("/bin/false"),
16744 Vec::new(),
16745 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16746 codex_executor(mode, None),
16747 credentials.clone(),
16748 "openai".to_string(),
16749 1,
16750 );
16751 let mut session = Session::new(format!("child-{label}"), "model");
16752 session.add_message(bamboo_agent_core::Message::user("test"));
16753 let spec = runner.build_spec(
16754 &session,
16755 &crate::runtime::execution::SpawnJob {
16756 parent_session_id: "parent".to_string(),
16757 child_session_id: format!("child-{label}"),
16758 model: "gpt-5.4".to_string(),
16759 disabled_tools: None,
16760 },
16761 );
16762 assert!(
16763 spec.secrets.provider_credentials.is_empty(),
16764 "{label} Codex must not receive the session provider key"
16765 );
16766 }
16767 }
16768
16769 #[test]
16770 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16771 let credentials = vec![
16772 ScopedCredential {
16773 provider: "openai".to_string(),
16774 api_key: "selected-openai-secret".to_string(),
16775 base_url: None,
16776 provider_type: Some("openai".to_string()),
16777 credential_ref: Some("provider.openai.api_key".to_string()),
16778 },
16779 ScopedCredential {
16780 provider: "other".to_string(),
16781 api_key: "unrelated-secret".to_string(),
16782 base_url: None,
16783 provider_type: Some("openai".to_string()),
16784 credential_ref: Some("provider.other.api_key".to_string()),
16785 },
16786 ];
16787 let runner = ActorChildRunner::new(
16788 "echo-test".to_string(),
16789 PathBuf::from("/bin/false"),
16790 Vec::new(),
16791 std::env::temp_dir().join("bamboo-echo-provider-570"),
16792 ExecutorSpec::Echo,
16793 credentials,
16794 "openai".to_string(),
16795 1,
16796 );
16797 let spec = runner.build_spec(
16798 &Session::new("child-echo", "model"),
16799 &crate::runtime::execution::SpawnJob {
16800 parent_session_id: "parent".to_string(),
16801 child_session_id: "child-echo".to_string(),
16802 model: "gpt-5.4".to_string(),
16803 disabled_tools: None,
16804 },
16805 );
16806
16807 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16808 assert_eq!(
16809 spec.secrets.provider_credentials[0].api_key,
16810 "selected-openai-secret"
16811 );
16812 }
16813
16814 #[tokio::test]
16815 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16816 fn provider_instance(
16817 provider_type: &str,
16818 api_key: &str,
16819 ) -> bamboo_config::ProviderInstanceConfig {
16820 bamboo_config::ProviderInstanceConfig {
16821 provider_type: provider_type.to_string(),
16822 label: None,
16823 api_key: api_key.to_string(),
16824 api_key_encrypted: None,
16825 credential_ref: None,
16826 base_url: None,
16827 model: None,
16828 fast_model: None,
16829 vision_model: None,
16830 reasoning_effort: None,
16831 responses_only_models: Vec::new(),
16832 request_overrides: None,
16833 enabled: true,
16834 extra: Default::default(),
16835 }
16836 }
16837
16838 let mut initial = bamboo_llm::Config::default();
16839 initial.provider_instances.insert(
16840 "old-provider".to_string(),
16841 provider_instance("openai", "old-key"),
16842 );
16843 initial.default_provider_instance = Some("old-provider".to_string());
16844 let live = Arc::new(tokio::sync::RwLock::new(initial));
16845
16846 let runner = ActorChildRunner::new(
16847 "echo-live-config-test".to_string(),
16848 PathBuf::from("/bin/false"),
16849 Vec::new(),
16850 std::env::temp_dir().join("bamboo-echo-live-config"),
16851 ExecutorSpec::Echo,
16852 Vec::new(),
16853 "stale-provider".to_string(),
16854 1,
16855 )
16856 .with_live_provider_config(live.clone());
16857 let job = crate::runtime::execution::SpawnJob {
16858 parent_session_id: "parent".to_string(),
16859 child_session_id: "child".to_string(),
16860 model: "model".to_string(),
16861 disabled_tools: None,
16862 };
16863
16864 let first = runner
16865 .build_live_spec(&Session::new("child", "model"), &job)
16866 .await;
16867 assert_eq!(
16868 first.model.as_ref().map(|model| model.provider.as_str()),
16869 Some("old-provider")
16870 );
16871 assert_eq!(first.secrets.provider_credentials.len(), 1);
16872 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16873
16874 let mut reloaded = bamboo_llm::Config::default();
16875 reloaded.provider_instances.insert(
16876 "new-provider".to_string(),
16877 provider_instance("openai", "new-key"),
16878 );
16879 reloaded.default_provider_instance = Some("new-provider".to_string());
16880 *live.write().await = reloaded;
16881
16882 let second = runner
16883 .build_live_spec(&Session::new("child", "model"), &job)
16884 .await;
16885 assert_eq!(
16886 second.model.as_ref().map(|model| model.provider.as_str()),
16887 Some("new-provider")
16888 );
16889 assert_eq!(second.secrets.provider_credentials.len(), 1);
16890 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16891 }
16892
16893 #[test]
16894 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16895 let mut executor = codex_executor(Some("custom"), None);
16896 if let ExecutorSpec::Codex {
16897 base_url,
16898 provider_key_ref,
16899 ..
16900 } = &mut executor
16901 {
16902 *base_url = Some("https://provider.example/v1".to_string());
16903 *provider_key_ref = Some("provider.custom.api_key".to_string());
16904 }
16905 let credentials = vec![
16906 ScopedCredential {
16907 provider: "openai".to_string(),
16908 api_key: "session-provider-secret".to_string(),
16909 base_url: None,
16910 provider_type: Some("openai".to_string()),
16911 credential_ref: Some("provider.openai.api_key".to_string()),
16912 },
16913 ScopedCredential {
16914 provider: "custom".to_string(),
16915 api_key: "selected-secret".to_string(),
16916 base_url: None,
16917 provider_type: Some("openai".to_string()),
16918 credential_ref: Some("provider.custom.api_key".to_string()),
16919 },
16920 ScopedCredential {
16921 provider: "other".to_string(),
16922 api_key: "unrelated-secret".to_string(),
16923 base_url: None,
16924 provider_type: Some("openai".to_string()),
16925 credential_ref: Some("provider.other.api_key".to_string()),
16926 },
16927 ];
16928 let runner = ActorChildRunner::new(
16929 "codex-test".to_string(),
16930 PathBuf::from("/bin/false"),
16931 Vec::new(),
16932 std::env::temp_dir().join("bamboo-codex-570"),
16933 executor,
16934 credentials,
16935 "openai".to_string(),
16936 1,
16937 );
16938 let mut session = Session::new("child-custom", "model");
16939 session.add_message(bamboo_agent_core::Message::user("test"));
16940 let spec = runner.build_spec(
16941 &session,
16942 &crate::runtime::execution::SpawnJob {
16943 parent_session_id: "parent".to_string(),
16944 child_session_id: "child-custom".to_string(),
16945 model: "gpt-5.4".to_string(),
16946 disabled_tools: None,
16947 },
16948 );
16949
16950 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16951 assert_eq!(
16952 spec.secrets.provider_credentials[0]
16953 .credential_ref
16954 .as_deref(),
16955 Some("provider.custom.api_key")
16956 );
16957 assert_eq!(
16958 spec.secrets.provider_credentials[0].api_key,
16959 "selected-secret"
16960 );
16961 }
16962
16963 fn spec_with(
16964 role: &str,
16965 provider: &str,
16966 model: &str,
16967 workspace: Option<&str>,
16968 disabled: Option<Vec<&str>>,
16969 ) -> ProvisionSpec {
16970 let mut spec = ProvisionSpec::new(
16971 ChildIdentity {
16972 child_id: "c".into(),
16973 parent_id: None,
16974 project_key: None,
16975 role: role.into(),
16976 depth: 0,
16977 },
16978 ExecutorSpec::Echo,
16979 "/tmp/fab".into(),
16980 );
16981 spec.workspace = workspace.map(|w| w.to_string());
16982 spec.model = Some(ModelRefSpec {
16983 provider: provider.into(),
16984 model: model.into(),
16985 });
16986 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16987 spec
16988 }
16989
16990 #[test]
16991 fn fingerprint_matches_interchangeable_children() {
16992 let a = spec_with(
16995 "explorer",
16996 "p",
16997 "m",
16998 Some("/ws"),
16999 Some(vec!["Bash", "Edit"]),
17000 );
17001 let mut b = spec_with(
17002 "explorer",
17003 "p",
17004 "m",
17005 Some("/ws"),
17006 Some(vec!["Edit", "Bash"]),
17007 );
17008 b.identity.child_id = "other".into();
17009 assert_eq!(
17010 ActorChildRunner::fingerprint(&a),
17011 ActorChildRunner::fingerprint(&b)
17012 );
17013 }
17014
17015 #[test]
17016 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
17017 let mut session =
17018 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
17019 session.root_session_id = "logical-root-681".to_string();
17020 let job = SpawnJob {
17021 parent_session_id: "logical-parent-681".to_string(),
17022 child_session_id: "logical-child-681".to_string(),
17023 model: "model".to_string(),
17024 disabled_tools: None,
17025 };
17026 let expected = LogicalSessionIdentity {
17027 creation: None,
17028 session_id: "logical-child-681".to_string(),
17029 parent_session_id: Some("logical-parent-681".to_string()),
17030 root_session_id: "logical-root-681".to_string(),
17031 };
17032
17033 let placements_and_transport_ids = [
17034 (Placement::Local, "local-mailbox-first"),
17035 (
17036 Placement::Remote {
17037 endpoint: "wss://remote.example/actor".to_string(),
17038 },
17039 "remote-process-44",
17040 ),
17041 (
17042 Placement::Schedulable {
17043 pool: "gpu-pool".to_string(),
17044 },
17045 "scheduled-mailbox-9",
17046 ),
17047 (Placement::Local, "warm-mailbox-reused-77"),
17049 ];
17050 for (placement, transport_id) in placements_and_transport_ids {
17051 let mut provision = spec_with("worker", "provider", "model", None, None);
17052 provision.placement = placement;
17053 provision.identity.child_id = transport_id.to_string();
17054 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17055 assert_ne!(
17056 provision.identity.child_id, expected.session_id,
17057 "test fixture must prove transport identity is independent"
17058 );
17059 }
17060 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
17061 original.executor = ExecutorSpec::BambooRuntime;
17062 original.identity.depth = session.spawn_depth;
17063 original.capabilities.required_child_context = true;
17064 original.capabilities.child_creation_identity = true;
17065 original.capabilities.enforce_permissions = true;
17066 original.capabilities.native_tool_ceiling_required = true;
17067 original.capabilities.native_tool_ceiling =
17068 Some(bamboo_subagent::proto::NativeToolCeiling {
17069 version: 1,
17070 child_session_id: session.id.clone(),
17071 parent_session_id: job.parent_session_id.clone(),
17072 root_session_id: session.root_session_id.clone(),
17073 created_at: session.created_at,
17074 spawn_depth: session.spawn_depth,
17075 project_id: None,
17076 tools: vec![],
17077 });
17078 original.validate().unwrap();
17079 let first = ActorChildRunner::fresh_required_worker_spec(&original);
17080 let second = ActorChildRunner::fresh_required_worker_spec(&original);
17081 assert_ne!(first.identity.child_id, second.identity.child_id);
17082 assert_ne!(first.identity.child_id, original.identity.child_id);
17083 for physical in [first, second] {
17084 physical.validate().unwrap();
17085 let mut actual = serde_json::to_value(&physical).unwrap();
17086 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
17087 assert_eq!(actual, serde_json::to_value(&original).unwrap());
17088 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17089 }
17090 }
17091
17092 #[test]
17093 fn fingerprint_separates_distinct_runtimes() {
17094 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
17095 let base_fp = ActorChildRunner::fingerprint(&base);
17096 assert_ne!(
17098 base_fp,
17099 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
17100 );
17101 assert_ne!(
17102 base_fp,
17103 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
17104 );
17105 assert_ne!(
17106 base_fp,
17107 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
17108 );
17109 assert_ne!(
17110 base_fp,
17111 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17112 );
17113 assert_ne!(
17114 base_fp,
17115 ActorChildRunner::fingerprint(&spec_with(
17116 "explorer",
17117 "p",
17118 "m",
17119 Some("/ws"),
17120 Some(vec!["Bash"])
17121 ))
17122 );
17123 }
17124
17125 #[test]
17126 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17127 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17128 before.secrets.provider_credentials.push(ScopedCredential {
17129 provider: "provider".to_string(),
17130 api_key: "old-secret-key".to_string(),
17131 base_url: Some("https://provider.example/v1".to_string()),
17132 provider_type: Some("openai".to_string()),
17133 credential_ref: None,
17134 });
17135 let mut after = before.clone();
17136 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17137
17138 let before_fingerprint = ActorChildRunner::fingerprint(&before);
17139 let after_fingerprint = ActorChildRunner::fingerprint(&after);
17140 assert_ne!(before_fingerprint, after_fingerprint);
17141 assert!(!before_fingerprint.contains("old-secret-key"));
17142 assert!(!after_fingerprint.contains("new-secret-key"));
17143 }
17144
17145 #[test]
17146 fn fingerprint_splits_on_baked_capabilities() {
17147 let base_fp =
17152 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17153
17154 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17155 creation.capabilities.child_creation_identity = true;
17156 let typed = ActorChildRunner::fingerprint(&creation);
17157 assert_ne!(
17158 base_fp, typed,
17159 "legacy worker pool cannot satisfy birth-required Run"
17160 );
17161 creation.identity.child_id = "different-logical-child".into();
17162 assert_eq!(
17163 typed,
17164 ActorChildRunner::fingerprint(&creation),
17165 "birth-required mode retains cross-Child pool reuse"
17166 );
17167
17168 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17169 depth.identity.depth = 2;
17170 assert_ne!(
17171 base_fp,
17172 ActorChildRunner::fingerprint(&depth),
17173 "depth must split"
17174 );
17175
17176 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17177 nested.capabilities.nested_spawn = true;
17178 assert_ne!(
17179 base_fp,
17180 ActorChildRunner::fingerprint(&nested),
17181 "nested_spawn must split"
17182 );
17183
17184 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17185 bypass.capabilities.bypass = true;
17186 assert_ne!(
17187 base_fp,
17188 ActorChildRunner::fingerprint(&bypass),
17189 "bypass must split"
17190 );
17191
17192 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17193 auto.capabilities.auto_approve_permissions = true;
17194 assert_ne!(
17195 base_fp,
17196 ActorChildRunner::fingerprint(&auto),
17197 "auto_approve_permissions must split"
17198 );
17199
17200 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17201 global_auto.capabilities.permission_requested_mode = "default".to_string();
17202 global_auto.capabilities.permission_effective_mode = "auto".to_string();
17203 global_auto.capabilities.auto_approve_permissions = true;
17204 let mut explicit_auto = global_auto.clone();
17205 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17206 assert_ne!(
17207 ActorChildRunner::fingerprint(&global_auto),
17208 ActorChildRunner::fingerprint(&explicit_auto),
17209 "permission_requested_mode must split global and explicit Auto"
17210 );
17211
17212 let mut plan_overlay = explicit_auto.clone();
17213 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17214 assert_ne!(
17215 ActorChildRunner::fingerprint(&explicit_auto),
17216 ActorChildRunner::fingerprint(&plan_overlay),
17217 "permission_effective_mode must split Plan overlay from Auto"
17218 );
17219
17220 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17221 enforce.capabilities.enforce_permissions = true;
17222 assert_ne!(
17223 base_fp,
17224 ActorChildRunner::fingerprint(&enforce),
17225 "enforce_permissions must split"
17226 );
17227
17228 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17229 cap.capabilities.max_spawn_depth = Some(8);
17230 assert_ne!(
17231 base_fp,
17232 ActorChildRunner::fingerprint(&cap),
17233 "max_spawn_depth must split"
17234 );
17235
17236 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17240 nha.capabilities.no_human_approver = true;
17241 assert_ne!(
17242 base_fp,
17243 ActorChildRunner::fingerprint(&nha),
17244 "no_human_approver must split"
17245 );
17246
17247 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17250 read_only.capabilities.read_only = true;
17251 assert_ne!(
17252 base_fp,
17253 ActorChildRunner::fingerprint(&read_only),
17254 "read_only must split"
17255 );
17256
17257 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17259 gro.capabilities.guardian_read_only = true;
17260 assert_ne!(
17261 base_fp,
17262 ActorChildRunner::fingerprint(&gro),
17263 "guardian_read_only must split"
17264 );
17265 }
17266
17267 #[test]
17268 fn fingerprint_splits_codex_exec_and_app_server_workers() {
17269 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17270 exec.executor = codex_executor(Some("inherit"), None);
17271 let mut app_server = exec.clone();
17272 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17273 *mode = Some("app_server".to_string());
17274 }
17275 assert_ne!(
17276 ActorChildRunner::fingerprint(&exec),
17277 ActorChildRunner::fingerprint(&app_server)
17278 );
17279 }
17280
17281 struct StaticDecider(bool);
17282
17283 #[async_trait]
17284 impl ChildApprovalDecider for StaticDecider {
17285 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17286 self.0
17287 }
17288 }
17289
17290 struct RecordingReviewer {
17291 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17292 }
17293
17294 #[async_trait]
17295 impl ChildApprovalReviewer for RecordingReviewer {
17296 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17297 let _ = self
17298 .reviewed
17299 .send((parent.to_string(), child.to_string(), request.clone()));
17300 true
17301 }
17302 }
17303
17304 struct SilentLink;
17309 #[async_trait]
17310 impl bamboo_subagent::ChildLink for SilentLink {
17311 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17312 Ok(())
17313 }
17314 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17315 std::future::pending().await
17316 }
17317 }
17318
17319 struct InstantTerminalLink {
17321 done: bool,
17322 }
17323
17324 struct ApprovalRoundTripLink {
17325 step: u8,
17326 approval_reply: Option<(String, bool)>,
17327 }
17328
17329 #[async_trait]
17330 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17331 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17332 if let ParentFrame::ApprovalReply { id, approved } = frame {
17333 self.approval_reply = Some((id, approved));
17334 self.step = 2;
17335 }
17336 Ok(())
17337 }
17338
17339 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17340 match self.step {
17341 0 => {
17342 self.step = 1;
17343 Ok(Some(ChildFrame::ApprovalRequest {
17344 id: "approval-1".into(),
17345 body: serde_json::json!({
17346 "tool_name": "Bash",
17347 "permission": "execute",
17348 "resource": "rm -rf target",
17349 "permission_request": {"reason_code": "hard_dangerous"}
17350 }),
17351 }))
17352 }
17353 1 => std::future::pending().await,
17354 2 => {
17355 self.step = 3;
17356 Ok(Some(ChildFrame::Terminal {
17357 status: TerminalStatus::Completed,
17358 result: Some("done".into()),
17359 error: None,
17360 final_event_watermark: None,
17361 transcript: vec![],
17362 }))
17363 }
17364 _ => std::future::pending().await,
17365 }
17366 }
17367 }
17368
17369 #[tokio::test]
17370 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17371 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17372 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17373 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17374 reviewed: review_tx,
17375 });
17376 let cancel = CancellationToken::new();
17377 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17378 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17379 let mut logical_session = Session::new("child-reviewer", "model");
17380 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17381 let mut link = ApprovalRoundTripLink {
17382 step: 0,
17383 approval_reply: None,
17384 };
17385
17386 let result = tokio::time::timeout(
17387 Duration::from_secs(1),
17388 drive(ActorDriveContext {
17389 expected_creation: None,
17390 client: &mut link,
17391 parent_session_id: "parent-reviewer",
17392 child_session_id: "child-reviewer",
17393 child_attempt: 0,
17394 approval_registry: None,
17395 approval_decider: None,
17396 approval_reviewer: Some(&reviewer),
17397 escalation_bridge: None,
17398 event_tx: &event_tx,
17399 cancel_token: &cancel,
17400 live_rx: &mut live_rx,
17401 delivery_rx: &mut delivery_rx,
17402 logical_session: &mut logical_session,
17403 expected_permission_posture: None,
17404 session_inbox_runtime: None,
17405 actor_directory_store: None,
17406 canonical_subagent_tool: None,
17407 ticket_service: None,
17408 activation_run_id: None,
17409 execution_epoch: 0,
17410 expected_source_actor_id: "child-reviewer",
17411 initial_inflight_claims: VecDeque::new(),
17412 plain_actor: false,
17413 remote_environment_lease: false,
17414 readonly_output: None,
17415 local_history_tools: None,
17416 local_history_read_only: false,
17417 strict_history_output: None,
17418 plain_input: None,
17419 canonical_activation: None,
17420 canonical_placement_ref: None,
17421 actor_event_observer: None,
17422 plain_run: None,
17423 first_frame_timeout: None,
17424 }),
17425 )
17426 .await
17427 .expect("worker must receive the reviewer verdict before terminating");
17428
17429 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17430 assert_eq!(
17431 link.approval_reply,
17432 Some(("approval-1".to_string(), true)),
17433 "reviewer verdict must traverse the live route back to the worker"
17434 );
17435 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17436 .await
17437 .expect("reviewer should be invoked off-loop")
17438 .expect("review channel should remain open");
17439 assert_eq!(parent, "parent-reviewer");
17440 assert_eq!(child, "child-reviewer");
17441 assert_eq!(
17442 body.pointer("/permission_request/reason_code")
17443 .and_then(serde_json::Value::as_str),
17444 Some("hard_dangerous")
17445 );
17446 assert!(
17447 event_rx.try_recv().is_err(),
17448 "must not emit a human-review event"
17449 );
17450 drop(live_guard);
17451 }
17452
17453 #[tokio::test]
17454 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17455 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17456 let cancel = CancellationToken::new();
17457 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17458 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17459 let mut logical_session = Session::new("child-no-reviewer", "model");
17460 let mut link = ApprovalRoundTripLink {
17461 step: 0,
17462 approval_reply: None,
17463 };
17464
17465 let result = tokio::time::timeout(
17466 Duration::from_secs(1),
17467 drive(ActorDriveContext {
17468 expected_creation: None,
17469 client: &mut link,
17470 parent_session_id: "parent-no-reviewer",
17471 child_session_id: "child-no-reviewer",
17472 child_attempt: 0,
17473 approval_registry: None,
17474 approval_decider: None,
17475 approval_reviewer: None,
17476 escalation_bridge: None,
17477 event_tx: &event_tx,
17478 cancel_token: &cancel,
17479 live_rx: &mut live_rx,
17480 delivery_rx: &mut delivery_rx,
17481 logical_session: &mut logical_session,
17482 expected_permission_posture: None,
17483 session_inbox_runtime: None,
17484 actor_directory_store: None,
17485 canonical_subagent_tool: None,
17486 ticket_service: None,
17487 activation_run_id: None,
17488 execution_epoch: 0,
17489 expected_source_actor_id: "child-no-reviewer",
17490 initial_inflight_claims: VecDeque::new(),
17491 plain_actor: false,
17492 remote_environment_lease: false,
17493 readonly_output: None,
17494 local_history_tools: None,
17495 local_history_read_only: false,
17496 strict_history_output: None,
17497 plain_input: None,
17498 canonical_activation: None,
17499 canonical_placement_ref: None,
17500 actor_event_observer: None,
17501 plain_run: None,
17502 first_frame_timeout: None,
17503 }),
17504 )
17505 .await
17506 .expect("fail-closed reply must unblock the child immediately");
17507
17508 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17509 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17510 assert!(
17511 event_rx.try_recv().is_err(),
17512 "missing parent review must not surface a manual approval event"
17513 );
17514 }
17515 #[async_trait]
17516 impl bamboo_subagent::ChildLink for InstantTerminalLink {
17517 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17518 Ok(())
17519 }
17520 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17521 if self.done {
17522 std::future::pending().await
17523 } else {
17524 self.done = true;
17525 Ok(Some(ChildFrame::Terminal {
17526 status: TerminalStatus::Completed,
17527 result: Some("done".into()),
17528 error: None,
17529 final_event_watermark: None,
17530 transcript: vec![],
17531 }))
17532 }
17533 }
17534 }
17535
17536 #[tokio::test]
17537 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17538 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17539 let cancel = CancellationToken::new();
17540 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17541 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17542 let mut logical_session = Session::new("child-x", "model");
17543 let mut link = SilentLink;
17544 let r = drive(ActorDriveContext {
17545 expected_creation: None,
17546 client: &mut link,
17547 parent_session_id: "parent-x",
17548 child_session_id: "child-x",
17549 child_attempt: 0,
17550 approval_registry: None,
17551 approval_decider: None,
17552 approval_reviewer: None,
17553 escalation_bridge: None,
17554 event_tx: &event_tx,
17555 cancel_token: &cancel,
17556 live_rx: &mut live_rx,
17557 delivery_rx: &mut delivery_rx,
17558 logical_session: &mut logical_session,
17559 expected_permission_posture: None,
17560 session_inbox_runtime: None,
17561 actor_directory_store: None,
17562 canonical_subagent_tool: None,
17563 ticket_service: None,
17564 activation_run_id: None,
17565 execution_epoch: 0,
17566 expected_source_actor_id: "child-x",
17567 initial_inflight_claims: VecDeque::new(),
17568 plain_actor: false,
17569 remote_environment_lease: false,
17570 readonly_output: None,
17571 local_history_tools: None,
17572 local_history_read_only: false,
17573 strict_history_output: None,
17574 plain_input: None,
17575 canonical_activation: None,
17576 canonical_placement_ref: None,
17577 actor_event_observer: None,
17578 plain_run: None,
17579 first_frame_timeout: Some(Duration::from_millis(100)),
17580 })
17581 .await;
17582 assert!(
17583 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17584 "a silent worker must trip the first-frame watchdog, got {r:?}"
17585 );
17586 }
17587
17588 #[tokio::test]
17589 async fn drive_does_not_trip_when_a_frame_arrives() {
17590 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17591 let cancel = CancellationToken::new();
17592 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17593 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17594 let mut logical_session = Session::new("child-y", "model");
17595 let mut link = InstantTerminalLink { done: false };
17596 let r = drive(ActorDriveContext {
17599 expected_creation: None,
17600 client: &mut link,
17601 parent_session_id: "parent-y",
17602 child_session_id: "child-y",
17603 child_attempt: 0,
17604 approval_registry: None,
17605 approval_decider: None,
17606 approval_reviewer: None,
17607 escalation_bridge: None,
17608 event_tx: &event_tx,
17609 cancel_token: &cancel,
17610 live_rx: &mut live_rx,
17611 delivery_rx: &mut delivery_rx,
17612 logical_session: &mut logical_session,
17613 expected_permission_posture: None,
17614 session_inbox_runtime: None,
17615 actor_directory_store: None,
17616 canonical_subagent_tool: None,
17617 ticket_service: None,
17618 activation_run_id: None,
17619 execution_epoch: 0,
17620 expected_source_actor_id: "child-y",
17621 initial_inflight_claims: VecDeque::new(),
17622 plain_actor: false,
17623 remote_environment_lease: false,
17624 readonly_output: None,
17625 local_history_tools: None,
17626 local_history_read_only: false,
17627 strict_history_output: None,
17628 plain_input: None,
17629 canonical_activation: None,
17630 canonical_placement_ref: None,
17631 actor_event_observer: None,
17632 plain_run: None,
17633 first_frame_timeout: Some(Duration::from_millis(50)),
17634 })
17635 .await;
17636 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17637 }
17638
17639 #[tokio::test]
17640 async fn child_approval_fails_closed_without_decider() {
17641 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17643 assert!(!decide_child_approval(None, "child-1", &body).await);
17644 }
17645
17646 #[tokio::test]
17647 async fn child_approval_honors_wired_decider() {
17648 let body =
17649 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17650 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17651 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17652 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17653 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17654 }
17655
17656 use crate::runtime::execution::SpawnJob;
17659 use bamboo_agent_core::Session;
17660
17661 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17664 ActorChildRunner::new(
17665 "test-actor".into(),
17666 PathBuf::from("/bin/false"),
17667 vec![],
17668 std::env::temp_dir().join("bamboo-test-fab-193"),
17669 ExecutorSpec::Echo,
17670 vec![],
17671 "anthropic".into(),
17672 4,
17673 )
17674 .with_remote_placements(placements)
17675 }
17676
17677 fn session_of_role(role: &str, assignment: &str) -> Session {
17680 let mut s = Session::new("child-1", "test-model");
17681 s.metadata
17682 .insert("subagent_type".to_string(), role.to_string());
17683 s.add_message(bamboo_agent_core::Message::user(assignment));
17684 s
17685 }
17686
17687 fn job_for(child: &str) -> SpawnJob {
17688 SpawnJob {
17689 parent_session_id: "parent-1".into(),
17690 child_session_id: child.into(),
17691 model: String::new(),
17692 disabled_tools: None,
17693 }
17694 }
17695
17696 #[tokio::test]
17697 async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17698 let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17699 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17700 let mut remote = HashMap::new();
17701 remote.insert(
17702 "remote-role".into(),
17703 ResolvedRemotePlacement {
17704 endpoint: "wss://worker.example:8443".into(),
17705 token: None,
17706 ca_cert_file: None,
17707 host_label: None,
17708 broker_peer: None,
17709 requirements: None,
17710 },
17711 );
17712 let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17713 let root = Session::new("provision-root", "model");
17714 for role in ["local-role", "remote-role"] {
17715 let mut child =
17716 Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17717 child.metadata.insert("subagent_type".into(), role.into());
17718 let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17719 assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17720 assert!(!first.capabilities.nested_spawn);
17721 assert_eq!(
17722 matches!(first.placement, Placement::Remote { .. }),
17723 role == "remote-role"
17724 );
17725 live.write().await.subagents_mut().max_spawn_depth = Some(7);
17726 let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17727 assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17728 assert!(second.capabilities.nested_spawn);
17729 assert_ne!(
17730 ActorChildRunner::fingerprint(&first),
17731 ActorChildRunner::fingerprint(&second)
17732 );
17733 assert_eq!(second.identity.depth, 1);
17734 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17735 }
17736 let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17737 let spec = fallback.build_spec(&root, &job_for(&root.id));
17738 assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17739 assert!(!spec.capabilities.nested_spawn);
17740 }
17741
17742 struct RecordingChildSessionPort {
17743 parent: Session,
17744 saved: std::sync::Mutex<Option<Session>>,
17745 }
17746
17747 impl RecordingChildSessionPort {
17748 fn saved_child(&self) -> Session {
17749 self.saved
17750 .lock()
17751 .expect("saved-child fixture lock")
17752 .clone()
17753 .expect("create_child_action must save the child")
17754 }
17755 }
17756
17757 #[async_trait]
17758 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17759 async fn load_root_session(
17760 &self,
17761 root_id: &str,
17762 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17763 assert_eq!(root_id, self.parent.id);
17764 Ok(self.parent.clone())
17765 }
17766
17767 async fn load_child_for_parent(
17768 &self,
17769 _parent_id: &str,
17770 _child_id: &str,
17771 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17772 unreachable!("create_child_action does not reload the child")
17773 }
17774
17775 async fn save_child_session(
17776 &self,
17777 child: &mut Session,
17778 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17779 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17780 Ok(())
17781 }
17782
17783 async fn save_child_session_authoritative_flags(
17784 &self,
17785 _child: &mut Session,
17786 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17787 unreachable!("new-child creation uses the ordinary save")
17788 }
17789
17790 async fn is_child_running(&self, _child_id: &str) -> bool {
17791 false
17792 }
17793
17794 async fn list_children(
17795 &self,
17796 _parent_id: &str,
17797 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17798 Vec::new()
17799 }
17800
17801 async fn enqueue_child_run(
17802 &self,
17803 _parent: &Session,
17804 _child: &Session,
17805 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17806 unreachable!("fixture creates the child with auto_run=false")
17807 }
17808
17809 async fn cancel_child_run_and_wait(
17810 &self,
17811 _child_id: &str,
17812 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17813 unreachable!("create_child_action does not cancel")
17814 }
17815
17816 async fn delete_child_session(
17817 &self,
17818 _parent_id: &str,
17819 _child_id: &str,
17820 ) -> Result<
17821 crate::session_app::child_session::DeleteChildResult,
17822 crate::session_app::child_session::ChildSessionError,
17823 > {
17824 unreachable!("create_child_action does not delete")
17825 }
17826
17827 async fn get_child_runner_info(
17828 &self,
17829 _child_id: &str,
17830 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17831 None
17832 }
17833
17834 async fn register_parent_wait_for_child(
17835 &self,
17836 _parent_session_id: &str,
17837 _child_session_id: &str,
17838 _tool_call_id: Option<&str>,
17839 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17840 unreachable!("create_child_action does not register a wait")
17841 }
17842
17843 async fn register_parent_wait_for_children(
17844 &self,
17845 _parent_session_id: &str,
17846 _child_session_ids: &[String],
17847 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17848 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17849 unreachable!("create_child_action does not register a wait")
17850 }
17851
17852 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17853 Vec::new()
17854 }
17855
17856 async fn find_resident_child(
17857 &self,
17858 _root_session_id: &str,
17859 _resident_name: &str,
17860 ) -> Option<String> {
17861 None
17862 }
17863
17864 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17865 }
17866
17867 #[test]
17868 fn build_spec_sets_remote_placement_for_matching_role() {
17869 let mut placements = HashMap::new();
17870 placements.insert(
17871 "explorer".to_string(),
17872 ResolvedRemotePlacement {
17873 endpoint: "wss://gpu-host:8443".into(),
17874 token: Some("T-secret".into()),
17875 ca_cert_file: None,
17876 host_label: None,
17877 broker_peer: None,
17878 requirements: None,
17879 },
17880 );
17881 let runner = bogus_runner(placements);
17882
17883 let s = session_of_role("explorer", "do the thing");
17885 let spec = runner.build_spec(&s, &job_for("child-1"));
17886 match &spec.placement {
17887 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17888 other => panic!("expected Remote, got {other:?}"),
17889 }
17890 assert!(spec.secrets.worker_auth_token.is_none());
17891 }
17892
17893 #[test]
17894 fn build_spec_leaves_local_for_unmatched_role() {
17895 let mut placements = HashMap::new();
17896 placements.insert(
17897 "explorer".to_string(),
17898 ResolvedRemotePlacement {
17899 endpoint: "wss://gpu-host:8443".into(),
17900 token: Some("T".into()),
17901 ca_cert_file: None,
17902 host_label: None,
17903 broker_peer: None,
17904 requirements: None,
17905 },
17906 );
17907 let runner = bogus_runner(placements);
17908
17909 let s = session_of_role("writer", "do the thing");
17911 let spec = runner.build_spec(&s, &job_for("child-1"));
17912 assert_eq!(spec.placement, Placement::Local);
17913 assert!(spec.secrets.worker_auth_token.is_none());
17914 }
17915
17916 #[test]
17917 fn build_spec_local_when_no_placements() {
17918 let runner = bogus_runner(HashMap::new());
17919 let s = session_of_role("explorer", "do the thing");
17920 let spec = runner.build_spec(&s, &job_for("child-1"));
17921 assert_eq!(spec.placement, Placement::Local);
17922 assert!(spec.secrets.worker_auth_token.is_none());
17923 }
17924
17925 #[test]
17926 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17927 for mode in [
17928 bamboo_domain::SessionPermissionMode::Auto,
17929 bamboo_domain::SessionPermissionMode::Bypass,
17930 ] {
17931 let runner = bogus_runner(HashMap::new());
17932 let mut child = session_of_role("planner", "inspect and plan");
17933 let runtime = child
17934 .agent_runtime_state
17935 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17936 runtime.set_permission_mode(mode);
17937 runtime.read_only = true;
17938
17939 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17940
17941 assert!(spec.capabilities.read_only);
17942 assert!(spec.capabilities.read_only_enforced());
17943 assert!(
17944 spec.capabilities.guardian_read_only,
17945 "the compatibility alias must protect planners on older workers"
17946 );
17947 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17948 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17949 assert!(
17950 !spec.capabilities.bypass,
17951 "read-only must win over requested {mode:?}"
17952 );
17953 assert_eq!(
17954 spec.capabilities.auto_approve_permissions,
17955 mode == bamboo_domain::SessionPermissionMode::Auto,
17956 "Auto may suppress prompts but must not widen Plan authorization"
17957 );
17958 let disabled = spec
17959 .disabled_tools
17960 .as_ref()
17961 .expect("read-only state must provision a host-owned denylist");
17962 for tool_name in [
17963 "Edit",
17964 "Write",
17965 "Plan",
17966 "SubAgent",
17967 "session_control",
17968 "request_permissions",
17969 ] {
17970 assert!(
17971 disabled.iter().any(|name| name == tool_name),
17972 "read-only provisioning must disable {tool_name}"
17973 );
17974 }
17975 }
17976
17977 let runner = bogus_runner(HashMap::new());
17978 let cosmetic_planner = runner.build_spec(
17979 &session_of_role("planner", "label only"),
17980 &job_for("cosmetic"),
17981 );
17982 assert!(
17983 !cosmetic_planner.capabilities.read_only_enforced(),
17984 "a cosmetic role label must not change authority"
17985 );
17986 assert!(cosmetic_planner.disabled_tools.is_none());
17987
17988 let cosmetic_guardian = runner.build_spec(
17989 &session_of_role("guardian", "label only"),
17990 &job_for("cosmetic-guardian"),
17991 );
17992 assert!(
17993 !cosmetic_guardian.capabilities.read_only_enforced(),
17994 "a Guardian label without typed state or the legacy denylist is cosmetic"
17995 );
17996
17997 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17998 let mut legacy_job = job_for("legacy-guardian");
17999 legacy_job.disabled_tools = Some(vec![
18000 "Edit".to_string(),
18001 "Write".to_string(),
18002 "SubAgent".to_string(),
18003 "WebFetch".to_string(),
18004 ]);
18005 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
18006 assert!(
18007 legacy_spec.capabilities.read_only_enforced(),
18008 "an in-flight pre-upgrade Guardian keeps its read-only authority"
18009 );
18010
18011 let mut differently_named = session_of_role("explorer", "typed authority");
18012 differently_named
18013 .agent_runtime_state
18014 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18015 .read_only = true;
18016 let typed = runner.build_spec(&differently_named, &job_for("typed"));
18017 assert!(
18018 typed.capabilities.read_only_enforced(),
18019 "typed state must enforce read-only independently of role"
18020 );
18021 }
18022
18023 #[test]
18024 fn typed_read_only_forces_native_external_executor_profiles() {
18025 let mut planner = session_of_role("planner", "inspect and plan");
18026 planner
18027 .agent_runtime_state
18028 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18029 .read_only = true;
18030
18031 let claude_runner = ActorChildRunner::new(
18032 "test-actor".into(),
18033 PathBuf::from("/bin/false"),
18034 vec![],
18035 std::env::temp_dir().join("bamboo-test-plan-claude"),
18036 ExecutorSpec::ClaudeCode {
18037 binary: None,
18038 model: None,
18039 permission_mode: Some("bypassPermissions".to_string()),
18040 inherit_user_config: Some(true),
18041 forward_env: None,
18042 },
18043 vec![],
18044 "anthropic".into(),
18045 4,
18046 );
18047 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
18048 let disabled = claude_spec
18049 .disabled_tools
18050 .as_ref()
18051 .expect("typed read-only child must receive a host-owned denylist");
18052 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
18053 assert!(
18054 disabled.iter().any(|name| name == tool),
18055 "typed read-only child must not advertise {tool}"
18056 );
18057 }
18058 assert!(matches!(
18059 claude_spec.executor,
18060 ExecutorSpec::ClaudeCode {
18061 permission_mode: Some(ref mode),
18062 inherit_user_config: Some(false),
18063 ..
18064 } if mode == "plan"
18065 ));
18066 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
18067
18068 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
18069 if let ExecutorSpec::Codex {
18070 sandbox,
18071 approval_policy,
18072 network_access,
18073 allow_danger_bypass,
18074 permission_profile,
18075 ..
18076 } = &mut codex_exec
18077 {
18078 *sandbox = Some("danger-full-access".to_string());
18079 *approval_policy = Some("on-failure".to_string());
18080 *network_access = Some(true);
18081 *allow_danger_bypass = Some(true);
18082 *permission_profile = Some("full-access".to_string());
18083 }
18084 let codex_runner = ActorChildRunner::new(
18085 "test-actor".into(),
18086 PathBuf::from("/bin/false"),
18087 vec![],
18088 std::env::temp_dir().join("bamboo-test-plan-codex"),
18089 codex_exec,
18090 vec![],
18091 "openai".into(),
18092 4,
18093 );
18094 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
18095 assert_eq!(
18096 expected_permission_executor_mapping(
18097 &codex_spec.executor,
18098 codex_spec.capabilities.permission_resolution().unwrap(),
18099 false,
18100 )
18101 .unwrap()
18102 .as_deref(),
18103 Some("codex_exec:approval_policy=never")
18104 );
18105 assert!(matches!(
18106 codex_spec.executor,
18107 ExecutorSpec::Codex {
18108 sandbox: Some(ref sandbox),
18109 inherit_user_config: Some(false),
18110 approval_policy: Some(ref approval_policy),
18111 network_access: Some(false),
18112 allow_danger_bypass: Some(false),
18113 permission_profile: Some(ref profile),
18114 ..
18115 } if sandbox == "read-only"
18116 && approval_policy == "never"
18117 && profile == "read-only"
18118 ));
18119
18120 let mut app_server = codex_executor(Some("inherit"), Some(true));
18121 if let ExecutorSpec::Codex {
18122 mode,
18123 sandbox,
18124 approval_policy,
18125 network_access,
18126 allow_danger_bypass,
18127 permission_profile,
18128 ..
18129 } = &mut app_server
18130 {
18131 *mode = Some("app_server".to_string());
18132 *sandbox = Some("danger-full-access".to_string());
18133 *approval_policy = Some("on-request".to_string());
18134 *network_access = Some(true);
18135 *allow_danger_bypass = Some(true);
18136 *permission_profile = Some("full-access".to_string());
18137 }
18138 let app_server_runner = ActorChildRunner::new(
18139 "test-actor".into(),
18140 PathBuf::from("/bin/false"),
18141 vec![],
18142 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18143 app_server,
18144 vec![],
18145 "openai".into(),
18146 4,
18147 );
18148 let app_server_spec =
18149 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18150 assert_eq!(
18151 expected_permission_executor_mapping(
18152 &app_server_spec.executor,
18153 app_server_spec
18154 .capabilities
18155 .permission_resolution()
18156 .unwrap(),
18157 false,
18158 )
18159 .unwrap()
18160 .as_deref(),
18161 Some("codex_app_server:approvalPolicy=never")
18162 );
18163 assert!(matches!(
18164 app_server_spec.executor,
18165 ExecutorSpec::Codex {
18166 mode: Some(ref mode),
18167 sandbox: Some(ref sandbox),
18168 inherit_user_config: Some(false),
18169 approval_policy: Some(ref approval_policy),
18170 network_access: Some(false),
18171 allow_danger_bypass: Some(false),
18172 permission_profile: Some(ref profile),
18173 ..
18174 } if mode == "app_server"
18175 && sandbox == "read-only"
18176 && approval_policy == "on-request"
18177 && profile == "read-only"
18178 ));
18179 }
18180
18181 #[test]
18182 fn typed_read_only_rejects_both_codex_executor_modes() {
18183 let mut planner = session_of_role("planner", "inspect and plan");
18184 planner
18185 .agent_runtime_state
18186 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18187 .read_only = true;
18188
18189 for mode in ["exec", "app_server"] {
18190 let mut executor = codex_executor(Some("inherit"), Some(true));
18191 if let ExecutorSpec::Codex {
18192 mode: configured_mode,
18193 ..
18194 } = &mut executor
18195 {
18196 *configured_mode = Some(mode.to_string());
18197 }
18198 let runner = ActorChildRunner::new(
18199 "test-actor".into(),
18200 PathBuf::from("/bin/false"),
18201 vec![],
18202 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18203 executor,
18204 vec![],
18205 "openai".into(),
18206 4,
18207 );
18208 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18209 let error = ensure_read_only_activation_is_enforceable(&spec)
18210 .expect_err("Codex command execution must fail closed for read-only activations");
18211 let message = error.to_string();
18212 assert!(message.contains("Codex executor"), "{message}");
18213 assert!(message.contains("command execution"), "{message}");
18214 }
18215 }
18216
18217 #[tokio::test]
18218 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18219 let mut planner = session_of_role("planner", "inspect and plan");
18220 planner
18221 .agent_runtime_state
18222 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18223 .read_only = true;
18224
18225 let runner = ActorChildRunner::new(
18226 "test-actor".into(),
18227 PathBuf::from("/bin/false"),
18228 vec![],
18229 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18230 codex_executor(Some("inherit"), Some(true)),
18231 vec![],
18232 "openai".into(),
18233 4,
18234 );
18235 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18236 let error = runner
18237 .execute_external_child(
18238 &mut planner,
18239 &job_for("planner-codex-no-spawn"),
18240 event_tx,
18241 CancellationToken::new(),
18242 )
18243 .await
18244 .expect_err("typed read-only Codex must fail before invoking /bin/false");
18245 let message = error.to_string();
18246 assert!(message.contains("Codex executor"), "{message}");
18247 assert!(message.contains("command execution"), "{message}");
18248 }
18249
18250 #[test]
18251 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18252 let runner = bogus_runner(HashMap::new());
18253 let ordinary = session_of_role("worker", "ordinary work");
18254 for placement in [
18255 Placement::Local,
18256 Placement::Remote {
18257 endpoint: "wss://fixture.invalid/actor".into(),
18258 },
18259 Placement::Schedulable {
18260 pool: "fixture-pool".into(),
18261 },
18262 ] {
18263 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18264 spec.placement = placement;
18265 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18266 for marker in [
18267 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18268 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18269 ] {
18270 let mut ticket = ordinary.clone();
18271 ticket.metadata.insert(marker.into(), "fixture".into());
18272 assert_eq!(
18273 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18274 matches!(spec.placement, Placement::Local)
18275 );
18276 }
18277 }
18278 }
18279
18280 #[tokio::test]
18281 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18282 for marker in [
18283 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18284 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18285 ] {
18286 for schedulable in [false, true] {
18287 let mut session = session_of_role("worker", "Ticket work");
18288 session.metadata.insert(marker.into(), "fixture".into());
18289 let before = serde_json::to_value(&session).unwrap();
18290 let remote = if schedulable {
18291 HashMap::new()
18292 } else {
18293 HashMap::from([(
18294 "worker".into(),
18295 ResolvedRemotePlacement {
18296 endpoint: "wss://fixture.invalid/actor".into(),
18297 ..Default::default()
18298 },
18299 )])
18300 };
18301 let sched = if schedulable {
18302 HashMap::from([(
18303 "worker".into(),
18304 sched_placement("fixture-pool", "wss://fixture.invalid"),
18305 )])
18306 } else {
18307 HashMap::new()
18308 };
18309 let runner = bogus_sched_runner(remote, sched);
18310 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18311 let error = runner
18312 .execute_external_child(
18313 &mut session,
18314 &job_for("ticket-no-remote"),
18315 events,
18316 CancellationToken::new(),
18317 )
18318 .await
18319 .unwrap_err();
18320 assert!(
18321 error
18322 .to_string()
18323 .contains("Ticket execution requires local placement"),
18324 "{error}"
18325 );
18326 assert_eq!(serde_json::to_value(&session).unwrap(), before);
18327 assert!(observations.try_recv().is_err());
18328 }
18329 }
18330 }
18331
18332 #[test]
18333 fn typed_read_only_rejects_resident_actor_placements() {
18334 let runner = bogus_runner(HashMap::new());
18335 let mut planner = session_of_role("planner", "inspect and plan");
18336 planner
18337 .agent_runtime_state
18338 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18339 .read_only = true;
18340
18341 for placement in [
18342 Placement::Remote {
18343 endpoint: "wss://resident.example/actor".to_string(),
18344 },
18345 Placement::Schedulable {
18346 pool: "resident-pool".to_string(),
18347 },
18348 ] {
18349 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18350 spec.placement = placement;
18351
18352 let error = ensure_read_only_activation_is_enforceable(&spec)
18353 .expect_err("resident placement must fail closed for read-only activation");
18354 let message = error.to_string();
18355 assert!(message.contains("requires local placement"), "{message}");
18356 assert!(message.contains("read-only tool boundary"), "{message}");
18357 }
18358
18359 let local = runner.build_spec(&planner, &job_for("read-only-local"));
18360 ensure_read_only_activation_is_enforceable(&local)
18361 .expect("a locally provisioned read-only worker is enforceable");
18362 }
18363
18364 #[tokio::test]
18365 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18366 for (label, mode) in [
18370 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18371 ("auto", bamboo_domain::SessionPermissionMode::Auto),
18372 ] {
18373 let runner = bogus_runner(HashMap::new());
18374 let mut parent = Session::new(format!("parent-{label}"), "test-model");
18375 parent
18376 .agent_runtime_state
18377 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18378 .set_permission_mode(mode);
18379 let workspace = tempfile::tempdir().expect("workspace fixture");
18380 let port = RecordingChildSessionPort {
18381 parent: parent.clone(),
18382 saved: Default::default(),
18383 };
18384 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18385 crate::session_app::child_session::create_child_action(
18386 &port,
18387 crate::session_app::child_session::CreateChildInput {
18388 parent_session: parent,
18389 child_id: child_id.clone(),
18390 title: format!("{label} child"),
18391 responsibility: "Run ordinary commands".to_string(),
18392 assignment_prompt: "run an ordinary command".to_string(),
18393 subagent_type: "explorer".to_string(),
18394 workspace: workspace.path().to_string_lossy().into_owned(),
18395 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18396 model_override: None,
18397 model_ref_override: None,
18398 runtime_metadata: HashMap::new(),
18399 read_only: false,
18400 auto_run: false,
18401 reasoning_effort: None,
18402 lifecycle: None,
18403 resident_name: None,
18404 resident_context: None,
18405 disabled_tools: None,
18406 context_fork: None,
18407 },
18408 )
18409 .await
18410 .expect("create inherited-permission child");
18411 let child = port.saved_child();
18412
18413 assert_eq!(
18414 child
18415 .agent_runtime_state
18416 .as_ref()
18417 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18418 Some(mode),
18419 "create_child_action must inherit {label} from the parent"
18420 );
18421
18422 let spec = runner.build_spec(&child, &job_for(&child_id));
18423
18424 assert_eq!(
18425 spec.capabilities.bypass,
18426 mode == bamboo_domain::SessionPermissionMode::Bypass
18427 );
18428 assert_eq!(
18429 spec.capabilities.auto_approve_permissions,
18430 mode == bamboo_domain::SessionPermissionMode::Auto
18431 );
18432 assert!(
18433 spec.capabilities.enforce_permissions,
18434 "policy evaluation must remain active under {label}"
18435 );
18436 }
18437 }
18438
18439 #[tokio::test]
18440 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18441 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18442 let workspace = tempfile::tempdir().expect("workspace fixture");
18443
18444 for (role, lifecycle, resident_name) in [
18445 ("explorer", None, None),
18446 ("resident", Some("resident"), Some("stable-reviewer")),
18447 ("guardian", None, None),
18448 ] {
18449 let mut parent = Session::new(format!("parent-{role}"), "test-model");
18450 parent.set_project_id_meta(project_id.to_string());
18451 let port = RecordingChildSessionPort {
18452 parent: parent.clone(),
18453 saved: Default::default(),
18454 };
18455 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18456 crate::session_app::child_session::create_child_action(
18457 &port,
18458 crate::session_app::child_session::CreateChildInput {
18459 parent_session: parent,
18460 child_id: child_id.clone(),
18461 title: format!("{role} child"),
18462 responsibility: "Review the assigned work".to_string(),
18463 assignment_prompt: "inspect the change".to_string(),
18464 subagent_type: role.to_string(),
18465 workspace: workspace.path().to_string_lossy().into_owned(),
18466 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18467 model_override: None,
18468 model_ref_override: None,
18469 runtime_metadata: HashMap::new(),
18470 read_only: false,
18471 auto_run: false,
18472 reasoning_effort: None,
18473 lifecycle: lifecycle.map(str::to_string),
18474 resident_name: resident_name.map(str::to_string),
18475 resident_context: None,
18476 disabled_tools: None,
18477 context_fork: None,
18478 },
18479 )
18480 .await
18481 .expect("create Project-inheriting child");
18482 let child = port.saved_child();
18483 assert_eq!(
18484 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18485 Some(project_id.clone()),
18486 "{role} child must inherit its parent's Project"
18487 );
18488
18489 assert_eq!(
18490 project_id_for_actor_run(&child).expect("valid actor Project identity"),
18491 Some(project_id.clone()),
18492 "{role} actor RunSpec must preserve inherited Project identity"
18493 );
18494 }
18495 }
18496
18497 #[test]
18498 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18499 assert_eq!(placement_metadata(&Placement::Local, None), None);
18501
18502 let r = placement_metadata(
18504 &Placement::Remote {
18505 endpoint: "wss://10.0.0.5:8443/stream".into(),
18506 },
18507 None,
18508 )
18509 .unwrap();
18510 assert!(r.contains(r#""kind":"remote""#), "{r}");
18511 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18512
18513 let labeled = placement_metadata(
18515 &Placement::Remote {
18516 endpoint: "ws://169.254.230.101:8899".into(),
18517 },
18518 Some("mini"),
18519 )
18520 .unwrap();
18521 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18522
18523 let s = placement_metadata(
18525 &Placement::Schedulable {
18526 pool: "explorers".into(),
18527 },
18528 Some("mini"),
18529 )
18530 .unwrap();
18531 assert!(s.contains(r#""kind":"remote""#), "{s}");
18532 assert!(s.contains(r#""host":"mini""#), "{s}");
18533
18534 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18536 assert_eq!(p.kind, "remote");
18537 assert_eq!(p.host, "mini");
18538 }
18539
18540 #[tokio::test]
18543 async fn execute_external_child_rejects_direct_remote_worker() {
18544 let mut placements = HashMap::new();
18545 placements.insert(
18546 "explorer".to_string(),
18547 ResolvedRemotePlacement {
18548 endpoint: "ws://127.0.0.1:1".into(),
18549 token: Some("remote-test-token".into()),
18550 ca_cert_file: None,
18551 host_label: Some("mini-e2e".into()),
18552 broker_peer: None,
18553 requirements: None,
18554 },
18555 );
18556 let runner = bogus_runner(placements);
18557 let mut session = session_of_role("explorer", "hello remote");
18558 let job = job_for("child-1");
18559 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18560 let error = runner
18561 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18562 .await
18563 .expect_err("direct remote transport must fail closed");
18564 assert!(error
18565 .to_string()
18566 .contains("remote_broker_activation_unavailable"));
18567 assert!(session
18568 .messages
18569 .iter()
18570 .all(|message| message.role != Role::Assistant));
18571 }
18572
18573 fn bogus_sched_runner(
18579 remote: HashMap<String, ResolvedRemotePlacement>,
18580 sched: HashMap<String, ResolvedSchedulablePlacement>,
18581 ) -> ActorChildRunner {
18582 ActorChildRunner::new(
18583 "test-actor".into(),
18584 PathBuf::from("/bin/false"),
18585 vec![],
18586 std::env::temp_dir().join("bamboo-test-fab-181"),
18587 ExecutorSpec::Echo,
18588 vec![],
18589 "anthropic".into(),
18590 4,
18591 )
18592 .with_remote_placements(remote)
18593 .with_schedulable_placements(sched)
18594 }
18595
18596 fn sched_placement(
18597 pool: &str,
18598 _registry_url: impl Into<String>,
18599 ) -> ResolvedSchedulablePlacement {
18600 ResolvedSchedulablePlacement {
18601 pool: pool.into(),
18602 host_label: None,
18603 requirements: None,
18604 ..Default::default()
18605 }
18606 }
18607
18608 #[test]
18609 fn build_spec_sets_schedulable_placement_for_matching_role() {
18610 let mut sched = HashMap::new();
18611 sched.insert(
18612 "explorer".to_string(),
18613 sched_placement("gpu-pool", "unused"),
18614 );
18615 let runner = bogus_sched_runner(HashMap::new(), sched);
18616
18617 let s = session_of_role("explorer", "do the thing");
18618 let spec = runner.build_spec(&s, &job_for("child-1"));
18619 match &spec.placement {
18620 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18621 other => panic!("expected Schedulable, got {other:?}"),
18622 }
18623 assert!(spec.secrets.worker_auth_token.is_none());
18625 }
18626
18627 #[test]
18628 fn build_spec_remote_wins_when_role_in_both_maps() {
18629 let mut remote = HashMap::new();
18632 remote.insert(
18633 "explorer".to_string(),
18634 ResolvedRemotePlacement {
18635 endpoint: "wss://fixed-host:8443".into(),
18636 token: Some("T-remote".into()),
18637 ca_cert_file: None,
18638 host_label: None,
18639 broker_peer: None,
18640 requirements: None,
18641 },
18642 );
18643 let mut sched = HashMap::new();
18644 sched.insert(
18645 "explorer".to_string(),
18646 sched_placement("gpu-pool", "https://control-plane:9562"),
18647 );
18648 let runner = bogus_sched_runner(remote, sched);
18649
18650 let s = session_of_role("explorer", "do the thing");
18651 let spec = runner.build_spec(&s, &job_for("child-1"));
18652 match &spec.placement {
18653 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18654 other => panic!("expected Remote (precedence), got {other:?}"),
18655 }
18656 assert!(spec.secrets.worker_auth_token.is_none());
18657 }
18658
18659 #[test]
18660 fn build_spec_local_for_unmatched_schedulable_role() {
18661 let mut sched = HashMap::new();
18662 sched.insert(
18663 "explorer".to_string(),
18664 sched_placement("gpu-pool", "https://control-plane:9562"),
18665 );
18666 let runner = bogus_sched_runner(HashMap::new(), sched);
18667 let s = session_of_role("writer", "do the thing");
18668 let spec = runner.build_spec(&s, &job_for("child-1"));
18669 assert_eq!(spec.placement, Placement::Local);
18670 assert!(spec.secrets.worker_auth_token.is_none());
18671 }
18672
18673 #[test]
18678 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18679 let mut remote = HashMap::new();
18681 remote.insert(
18682 "explorer".to_string(),
18683 ResolvedRemotePlacement {
18684 endpoint: "ws://169.254.230.101:8899".into(),
18685 token: None,
18686 ca_cert_file: None,
18687 host_label: Some("mini".into()),
18688 broker_peer: None,
18689 requirements: None,
18690 },
18691 );
18692 let runner = bogus_runner(remote);
18693 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18694 let stamp = runner
18695 .placement_stamp_for(&spec)
18696 .expect("remote child is stamped");
18697 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18698 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18699
18700 let mut remote_nolabel = HashMap::new();
18702 remote_nolabel.insert(
18703 "explorer".to_string(),
18704 ResolvedRemotePlacement {
18705 endpoint: "ws://169.254.230.101:8899".into(),
18706 token: None,
18707 ca_cert_file: None,
18708 host_label: None,
18709 broker_peer: None,
18710 requirements: None,
18711 },
18712 );
18713 let r2 = bogus_runner(remote_nolabel);
18714 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18715 assert!(r2
18716 .placement_stamp_for(&spec2)
18717 .unwrap()
18718 .contains(r#""host":"169.254.230.101""#));
18719
18720 let mut sched = HashMap::new();
18722 sched.insert(
18723 "mac-mini-monitor".to_string(),
18724 ResolvedSchedulablePlacement {
18725 pool: "mac-mini-monitor".into(),
18726 host_label: Some("mini".into()),
18727 requirements: None,
18728 ..Default::default()
18729 },
18730 );
18731 let sr = bogus_sched_runner(HashMap::new(), sched);
18732 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18733 let stamp3 = sr
18734 .placement_stamp_for(&spec3)
18735 .expect("scheduled child is stamped");
18736 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18737 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18738
18739 let local = bogus_runner(HashMap::new());
18741 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18742 assert_eq!(local.placement_stamp_for(&spec4), None);
18743 }
18744
18745 #[tokio::test]
18746 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18747 let mut sched = HashMap::new();
18748 sched.insert(
18749 "explorer".to_string(),
18750 sched_placement("gpu-pool", "unused"),
18751 );
18752 let runner = bogus_sched_runner(HashMap::new(), sched);
18753 let mut session = session_of_role("explorer", "do the thing");
18754 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18755 let error = runner
18756 .execute_external_child(
18757 &mut session,
18758 &job_for("child-1"),
18759 event_tx,
18760 CancellationToken::new(),
18761 )
18762 .await
18763 .expect_err("legacy bus presence cannot grant placement");
18764 assert!(error
18765 .to_string()
18766 .contains("remote_broker_activation_unavailable"));
18767 }
18768}