1use std::collections::{BTreeSet, HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use crate::placement_scheduler::PlacementScheduler;
20use async_trait::async_trait;
21use bamboo_agent_core::storage::Storage;
22use bamboo_agent_core::tools::tool_start_arguments_for_display;
23use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
24use bamboo_domain::poison::PoisonRecover;
25use bamboo_domain::{
26 ActorActivationClaim, ActorActivationFence, ActorActivationFinish, ActorDirectoryPort,
27 ActorLogicalState, ActorSnapshotLimits, ActorSnapshotPort, ActorSnapshotPrincipal, HookResult,
28 ParentQuestion, ParentQuestionCheckpointV1, SessionInboxClaim, SessionInboxConsumerId,
29 SessionInboxFailureReport, SessionInboxLeaseRequest, SessionInboxOwnedClaim, SessionInboxPort,
30 PARENT_QUESTION_CHECKPOINT_ACTION,
31};
32use bamboo_domain::{HostPlacementIntent, HostPlacementRequest, WorkerSlotLease};
33use sha2::{Digest, Sha256};
34use tokio::sync::mpsc;
35use tokio_util::sync::CancellationToken;
36
37use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
38use bamboo_subagent::proto::{
39 ActorEventBatch, AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame,
40 PermissionPolicyContext, RunSpec, SessionMessageDelivery, TerminalStatus,
41};
42use bamboo_subagent::provision::{
43 ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
44};
45
46use super::actor_event_router::{
47 ActorEventClass, ActorEventRoute, ActorEventRouteError, ActorEventRouter,
48};
49use super::actor_event_stream::{ActorEventObserver, PublicActorEvent};
50use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
51
52pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 200;
56
57pub use bamboo_config::DEFAULT_MAX_SPAWN_DEPTH as MAX_SPAWN_DEPTH;
59
60const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
62
63const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
67
68const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
71
72const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
76
77const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
84
85fn active_scoped_session_deny_count(
86 config: &bamboo_tools::permission::PermissionConfig,
87 session_id: &str,
88) -> usize {
89 config
90 .temporary_grants()
91 .into_iter()
92 .filter(|grant| {
93 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
94 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
95 && grant.session_id.as_deref() == Some(session_id)
96 })
97 .count()
98}
99
100fn ensure_no_active_scoped_session_denies(
101 config: &bamboo_tools::permission::PermissionConfig,
102 session_id: &str,
103) -> Result<(), AgentError> {
104 let count = active_scoped_session_deny_count(config, session_id);
105 if count == 0 {
106 Ok(())
107 } else {
108 Err(AgentError::LLM(format!(
109 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
110 )))
111 }
112}
113
114pub struct IssuedCodexRunToken {
117 pub token_id: String,
118 pub token: String,
119}
120
121pub trait CodexRunTokenAuthority: Send + Sync + 'static {
124 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
125 fn revoke(&self, token_id: &str);
126}
127
128struct CodexRunTokenGuard {
129 authority: Arc<dyn CodexRunTokenAuthority>,
130 token_id: String,
131}
132
133impl Drop for CodexRunTokenGuard {
134 fn drop(&mut self) {
135 self.authority.revoke(&self.token_id);
136 }
137}
138
139fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
140 matches!(
141 executor,
142 ExecutorSpec::Codex {
143 auth_mode: Some(mode),
144 ..
145 } if mode == "bamboo"
146 ) || matches!(
147 executor,
148 ExecutorSpec::Codex {
149 auth_mode: None,
150 inherit_user_config,
151 ..
152 } if !inherit_user_config.unwrap_or(false)
153 )
154}
155
156fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
157 match executor {
158 ExecutorSpec::ClaudeCode {
159 permission_mode, ..
160 } => permission_mode
161 .as_deref()
162 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
163 ExecutorSpec::Codex {
164 permission_profile,
165 sandbox,
166 ..
167 } => {
168 permission_profile
169 .as_deref()
170 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
171 || sandbox
172 .as_deref()
173 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
174 }
175 _ => false,
176 }
177}
178
179fn expected_permission_executor_mapping(
187 executor: &ExecutorSpec,
188 resolution: bamboo_domain::PermissionModeResolution,
189 has_explicit_deny: bool,
190) -> Result<Option<String>, AgentError> {
191 let mapping = match executor {
192 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
193 ExecutorSpec::BambooRuntime => {
194 format!("bamboo_runtime:{}", resolution.effective.as_str())
195 }
196 ExecutorSpec::ClaudeCode {
197 permission_mode, ..
198 } => {
199 if has_explicit_deny {
200 "claude_code:blocked_explicit_deny".to_string()
201 } else {
202 let mode = match resolution.effective {
203 bamboo_domain::PermissionMode::Plan => "plan",
204 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
205 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
206 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
207 bamboo_domain::PermissionMode::Default
208 | bamboo_domain::PermissionMode::BypassPermissions => {
209 permission_mode.as_deref().unwrap_or("default")
210 }
211 };
212 format!("claude_code:permission_mode={mode}")
213 }
214 }
215 ExecutorSpec::Codex {
216 mode,
217 sandbox,
218 approval_policy,
219 allow_danger_bypass,
220 ..
221 } => match mode.as_deref().unwrap_or("exec") {
222 "exec" => {
223 let approval_policy = expected_codex_exec_approval_policy(
224 sandbox.as_deref(),
225 approval_policy.as_deref(),
226 allow_danger_bypass.unwrap_or(false),
227 resolution,
228 )?;
229 if has_explicit_deny {
230 "codex_exec:blocked_explicit_deny".to_string()
231 } else {
232 format!("codex_exec:approval_policy={approval_policy}")
233 }
234 }
235 "app_server" => {
236 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
237 return Err(AgentError::LLM(
238 "invalid Codex app-server permission posture configuration".to_string(),
239 ));
240 }
241 if has_explicit_deny {
242 "codex_app_server:blocked_explicit_deny".to_string()
243 } else {
244 let approval_policy = if resolution.suppress_approval_prompts()
245 || resolution.effective == bamboo_domain::PermissionMode::Plan
246 {
247 "never"
248 } else {
249 "on-request"
250 };
251 format!("codex_app_server:approvalPolicy={approval_policy}")
252 }
253 }
254 _ => {
255 return Err(AgentError::LLM(
256 "unsupported Codex executor mode for permission posture contract".to_string(),
257 ));
258 }
259 },
260 };
261 Ok(Some(mapping))
262}
263
264fn expected_codex_exec_approval_policy(
265 sandbox: Option<&str>,
266 approval_policy: Option<&str>,
267 allow_danger_bypass: bool,
268 resolution: bamboo_domain::PermissionModeResolution,
269) -> Result<&'static str, AgentError> {
270 let configured = match approval_policy {
271 None | Some("never") => "never",
272 Some("on-failure") => "on-failure",
273 Some(_) => {
274 return Err(AgentError::LLM(
275 "invalid Codex exec permission posture configuration".to_string(),
276 ));
277 }
278 };
279 if resolution.suppress_approval_prompts()
280 || resolution.effective == bamboo_domain::PermissionMode::Plan
281 {
282 return Ok("never");
283 }
284 match sandbox {
285 Some("danger-full-access") => Ok("never"),
286 Some("read-only") | Some("workspace-write") => Ok(configured),
287 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
288 None => Ok(configured),
289 Some(_) => Err(AgentError::LLM(
290 "invalid Codex exec permission posture configuration".to_string(),
291 )),
292 }
293}
294
295fn workspace_is_bamboo_owned(raw: &str) -> bool {
296 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
297 let configured_root = bamboo_config::paths::resolve_workspace_root();
298 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
299 if workspace.starts_with(&configured_root) {
300 return true;
301 }
302
303 workspace.ancestors().any(|candidate| {
308 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
309 return false;
310 };
311 let Some(worktree_root) = candidate.parent() else {
312 return false;
313 };
314 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
315 || worktree_root.parent().and_then(Path::file_name)
316 != Some(std::ffi::OsStr::new(".bamboo"))
317 {
318 return false;
319 }
320 let marker = worktree_root.join(".bamboo-owned").join(name);
321 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
322 })
323}
324
325fn build_codex_run_secrets(
326 executor: &ExecutorSpec,
327 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
328 child_session_id: &str,
329) -> Result<
330 (
331 bamboo_subagent::proto::RunSecrets,
332 Option<CodexRunTokenGuard>,
333 ),
334 AgentError,
335> {
336 if !executor_uses_bamboo_codex(executor) {
337 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
338 }
339
340 let authority = authority.ok_or_else(|| {
341 AgentError::LLM(
342 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
343 )
344 })?;
345 let issued = authority
346 .issue(child_session_id)
347 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
348 let guard = CodexRunTokenGuard {
349 authority,
350 token_id: issued.token_id,
351 };
352 Ok((
353 bamboo_subagent::proto::RunSecrets {
354 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
355 },
356 Some(guard),
357 ))
358}
359
360struct PooledWorker {
366 worker: SpawnedChild,
367 mailbox_id: String,
369 parked_at: Option<Instant>,
372}
373
374type WorkerPool = HashMap<String, Vec<PooledWorker>>;
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377enum PoolReapReason {
378 ProcessExited,
379 IdleTimeout,
380}
381
382impl PoolReapReason {
383 fn as_str(self) -> &'static str {
384 match self {
385 Self::ProcessExited => "process_exited_while_parked",
386 Self::IdleTimeout => "pool_idle_timeout",
387 }
388 }
389}
390
391fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
392 parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
393}
394
395fn idle_pool_limit_reason(
396 pool: &WorkerPool,
397 key: &str,
398 max_per_key: usize,
399 max_total: usize,
400) -> Option<&'static str> {
401 if pool.get(key).map_or(0, Vec::len) >= max_per_key {
402 Some("pool_per_key_limit")
403 } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
404 Some("pool_global_limit")
405 } else {
406 None
407 }
408}
409
410fn take_reapable_workers(
411 pool: &mut WorkerPool,
412 now: Instant,
413 timeout: Duration,
414) -> Vec<(String, PoolReapReason, PooledWorker)> {
415 let mut reaped = Vec::new();
416 for (key, bucket) in pool.iter_mut() {
417 let mut retained = Vec::with_capacity(bucket.len());
418 for mut worker in std::mem::take(bucket) {
419 let reason = if !worker.worker.is_alive() {
420 Some(PoolReapReason::ProcessExited)
421 } else if parked_worker_expired(worker.parked_at, now, timeout) {
422 Some(PoolReapReason::IdleTimeout)
423 } else {
424 None
425 };
426 if let Some(reason) = reason {
427 reaped.push((key.clone(), reason, worker));
428 } else {
429 retained.push(worker);
430 }
431 }
432 *bucket = retained;
433 }
434 pool.retain(|_, bucket| !bucket.is_empty());
435 reaped
436}
437
438async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
439 let now = Instant::now();
440 let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
441 let reaped = {
442 let mut pool = pool.lock().await;
443 take_reapable_workers(&mut pool, now, timeout)
444 };
445 let count = reaped.len();
446 for (pool_key, reason, worker) in reaped {
447 let idle_ms = worker
448 .parked_at
449 .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
450 tracing::info!(
451 pool_key,
452 mailbox_id = %worker.mailbox_id,
453 worker_pid = ?worker.worker.pid(),
454 worker_idle_ms = ?idle_ms,
455 shutdown_reason = reason.as_str(),
456 "reaping parked sub-agent worker"
457 );
458 worker.worker.kill().await;
459 }
460 count
461}
462
463#[derive(Clone, Default)]
468pub struct ResolvedRemotePlacement {
469 pub endpoint: String,
470 pub token: Option<String>,
471 pub ca_cert_file: Option<PathBuf>,
472 pub host_label: Option<String>,
476 pub broker_peer: Option<Result<bamboo_config::RemoteBrokerPeer, ()>>,
478 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
479}
480impl std::fmt::Debug for ResolvedRemotePlacement {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 f.debug_struct("ResolvedRemotePlacement")
483 .field("broker_peer", &self.broker_peer.is_some())
484 .finish_non_exhaustive()
485 }
486}
487
488#[derive(Debug, Clone, Default)]
495pub struct ResolvedSchedulablePlacement {
496 pub pool: String,
497 pub host_label: Option<String>,
501 pub endpoint: String,
502 pub token: Option<String>,
503 pub ca_cert_file: Option<PathBuf>,
504 pub broker_parent: Option<bamboo_config::SchedulableBrokerParent>,
505 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
506}
507
508#[derive(Clone)]
509struct ScopedPlacementRoute {
510 endpoint: String,
511 token: String,
512 ca_cert_file: PathBuf,
513 parent: bamboo_subagent::AgentRef,
514 worker_role: String,
515 pinned_mailbox: Option<String>,
516 requirements: bamboo_config::OperatorPlacementRequirements,
517}
518
519impl ScopedPlacementRoute {
520 async fn query(&self) -> Result<bamboo_broker::BrokerClient, AgentError> {
521 let tls = bamboo_broker::client_config_trusting_cert(&self.ca_cert_file)
522 .map_err(|_| remote_broker_unavailable())?;
523 bamboo_broker::BrokerClient::connect_with_tls(
524 &self.endpoint,
525 self.parent.clone(),
526 &self.token,
527 Some(tls),
528 )
529 .await
530 .map_err(|_| remote_broker_unavailable())
531 }
532}
533
534struct ActivePlacementLease {
539 registry: bamboo_storage::v2::FileHostRegistry,
540 actor_store: Arc<bamboo_storage::SessionStoreV2>,
541 route: ScopedPlacementRoute,
542 mailbox: String,
543 host_ref: String,
544 fence: ActorActivationFence,
545 placement_ref: bamboo_domain::ActorPlacementRef,
546 lease: Arc<tokio::sync::Mutex<WorkerSlotLease>>,
547 heartbeat: tokio::task::JoinHandle<()>,
548 run_cancel: CancellationToken,
549 lost: Arc<AtomicBool>,
550 release_on_drop: AtomicBool,
551}
552
553async fn fail_pre_dispatch_activation(
557 actor_store: &bamboo_storage::SessionStoreV2,
558 fence: &ActorActivationFence,
559) -> Result<(), bamboo_domain::ActorDirectoryError> {
560 actor_store
561 .finish_activation(fence, chrono::Utc::now(), ActorActivationFinish::Failed)
562 .await
563 .map(|_| ())
564}
565
566async fn send_actor_run(
570 client: &mut dyn bamboo_subagent::ChildLink,
571 spec: RunSpec,
572 send_invoked: &mut bool,
573) -> Result<(), AgentError> {
574 *send_invoked = true;
575 client
576 .send(ParentFrame::Run(spec))
577 .await
578 .map_err(|error| AgentError::LLM(format!("actor run dispatch failed: {error}")))
579}
580
581fn remote_activation_finish(
582 result: &Result<Option<String>, AgentError>,
583 accepted_terminal: Option<TerminalStatus>,
584) -> ActorActivationFinish {
585 if result.is_ok() {
586 ActorActivationFinish::Succeeded
587 } else if matches!(result, Err(AgentError::Cancelled))
588 && accepted_terminal == Some(TerminalStatus::Cancelled)
589 {
590 ActorActivationFinish::Cancelled
591 } else {
592 ActorActivationFinish::Failed
593 }
594}
595
596async fn append_fenced_remote_reply(
600 actor_store: &bamboo_storage::SessionStoreV2,
601 fence: &ActorActivationFence,
602 session: &mut Session,
603 text: &str,
604) -> Result<(), AgentError> {
605 let message = bamboo_agent_core::Message::assistant(text, None);
606 let committed = actor_store
607 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
608 fence: fence.clone(),
609 expected_created_at: session.created_at,
610 expected_messages: session.messages.clone(),
611 expected_provider_transcript: session.provider_transcript.clone(),
612 messages: vec![message],
613 native_groups: Vec::new(),
614 })
615 .await
616 .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
617 session.messages = committed.messages;
618 session.provider_transcript = committed.provider_transcript;
619 session.updated_at = committed.updated_at;
620 Ok(())
621}
622
623impl ActivePlacementLease {
624 async fn reserve(
625 registry: bamboo_storage::v2::FileHostRegistry,
626 actor_store: Arc<bamboo_storage::SessionStoreV2>,
627 route: ScopedPlacementRoute,
628 mailbox: String,
629 actor_id: String,
630 run_id: String,
631 project_id: Option<bamboo_domain::ProjectId>,
632 required_tools: BTreeSet<String>,
633 parent_cancel: &CancellationToken,
634 ) -> Result<Self, AgentError> {
635 let scheduler = PlacementScheduler::new(registry.clone());
636 let mut query = route.query().await?;
637 let observed = query
638 .observe_host_capacity(&mailbox, &route.worker_role)
639 .await
640 .map_err(|_| remote_broker_unavailable())?
641 .ok_or_else(remote_broker_unavailable)?;
642 let host = scheduler
643 .observe_scoped_host(
644 &mut query,
645 &observed.host_ref,
646 &mailbox,
647 &route.worker_role,
648 chrono::Duration::seconds(110),
649 )
650 .await
651 .map_err(|_| remote_broker_unavailable())?;
652 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
653 || host.connection_generation != observed.connection_generation
654 || route
655 .pinned_mailbox
656 .as_deref()
657 .is_some_and(|pinned| pinned != mailbox)
658 {
659 return Err(remote_broker_unavailable());
660 }
661 let now = chrono::Utc::now();
662 let request = HostPlacementRequest {
663 intent: HostPlacementIntent::PinnedHost {
664 host_ref: host.host_ref.clone(),
665 },
666 actor_id,
667 run_id,
668 project_id: project_id.clone(),
669 trust_zone: route.requirements.trust_zone.clone(),
670 workspace_label: route.requirements.workspace_label.clone(),
671 executor: "bamboo-runtime".into(),
672 required_tools,
673 network_zone: route.requirements.network_zone.clone(),
674 require_network_isolation: route.requirements.require_network_isolation,
675 preferred_host_ref: None,
676 now,
677 lease_expires_at: now + chrono::Duration::seconds(80),
678 };
679 if !request.eligible(&host) {
680 return Err(remote_broker_unavailable());
681 }
682 let lease = scheduler
683 .reserve(request)
684 .await
685 .map_err(|_| remote_broker_unavailable())?;
686 let placement_ref = bamboo_domain::ActorPlacementRef {
687 class: bamboo_domain::ActorPlacementClass::Remote,
688 lease_id: lease.lease_id.clone(),
689 slot_epoch: Some(lease.epoch),
690 };
691 let mut claimed_fence = None;
692 let actor_binding = async {
693 let actor = actor_store
694 .ensure_actor(&lease.actor_id)
695 .await
696 .map_err(|_| remote_broker_unavailable())?;
697 if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
698 return Err(remote_broker_unavailable());
699 }
700 let placement_intent = if route.pinned_mailbox.is_some() {
701 bamboo_domain::ActorPlacementIntent::PinnedHost {
702 host_ref: host.host_ref.clone(),
703 }
704 } else {
705 bamboo_domain::ActorPlacementIntent::Pool {
706 pool_id: route.worker_role.clone(),
707 }
708 };
709 actor_store
710 .bind_control_plane(
711 &lease.actor_id,
712 bamboo_domain::ActorControlPlaneBinding {
713 policy_revision: None,
714 placement_intent: Some(placement_intent),
715 },
716 )
717 .await
718 .map_err(|_| {
719 tracing::warn!(stage = "actor_bind", "remote placement admission failed");
720 remote_broker_unavailable()
721 })?;
722 let activation = actor_store
723 .claim_activation(&ActorActivationClaim {
724 actor_id: lease.actor_id.clone(),
725 run_id: lease.run_id.clone(),
726 lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
727 lease_expires_at: lease.expires_at,
728 inbox_generation: 0,
729 placement_ref: Some(placement_ref.clone()),
730 now: chrono::Utc::now(),
731 })
732 .await
733 .map_err(|_| {
734 tracing::warn!(stage = "actor_claim", "remote placement admission failed");
735 remote_broker_unavailable()
736 })?;
737 let fence = activation.fence();
738 claimed_fence = Some(fence.clone());
739 actor_store
740 .start_activation(&fence, chrono::Utc::now())
741 .await
742 .map_err(|_| {
743 tracing::warn!(stage = "actor_start", "remote placement admission failed");
744 remote_broker_unavailable()
745 })?;
746 let reservation = registry
747 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
748 placement_ref: placement_ref.clone(),
749 actor_id: lease.actor_id.clone(),
750 run_id: lease.run_id.clone(),
751 observed_host_ref: host.host_ref.clone(),
752 observed_connection_generation: host.connection_generation.clone(),
753 now: chrono::Utc::now(),
754 })
755 .await
756 .map_err(|error| {
757 tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
758 remote_broker_unavailable()
759 })?;
760 if reservation.mailbox != mailbox || reservation.lease != lease {
761 tracing::warn!(stage = "slot_binding", "remote placement admission failed");
762 return Err(remote_broker_unavailable());
763 }
764 Ok::<_, AgentError>(fence)
765 }
766 .await;
767 let fence = match actor_binding {
768 Ok(fence) => fence,
769 Err(error) => {
770 if let Some(fence) = claimed_fence.as_ref() {
771 if let Err(rollback_error) =
772 fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
773 {
774 tracing::warn!(
775 stage = "actor_pre_dispatch_rollback",
776 reason = %rollback_error,
777 "remote placement activation rollback failed"
778 );
779 }
780 }
781 let _ = registry.release_slot(&lease).await;
782 return Err(error);
783 }
784 };
785 let lease = Arc::new(tokio::sync::Mutex::new(lease));
786 let host_ref = host.host_ref.clone();
787 let lost = Arc::new(AtomicBool::new(false));
788 let run_cancel = parent_cancel.child_token();
789 let heartbeat = {
790 let registry = registry.clone();
791 let actor_store = actor_store.clone();
792 let route = route.clone();
793 let host_ref = host_ref.clone();
794 let mailbox = mailbox.clone();
795 let fence = fence.clone();
796 let placement_ref = placement_ref.clone();
797 let lease = lease.clone();
798 let lost = lost.clone();
799 let run_cancel = run_cancel.clone();
800 tokio::spawn(async move {
801 let mut interval = tokio::time::interval(Duration::from_secs(30));
802 interval.tick().await;
803 loop {
804 tokio::select! {
805 _ = run_cancel.cancelled() => break,
806 _ = interval.tick() => {}
807 }
808 let refreshed = async {
809 let mut query = route.query().await?;
810 let host = PlacementScheduler::new(registry.clone())
811 .observe_scoped_host(
812 &mut query,
813 &host_ref,
814 &mailbox,
815 &route.worker_role,
816 chrono::Duration::seconds(110),
817 )
818 .await
819 .map_err(|_| remote_broker_unavailable())?;
820 let current = lease.lock().await.clone();
821 if host.connection_generation != current.connection_generation {
822 return Err(remote_broker_unavailable());
823 }
824 let entry = actor_store
825 .inspect_actor(¤t.actor_id)
826 .await
827 .map_err(|_| remote_broker_unavailable())?;
828 if entry.activation.as_ref().is_none_or(|activation| {
829 activation.fence() != fence
830 || activation.placement_ref.as_ref() != Some(&placement_ref)
831 }) {
832 return Err(remote_broker_unavailable());
833 }
834 let now = chrono::Utc::now();
835 registry
836 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
837 placement_ref: placement_ref.clone(),
838 actor_id: current.actor_id.clone(),
839 run_id: current.run_id.clone(),
840 observed_host_ref: host.host_ref.clone(),
841 observed_connection_generation: host.connection_generation.clone(),
842 now,
843 })
844 .await
845 .map_err(|_| remote_broker_unavailable())?;
846 let renewed = registry
847 .renew_slot(¤t, now, now + chrono::Duration::seconds(80))
848 .await
849 .map_err(|_| remote_broker_unavailable())?;
850 *lease.lock().await = renewed;
851 let expiry = lease.lock().await.expires_at;
852 actor_store
853 .renew_activation(&fence, now, expiry)
854 .await
855 .map_err(|_| remote_broker_unavailable())?;
856 Ok::<(), AgentError>(())
857 }
858 .await;
859 if refreshed.is_err() {
860 lost.store(true, Ordering::SeqCst);
861 run_cancel.cancel();
862 break;
863 }
864 }
865 })
866 };
867 Ok(Self {
868 registry,
869 actor_store,
870 route,
871 mailbox,
872 host_ref,
873 fence,
874 placement_ref,
875 lease,
876 heartbeat,
877 run_cancel,
878 lost,
879 release_on_drop: AtomicBool::new(false),
880 })
881 }
882
883 fn lost(&self) -> bool {
884 self.lost.load(Ordering::SeqCst)
885 }
886
887 async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
888 if send_invoked {
889 return Ok(false);
890 }
891 self.abort_before_dispatch().await?;
892 Ok(true)
893 }
894
895 async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
896 self.heartbeat.abort();
897 let _ = (&mut self.heartbeat).await;
898 let current = self.lease.lock().await.clone();
899 let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
900 let released = self.registry.release_slot(¤t).await;
901 if let Err(error) = failed {
902 tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
903 return Err(remote_broker_unavailable());
904 }
905 if let Err(error) = released {
906 if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
907 tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
908 return Err(remote_broker_unavailable());
909 }
910 }
911 Ok(())
912 }
913
914 async fn complete(
915 &mut self,
916 outcome: ActorActivationFinish,
917 terminal_proven: bool,
918 ) -> Result<(), AgentError> {
919 if !terminal_proven {
920 return if outcome == ActorActivationFinish::Succeeded {
923 Err(remote_broker_unavailable())
924 } else {
925 Ok(())
926 };
927 }
928 self.heartbeat.abort();
931 let _ = (&mut self.heartbeat).await;
932 let current = self.lease.lock().await.clone();
933 let mut query = self.route.query().await?;
937 let observed = query
938 .observe_host_capacity(&self.mailbox, &self.route.worker_role)
939 .await
940 .map_err(|_| remote_broker_unavailable())?
941 .ok_or_else(remote_broker_unavailable)?;
942 if observed.host_ref != self.host_ref
943 || observed.connection_generation != current.connection_generation
944 || observed.mailbox != self.mailbox
945 || observed.role.as_deref() != Some(self.route.worker_role.as_str())
946 {
947 return Err(remote_broker_unavailable());
948 }
949 let entry = self
950 .actor_store
951 .inspect_actor(¤t.actor_id)
952 .await
953 .map_err(|_| remote_broker_unavailable())?;
954 if entry.activation.as_ref().is_none_or(|activation| {
955 activation.fence() != self.fence
956 || activation.placement_ref.as_ref() != Some(&self.placement_ref)
957 }) {
958 return Err(remote_broker_unavailable());
959 }
960 self.registry
961 .validate_slot(¤t, chrono::Utc::now())
962 .await
963 .map_err(|_| remote_broker_unavailable())?;
964 self.actor_store
965 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
966 .await
967 .map_err(|_| remote_broker_unavailable())?;
968 self.release_on_drop.store(true, Ordering::SeqCst);
971 match self.registry.release_slot(¤t).await {
972 Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
973 self.release_on_drop.store(false, Ordering::SeqCst);
974 Ok(())
975 }
976 Err(error) => {
977 tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
978 Err(remote_broker_unavailable())
979 }
980 }
981 }
982}
983
984impl Drop for ActivePlacementLease {
985 fn drop(&mut self) {
986 self.run_cancel.cancel();
987 self.heartbeat.abort();
988 if !self.release_on_drop.load(Ordering::SeqCst) {
989 return;
990 }
991 let registry = self.registry.clone();
992 let actor_store = self.actor_store.clone();
993 let fence = self.fence.clone();
994 let placement_ref = self.placement_ref.clone();
995 let lease = self.lease.clone();
996 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
997 runtime.spawn(async move {
998 let current = lease.lock().await.clone();
999 if actor_store
1000 .inspect_actor(¤t.actor_id)
1001 .await
1002 .ok()
1003 .and_then(|entry| entry.activation)
1004 .is_some_and(|activation| {
1005 activation.fence() == fence
1006 && activation.placement_ref.as_ref() == Some(&placement_ref)
1007 })
1008 {
1009 let _ = registry.release_slot(¤t).await;
1010 }
1011 });
1012 }
1013 }
1014}
1015
1016pub struct BrokerTerminalReceiptReconciler {
1021 store: Arc<bamboo_storage::SessionStoreV2>,
1022 bus: Option<bamboo_subagent::BusEndpoint>,
1023 remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1024 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1025}
1026
1027struct ReceiptRemoteRoute {
1028 endpoint: String,
1029 token: String,
1030 ca_cert_file: PathBuf,
1031 parent: bamboo_subagent::AgentRef,
1032}
1033
1034#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1035pub struct BrokerReceiptRepairReport {
1036 pub candidates: usize,
1037 pub repaired: usize,
1038 pub blocked: usize,
1039}
1040
1041impl BrokerTerminalReceiptReconciler {
1042 pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1043 let subagents = config.subagents();
1044 let mut counts = HashMap::<&str, usize>::new();
1045 for placement in &subagents.remote_placements {
1046 *counts.entry(placement.role.as_str()).or_default() += 1;
1047 }
1048 let mut remote = HashMap::new();
1049 for placement in &subagents.remote_placements {
1050 let route = (counts.get(placement.role.as_str()) == Some(&1))
1051 .then(|| receipt_remote_route(placement))
1052 .flatten();
1053 remote.insert(placement.role.clone(), route);
1054 }
1055 Self {
1056 store,
1057 ticket_service: None,
1058 bus: subagents.broker.clone().and_then(|broker| {
1059 (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1060 bamboo_subagent::BusEndpoint {
1061 endpoint: broker.endpoint,
1062 token: broker.token,
1063 },
1064 )
1065 }),
1066 remote,
1067 }
1068 }
1069
1070 pub fn with_ticket_service(
1071 mut self,
1072 service: Option<Arc<bamboo_tickets::TicketService>>,
1073 ) -> Self {
1074 self.ticket_service = service;
1075 self
1076 }
1077
1078 pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1081 let candidates = match self
1082 .store
1083 .discover_unconfirmed_broker_terminal_children()
1084 .await
1085 {
1086 Ok(candidates) => candidates,
1087 Err(error) => {
1088 tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1089 return BrokerReceiptRepairReport {
1090 blocked: 1,
1091 ..BrokerReceiptRepairReport::default()
1092 };
1093 }
1094 };
1095 let mut report = BrokerReceiptRepairReport {
1096 candidates: candidates.len(),
1097 ..BrokerReceiptRepairReport::default()
1098 };
1099 for child in candidates {
1100 let result =
1101 tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1102 match result {
1103 Ok(Ok(())) => report.repaired += 1,
1104 Ok(Err(reason)) => {
1105 report.blocked += 1;
1106 tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1107 }
1108 Err(_) => {
1109 report.blocked += 1;
1110 tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1111 }
1112 }
1113 }
1114 report
1115 }
1116
1117 async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1118 if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1119 || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1120 || child.metadata.get("external.agent_id").map(String::as_str)
1121 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1122 {
1123 return Err("Child has no trusted built-in actor recovery route");
1124 }
1125 let current = self
1126 .store
1127 .load_session(&child.id)
1128 .await
1129 .map_err(|_| "canonical Child reload failed")?
1130 .ok_or("canonical Child disappeared")?;
1131 if current.kind != bamboo_domain::SessionKind::Child
1132 || current.id != child.id
1133 || current.created_at != child.created_at
1134 || current.parent_session_id != child.parent_session_id
1135 || current.root_session_id != child.root_session_id
1136 || current.project_id_meta() != child.project_id_meta()
1137 || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1138 || current
1139 .metadata
1140 .get("external.protocol")
1141 .map(String::as_str)
1142 != Some("actor")
1143 || current
1144 .metadata
1145 .get("external.agent_id")
1146 .map(String::as_str)
1147 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1148 || bamboo_domain::ActorSession::from_session(¤t).is_err()
1149 {
1150 return Err("canonical Child birth, Root, or Project changed");
1151 }
1152 let root = self
1153 .store
1154 .load_session(&child.root_session_id)
1155 .await
1156 .map_err(|_| "canonical Root reload failed")?
1157 .ok_or("canonical Root disappeared")?;
1158 if root.kind != bamboo_domain::SessionKind::Root
1159 || root.id != child.root_session_id
1160 || root.root_session_id != child.root_session_id
1161 || root.project_id_meta() != child.project_id_meta()
1162 {
1163 return Err("canonical Root identity changed");
1164 }
1165 let parent_id = child
1166 .parent_session_id
1167 .as_deref()
1168 .ok_or("Child parent identity missing")?;
1169 let parent = self
1170 .store
1171 .load_session(parent_id)
1172 .await
1173 .map_err(|_| "canonical parent reload failed")?
1174 .ok_or("canonical parent disappeared")?;
1175 if parent.id != parent_id
1176 || !matches!(
1177 parent.kind,
1178 bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1179 )
1180 || parent.root_session_id != child.root_session_id
1181 || parent.project_id_meta() != child.project_id_meta()
1182 || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1183 {
1184 return Err("canonical parent lineage changed");
1185 }
1186
1187 let receipts = self
1191 .store
1192 .recover_broker_terminal_receipts(¤t)
1193 .await
1194 .map_err(|_| "Host broker terminal checkpoint unverified")?;
1195 if receipts.is_empty() {
1196 return Ok(());
1197 }
1198 let role = current
1199 .metadata
1200 .get("subagent_type")
1201 .map(String::as_str)
1202 .unwrap_or("worker");
1203 for receipt in receipts {
1204 publish_recovered_completion_source(&self.store, &receipt)
1205 .await
1206 .map_err(|_| "committed Child completion source remains pending")?;
1207 if current
1208 .metadata
1209 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1210 {
1211 let service = self
1212 .ticket_service
1213 .as_ref()
1214 .ok_or("Ticket authority unavailable; no result ACK")?;
1215 crate::ticket_runtime::checkpoint_ticket_result(
1216 service,
1217 self.store.as_ref(),
1218 ¤t,
1219 &receipt.activation_run_id,
1220 )
1221 .await
1222 .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1223 }
1224 let local_mailbox = format!("p-{}", child.id);
1225 let mut link = if receipt.parent_mailbox == local_mailbox {
1228 let bus = self
1229 .bus
1230 .as_ref()
1231 .ok_or("local broker receipt bus unavailable")?;
1232 bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1233 &bus.endpoint,
1234 bamboo_subagent::AgentRef {
1235 session_id: local_mailbox,
1236 role: None,
1237 },
1238 &bus.token,
1239 )
1240 .await
1241 .map_err(|_| "local broker receipt connection unavailable")?
1242 } else if let Some(remote) = self.remote.get(role) {
1243 let route = remote
1244 .as_ref()
1245 .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1246 if receipt.parent_mailbox != route.parent.session_id {
1247 return Err("fixed remote parent mailbox changed since Host checkpoint");
1248 }
1249 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1250 .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1251 bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1252 &route.endpoint,
1253 route.parent.clone(),
1254 &route.token,
1255 tls,
1256 )
1257 .await
1258 .map_err(|_| "fixed remote broker receipt connection unavailable")?
1259 } else {
1260 return Err("committed broker receipt route is unavailable");
1261 };
1262 if link.parent_mailbox() != receipt.parent_mailbox {
1263 return Err("broker receipt link mailbox differs from Host checkpoint");
1264 }
1265 bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1266 &mut link,
1267 &receipt.broker_identity,
1268 &receipt.message_ids,
1269 )
1270 .await
1271 .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1272 self.store
1273 .clear_acknowledged_broker_terminal_receipt(
1274 &receipt.session_id,
1275 receipt.created_at,
1276 &receipt.activation_run_id,
1277 &receipt.parent_mailbox,
1278 )
1279 .await
1280 .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1281 }
1282 Ok(())
1283 }
1284}
1285
1286async fn publish_recovered_completion_source(
1290 store: &Arc<bamboo_storage::SessionStoreV2>,
1291 receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1292) -> Result<(), String> {
1293 let expected = store
1294 .load_session(&receipt.session_id)
1295 .await
1296 .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1297 .ok_or("Child completion recovery snapshot missing")?;
1298 if expected.id != receipt.session_id
1299 || expected.created_at != receipt.created_at
1300 || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1301 || expected.root_session_id != receipt.root_session_id
1302 || expected.project_id_meta() != receipt.project_id
1303 {
1304 return Err("Child completion recovery identity changed".into());
1305 }
1306 let mut candidate = expected.clone();
1307 if candidate.last_run_status().as_deref() == Some("running") {
1308 candidate.set_last_run_status(receipt.terminal_status.clone());
1309 if let Some(error) = &receipt.terminal_error {
1310 candidate.set_last_run_error(error.clone());
1311 } else {
1312 candidate.clear_last_run_error();
1313 }
1314 }
1315 if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1316 &mut candidate,
1317 &receipt.activation_run_id,
1318 )? {
1319 let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1320 .ok_or("Child completion recovery source invalid")?;
1321 let published = candidate
1322 .metadata
1323 .get("runtime.child_completion_source_v1")
1324 .ok_or("Child completion recovery source missing")?;
1325 let saved = store
1329 .publish_broker_terminal_source(receipt, &expected, published)
1330 .await
1331 .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1332 if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1333 != Some(&source)
1334 {
1335 return Err("Child completion recovery source changed".into());
1336 }
1337 }
1338 Ok(())
1339}
1340
1341fn receipt_remote_route(
1342 placement: &bamboo_config::RemoteActorPlacement,
1343) -> Option<ReceiptRemoteRoute> {
1344 let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1345 let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1346 if placement.endpoint.len() > 2048
1347 || endpoint.scheme() != "wss"
1348 || endpoint.host_str().is_none()
1349 || !endpoint.username().is_empty()
1350 || endpoint.password().is_some()
1351 || endpoint.query().is_some()
1352 || endpoint.fragment().is_some()
1353 {
1354 return None;
1355 }
1356 let token_env = placement
1357 .token_env
1358 .as_deref()
1359 .filter(|name| !name.is_empty() && name.len() <= 256)?;
1360 let token = std::env::var(token_env).ok()?;
1361 if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1362 return None;
1363 }
1364 let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1365 bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1366 Some(ReceiptRemoteRoute {
1367 endpoint: placement.endpoint.clone(),
1368 token,
1369 ca_cert_file,
1370 parent: bamboo_subagent::AgentRef {
1371 session_id: peer.parent_mailbox.clone(),
1372 role: peer.parent_role.clone(),
1373 },
1374 })
1375}
1376
1377enum PlacementKind {
1382 Local,
1383 Remote,
1384 Schedulable,
1385}
1386
1387fn ensure_ticket_placement_is_enforceable(
1389 session: &Session,
1390 spec: &ProvisionSpec,
1391) -> Result<(), AgentError> {
1392 let ticket = session
1393 .metadata
1394 .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1395 || session
1396 .metadata
1397 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1398 if ticket && !matches!(spec.placement, Placement::Local) {
1399 return Err(AgentError::LLM(
1400 "Ticket execution requires local placement and owned stop proof".into(),
1401 ));
1402 }
1403 Ok(())
1404}
1405
1406fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1415 if !spec.capabilities.read_only_enforced() {
1416 return Ok(());
1417 }
1418
1419 if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1420 return Err(AgentError::LLM(format!(
1421 "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1422 spec.identity.role
1423 )));
1424 }
1425
1426 if !matches!(spec.placement, Placement::Local) {
1427 return Err(AgentError::LLM(format!(
1428 "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1429 spec.identity.role
1430 )));
1431 }
1432
1433 Ok(())
1434}
1435
1436pub struct ActorChildRunner {
1438 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1439 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1440 agent_id: String,
1441 worker_bin: PathBuf,
1442 worker_args: Vec<String>,
1443 builtin_required_context_route: bool,
1444 native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1445 fabric_dir: PathBuf,
1446 executor: ExecutorSpec,
1447 credentials: Vec<ScopedCredential>,
1452 default_provider: String,
1454 max_spawn_depth: u32,
1456 live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1460 bus: Option<bamboo_subagent::BusEndpoint>,
1463 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1467 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1473 max_idle_per_key: usize,
1474 max_idle_total: usize,
1475 pool_reaper_started: AtomicBool,
1476 next_execution_epoch: AtomicU64,
1480 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1484 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1488 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1499 remote_placements: HashMap<String, ResolvedRemotePlacement>,
1505 strict_remote_subscription: tokio::sync::Mutex<()>,
1507 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1515 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1521 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1524 actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1525 canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1526 ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1527 actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1528 pending_durable_links: tokio::sync::Mutex<
1531 HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1532 >,
1533}
1534
1535struct PendingDurableChildLink {
1536 link: Box<dyn bamboo_subagent::ChildLink>,
1537 host_nested_wait_handoff: bool,
1540 strict_history: Option<StrictTerminalHistory>,
1542 strict_history_required: bool,
1543 strict_history_committed: bool,
1544}
1545
1546#[derive(Clone)]
1547struct StrictTerminalHistory {
1548 proof: bamboo_storage::v2::HostTerminalCompleteness,
1549 watermark: bamboo_subagent::ActorEventWatermark,
1550}
1551
1552fn effective_broker_terminal_status(
1553 raw: TerminalStatus,
1554 host_nested_wait_handoff: bool,
1555) -> Result<&'static str, String> {
1556 if host_nested_wait_handoff {
1557 if raw != TerminalStatus::Completed {
1558 return Err("nested wait cannot project a failed broker terminal".into());
1559 }
1560 Ok("suspended")
1561 } else {
1562 Ok(broker_terminal_status_label(raw))
1563 }
1564}
1565
1566fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1567 match status {
1568 TerminalStatus::Completed => "completed",
1569 TerminalStatus::Suspended => "suspended",
1570 TerminalStatus::Error => "error",
1571 TerminalStatus::Cancelled => "cancelled",
1572 }
1573}
1574
1575#[async_trait]
1585pub trait ChildApprovalDecider: Send + Sync {
1586 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1589}
1590
1591async fn decide_child_approval(
1594 decider: Option<&Arc<dyn ChildApprovalDecider>>,
1595 child_session_id: &str,
1596 request: &serde_json::Value,
1597) -> bool {
1598 match decider {
1599 Some(decider) => decider.decide(child_session_id, request).await,
1600 None => false,
1601 }
1602}
1603
1604const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1608
1609#[derive(Clone)]
1620pub struct ChildApprovalScope {
1621 stamp: serde_json::Value,
1622 deadline: chrono::DateTime<chrono::Utc>,
1623 parent: String,
1624 child: String,
1625 run: String,
1626 epoch: u64,
1627 current_epoch: Arc<AtomicU64>,
1628 cancel: CancellationToken,
1629 router: Arc<crate::SessionActivationRouter>,
1630 admission: Arc<AtomicUsize>,
1631}
1632
1633pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1634impl Drop for ChildApprovalAdmission {
1635 fn drop(&mut self) {
1636 self.0.store(2, Ordering::Release);
1637 }
1638}
1639
1640impl ChildApprovalScope {
1641 pub fn new(
1643 parent: &str,
1644 child: &str,
1645 transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1646 router: Arc<crate::SessionActivationRouter>,
1647 cancel: CancellationToken,
1648 current_epoch: Arc<AtomicU64>,
1649 ) -> Self {
1650 let (attempt, run, epoch, reply, deadline) = transport;
1651 let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1652 Self {
1653 stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1654 "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1655 "deadline": deadline}),
1656 deadline,
1657 parent: parent.into(),
1658 child: child.into(),
1659 run: run.into(),
1660 epoch,
1661 current_epoch,
1662 cancel,
1663 router,
1664 admission: Arc::new(AtomicUsize::new(0)),
1665 }
1666 }
1667 pub fn stamp(&self) -> &serde_json::Value {
1668 &self.stamp
1669 }
1670 pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1671 self.deadline
1672 }
1673 pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1674 self.admission
1675 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1676 .ok()
1677 .map(|_| ChildApprovalAdmission(self.admission.clone()))
1678 }
1679 pub fn is_admitting(&self) -> bool {
1680 self.admission.load(Ordering::Acquire) == 1
1681 }
1682 pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1683 self.parent == parent
1684 && self.child == child
1685 && !self.cancel.is_cancelled()
1686 && self.current_epoch.load(Ordering::Acquire) == self.epoch
1687 && self.router.owns_run(child, &self.run).await
1688 }
1689}
1690
1691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1692pub enum ChildApprovalReview {
1693 Reply(bool),
1694 NoReply,
1695}
1696
1697#[async_trait]
1698pub trait ChildApprovalReviewer: Send + Sync {
1699 async fn review(
1702 &self,
1703 parent_session_id: &str,
1704 child_session_id: &str,
1705 request: &serde_json::Value,
1706 ) -> bool;
1707
1708 async fn review_scoped(
1709 &self,
1710 parent: &str,
1711 child: &str,
1712 request: &serde_json::Value,
1713 _scope: &ChildApprovalScope,
1714 ) -> ChildApprovalReview {
1715 ChildApprovalReview::Reply(self.review(parent, child, request).await)
1716 }
1717}
1718
1719fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1720 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1721 &SLOT
1722}
1723
1724pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1726 let _ = child_approval_reviewer_slot().set(reviewer);
1727}
1728
1729pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1731 child_approval_reviewer_slot().get().cloned()
1732}
1733
1734impl ActorChildRunner {
1735 #[allow(clippy::too_many_arguments)]
1736 pub fn new(
1737 agent_id: String,
1738 worker_bin: PathBuf,
1739 worker_args: Vec<String>,
1740 fabric_dir: PathBuf,
1741 executor: ExecutorSpec,
1742 credentials: Vec<ScopedCredential>,
1743 default_provider: String,
1744 max_concurrent: usize,
1745 ) -> Self {
1746 Self {
1747 approval_registry: None,
1748 permission_config: None,
1749 agent_id,
1750 worker_bin,
1751 worker_args,
1752 builtin_required_context_route: false,
1753 fabric_dir,
1754 executor,
1755 credentials,
1756 default_provider,
1757 max_spawn_depth: MAX_SPAWN_DEPTH,
1758 live_provider_config: None,
1759 native_tool_ceiling: None,
1760 bus: None,
1761 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1762 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1763 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1764 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1765 pool_reaper_started: AtomicBool::new(false),
1766 next_execution_epoch: AtomicU64::new(0),
1767 approval_decider: None,
1768 approval_reviewer: None,
1769 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1770 remote_placements: HashMap::new(),
1771 strict_remote_subscription: tokio::sync::Mutex::new(()),
1772 schedulable_placements: HashMap::new(),
1773 codex_run_tokens: None,
1774 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1775 actor_directory_store: std::sync::Mutex::new(None),
1776 canonical_subagent_tool: std::sync::Mutex::new(None),
1777 ticket_service: std::sync::Mutex::new(None),
1778 actor_event_observer: std::sync::Mutex::new(None),
1779 pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1780 }
1781 }
1782
1783 pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1788 self.builtin_required_context_route = supported;
1789 self
1790 }
1791
1792 pub(crate) fn with_native_tool_ceiling_source(
1793 mut self,
1794 source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1795 ) -> Self {
1796 self.native_tool_ceiling = source;
1797 self
1798 }
1799
1800 async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1801 bamboo_subagent::fleet::require_worker_capability(
1802 &self.worker_bin,
1803 &self.worker_args,
1804 bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1805 )
1806 .await
1807 .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1808 }
1809
1810 pub fn with_live_provider_config(
1811 mut self,
1812 config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1813 ) -> Self {
1814 self.live_provider_config = Some(config);
1815 self
1816 }
1817
1818 pub fn with_max_spawn_depth(mut self, max_spawn_depth: u32) -> Self {
1819 self.max_spawn_depth = max_spawn_depth;
1820 self
1821 }
1822
1823 pub fn with_approval_registry(
1824 mut self,
1825 registry: super::approval_registry::SharedApprovalRegistry,
1826 ) -> Self {
1827 self.approval_registry = Some(registry);
1828 self
1829 }
1830
1831 pub fn with_permission_config(
1832 mut self,
1833 config: Arc<bamboo_tools::permission::PermissionConfig>,
1834 ) -> Self {
1835 self.permission_config = Some(config);
1836 self
1837 }
1838
1839 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1844 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1845 self
1846 }
1847
1848 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1851 self.approval_decider = Some(decider);
1852 self
1853 }
1854
1855 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1856 self.approval_reviewer = Some(reviewer);
1857 self
1858 }
1859
1860 pub fn with_codex_run_tokens(
1861 mut self,
1862 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1863 ) -> Self {
1864 self.codex_run_tokens = authority;
1865 self
1866 }
1867
1868 pub fn with_remote_placements(
1874 mut self,
1875 placements: HashMap<String, ResolvedRemotePlacement>,
1876 ) -> Self {
1877 self.remote_placements = placements;
1878 self
1879 }
1880
1881 pub fn with_schedulable_placements(
1887 mut self,
1888 placements: HashMap<String, ResolvedSchedulablePlacement>,
1889 ) -> Self {
1890 self.schedulable_placements = placements;
1891 self
1892 }
1893
1894 fn fingerprint(spec: &ProvisionSpec) -> String {
1913 let role = spec.identity.role.as_str();
1914 let (provider, model) = spec
1915 .model
1916 .as_ref()
1917 .map(|m| (m.provider.as_str(), m.model.as_str()))
1918 .unwrap_or(("", ""));
1919 let workspace = spec.workspace.as_deref().unwrap_or("");
1920 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1921 tools.sort();
1922 let caps = &spec.capabilities;
1923 let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1928 "none".to_string()
1929 } else {
1930 let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1931 .expect("scoped provider credentials are serializable");
1932 hex::encode(Sha256::digest(serialized))
1933 };
1934 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1937 format!(
1938 "{role}\u{1}{provider}\u{1}{model}\u{1}cred={credential_revision}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}pr={}\u{1}pe={}\u{1}by={}\u{1}auto={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}ro={}\u{1}gro={}\u{1}ci={}\u{1}executor={executor}",
1939 tools.join(","),
1940 spec.identity.depth,
1941 caps.nested_spawn,
1942 caps.permission_requested_mode,
1943 caps.permission_effective_mode,
1944 caps.bypass,
1945 caps.auto_approve_permissions,
1946 caps.enforce_permissions,
1947 caps.max_spawn_depth.unwrap_or(0),
1948 caps.no_human_approver,
1955 caps.read_only,
1958 caps.guardian_read_only,
1961 caps.child_creation_identity,
1962 )
1963 }
1964
1965 fn ensure_pool_reaper(&self) {
1968 if self
1969 .pool_reaper_started
1970 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1971 .is_err()
1972 {
1973 return;
1974 }
1975 let pool = Arc::downgrade(&self.pool);
1976 tokio::spawn(async move {
1977 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1978 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1979 interval.tick().await;
1982 loop {
1983 interval.tick().await;
1984 let Some(pool) = pool.upgrade() else {
1985 break;
1986 };
1987 let _ = reap_worker_pool_once(&pool).await;
1988 }
1989 });
1990 }
1991
1992 fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1993 let mut physical = spec.clone();
1994 physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1998 physical
1999 }
2000
2001 async fn acquire_bus_worker(
2007 &self,
2008 key: &str,
2009 spec: &ProvisionSpec,
2010 ) -> crate::runtime::runner::Result<PooledWorker> {
2011 if spec.capabilities.required_child_context {
2012 let physical = Self::fresh_required_worker_spec(spec);
2013 let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2014 .await
2015 .map_err(|error| {
2016 AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2017 })?;
2018 let mailbox_id = worker.record.agent_id.clone();
2019 return Ok(PooledWorker {
2020 worker,
2021 mailbox_id,
2022 parked_at: None,
2023 });
2024 }
2025 self.ensure_pool_reaper();
2026 loop {
2030 let candidate = {
2031 let mut pool = self.pool.lock().await;
2032 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2033 if pool.get(key).is_some_and(Vec::is_empty) {
2034 pool.remove(key);
2035 }
2036 candidate
2037 };
2038 let Some(mut candidate) = candidate else {
2039 break;
2040 };
2041 let now = Instant::now();
2042 let expired = parked_worker_expired(
2043 candidate.parked_at,
2044 now,
2045 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2046 );
2047 if candidate.worker.is_alive() && !expired {
2048 candidate.parked_at = None;
2049 return Ok(candidate);
2050 }
2051 tracing::info!(
2052 pool_key = key,
2053 mailbox_id = %candidate.mailbox_id,
2054 worker_pid = ?candidate.worker.pid(),
2055 shutdown_reason = if expired {
2056 PoolReapReason::IdleTimeout.as_str()
2057 } else {
2058 PoolReapReason::ProcessExited.as_str()
2059 },
2060 "discarding unusable parked sub-agent worker during checkout"
2061 );
2062 candidate.worker.kill().await;
2063 }
2064
2065 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2066 .await
2067 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2068 let mailbox_id = spawned.record.agent_id.clone();
2069 Ok(PooledWorker {
2070 worker: spawned,
2071 mailbox_id,
2072 parked_at: None,
2073 })
2074 }
2075
2076 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2080 self.ensure_pool_reaper();
2081 if !worker.worker.is_alive() {
2082 tracing::info!(
2083 pool_key = key,
2084 mailbox_id = %worker.mailbox_id,
2085 worker_pid = ?worker.worker.pid(),
2086 shutdown_reason = "process_exited_before_park",
2087 "discarding sub-agent worker instead of parking"
2088 );
2089 worker.worker.kill().await;
2090 return;
2091 }
2092 worker.parked_at = Some(Instant::now());
2093 let mut pool = self.pool.lock().await;
2094 let key_count = pool.get(key).map_or(0, Vec::len);
2095 let total_count = pool.values().map(Vec::len).sum::<usize>();
2096 let limit_reason =
2097 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2098 if let Some(shutdown_reason) = limit_reason {
2099 drop(pool);
2100 tracing::info!(
2101 pool_key = key,
2102 mailbox_id = %worker.mailbox_id,
2103 worker_pid = ?worker.worker.pid(),
2104 idle_workers_for_key = key_count,
2105 idle_workers_total = total_count,
2106 max_idle_per_key = self.max_idle_per_key,
2107 max_idle_total = self.max_idle_total,
2108 shutdown_reason,
2109 "discarding sub-agent worker because the warm pool is full"
2110 );
2111 worker.worker.kill().await;
2112 return;
2113 }
2114 pool.entry(key.to_string()).or_default().push(worker);
2115 }
2116
2117 fn build_spec_with_provider_config(
2120 &self,
2121 session: &Session,
2122 job: &SpawnJob,
2123 credentials: &[ScopedCredential],
2124 default_provider: &str,
2125 max_spawn_depth: u32,
2126 ) -> ProvisionSpec {
2127 let mut spec = ProvisionSpec::new(
2128 ChildIdentity {
2129 child_id: job.child_session_id.clone(),
2130 parent_id: Some(job.parent_session_id.clone()),
2131 project_key: None,
2132 role: session
2133 .metadata
2134 .get("subagent_type")
2135 .cloned()
2136 .unwrap_or_else(|| "worker".to_string()),
2137 depth: session.spawn_depth,
2142 },
2143 self.executor.clone(),
2144 self.fabric_dir.to_string_lossy().into_owned(),
2145 );
2146 spec.workspace = session.workspace.clone();
2147 if let ExecutorSpec::Codex {
2148 workspace_owned, ..
2149 } = &mut spec.executor
2150 {
2151 *workspace_owned = Some(
2152 spec.workspace
2153 .as_deref()
2154 .is_some_and(workspace_is_bamboo_owned),
2155 );
2156 }
2157 spec.bus = self.bus.clone();
2160 spec.model = session
2163 .model_ref
2164 .as_ref()
2165 .map(|r| ModelRefSpec {
2166 provider: r.provider.clone(),
2167 model: r.model.clone(),
2168 })
2169 .or_else(|| {
2170 let m = job.model.trim();
2171 (!m.is_empty()).then(|| ModelRefSpec {
2172 provider: default_provider.to_string(),
2173 model: m.to_string(),
2174 })
2175 });
2176 spec.disabled_tools = job.disabled_tools.clone();
2177 match &spec.executor {
2178 ExecutorSpec::Codex {
2184 auth_mode,
2185 provider_key_ref,
2186 ..
2187 } => {
2188 if auth_mode.as_deref() == Some("custom") {
2189 if let Some(reference) = provider_key_ref {
2190 if let Some(credential) = credentials.iter().find(|credential| {
2191 credential.credential_ref.as_deref() == Some(reference)
2192 }) {
2193 spec.secrets.provider_credentials.push(credential.clone());
2194 } else {
2195 tracing::warn!(
2196 "actor child {}: custom Codex credential reference '{}' did not resolve",
2197 job.child_session_id,
2198 reference
2199 );
2200 }
2201 } else {
2202 tracing::warn!(
2203 "actor child {}: custom Codex executor has no credential reference",
2204 job.child_session_id
2205 );
2206 }
2207 }
2208 }
2209 _ => {
2212 let provider = spec
2213 .model
2214 .as_ref()
2215 .map(|model| model.provider.as_str())
2216 .filter(|provider| !provider.trim().is_empty())
2217 .unwrap_or(default_provider);
2218 if let Some(credential) = credentials
2219 .iter()
2220 .find(|credential| credential.provider == provider)
2221 {
2222 spec.secrets.provider_credentials.push(credential.clone());
2223 } else {
2224 tracing::warn!(
2225 "actor child {}: no credential found for provider '{}'",
2226 job.child_session_id,
2227 provider
2228 );
2229 }
2230 }
2231 }
2232 spec.capabilities.nested_spawn = session.spawn_depth < max_spawn_depth;
2235 spec.capabilities.max_spawn_depth = Some(max_spawn_depth);
2236 spec.capabilities.enforce_permissions = true;
2242 let requested_permission_mode = session
2247 .agent_runtime_state
2248 .as_ref()
2249 .map(|state| state.effective_permission_mode())
2250 .unwrap_or_default();
2251 let configured_permission_mode = self
2252 .permission_config
2253 .as_ref()
2254 .map(|config| config.mode())
2255 .unwrap_or_default();
2256 spec.capabilities.no_human_approver = session
2261 .agent_runtime_state
2262 .as_ref()
2263 .is_some_and(|s| s.no_human_approver);
2264 let typed_read_only = session
2270 .agent_runtime_state
2271 .as_ref()
2272 .is_some_and(|state| state.read_only);
2273 let legacy_guardian_read_only = spec.identity.role == "guardian"
2274 && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2275 ["Edit", "Write", "SubAgent", "WebFetch"]
2276 .iter()
2277 .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2278 });
2279 let read_only = typed_read_only || legacy_guardian_read_only;
2280 spec.capabilities.read_only = read_only;
2281 spec.capabilities.guardian_read_only = read_only;
2285 if spec.capabilities.read_only_enforced() {
2286 spec.capabilities.nested_spawn = false;
2289 spec.disabled_tools
2294 .get_or_insert_with(Default::default)
2295 .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2296 match &mut spec.executor {
2297 ExecutorSpec::ClaudeCode {
2298 permission_mode,
2299 inherit_user_config,
2300 ..
2301 } => {
2302 *permission_mode = Some("plan".to_string());
2303 *inherit_user_config = Some(false);
2306 }
2307 ExecutorSpec::Codex {
2308 mode,
2309 sandbox,
2310 inherit_user_config,
2311 approval_policy,
2312 network_access,
2313 allow_danger_bypass,
2314 permission_profile,
2315 ..
2316 } => {
2317 *sandbox = Some("read-only".to_string());
2318 *inherit_user_config = Some(false);
2319 *approval_policy = Some(
2322 if mode.as_deref() == Some("app_server") {
2323 "on-request"
2324 } else {
2325 "never"
2326 }
2327 .to_string(),
2328 );
2329 *network_access = Some(false);
2330 *allow_danger_bypass = Some(false);
2331 *permission_profile = Some("read-only".to_string());
2332 }
2333 _ => {}
2334 }
2335 }
2336 let read_only_overlay = session
2337 .agent_runtime_state
2338 .as_ref()
2339 .is_some_and(|state| state.plan_mode.is_some())
2340 || spec.capabilities.read_only_enforced()
2341 || executor_has_read_only_permission_profile(&spec.executor);
2342 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2343 requested_permission_mode,
2344 configured_permission_mode,
2345 read_only_overlay,
2346 );
2347 spec.capabilities.bypass = permission_resolution.bypass_permissions();
2348 spec.capabilities.auto_approve_permissions =
2349 permission_resolution.suppress_approval_prompts();
2350 spec.capabilities.permission_requested_mode =
2351 permission_resolution.requested.as_str().to_string();
2352 spec.capabilities.permission_effective_mode =
2353 permission_resolution.effective.as_str().to_string();
2354 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2361 spec.placement = Placement::Remote {
2362 endpoint: placement.endpoint.clone(),
2363 };
2364 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2365 {
2366 spec.placement = Placement::Schedulable {
2374 pool: placement.pool.clone(),
2375 };
2376 }
2377 spec
2378 }
2379
2380 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2383 self.build_spec_with_provider_config(
2384 session,
2385 job,
2386 &self.credentials,
2387 &self.default_provider,
2388 self.max_spawn_depth,
2389 )
2390 }
2391
2392 async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2397 let Some(config) = self.live_provider_config.as_ref() else {
2398 return self.build_spec(session, job);
2399 };
2400
2401 let config = config.read().await;
2402 let credentials = super::runtime::extract_provider_credentials(&config);
2403 let default_provider = config.effective_default_provider().to_string();
2404 self.build_spec_with_provider_config(
2405 session,
2406 job,
2407 &credentials,
2408 &default_provider,
2409 config.subagents().effective_max_spawn_depth(),
2410 )
2411 }
2412
2413 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2419 let host_label = match &spec.placement {
2420 Placement::Remote { .. } => self
2421 .remote_placements
2422 .get(spec.identity.role.as_str())
2423 .and_then(|p| p.host_label.as_deref()),
2424 Placement::Schedulable { .. } => self
2425 .schedulable_placements
2426 .get(spec.identity.role.as_str())
2427 .and_then(|p| p.host_label.as_deref()),
2428 Placement::Local => None,
2429 };
2430 placement_metadata(&spec.placement, host_label)
2431 }
2432
2433 fn scoped_placement_route(
2434 &self,
2435 spec: &ProvisionSpec,
2436 ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2437 match &spec.placement {
2438 Placement::Local => Ok(None),
2439 Placement::Remote { .. } => {
2440 let placement = self
2441 .remote_placements
2442 .get(&spec.identity.role)
2443 .ok_or_else(remote_broker_unavailable)?;
2444 let peer = placement
2445 .broker_peer
2446 .as_ref()
2447 .and_then(|peer| peer.as_ref().ok())
2448 .ok_or_else(remote_broker_unavailable)?;
2449 let worker_role = peer
2450 .worker_role
2451 .clone()
2452 .ok_or_else(remote_broker_unavailable)?;
2453 Ok(Some(ScopedPlacementRoute {
2454 endpoint: placement.endpoint.clone(),
2455 token: placement
2456 .token
2457 .clone()
2458 .ok_or_else(remote_broker_unavailable)?,
2459 ca_cert_file: placement
2460 .ca_cert_file
2461 .clone()
2462 .ok_or_else(remote_broker_unavailable)?,
2463 parent: bamboo_subagent::AgentRef {
2464 session_id: peer.parent_mailbox.clone(),
2465 role: peer.parent_role.clone(),
2466 },
2467 worker_role,
2468 pinned_mailbox: Some(peer.worker_mailbox.clone()),
2469 requirements: placement
2470 .requirements
2471 .clone()
2472 .ok_or_else(remote_broker_unavailable)?,
2473 }))
2474 }
2475 Placement::Schedulable { .. } => {
2476 let placement = self
2477 .schedulable_placements
2478 .get(&spec.identity.role)
2479 .ok_or_else(remote_broker_unavailable)?;
2480 let parent = placement
2481 .broker_parent
2482 .as_ref()
2483 .ok_or_else(remote_broker_unavailable)?;
2484 Ok(Some(ScopedPlacementRoute {
2485 endpoint: placement.endpoint.clone(),
2486 token: placement
2487 .token
2488 .clone()
2489 .ok_or_else(remote_broker_unavailable)?,
2490 ca_cert_file: placement
2491 .ca_cert_file
2492 .clone()
2493 .ok_or_else(remote_broker_unavailable)?,
2494 parent: bamboo_subagent::AgentRef {
2495 session_id: parent.parent_mailbox.clone(),
2496 role: Some(parent.parent_role.clone()),
2497 },
2498 worker_role: placement.pool.clone(),
2499 pinned_mailbox: None,
2500 requirements: placement
2501 .requirements
2502 .clone()
2503 .ok_or_else(remote_broker_unavailable)?,
2504 }))
2505 }
2506 }
2507 }
2508
2509 async fn select_scoped_worker(
2510 &self,
2511 route: &ScopedPlacementRoute,
2512 registry: &bamboo_storage::v2::FileHostRegistry,
2513 actor_id: &str,
2514 project_id: Option<bamboo_domain::ProjectId>,
2515 required_tools: &BTreeSet<String>,
2516 ) -> Result<String, AgentError> {
2517 if let Some(mailbox) = &route.pinned_mailbox {
2518 return Ok(mailbox.clone());
2519 }
2520 let mut query = route.query().await?;
2521 let candidates = query
2522 .list_connected(&route.worker_role)
2523 .await
2524 .map_err(|_| remote_broker_unavailable())?;
2525 let scheduler = PlacementScheduler::new(registry.clone());
2526 let mut selected: Option<(String, usize, u16)> = None;
2527 for mailbox in candidates {
2528 let Some(observed) = query
2529 .observe_host_capacity(&mailbox, &route.worker_role)
2530 .await
2531 .map_err(|_| remote_broker_unavailable())?
2532 else {
2533 continue;
2534 };
2535 let Ok(host) = scheduler
2536 .observe_scoped_host(
2537 &mut query,
2538 &observed.host_ref,
2539 &mailbox,
2540 &route.worker_role,
2541 chrono::Duration::seconds(110),
2542 )
2543 .await
2544 else {
2545 continue;
2546 };
2547 let now = chrono::Utc::now();
2548 let request = HostPlacementRequest {
2549 intent: HostPlacementIntent::Pool {
2550 pool_id: route.worker_role.clone(),
2551 },
2552 actor_id: actor_id.to_owned(),
2553 run_id: "selection".into(),
2554 project_id: project_id.clone(),
2555 trust_zone: route.requirements.trust_zone.clone(),
2556 workspace_label: route.requirements.workspace_label.clone(),
2557 executor: "bamboo-runtime".into(),
2558 required_tools: required_tools.clone(),
2559 network_zone: route.requirements.network_zone.clone(),
2560 require_network_isolation: route.requirements.require_network_isolation,
2561 preferred_host_ref: None,
2562 now,
2563 lease_expires_at: now + chrono::Duration::seconds(80),
2564 };
2565 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2566 || !request.eligible(&host)
2567 {
2568 continue;
2569 }
2570 let load = host.active_slots(now);
2571 if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2572 load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2573 }) {
2574 selected = Some((mailbox, load, host.max_slots));
2575 }
2576 }
2577 selected
2578 .map(|(mailbox, _, _)| mailbox)
2579 .ok_or_else(remote_broker_unavailable)
2580 }
2581}
2582
2583#[async_trait]
2584impl ExternalChildRunner for ActorChildRunner {
2585 async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2586 let role = session
2587 .metadata
2588 .get("subagent_type")
2589 .map(String::as_str)
2590 .unwrap_or("worker");
2591 let current = std::env::current_exe()
2592 .ok()
2593 .and_then(|path| path.canonicalize().ok());
2594 if self.native_tool_ceiling.is_none()
2595 || !self.builtin_required_context_route
2596 || !self.should_handle(session).await
2597 || current.is_none()
2598 || self.worker_bin.canonicalize().ok() != current
2599 || self.worker_args != ["subagent-worker".to_string()]
2600 || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2601 || self.bus.is_none()
2602 || self.remote_placements.contains_key(role)
2603 || self.schedulable_placements.contains_key(role)
2604 {
2605 return Err(
2606 "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2607 .into(),
2608 );
2609 }
2610 bamboo_subagent::fleet::require_worker_capability(
2611 &self.worker_bin,
2612 &self.worker_args,
2613 bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2614 )
2615 .await
2616 .map_err(|_| {
2617 "required_child_context_unsupported: worker capability unconfirmed".to_string()
2618 })?;
2619 bamboo_subagent::fleet::require_worker_capability(
2620 &self.worker_bin,
2621 &self.worker_args,
2622 bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2623 )
2624 .await
2625 .map_err(|_| {
2626 "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2627 })?;
2628 bamboo_subagent::fleet::require_worker_capability(
2629 &self.worker_bin,
2630 &self.worker_args,
2631 bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2632 )
2633 .await
2634 .map_err(|_| {
2635 "required_child_context_unsupported: worker birth capability unconfirmed".into()
2636 })
2637 }
2638 async fn should_handle(&self, session: &Session) -> bool {
2639 session.metadata.get("runtime.kind") == Some(&"external".to_string())
2640 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2641 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2642 }
2643
2644 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2645 *self.escalation_bridge.lock().recover_poison() = bridge;
2646 }
2647
2648 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2649 *self.session_inbox_runtime.lock().recover_poison() = binding;
2650 }
2651
2652 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2653 *self.actor_directory_store.lock().recover_poison() = store;
2654 }
2655
2656 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2657 *self.canonical_subagent_tool.lock().recover_poison() = tool;
2658 }
2659
2660 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2661 *self.ticket_service.lock().recover_poison() = service;
2662 }
2663
2664 fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2665 *self.actor_event_observer.lock().recover_poison() = observer;
2666 }
2667
2668 async fn prepare_durable_child_delivery(
2669 &self,
2670 session: &Session,
2671 activation_run_id: &str,
2672 ) -> Result<bool, String> {
2673 let key = (
2674 session.id.clone(),
2675 session.created_at,
2676 activation_run_id.to_owned(),
2677 );
2678 let (delivery, host_nested_wait_handoff, strict_history) = {
2679 let pending = self.pending_durable_links.lock().await;
2680 let Some(pending) = pending.get(&key) else {
2681 return Ok(false);
2682 };
2683 if pending.strict_history_required && pending.strict_history.is_none() {
2684 return Err("Host strict terminal completeness proof missing".into());
2685 }
2686 (
2687 pending
2688 .link
2689 .durable_delivery_receipt()
2690 .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2691 pending.host_nested_wait_handoff,
2692 pending.strict_history.clone(),
2693 )
2694 };
2695 let expected_status =
2696 effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2697 if session.last_run_status().as_deref() != Some(expected_status) {
2698 tracing::warn!(
2699 child_session_id = %session.id,
2700 activation_run_id,
2701 expected_status,
2702 actual_status = ?session.last_run_status(),
2703 "Host final status differs from accepted broker terminal"
2704 );
2705 return Err("Host final status differs from accepted broker terminal".into());
2706 }
2707 let store = self
2708 .actor_directory_store
2709 .lock()
2710 .recover_poison()
2711 .clone()
2712 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2713 let binding = self
2714 .session_inbox_runtime
2715 .lock()
2716 .recover_poison()
2717 .clone()
2718 .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2719 if !binding
2720 .router
2721 .owns_run(&session.id, activation_run_id)
2722 .await
2723 {
2724 return Err("stale Child activation cannot prepare broker receipt".into());
2725 }
2726 let ticket_service = self.ticket_service.lock().recover_poison().clone();
2727 let yielded_call = if expected_status == "completed"
2728 && session
2729 .metadata
2730 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2731 {
2732 let service = ticket_service
2733 .as_deref()
2734 .ok_or("Ticket authority unavailable for broker question")?;
2735 crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2736 .map_err(|e| e.to_string())?
2737 } else {
2738 None
2739 };
2740 let route = bamboo_storage::v2::BrokerTerminalRoute {
2741 activation_run_id,
2742 broker_identity: &delivery.broker_identity,
2743 parent_mailbox: &delivery.parent_mailbox,
2744 broker_correlation_id: &delivery.correlation_id,
2745 message_ids: &delivery.message_ids,
2746 };
2747 if let Some(call_id) = yielded_call {
2748 let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2749 session,
2750 activation_run_id,
2751 &call_id,
2752 )
2753 .map_err(|error| error.to_string())?;
2754 if let Some(history) = strict_history.as_ref() {
2755 store
2756 .prepare_broker_tool_yield_receipt_with_completeness(
2757 session,
2758 route,
2759 &proof,
2760 &history.proof,
2761 )
2762 .await
2763 } else {
2764 store
2765 .prepare_broker_tool_yield_receipt(session, route, &proof)
2766 .await
2767 }
2768 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2769 } else {
2770 if let Some(history) = strict_history.as_ref() {
2771 store
2772 .prepare_broker_terminal_receipt_with_completeness(
2773 session,
2774 route,
2775 &history.proof,
2776 )
2777 .await
2778 } else {
2779 store
2780 .prepare_broker_terminal_receipt(
2781 session,
2782 activation_run_id,
2783 &delivery.broker_identity,
2784 &delivery.parent_mailbox,
2785 &delivery.correlation_id,
2786 &delivery.message_ids,
2787 )
2788 .await
2789 }
2790 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2791 }
2792 Ok(true)
2793 }
2794
2795 async fn commit_durable_child_delivery(
2796 &self,
2797 session: &Session,
2798 activation_run_id: &str,
2799 ) -> Result<(), String> {
2800 let key = (
2801 session.id.clone(),
2802 session.created_at,
2803 activation_run_id.to_owned(),
2804 );
2805 let mut pending_links = self.pending_durable_links.lock().await;
2806 let Some(pending) = pending_links.get_mut(&key) else {
2807 return Ok(());
2808 };
2809 let delivery = pending
2810 .link
2811 .durable_delivery_receipt()
2812 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2813 let expected_status = effective_broker_terminal_status(
2814 delivery.terminal_status,
2815 pending.host_nested_wait_handoff,
2816 )?;
2817 if session.last_run_status().as_deref() != Some(expected_status) {
2818 return Err("Host final status differs from accepted broker terminal".into());
2819 }
2820 if pending.strict_history_required && pending.strict_history.is_none() {
2821 return Err("Host strict terminal completeness proof missing".into());
2822 }
2823 let store = self
2824 .actor_directory_store
2825 .lock()
2826 .recover_poison()
2827 .clone()
2828 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2829 let committed = if let Some(history) = pending.strict_history.as_ref() {
2830 store
2831 .commit_broker_terminal_receipt_with_completeness(
2832 session,
2833 activation_run_id,
2834 &history.proof,
2835 )
2836 .await
2837 } else {
2838 store
2839 .commit_broker_terminal_receipt(session, activation_run_id)
2840 .await
2841 }
2842 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2843 if committed.broker_identity != delivery.broker_identity
2844 || committed.parent_mailbox != delivery.parent_mailbox
2845 || committed.broker_correlation_id != delivery.correlation_id
2846 || committed.message_ids != delivery.message_ids
2847 || committed.terminal_status != expected_status
2848 {
2849 return Err("Host broker receipt changed before completion".into());
2850 }
2851 if let Some(history) = pending.strict_history.as_ref() {
2852 let proof = committed
2853 .terminal_completeness
2854 .as_ref()
2855 .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2856 let creation = bamboo_subagent::proto::ChildCreationIdentity {
2857 created_at: session.created_at,
2858 spawn_depth: session.spawn_depth,
2859 };
2860 validate_strict_history_terminal(
2861 Some(&history.watermark),
2862 session,
2863 session
2864 .parent_session_id
2865 .as_deref()
2866 .ok_or("Child parent missing")?,
2867 Some(activation_run_id),
2868 proof.execution_epoch,
2869 Some(&creation),
2870 proof.contiguous_applied_seq,
2871 )
2872 .map_err(|error| error.to_string())?;
2873 }
2874 publish_recovered_completion_source(&store, &committed).await?;
2878 pending.strict_history_committed = true;
2879 Ok(())
2880 }
2881
2882 async fn confirm_durable_child_delivery(
2883 &self,
2884 session: &Session,
2885 activation_run_id: &str,
2886 save_succeeded: bool,
2887 ) -> Result<(), String> {
2888 if save_succeeded
2889 && session
2890 .metadata
2891 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2892 {
2893 let service = self
2894 .ticket_service
2895 .lock()
2896 .recover_poison()
2897 .clone()
2898 .ok_or_else(|| {
2899 "Ticket authority unavailable for terminal checkpoint".to_string()
2900 })?;
2901 let runtime = self
2902 .session_inbox_runtime
2903 .lock()
2904 .recover_poison()
2905 .clone()
2906 .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2907 crate::ticket_runtime::checkpoint_ticket_result(
2908 &service,
2909 runtime.storage.as_ref(),
2910 session,
2911 activation_run_id,
2912 )
2913 .await
2914 .map_err(|e| e.to_string())?;
2915 }
2916 let key = (
2917 session.id.clone(),
2918 session.created_at,
2919 activation_run_id.to_owned(),
2920 );
2921 let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2922 return Ok(());
2923 };
2924 let delivery = pending
2927 .link
2928 .durable_delivery_receipt()
2929 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2930 let expected_status = effective_broker_terminal_status(
2931 delivery.terminal_status,
2932 pending.host_nested_wait_handoff,
2933 )?;
2934 if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2935 return Ok(());
2936 }
2937 let store = self
2938 .actor_directory_store
2939 .lock()
2940 .recover_poison()
2941 .clone()
2942 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2943 let committed = if pending.strict_history_required {
2944 if !pending.strict_history_committed {
2945 return Err("Host strict completeness was not committed before ACK".into());
2946 }
2947 let history = pending
2948 .strict_history
2949 .as_ref()
2950 .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2951 store
2952 .commit_broker_terminal_receipt_with_completeness(
2953 session,
2954 activation_run_id,
2955 &history.proof,
2956 )
2957 .await
2958 } else {
2959 store
2960 .commit_broker_terminal_receipt(session, activation_run_id)
2961 .await
2962 }
2963 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2964 if committed.broker_identity != delivery.broker_identity
2965 || committed.parent_mailbox != delivery.parent_mailbox
2966 || committed.broker_correlation_id != delivery.correlation_id
2967 || committed.message_ids != delivery.message_ids
2968 || committed.terminal_status != expected_status
2969 {
2970 return Err("Host broker receipt changed before ACK".into());
2971 }
2972 pending
2973 .link
2974 .acknowledge_durable_frames()
2975 .await
2976 .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2977 store
2978 .clear_acknowledged_broker_terminal_receipt(
2979 &session.id,
2980 session.created_at,
2981 activation_run_id,
2982 &delivery.parent_mailbox,
2983 )
2984 .await
2985 .map_err(|error| {
2986 format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2987 })
2988 }
2989
2990 #[tracing::instrument(
2991 name = "actor_child_run",
2992 level = "warn",
2993 skip_all,
2994 fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2995 )]
2996 async fn execute_external_child(
2997 &self,
2998 session: &mut Session,
2999 job: &SpawnJob,
3000 event_tx: mpsc::Sender<AgentEvent>,
3001 cancel_token: CancellationToken,
3002 ) -> crate::runtime::runner::Result<()> {
3003 crate::session_app::child_session::named_profile::validate_named_profile(session)
3004 .map_err(|error| AgentError::LLM(error.to_string()))?;
3005 let escalation = self.escalation_bridge.lock().recover_poison().clone();
3014 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3015 let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3016 let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3017 let ticket_service = self.ticket_service.lock().recover_poison().clone();
3018 let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3019 let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3020 .map_err(|error| AgentError::Budget(error.to_string()))?;
3021 if crate::session_app::child_session::named_profile::has_named_profile(session)
3022 && required_context.is_none()
3023 {
3024 return Err(AgentError::LLM(
3025 "named_profile_requires_strict_context_route".into(),
3026 ));
3027 }
3028 let assignment = required_context
3029 .as_ref()
3030 .map(|binding| binding.payload.required_assignment.clone())
3031 .unwrap_or_else(|| extract_assignment(session));
3032 let mut spec = self.build_live_spec(session, job).await;
3033 ensure_ticket_placement_is_enforceable(session, &spec)?;
3036 let scoped_route = self.scoped_placement_route(&spec)?;
3037 let strict_remote = scoped_route.as_ref();
3038 if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3039 return Err(remote_broker_unavailable());
3040 }
3041 let lineage = if strict_remote.is_some() {
3042 Some(
3043 remote_canonical_lineage(
3044 session_inbox_runtime
3045 .as_ref()
3046 .ok_or_else(remote_broker_unavailable)?,
3047 session,
3048 )
3049 .await?,
3050 )
3051 } else {
3052 None
3053 };
3054 let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3055 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3056 {
3057 let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3058 AgentError::LLM(
3059 "child creation identity unavailable: canonical host Storage required".into(),
3060 )
3061 })?;
3062 spec.capabilities.child_creation_identity = true;
3063 Some(canonical_child_creation(binding, session, job).await?)
3064 } else {
3065 None
3066 };
3067 spec.reusable = required_context.is_none();
3070 spec.capabilities.required_child_context = required_context.is_some();
3071 if required_context.is_some() {
3072 self.validate_required_child_context_route(session)
3073 .await
3074 .map_err(AgentError::LLM)?;
3075 }
3076 if required_context.is_some() {
3077 let names = self
3078 .native_tool_ceiling
3079 .as_ref()
3080 .unwrap()
3081 .observe(session)
3082 .await
3083 .map_err(AgentError::LLM)?;
3084 let birth = creation
3085 .as_ref()
3086 .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3087 spec.capabilities.native_tool_ceiling_required = true;
3088 spec.capabilities.native_tool_ceiling =
3089 Some(bamboo_subagent::proto::NativeToolCeiling {
3090 version: 1,
3091 child_session_id: session.id.clone(),
3092 parent_session_id: job.parent_session_id.clone(),
3093 root_session_id: session.root_session_id.clone(),
3094 created_at: birth.created_at,
3095 spawn_depth: birth.spawn_depth,
3096 project_id: project_id_for_actor_run(session)?,
3097 tools: names,
3098 });
3099 spec.capabilities.mcp = None;
3101 spec.capabilities.mcp_proxy = None;
3102 spec.capabilities.skills_dir = None;
3103 spec.capabilities.nested_spawn = false;
3104 spec.validate()
3105 .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3106 }
3107 let first_reply_store = actor_directory_store.clone().filter(|_| {
3110 required_context.is_some()
3111 && crate::session_app::child_session::named_profile::has_named_profile(session)
3112 && matches!(spec.placement, Placement::Local)
3113 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3114 && spec.capabilities.native_tool_ceiling_required
3115 && spec
3116 .capabilities
3117 .native_tool_ceiling
3118 .as_ref()
3119 .is_some_and(|ceiling| {
3120 ceiling.tools.is_empty()
3121 || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3122 })
3123 });
3124 let plain_actor_store = if let Some(store) = first_reply_store {
3125 let root = bamboo_agent_core::storage::Storage::load_session(
3126 store.as_ref(),
3127 &session.root_session_id,
3128 )
3129 .await
3130 .map_err(|_| plain_actor_unsupported())?
3131 .ok_or_else(|| plain_actor_unsupported())?;
3132 if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3133 return Err(plain_actor_unsupported());
3134 }
3135 if root.root_orchestration_only_enabled() {
3136 if !plain_initial_history(session)
3137 && (spec
3138 .capabilities
3139 .native_tool_ceiling
3140 .as_ref()
3141 .is_none_or(|ceiling| !ceiling.tools.is_empty())
3142 || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3143 {
3144 return Err(plain_actor_unsupported());
3145 }
3146 Some(store)
3147 } else if spec
3148 .capabilities
3149 .native_tool_ceiling
3150 .as_ref()
3151 .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3152 {
3153 require_unowned_glob(store.as_ref(), session).await?;
3156 None
3157 } else {
3158 let observed = store
3161 .actor_subtree_snapshot(
3162 ActorSnapshotPrincipal::host_owner(),
3163 &session.root_session_id,
3164 &session.id,
3165 ActorSnapshotLimits::default(),
3166 )
3167 .await
3168 .map_err(|_| plain_actor_unsupported())?;
3169 if observed
3170 .nodes
3171 .iter()
3172 .any(|node| node.actor_id == session.id && node.activation.is_some())
3173 {
3174 return Err(plain_actor_unsupported());
3175 }
3176 None
3177 }
3178 } else {
3179 None
3180 };
3181 let readonly_actor = plain_actor_store.is_some()
3182 && spec
3183 .capabilities
3184 .native_tool_ceiling
3185 .as_ref()
3186 .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3187 let local_history_tools = (plain_actor_store.is_none()
3188 && required_context.is_some()
3189 && (crate::session_app::child_session::named_profile::has_named_profile(session)
3190 || session
3191 .metadata
3192 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3193 && matches!(spec.placement, Placement::Local)
3194 && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3195 .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3196 .flatten()
3197 .filter(|ceiling| {
3198 bamboo_subagent::proto::LocalToolMessages::supports_tools(
3199 &ceiling.tools,
3200 spec.capabilities.read_only_enforced(),
3201 )
3202 })
3203 .map(|ceiling| ceiling.tools.clone());
3204 if plain_actor_store.is_some() && !readonly_actor {
3205 self.require_initial_input_release_worker().await?;
3206 spec.capabilities.initial_input_release_required = true;
3207 spec.validate().map_err(|_| plain_actor_unsupported())?;
3208 }
3209 let mut readonly_messages = Vec::new();
3210 let mut plain_activation = None;
3211 if spec.limits.idle_timeout_secs.is_none() {
3212 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3213 }
3214 ensure_read_only_activation_is_enforceable(&spec)?;
3215 let pool_key = Self::fingerprint(&spec);
3216
3217 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3222 {
3223 return Err(AgentError::LLM(
3224 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3225 .to_string(),
3226 ));
3227 }
3228 let project_id = project_id_for_actor_run(session)?;
3229 let requested_permission_mode = session
3230 .agent_runtime_state
3231 .as_ref()
3232 .map(|state| state.effective_permission_mode())
3233 .unwrap_or_default();
3234 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3239 ensure_no_active_scoped_session_denies(config, &session.id)?;
3240 let read_only_overlay = session
3241 .agent_runtime_state
3242 .as_ref()
3243 .is_some_and(|state| state.plan_mode.is_some())
3244 || spec.capabilities.read_only_enforced()
3245 || executor_has_read_only_permission_profile(&spec.executor);
3246 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3247 requested_permission_mode,
3248 config.mode(),
3249 read_only_overlay,
3250 );
3251 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3252 AgentError::LLM(format!(
3253 "failed to serialize permission policy for external executor: {error}"
3254 ))
3255 })?;
3256 Some(PermissionPolicyContext {
3257 revision: config.policy_revision(),
3258 requested_mode: resolution.requested.as_str().to_string(),
3259 effective_mode: resolution.effective.as_str().to_string(),
3260 bypass_permissions: resolution.bypass_permissions(),
3261 auto_approve_permissions: resolution.suppress_approval_prompts(),
3262 session_id: session.id.clone(),
3263 workspace_path: session.workspace.clone(),
3264 environment_lease: None,
3265 inherit_session_grants: false,
3266 policy,
3267 })
3268 } else {
3269 None
3270 };
3271 let provisioned_permission =
3272 spec.capabilities.permission_resolution().map_err(|error| {
3273 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3274 })?;
3275 let policy_resolution = permission_policy
3276 .as_ref()
3277 .map(|context| {
3278 context.resolved_modes().map(|(requested, effective)| {
3279 bamboo_domain::PermissionModeResolution {
3280 requested,
3281 effective,
3282 }
3283 })
3284 })
3285 .transpose()
3286 .map_err(|error| {
3287 AgentError::LLM(format!("invalid host permission posture: {error}"))
3288 })?;
3289 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3290 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3291 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3292 .is_some()
3293 });
3294 let expected_executor_mapping = expected_permission_executor_mapping(
3295 &spec.executor,
3296 policy_resolution.unwrap_or(provisioned_permission),
3297 has_explicit_deny,
3298 )?;
3299 let mut expected_permission_posture =
3300 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3301 policy_revision: permission_policy
3302 .as_ref()
3303 .map(|context| context.revision)
3304 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3305 .unwrap_or_default(),
3306 resolution: policy_resolution.unwrap_or(provisioned_permission),
3307 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3308 executor_mapping,
3309 });
3310
3311 let _slot = if strict_remote.is_some() {
3316 tokio::select! { biased;
3317 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3318 permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3319 }
3320 } else {
3321 self.concurrency
3322 .acquire()
3323 .await
3324 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3325 };
3326 let _remote_subscription = if strict_remote.is_some() {
3327 Some(tokio::select! { biased;
3328 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3329 guard = self.strict_remote_subscription.lock() => guard,
3330 })
3331 } else {
3332 None
3333 };
3334
3335 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3342 &spec.executor,
3343 self.codex_run_tokens.clone(),
3344 &job.child_session_id,
3345 )?;
3346
3347 let kind = match spec.placement {
3355 Placement::Remote { .. } => PlacementKind::Remote,
3356 Placement::Schedulable { .. } => PlacementKind::Schedulable,
3357 Placement::Local => PlacementKind::Local,
3358 };
3359 let remote = !matches!(kind, PlacementKind::Local);
3360 let scoped_registry = if remote {
3361 let store = actor_directory_store
3362 .as_ref()
3363 .ok_or_else(remote_broker_unavailable)?;
3364 Some(
3365 bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3366 .await
3367 .map_err(|_| remote_broker_unavailable())?,
3368 )
3369 } else {
3370 None
3371 };
3372 let scoped_tools = scoped_route.as_ref().map(|route| {
3373 let mut tools = route.requirements.required_tools.clone();
3374 if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3375 tools.extend(ceiling.tools.iter().cloned());
3376 }
3377 tools
3378 });
3379
3380 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3386 session
3387 .metadata
3388 .insert("placement".to_string(), placement_meta);
3389 }
3390
3391 let mut attempt = 0u8;
3398 let (result, actor) = loop {
3399 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3400 let (actor, mut client) = match kind {
3401 PlacementKind::Remote => {
3402 let route = scoped_route
3403 .as_ref()
3404 .ok_or_else(remote_broker_unavailable)?;
3405 let mailbox_id = route
3406 .pinned_mailbox
3407 .clone()
3408 .ok_or_else(remote_broker_unavailable)?;
3409 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3410 .map_err(|_| remote_broker_unavailable())?;
3411 let connect =
3412 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3413 &route.endpoint,
3414 route.parent.clone(),
3415 &route.token,
3416 bamboo_subagent::AgentRef {
3417 session_id: mailbox_id.clone(),
3418 role: Some(route.worker_role.clone()),
3419 },
3420 tls,
3421 );
3422 let link = tokio::select! { biased;
3423 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3424 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3425 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3426 };
3427 let record = AgentRecord {
3428 agent_id: job.child_session_id.clone(),
3429 role: spec.identity.role.clone(),
3430 labels: Vec::new(),
3431 endpoint: String::new(),
3432 pid: 0,
3433 version: String::new(),
3434 started_at: chrono::Utc::now(),
3435 lease_expires_at: chrono::Utc::now(),
3436 };
3437 (
3438 PooledWorker {
3439 worker: SpawnedChild::remote(record),
3440 mailbox_id,
3441 parked_at: None,
3442 },
3443 Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3444 )
3445 }
3446 PlacementKind::Schedulable => {
3447 let route = scoped_route
3448 .as_ref()
3449 .ok_or_else(remote_broker_unavailable)?;
3450 let registry = scoped_registry
3451 .as_ref()
3452 .ok_or_else(remote_broker_unavailable)?;
3453 let mailbox_id = self
3454 .select_scoped_worker(
3455 route,
3456 registry,
3457 &session.id,
3458 project_id.clone(),
3459 scoped_tools
3460 .as_ref()
3461 .ok_or_else(remote_broker_unavailable)?,
3462 )
3463 .await?;
3464 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3465 .map_err(|_| remote_broker_unavailable())?;
3466 let connect =
3467 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3468 &route.endpoint,
3469 route.parent.clone(),
3470 &route.token,
3471 bamboo_subagent::AgentRef {
3472 session_id: mailbox_id.clone(),
3473 role: Some(route.worker_role.clone()),
3474 },
3475 tls,
3476 );
3477 let link = tokio::select! { biased;
3478 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3479 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3480 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3481 };
3482 let actor = PooledWorker {
3483 worker: SpawnedChild::remote(AgentRecord {
3484 agent_id: mailbox_id.clone(),
3485 role: spec.identity.role.clone(),
3486 labels: Vec::new(),
3487 endpoint: route.endpoint.clone(),
3488 pid: 0,
3489 version: String::new(),
3490 started_at: chrono::Utc::now(),
3491 lease_expires_at: chrono::Utc::now(),
3492 }),
3493 mailbox_id,
3494 parked_at: None,
3495 };
3496 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3497 (actor, client)
3498 }
3499 PlacementKind::Local => {
3500 let bus = self.bus.as_ref().ok_or_else(|| {
3507 AgentError::LLM(
3508 "local sub-agents require a mailbox bus (subagents.broker); none is \
3509 configured and the bus could not be embedded"
3510 .to_string(),
3511 )
3512 })?;
3513 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3514 let parent = bamboo_subagent::AgentRef {
3515 session_id: format!("p-{}", job.child_session_id),
3516 role: None,
3517 };
3518 let link = bamboo_broker::BrokerChildLink::connect(
3519 &bus.endpoint,
3520 parent,
3521 &bus.token,
3522 actor.mailbox_id.clone(),
3523 )
3524 .await
3525 .map_err(|e| {
3526 AgentError::LLM(format!("broker child link connect failed: {e}"))
3527 })?;
3528 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3529 (actor, client)
3530 }
3531 };
3532
3533 if let Some(store) = actor_directory_store.as_ref() {
3539 let recovery = async {
3540 let receipts = store
3541 .recover_broker_terminal_receipts(session)
3542 .await
3543 .map_err(|error| {
3544 format!("Host broker receipt recovery blocked: {error}")
3545 })?;
3546 for receipt in receipts {
3547 if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3551 return Err(
3552 "old Child broker parent mailbox changed before ACK".to_string()
3553 );
3554 }
3555 client
3556 .acknowledge_recovered_durable_frames(
3557 &receipt.broker_identity,
3558 &receipt.message_ids,
3559 )
3560 .await
3561 .map_err(|error| {
3562 format!("old Child broker ACK unconfirmed: {error}")
3563 })?;
3564 store
3565 .clear_acknowledged_broker_terminal_receipt(
3566 &receipt.session_id,
3567 receipt.created_at,
3568 &receipt.activation_run_id,
3569 &receipt.parent_mailbox,
3570 )
3571 .await
3572 .map_err(|error| {
3573 format!("old Child broker ACK cleanup failed: {error}")
3574 })?;
3575 }
3576 Ok::<(), String>(())
3577 }
3578 .await;
3579 if let Err(error) = recovery {
3580 if !remote {
3581 actor.worker.kill().await;
3582 }
3583 return Err(AgentError::LLM(error));
3584 }
3585 }
3586
3587 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3592 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3593 Some(binding) => {
3594 let run_id = binding
3595 .router
3596 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3597 .await;
3598 if run_id.is_none() {
3599 tracing::debug!(
3600 session_id = %job.child_session_id,
3601 "actor driver had no current SessionInbox activation owner to bind"
3602 );
3603 }
3604 run_id
3605 }
3606 None => None,
3607 };
3608 tracing::Span::current().record(
3609 "activation_run_id",
3610 bound_activation_run_id.as_deref().unwrap_or("unbound"),
3611 );
3612 drop(delivery_tx);
3613 let initial_pairs = match (
3614 session_inbox_runtime.as_ref(),
3615 bound_activation_run_id.as_deref(),
3616 ) {
3617 (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3618 match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3619 .await
3620 {
3621 Ok(deliveries) => deliveries,
3622 Err(error) => {
3623 binding
3624 .router
3625 .detach_delivery_sink(&job.child_session_id, run_id)
3626 .await;
3627 if !remote {
3628 actor.worker.kill().await;
3629 }
3630 return Err(error);
3631 }
3632 }
3633 }
3634 _ => Vec::new(),
3635 };
3636 let mut initial_session_messages = initial_pairs
3637 .iter()
3638 .map(|(_, delivery)| delivery.clone())
3639 .collect::<Vec<_>>();
3640 let mut initial_inflight_claims = initial_pairs
3641 .into_iter()
3642 .map(|(claim, _)| claim)
3643 .collect::<VecDeque<_>>();
3644 let mut messages = if let Some(binding) = &required_context {
3647 binding
3648 .validate_messages(&session.id, &session.messages)
3649 .map_err(|error| AgentError::Budget(error.to_string()))?;
3650 session
3651 .messages
3652 .iter()
3653 .map(serde_json::to_value)
3654 .collect::<std::result::Result<Vec<_>, _>>()
3655 .map_err(|_| {
3656 AgentError::Budget(
3657 bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3658 )
3659 })?
3660 } else {
3661 session
3662 .messages
3663 .iter()
3664 .filter_map(|message| serde_json::to_value(message).ok())
3665 .collect()
3666 };
3667
3668 if strict_remote.is_some() {
3669 let current =
3670 remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3671 .await;
3672 if current.as_ref().ok() != lineage.as_ref()
3673 || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3674 || cancel_token.is_cancelled()
3675 {
3676 if let (Some(binding), Some(run_id)) = (
3677 session_inbox_runtime.as_ref(),
3678 bound_activation_run_id.as_deref(),
3679 ) {
3680 binding
3681 .router
3682 .detach_delivery_sink(&job.child_session_id, run_id)
3683 .await;
3684 }
3685 return Err(if cancel_token.is_cancelled() {
3686 AgentError::Cancelled
3687 } else {
3688 remote_broker_unavailable()
3689 });
3690 }
3691 }
3692 if let Some(expected) = creation.as_ref() {
3693 let checked =
3694 canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3695 .await;
3696 if checked.as_ref().ok() != Some(expected) {
3697 if let (Some(binding), Some(run_id)) = (
3698 session_inbox_runtime.as_ref(),
3699 bound_activation_run_id.as_deref(),
3700 ) {
3701 binding
3702 .router
3703 .detach_delivery_sink(&job.child_session_id, run_id)
3704 .await;
3705 }
3706 actor.worker.kill().await;
3707 return Err(AgentError::LLM(
3708 "child creation identity changed before dispatch".into(),
3709 ));
3710 }
3711 }
3712 if let Some(store) = &plain_actor_store {
3713 let started = async {
3714 let policy = permission_policy
3719 .as_ref()
3720 .ok_or_else(|| plain_actor_unsupported())?;
3721 let config = self
3722 .permission_config
3723 .as_ref()
3724 .ok_or_else(|| plain_actor_unsupported())?;
3725 if config.policy_revision() != policy.revision
3726 || !matches!(spec.placement, Placement::Local)
3727 {
3728 return Err(plain_actor_unsupported());
3729 }
3730 store
3731 .bind_control_plane(
3732 &session.id,
3733 bamboo_domain::ActorControlPlaneBinding {
3734 policy_revision: Some(policy.revision),
3735 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3736 },
3737 )
3738 .await
3739 .map_err(|error| AgentError::LLM(error.to_string()))?;
3740 PlainActorActivation::start_and_prepare(
3741 store.clone(),
3742 session,
3743 session_inbox_runtime.as_ref().unwrap(),
3744 bound_activation_run_id.as_deref(),
3745 self.permission_config.clone(),
3746 !readonly_actor,
3747 spec.capabilities
3748 .initial_input_release_required
3749 .then_some(actor.mailbox_id.as_str()),
3750 )
3751 .await
3752 }
3753 .await;
3754 match started {
3755 Ok((activation, prepared)) => {
3756 if let Some((prefix, deliveries)) = prepared {
3757 messages = prefix;
3758 initial_session_messages = deliveries;
3759 }
3760 plain_activation = Some(activation);
3761 }
3762 Err(error) => {
3763 if let Some(run_id) = bound_activation_run_id.as_deref() {
3764 session_inbox_runtime
3765 .as_ref()
3766 .unwrap()
3767 .router
3768 .detach_delivery_sink(&job.child_session_id, run_id)
3769 .await;
3770 }
3771 actor.worker.kill().await;
3772 return Err(error);
3773 }
3774 }
3775 }
3776 let mut logical_identity = logical_identity_for_actor_run(session, job);
3777 logical_identity.creation = creation.clone();
3778 let run_permission_policy = if strict_remote.is_some() {
3779 let prepared = async {
3780 let mut policy = permission_policy.clone().ok_or_else(|| {
3781 AgentError::LLM("remote_environment_permission_policy_missing".into())
3782 })?;
3783 let workspace = session.workspace.as_deref().ok_or_else(|| {
3784 AgentError::LLM("remote_environment_workspace_missing".into())
3785 })?;
3786 let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3787 AgentError::LLM("remote_environment_activation_missing".into())
3788 })?;
3789 let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3790 workspace,
3791 &session.id,
3792 run_id,
3793 execution_epoch,
3794 )
3795 .await
3796 .map_err(|error| AgentError::LLM(error.into()))?;
3797 policy.workspace_path = None;
3798 policy.environment_lease = Some(lease);
3799 Ok::<_, AgentError>(Some(policy))
3800 }
3801 .await;
3802 match prepared {
3803 Ok(policy) => policy,
3804 Err(error) => {
3805 if let (Some(binding), Some(run_id)) = (
3806 session_inbox_runtime.as_ref(),
3807 bound_activation_run_id.as_deref(),
3808 ) {
3809 binding
3810 .router
3811 .detach_delivery_sink(&job.child_session_id, run_id)
3812 .await;
3813 }
3814 return Err(error);
3815 }
3816 }
3817 } else {
3818 permission_policy.clone()
3819 };
3820 if session
3821 .metadata
3822 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3823 {
3824 let admission = async {
3825 let service = ticket_service
3826 .as_ref()
3827 .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3828 let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3829 AgentError::LLM("Ticket activation runtime unavailable".into())
3830 })?;
3831 let run_id = bound_activation_run_id
3832 .as_deref()
3833 .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3834 crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3835 .await
3836 .map_err(|e| AgentError::LLM(e.to_string()))
3837 }
3838 .await;
3839 if let Err(error) = admission {
3840 if let (Some(runtime), Some(run_id)) = (
3841 session_inbox_runtime.as_ref(),
3842 bound_activation_run_id.as_deref(),
3843 ) {
3844 runtime
3845 .router
3846 .detach_delivery_sink(&job.child_session_id, run_id)
3847 .await;
3848 }
3849 actor.worker.kill().await;
3850 return Err(error);
3851 }
3852 }
3853 let mut run_spec = RunSpec {
3854 assignment: assignment.clone(),
3856 logical_session: Some(logical_identity),
3857 project_id: project_id.clone(),
3858 reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3862 {
3863 session
3864 .reasoning_effort
3865 .map(|effort| effort.as_str().to_owned())
3866 } else {
3867 None
3868 },
3869 permission_policy: run_permission_policy,
3870 messages,
3871 activation_run_id: bound_activation_run_id.clone(),
3872 execution_epoch,
3873 initial_session_messages,
3874 secrets: run_secrets.clone(),
3875 };
3876 let mut active_lease = if remote {
3877 let reserve = ActivePlacementLease::reserve(
3878 scoped_registry
3879 .as_ref()
3880 .ok_or_else(remote_broker_unavailable)?
3881 .clone(),
3882 actor_directory_store
3883 .as_ref()
3884 .ok_or_else(remote_broker_unavailable)?
3885 .clone(),
3886 scoped_route
3887 .as_ref()
3888 .ok_or_else(remote_broker_unavailable)?
3889 .clone(),
3890 actor.mailbox_id.clone(),
3891 session.id.clone(),
3892 bound_activation_run_id
3893 .clone()
3894 .ok_or_else(remote_broker_unavailable)?,
3895 project_id.clone(),
3896 scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3897 &cancel_token,
3898 )
3899 .await;
3900 match reserve {
3901 Ok(lease) => Some(lease),
3902 Err(error) => {
3903 if let (Some(binding), Some(run_id)) = (
3904 session_inbox_runtime.as_ref(),
3905 bound_activation_run_id.as_deref(),
3906 ) {
3907 binding
3908 .router
3909 .detach_delivery_sink(&job.child_session_id, run_id)
3910 .await;
3911 }
3912 return Err(error);
3913 }
3914 }
3915 } else {
3916 None
3917 };
3918 if let Some(lease) = active_lease.as_mut() {
3919 let seeded = async {
3920 let binding = session_inbox_runtime
3921 .as_ref()
3922 .ok_or_else(remote_broker_unavailable)?;
3923 let run_id = bound_activation_run_id
3924 .as_deref()
3925 .ok_or_else(remote_broker_unavailable)?;
3926 let pairs = claim_canonical_deliveries(
3927 binding,
3928 session,
3929 run_id,
3930 usize::MAX,
3931 Some((lease.actor_store.as_ref(), &lease.fence)),
3932 )
3933 .await?;
3934 if let Some(binding) = &required_context {
3935 binding
3936 .validate_messages(&session.id, &session.messages)
3937 .map_err(|error| AgentError::Budget(error.to_string()))?;
3938 }
3939 run_spec.messages = session
3940 .messages
3941 .iter()
3942 .map(serde_json::to_value)
3943 .collect::<std::result::Result<Vec<_>, _>>()
3944 .map_err(|_| remote_broker_unavailable())?;
3945 run_spec.initial_session_messages =
3946 pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3947 initial_inflight_claims = pairs
3948 .into_iter()
3949 .map(|(claim, _)| claim)
3950 .collect::<VecDeque<_>>();
3951 Ok::<(), AgentError>(())
3952 }
3953 .await;
3954 if let Err(error) = seeded {
3955 if let Err(rollback_error) = lease.abort_before_dispatch().await {
3956 tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3957 }
3958 if let (Some(binding), Some(run_id)) = (
3959 session_inbox_runtime.as_ref(),
3960 bound_activation_run_id.as_deref(),
3961 ) {
3962 binding
3963 .router
3964 .detach_delivery_sink(&job.child_session_id, run_id)
3965 .await;
3966 }
3967 return Err(error);
3968 }
3969 }
3970 let mut run_send_invoked = false;
3971 let dispatch = async {
3972 if let Some(activation) = plain_activation
3973 .as_ref()
3974 .filter(|activation| !activation.initial_inputs.is_empty())
3975 {
3976 activation.refresh_continuation_posture(
3977 session,
3978 &run_spec,
3979 &mut expected_permission_posture,
3980 )?;
3981 }
3982 send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3983 }
3984 .await;
3985 if let Err(e) = dispatch {
3986 if remote {
3987 tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3988 }
3989 if let (Some(binding), Some(run_id)) = (
3990 session_inbox_runtime.as_ref(),
3991 bound_activation_run_id.as_deref(),
3992 ) {
3993 binding
3994 .router
3995 .detach_delivery_sink(&job.child_session_id, run_id)
3996 .await;
3997 }
3998 if !remote {
3999 actor.worker.kill().await;
4000 }
4001 if let Some(activation) = &plain_activation {
4002 activation.finish(ActorActivationFinish::Failed).await?;
4003 }
4004 if let Some(lease) = active_lease.as_mut() {
4005 match lease.abort_if_run_unsent(run_send_invoked).await {
4006 Ok(false) => {
4007 tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4008 }
4009 Ok(true) => {}
4010 Err(rollback_error) => {
4011 tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4012 }
4013 }
4014 }
4015 return Err(e);
4016 }
4017
4018 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4022 let live_guard = super::live::register(
4023 &job.child_session_id,
4024 live_tx,
4025 attempt as u32,
4026 self.approval_registry.clone(),
4027 );
4028
4029 let mut strict_history = None;
4030 let result = drive(ActorDriveContext {
4031 client: &mut *client,
4032 parent_session_id: &job.parent_session_id,
4033 child_session_id: &job.child_session_id,
4034 child_attempt: attempt as u32,
4035 approval_registry: self.approval_registry.as_ref(),
4036 approval_decider: self.approval_decider.as_ref(),
4037 approval_reviewer: self.approval_reviewer.as_ref(),
4038 escalation_bridge: escalation.clone(),
4039 event_tx: &event_tx,
4040 cancel_token: active_lease
4041 .as_ref()
4042 .map_or(&cancel_token, |lease| &lease.run_cancel),
4043 live_rx: &mut live_rx,
4044 delivery_rx: &mut delivery_rx,
4045 logical_session: session,
4046 expected_permission_posture: expected_permission_posture.clone(),
4047 expected_creation: creation.as_ref(),
4048 session_inbox_runtime: session_inbox_runtime.as_ref(),
4049 actor_directory_store: actor_directory_store.as_deref(),
4050 canonical_subagent_tool: canonical_subagent_tool.clone(),
4051 ticket_service: ticket_service.clone(),
4052 activation_run_id: bound_activation_run_id.as_deref(),
4053 execution_epoch,
4054 expected_source_actor_id: &actor.mailbox_id,
4055 initial_inflight_claims,
4056 plain_actor: plain_activation.is_some(),
4063 remote_environment_lease: strict_remote.is_some(),
4064 readonly_output: if readonly_actor {
4065 Some(&mut readonly_messages)
4066 } else {
4067 None
4068 },
4069 local_history_tools: local_history_tools.as_deref(),
4070 local_history_read_only: spec.capabilities.read_only_enforced(),
4071 strict_history_output: Some(&mut strict_history),
4072 plain_input: plain_activation.as_ref(),
4073 canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4074 canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4075 actor_event_observer: actor_event_observer.as_deref(),
4076 plain_run: self
4079 .bus
4080 .as_ref()
4081 .map(|_| (&run_spec, &self.next_execution_epoch)),
4082 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4083 })
4084 .await;
4085 let mut result = if active_lease
4086 .as_ref()
4087 .is_some_and(ActivePlacementLease::lost)
4088 {
4089 Err(remote_broker_unavailable())
4090 } else {
4091 result
4092 };
4093 if local_history_tools.is_none() {
4094 let reply = result
4095 .as_ref()
4096 .ok()
4097 .and_then(|reply| reply.as_deref())
4098 .filter(|reply| !reply.is_empty())
4099 .map(str::to_owned);
4100 if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4101 if let Err(error) = append_fenced_remote_reply(
4102 lease.actor_store.as_ref(),
4103 &lease.fence,
4104 session,
4105 &reply,
4106 )
4107 .await
4108 {
4109 result = Err(error);
4110 }
4111 }
4112 }
4113 if remote {
4114 if let Err(error) = &result {
4115 let code = match error {
4116 AgentError::LLM(message) if message.starts_with("actor event") => {
4117 "actor_event"
4118 }
4119 AgentError::LLM(message) if message.starts_with("remote_actor") => {
4120 "remote_actor"
4121 }
4122 AgentError::LLM(message) if message.starts_with("remote_environment") => {
4123 if message.len() <= 128
4124 && message.bytes().all(|byte| {
4125 byte.is_ascii_lowercase()
4126 || byte.is_ascii_digit()
4127 || byte == b'_'
4128 })
4129 {
4130 message.as_str()
4131 } else {
4132 "environment"
4133 }
4134 }
4135 AgentError::LLM(message) if message.contains("permission posture") => {
4136 "permission_posture"
4137 }
4138 AgentError::LLM(message) if message.contains("broker") => "broker",
4139 AgentError::LLM(_) => "other_llm",
4140 AgentError::WorkerUnresponsive(_) => "unresponsive",
4141 AgentError::Cancelled => "cancelled",
4142 _ => "other",
4143 };
4144 tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4145 }
4146 }
4147 if let Some(lease) = active_lease.as_mut() {
4148 let accepted_terminal = client
4149 .durable_delivery_receipt()
4150 .map(|receipt| receipt.terminal_status);
4151 if let Err(error) = lease
4152 .complete(
4153 remote_activation_finish(&result, accepted_terminal),
4154 client.has_pending_durable_terminal(),
4155 )
4156 .await
4157 {
4158 tracing::warn!(
4159 stage = "actor_complete",
4160 "remote placement admission failed"
4161 );
4162 if let (Some(binding), Some(run_id)) = (
4163 session_inbox_runtime.as_ref(),
4164 bound_activation_run_id.as_deref(),
4165 ) {
4166 binding
4167 .router
4168 .detach_delivery_sink(&job.child_session_id, run_id)
4169 .await;
4170 }
4171 drop(live_guard);
4172 drop(client); drop(active_lease);
4174 return Err(error);
4175 }
4176 }
4177 drop(active_lease);
4178 if let (Some(binding), Some(run_id)) = (
4179 session_inbox_runtime.as_ref(),
4180 bound_activation_run_id.as_deref(),
4181 ) {
4182 binding
4183 .router
4184 .detach_delivery_sink(&job.child_session_id, run_id)
4185 .await;
4186 }
4187 drop(live_guard);
4193 if client.has_pending_durable_terminal() {
4197 if let Some(run_id) = bound_activation_run_id.as_deref() {
4198 let key = (session.id.clone(), session.created_at, run_id.to_owned());
4199 let host_nested_wait_handoff = session
4200 .metadata
4201 .get("runtime.actor_nested_wait_handoff")
4202 .is_some_and(|value| value == "true");
4203 let mut pending = self.pending_durable_links.lock().await;
4204 if pending.contains_key(&key) {
4205 return Err(AgentError::LLM(
4206 "child broker terminal receipt already awaits Host checkpoint".into(),
4207 ));
4208 }
4209 pending.insert(
4210 key,
4211 PendingDurableChildLink {
4212 link: client,
4213 host_nested_wait_handoff,
4214 strict_history,
4215 strict_history_required: local_history_tools.is_some(),
4216 strict_history_committed: false,
4217 },
4218 );
4219 } else {
4220 drop(client);
4222 }
4223 } else {
4224 drop(client);
4225 }
4226
4227 if required_context.is_none()
4231 && attempt == 0
4232 && matches!(kind, PlacementKind::Local)
4233 && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4234 {
4235 tracing::warn!(
4236 "actor child {} got no first frame; reaping the worker and respawning once",
4237 job.child_session_id
4238 );
4239 actor.worker.kill().await;
4240 attempt += 1;
4241 continue;
4242 }
4243 break (result, actor);
4244 };
4245
4246 let result = if strict_remote.is_some() {
4247 result.map_err(|error| match &error {
4248 AgentError::Cancelled => error,
4249 AgentError::LLM(message) => remote_environment_error_code(message)
4250 .map(|code| AgentError::LLM(code.to_owned()))
4251 .unwrap_or_else(remote_broker_unavailable),
4252 _ => remote_broker_unavailable(),
4253 })
4254 } else {
4255 result
4256 };
4257
4258 if remote {
4262 drop(actor);
4263 } else if required_context.is_some() {
4264 if session
4265 .metadata
4266 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4267 {
4268 let service = self.ticket_service.lock().recover_poison().clone();
4269 let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4270 let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4271 .ok()
4272 .flatten()
4273 .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4274 match actor.worker.kill_confirmed().await {
4275 Ok(proof) => {
4276 if let (Some(service), Some(runtime), Some(run_id)) =
4277 (service, runtime, owned_run_id.as_deref())
4278 {
4279 if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4280 &service,
4281 &runtime,
4282 session,
4283 run_id,
4284 &proof,
4285 result.is_ok(),
4286 )
4287 .await
4288 {
4289 tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4292 }
4293 }
4294 }
4295 Err(error) => {
4296 tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4297 }
4298 }
4299 } else {
4300 actor.worker.kill().await;
4301 }
4302 } else {
4303 match &result {
4304 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4305 Err(_) => actor.worker.kill().await,
4306 }
4307 }
4308
4309 if let Some(activation) = plain_activation {
4310 return match result {
4311 Ok(Some(text)) if !text.is_empty() => {
4312 if readonly_actor {
4313 let messages = std::mem::take(&mut readonly_messages);
4314 let committed = activation
4315 .store
4316 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4317 fence: activation.fence.clone(),
4318 expected_created_at: activation.created_at,
4319 expected_messages: session.messages.clone(),
4320 expected_provider_transcript: activation
4321 .provider_transcript
4322 .clone(),
4323 messages: messages.clone(),
4324 native_groups: Vec::new(),
4325 })
4326 .await;
4327 return match committed {
4328 Ok(committed) => {
4329 *session = committed;
4330 for message in messages {
4331 let _ = event_tx
4332 .send(AgentEvent::message_appended(&session.id, &message))
4333 .await;
4334 }
4335 activation
4336 .finish_with_observer(
4337 ActorActivationFinish::Succeeded,
4338 actor_event_observer.as_deref(),
4339 )
4340 .await
4341 }
4342 Err(error) => {
4343 let _ = activation.finish(ActorActivationFinish::Failed).await;
4344 Err(AgentError::LLM(format!(
4345 "actor reply commit failed: {error}"
4346 )))
4347 }
4348 };
4349 }
4350 match activation.append_reply(session, text, &event_tx).await {
4351 Ok(()) => {
4352 activation
4353 .finish_with_observer(
4354 ActorActivationFinish::Succeeded,
4355 actor_event_observer.as_deref(),
4356 )
4357 .await
4358 }
4359 Err(error) => {
4360 let _ = activation.finish(ActorActivationFinish::Failed).await;
4362 Err(AgentError::LLM(format!(
4363 "actor reply commit failed: {error}"
4364 )))
4365 }
4366 }
4367 }
4368 other => {
4369 let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4370 ActorActivationFinish::Cancelled
4371 } else {
4372 ActorActivationFinish::Failed
4373 };
4374 activation.finish(outcome).await?;
4375 match other {
4376 Err(error) => Err(error),
4377 _ => Err(plain_actor_unsupported()),
4378 }
4379 }
4380 };
4381 }
4382
4383 match result {
4387 Ok(Some(text)) => {
4388 if !remote && local_history_tools.is_none() && !text.is_empty() {
4389 session.add_message(bamboo_agent_core::Message::assistant(text, None));
4390 }
4391 Ok(())
4392 }
4393 Ok(None) => Ok(()),
4394 Err(e) => Err(e),
4395 }
4396 }
4397}
4398
4399async fn require_unowned_glob(
4402 store: &bamboo_storage::SessionStoreV2,
4403 session: &Session,
4404) -> Result<(), AgentError> {
4405 let rel = store
4406 .resolve_rel_path(&session.id)
4407 .await
4408 .ok_or_else(|| plain_actor_unsupported())?;
4409 if !store.bamboo_home_dir().is_absolute()
4410 || rel
4411 != format!(
4412 "sessions/{}/children/{}",
4413 session.root_session_id, session.id
4414 )
4415 {
4416 return Err(plain_actor_unsupported());
4417 }
4418 let directory = store.bamboo_home_dir().join(rel);
4419 let mut checked = PathBuf::new();
4420 for component in directory.components() {
4421 if !matches!(
4422 component,
4423 std::path::Component::RootDir | std::path::Component::Normal(_)
4424 ) {
4425 return Err(plain_actor_unsupported());
4426 }
4427 checked.push(component.as_os_str());
4428 if !tokio::fs::symlink_metadata(&checked)
4429 .await
4430 .map_err(|_| plain_actor_unsupported())?
4431 .is_dir()
4432 {
4433 return Err(plain_actor_unsupported());
4434 }
4435 }
4436 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4437 match tokio::fs::symlink_metadata(directory.join(name)).await {
4438 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4439 _ => return Err(plain_actor_unsupported()),
4440 }
4441 }
4442 Ok(())
4443}
4444
4445struct PlainActorActivation {
4451 store: Arc<bamboo_storage::SessionStoreV2>,
4452 fence: ActorActivationFence,
4453 inbox_generation: u64,
4454 created_at: chrono::DateTime<chrono::Utc>,
4455 provider_transcript: bamboo_domain::ProviderTranscriptState,
4456 input_consumer: SessionInboxConsumerId,
4457 input_deadline: chrono::DateTime<chrono::Utc>,
4458 actor_deadline: chrono::DateTime<chrono::Utc>,
4459 initial_release_required: bool,
4460 input_inbox: bamboo_storage::FileSessionInbox,
4461 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4462 initial_inputs: Vec<SessionInboxOwnedClaim>,
4463 initial_input_count: usize,
4464 recovering_pre_ack: bool,
4465 completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4466}
4467type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4468type PlainInitialBatch = (Vec<serde_json::Value>, Vec<SessionMessageDelivery>);
4469#[track_caller]
4470fn plain_actor_unsupported() -> AgentError {
4471 let source = std::panic::Location::caller();
4472 tracing::warn!(
4473 process_id = std::process::id(),
4474 source_file = source.file(),
4475 source_line = source.line(),
4476 source_column = source.column(),
4477 "plain Actor continuation unsupported"
4478 );
4479 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())
4480}
4481fn plain_initial_history(session: &Session) -> bool {
4482 !session.messages.iter().any(|message| {
4483 matches!(message.role, Role::Assistant | Role::Tool)
4484 || message
4485 .metadata
4486 .as_ref()
4487 .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4488 }) && session.provider_transcript.is_empty()
4489 && session.session_inbox_admission().is_none()
4490 && session.pending_injected_messages().is_none()
4491}
4492fn plain_completed_history(session: &Session) -> bool {
4495 session.provider_transcript.is_empty()
4496 && session.pending_injected_messages().is_none()
4497 && session
4498 .messages
4499 .last()
4500 .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4501 && !session
4502 .messages
4503 .iter()
4504 .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4505}
4506fn pre_ack_input(
4509 session: &Session,
4510) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4511 let Some(message) = session.messages.last() else {
4512 return Ok(None);
4513 };
4514 let Some(proof) = message
4515 .metadata
4516 .as_ref()
4517 .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4518 else {
4519 return Ok(None);
4520 };
4521 let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4522 .map_err(|_| plain_actor_unsupported())?;
4523 let generation = proof
4524 .get("generation")
4525 .and_then(serde_json::Value::as_u64)
4526 .filter(|g| *g > 0)
4527 .ok_or_else(|| plain_actor_unsupported())?;
4528 if message.role != Role::User
4529 || session
4530 .messages
4531 .iter()
4532 .filter(|m| m.id == message.id)
4533 .count()
4534 != 1
4535 || !session
4536 .session_inbox_admission()
4537 .is_some_and(|cursor| cursor.contains(&id))
4538 || !session.provider_transcript.is_empty()
4539 || session.pending_injected_messages().is_some()
4540 || session
4541 .messages
4542 .iter()
4543 .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4544 {
4545 return Err(plain_actor_unsupported());
4546 }
4547 Ok(Some((id, generation)))
4548}
4549fn qualified_pre_ack_owner(
4550 entry: &bamboo_domain::ActorDirectoryEntry,
4551 now: chrono::DateTime<chrono::Utc>,
4552) -> bool {
4553 let Some(old) = entry.activation.as_ref() else {
4554 return false;
4555 };
4556 let recoverable = (entry.actor.state == ActorLogicalState::Failed
4557 && old.status == bamboo_domain::ActorActivationStatus::Failed)
4558 || (entry.actor.state == ActorLogicalState::Active
4559 && old.status.is_live()
4560 && old.lease_expires_at <= now);
4561 let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4562 placement.class == bamboo_domain::ActorPlacementClass::Local
4563 && placement
4564 .lease_id
4565 .strip_prefix("owned-initial-release-v1:")
4566 .is_some_and(|worker| {
4567 !worker.is_empty()
4568 && worker.len() <= 256
4569 && !worker.contains("..")
4570 && worker.bytes().all(|b| {
4571 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4572 })
4573 })
4574 });
4575 recoverable && trusted
4576}
4577async fn expired_pre_ack_input(
4578 inbox: &bamboo_storage::FileSessionInbox,
4579 session: &Session,
4580) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4581 let Some((id, generation)) = pre_ack_input(session)? else {
4582 return Ok(None);
4583 };
4584 let backlog = inbox
4585 .inspect(&session.id)
4586 .await
4587 .map_err(|_| plain_actor_unsupported())?;
4588 let leases = inbox
4589 .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4590 .await
4591 .map_err(|_| plain_actor_unsupported())?;
4592 if backlog.pending != 0
4593 || backlog.claimed != 1
4594 || backlog.generation != generation
4595 || backlog.oldest_generation != Some(generation)
4596 || !backlog.activation_pending()
4597 || leases.len() != 1
4598 || !leases[0].expired
4599 || leases[0].generation != generation
4600 || inbox
4601 .was_admitted(&session.id, &id)
4602 .await
4603 .map_err(|_| plain_actor_unsupported())?
4604 {
4605 return Err(plain_actor_unsupported());
4606 }
4607 Ok(Some((id, generation)))
4608}
4609impl PlainActorActivation {
4610 fn start<'a>(
4613 store: Arc<bamboo_storage::SessionStoreV2>,
4614 session: &'a Session,
4615 binding: &'a SessionInboxRuntimeBinding,
4616 run_id: Option<&'a str>,
4617 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4618 allow_failed_retry: bool,
4619 release_worker: Option<&'a str>,
4620 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4621 {
4622 Box::pin(async move {
4623 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4624 let backlog = binding
4625 .inbox
4626 .inspect(&session.id)
4627 .await
4628 .map_err(|_| plain_actor_unsupported())?;
4629 let entry = store
4630 .ensure_actor(&session.id)
4631 .await
4632 .map_err(|error| AgentError::LLM(error.to_string()))?;
4633 let config = permission_config
4634 .as_ref()
4635 .ok_or_else(|| plain_actor_unsupported())?;
4636 if entry.actor.policy_revision != Some(config.policy_revision())
4637 || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4638 {
4639 return Err(plain_actor_unsupported());
4640 }
4641 let fresh = entry.actor.current_attempt == 0
4642 && entry.actor.state == ActorLogicalState::Cold
4643 && entry.activation.is_none()
4644 && backlog.pending == 0
4645 && backlog.claimed == 0
4646 && backlog.generation == 0;
4647 let new_input = backlog.pending == 1
4648 && backlog.claimed == 0
4649 && backlog.oldest_generation == Some(backlog.generation)
4650 && backlog.activation_pending();
4651 let retry = allow_failed_retry
4652 && entry.actor.state == ActorLogicalState::Failed
4653 && entry.actor.current_attempt > 0
4654 && entry.activation.as_ref().is_some_and(|old| {
4655 old.status == bamboo_domain::ActorActivationStatus::Failed
4656 && backlog.generation > old.inbox_generation
4657 })
4658 && new_input;
4659 let new_batch = backlog.pending > 1
4663 && backlog.pending <= bamboo_domain::SessionInboxLimits::default().max_claim_batch
4664 && backlog.claimed == 0
4665 && backlog
4666 .oldest_generation
4667 .is_some_and(|oldest| oldest < backlog.generation)
4668 && backlog.activation_pending();
4669 let continuation = allow_failed_retry
4670 && release_worker.is_some()
4671 && entry.actor.state == ActorLogicalState::Cold
4672 && entry.actor.current_attempt > 0
4673 && entry.activation.as_ref().is_some_and(|old| {
4674 old.status == bamboo_domain::ActorActivationStatus::Succeeded
4675 && backlog
4676 .oldest_generation
4677 .is_some_and(|oldest| oldest > old.inbox_generation)
4678 })
4679 && (new_input || new_batch)
4680 && plain_completed_history(session);
4681 let input_inbox = bamboo_storage::FileSessionInbox::new(
4682 store.clone(),
4683 bamboo_domain::SessionInboxLimits::default(),
4684 );
4685 let now = chrono::Utc::now();
4686 let replacement = allow_failed_retry
4690 && release_worker.is_some()
4691 && entry.actor.state == ActorLogicalState::Active
4692 && qualified_pre_ack_owner(&entry, now)
4693 && entry.activation.as_ref().is_some_and(|old| {
4694 old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4695 })
4696 && new_input
4697 && plain_initial_history(session);
4698 let recovery = if allow_failed_retry
4699 && release_worker.is_some()
4700 && qualified_pre_ack_owner(&entry, now)
4701 {
4702 expired_pre_ack_input(&input_inbox, session).await?
4703 } else {
4704 None
4705 };
4706 if (recovery.is_some() || continuation || replacement)
4707 && (!entry.actor.matches_session(session)
4708 || entry.actor.project_id
4709 != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4710 {
4711 return Err(plain_actor_unsupported());
4712 }
4713 if recovery.is_none()
4714 && !continuation
4715 && !replacement
4716 && ((!fresh && !retry) || !plain_initial_history(session))
4717 {
4718 return Err(plain_actor_unsupported());
4719 }
4720 let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4721 .max_total_secs
4722 .checked_add(60)
4723 .and_then(chrono::Duration::try_seconds)
4724 .ok_or_else(|| plain_actor_unsupported())?;
4725 let expires = now
4726 .checked_add_signed(duration)
4727 .ok_or_else(|| plain_actor_unsupported())?;
4728 let input_consumer = SessionInboxConsumerId::new();
4729 let input_deadline = expires.min(now + chrono::Duration::hours(1));
4730 let recovered = if let Some((id, generation)) = &recovery {
4731 if !binding.router.owns_run(&session.id, run_id).await {
4732 return Err(plain_actor_unsupported());
4733 }
4734 let lease = SessionInboxLeaseRequest {
4735 consumer: input_consumer.clone(),
4736 now,
4737 duration: input_deadline - now,
4738 };
4739 let mut claims = input_inbox
4740 .claim_owned(&session.id, 1, Some(run_id), &lease)
4741 .await
4742 .map_err(|_| plain_actor_unsupported())?;
4743 let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4744 if claim.claim.envelope.id != *id
4745 || claim.claim.generation != *generation
4746 || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4747 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4748 {
4749 return Err(plain_actor_unsupported());
4750 }
4751 Some(claim)
4752 } else {
4753 None
4754 };
4755 let claimed = store
4756 .claim_activation(&ActorActivationClaim {
4757 actor_id: session.id.clone(),
4758 run_id: run_id.into(),
4759 lease_owner: input_consumer.as_str().into(),
4760 lease_expires_at: expires,
4761 inbox_generation: recovered
4762 .as_ref()
4763 .map_or(backlog.generation, |claim| claim.claim.generation),
4764 placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4765 class: bamboo_domain::ActorPlacementClass::Local,
4766 lease_id: format!("owned-initial-release-v1:{worker}"),
4769 slot_epoch: None,
4770 }),
4771 now,
4772 })
4773 .await
4774 .map_err(|error| AgentError::LLM(error.to_string()))?;
4775 let activation = Self {
4776 store,
4777 fence: claimed.fence(),
4778 inbox_generation: claimed.inbox_generation,
4779 created_at: session.created_at,
4780 provider_transcript: session.provider_transcript.clone(),
4781 input_consumer,
4782 input_deadline,
4783 actor_deadline: claimed.lease_expires_at,
4784 initial_release_required: release_worker.is_some(),
4785 input_inbox,
4786 permission_config,
4787 initial_inputs: recovered.into_iter().collect(),
4788 initial_input_count: if continuation {
4789 backlog.pending
4790 } else {
4791 usize::from(claimed.inbox_generation > 0)
4792 },
4793 recovering_pre_ack: recovery.is_some(),
4794 completion_router: std::sync::Mutex::new(None),
4795 };
4796 if let Err(error) = activation
4797 .store
4798 .start_activation(&activation.fence, chrono::Utc::now())
4799 .await
4800 {
4801 let _ = activation.finish(ActorActivationFinish::Failed).await;
4802 return Err(AgentError::LLM(error.to_string()));
4803 }
4804 Ok(activation)
4805 })
4806 }
4807 fn start_and_prepare<'a>(
4808 store: Arc<bamboo_storage::SessionStoreV2>,
4809 session: &'a mut Session,
4810 binding: &'a SessionInboxRuntimeBinding,
4811 run_id: Option<&'a str>,
4812 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4813 allow_failed_retry: bool,
4814 release_worker: Option<&'a str>,
4815 ) -> std::pin::Pin<
4816 Box<
4817 impl std::future::Future<Output = Result<(Self, Option<PlainInitialBatch>), AgentError>>
4818 + Send
4819 + 'a,
4820 >,
4821 > {
4822 Box::pin(async move {
4823 let mut activation = Self::start(
4824 store,
4825 session,
4826 binding,
4827 run_id,
4828 permission_config,
4829 allow_failed_retry,
4830 release_worker,
4831 )
4832 .await?;
4833 if activation.inbox_generation == 0 {
4834 return Ok((activation, None));
4835 }
4836 let prepared = async {
4837 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4838 let backlog = activation
4839 .input_inbox
4840 .inspect(&session.id)
4841 .await
4842 .map_err(|_| plain_actor_unsupported())?;
4843 let expected = if activation.recovering_pre_ack {
4844 (0, 1)
4845 } else {
4846 (activation.initial_input_count, 0)
4847 };
4848 if (backlog.pending, backlog.claimed) != expected
4849 || backlog.generation != activation.inbox_generation
4850 {
4851 return Err(plain_actor_unsupported());
4852 }
4853 let claims = if activation.initial_inputs.is_empty() {
4854 activation
4858 .claim_inputs(binding, session, run_id, activation.initial_input_count)
4859 .await?
4860 } else {
4861 activation.initial_inputs.clone()
4862 };
4863 if claims.len() != activation.initial_input_count
4864 || claims.last().map(|claim| claim.claim.generation)
4865 != Some(activation.inbox_generation)
4866 || claims
4867 .windows(2)
4868 .any(|pair| pair[0].claim.generation >= pair[1].claim.generation)
4869 {
4870 return Err(plain_actor_unsupported());
4871 }
4872 let mut prefix = None;
4877 let mut deliveries = Vec::with_capacity(claims.len());
4878 for claim in &claims {
4879 let (messages, delivery) =
4880 activation.checkpoint_input(session, run_id, claim).await?;
4881 prefix.get_or_insert(messages);
4882 deliveries.push(delivery);
4883 }
4884 activation.initial_inputs = claims;
4885 Ok((prefix.ok_or_else(|| plain_actor_unsupported())?, deliveries))
4886 }
4887 .await;
4888 match prepared {
4889 Ok(delivery) => Ok((activation, Some(delivery))),
4890 Err(error) => {
4891 activation.finish(ActorActivationFinish::Failed).await?;
4892 Err(error)
4893 }
4894 }
4895 })
4896 }
4897 fn claim_input<'a>(
4898 &'a self,
4899 binding: &'a SessionInboxRuntimeBinding,
4900 session: &'a Session,
4901 run_id: &'a str,
4902 ) -> std::pin::Pin<
4903 Box<
4904 impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4905 + Send
4906 + 'a,
4907 >,
4908 > {
4909 Box::pin(async move { Ok(self.claim_inputs(binding, session, run_id, 1).await?.pop()) })
4910 }
4911 fn claim_inputs<'a>(
4912 &'a self,
4913 binding: &'a SessionInboxRuntimeBinding,
4914 session: &'a Session,
4915 run_id: &'a str,
4916 limit: usize,
4917 ) -> std::pin::Pin<
4918 Box<
4919 impl std::future::Future<Output = Result<Vec<SessionInboxOwnedClaim>, AgentError>>
4920 + Send
4921 + 'a,
4922 >,
4923 > {
4924 Box::pin(async move {
4925 if limit == 0 || limit > bamboo_domain::SessionInboxLimits::default().max_claim_batch {
4926 return Err(plain_actor_unsupported());
4927 }
4928 if !self
4929 .input_inbox
4930 .inspect(&session.id)
4931 .await
4932 .map_err(|_| plain_actor_unsupported())?
4933 .activation_pending()
4934 {
4935 return Ok(Vec::new());
4936 }
4937 if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4938 return Err(plain_actor_unsupported());
4939 }
4940 let now = chrono::Utc::now();
4941 if now >= self.input_deadline {
4942 return Err(plain_actor_unsupported());
4943 }
4944 let lease = SessionInboxLeaseRequest {
4945 consumer: self.input_consumer.clone(),
4946 now,
4947 duration: self.input_deadline - now,
4948 };
4949 let claims = self
4950 .input_inbox
4951 .claim_owned(&session.id, limit, Some(run_id), &lease)
4952 .await
4953 .map_err(|error| {
4954 tracing::warn!(%error, "Actor correction claim unconfirmed");
4955 AgentError::LLM(
4956 "Actor correction claim unconfirmed; durable input is preserved".into(),
4957 )
4958 })?;
4959 for claim in &claims {
4960 if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4961 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4962 {
4963 self.input_inbox
4964 .fail_owned(
4965 &session.id,
4966 claim,
4967 &SessionInboxFailureReport {
4968 now: chrono::Utc::now(),
4969 error_code: "unsupported_actor_input".into(),
4970 },
4971 )
4972 .await
4973 .map_err(|error| {
4974 tracing::warn!(%error, "Actor correction failure report unconfirmed");
4975 AgentError::LLM(
4976 "Actor correction failure report unconfirmed; durable input is preserved".into(),
4977 )
4978 })?;
4979 return Err(plain_actor_unsupported());
4980 }
4981 }
4982 Ok(claims)
4983 })
4984 }
4985 fn checkpoint_input<'a>(
4986 &'a self,
4987 session: &'a mut Session,
4988 run_id: &'a str,
4989 claim: &'a SessionInboxOwnedClaim,
4990 ) -> std::pin::Pin<
4991 Box<
4992 impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4993 >,
4994 > {
4995 Box::pin(async move {
4996 let request = bamboo_storage::ActorInputCheckpoint {
4999 fence: self.fence.clone(),
5000 expected_created_at: self.created_at,
5001 claim: claim.clone(),
5002 expected_messages: session.messages.clone(),
5003 expected_provider_transcript: session.provider_transcript.clone(),
5004 expected_admission: session.session_inbox_admission().cloned(),
5005 };
5006 let mut retries = 0;
5007 let committed = loop {
5008 let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
5009 if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
5010 && retries < 3
5011 {
5012 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5018 retries += 1;
5019 continue;
5020 }
5021 break result;
5022 }.map_err(|error| {
5023 tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
5024 AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
5025 })?;
5026 if self.recovering_pre_ack
5027 && committed.status
5028 != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5029 {
5030 return Err(plain_actor_unsupported());
5031 }
5032 let messages = session
5033 .messages
5034 .iter()
5035 .map(|message| {
5036 let mut worker = message.clone();
5037 if committed.status
5038 == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5039 && worker.id == committed.envelope.id.as_str()
5040 {
5041 worker
5042 .metadata
5043 .as_mut()
5044 .and_then(serde_json::Value::as_object_mut)
5045 .ok_or_else(|| plain_actor_unsupported())?
5046 .remove("_bamboo_owned_input_checkpoint");
5047 if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
5048 {
5049 return Err(plain_actor_unsupported());
5050 }
5051 }
5052 serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
5053 })
5054 .collect::<Result<Vec<_>, _>>()?;
5055 *session = committed.session;
5056 Ok((
5057 messages,
5058 SessionMessageDelivery {
5059 target_session_id: session.id.clone(),
5060 envelope: committed.envelope,
5061 canonical_claim_generation: committed.generation,
5062 activation_run_id: run_id.into(),
5063 activation_policy: claim.claim.activation_policy,
5064 },
5065 ))
5066 })
5067 }
5068 fn append_reply<'a>(
5069 &'a self,
5070 session: &'a mut Session,
5071 text: String,
5072 event_tx: &'a mpsc::Sender<AgentEvent>,
5073 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5074 {
5075 Box::pin(async move {
5076 let message = bamboo_agent_core::Message::assistant(text, None);
5077 let request = bamboo_storage::ActorTranscriptAppend {
5078 fence: self.fence.clone(),
5079 expected_created_at: self.created_at,
5080 expected_messages: session.messages.clone(),
5081 expected_provider_transcript: self.provider_transcript.clone(),
5082 messages: vec![message.clone()],
5083 native_groups: Vec::new(),
5084 };
5085 let mut retries = 0;
5086 *session = loop {
5087 let result = self.store.append_actor_transcript(request.clone()).await;
5088 if matches!(
5089 &result,
5090 Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5091 ) && retries < 3
5092 {
5093 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5098 retries += 1;
5099 continue;
5100 }
5101 break result;
5102 }
5103 .map_err(|error| {
5104 tracing::warn!(
5105 %error,
5106 "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5107 );
5108 AgentError::LLM(format!("actor reply commit failed: {error}"))
5109 })?;
5110 let _ = event_tx
5111 .send(AgentEvent::message_appended(&session.id, &message))
5112 .await;
5113 Ok(())
5114 })
5115 }
5116 fn continue_input<'a>(
5120 &'a self,
5121 client: &'a mut dyn bamboo_subagent::ChildLink,
5122 binding: &'a SessionInboxRuntimeBinding,
5123 session: &'a mut Session,
5124 run_id: &'a str,
5125 run: Option<(&'a RunSpec, &'a AtomicU64)>,
5126 expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5127 text: String,
5128 event_tx: &'a mpsc::Sender<AgentEvent>,
5129 ) -> std::pin::Pin<
5130 Box<
5131 impl std::future::Future<
5132 Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5133 > + Send
5134 + 'a,
5135 >,
5136 > {
5137 Box::pin(async move {
5138 let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5139 return Ok(None);
5140 };
5141 let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5142 self.append_reply(session, text, event_tx).await?;
5143 let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5144 self.refresh_continuation_posture(session, run, expected_posture)?;
5146 let epoch = epochs
5147 .fetch_add(1, Ordering::Relaxed)
5148 .checked_add(1)
5149 .ok_or_else(|| plain_actor_unsupported())?;
5150 let mut next = run.clone();
5151 next.messages = messages;
5152 next.initial_session_messages = vec![delivery];
5153 next.execution_epoch = epoch;
5154 client.send(ParentFrame::Run(next)).await.map_err(|_| {
5155 AgentError::LLM(
5156 "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5157 )
5158 })?;
5159 Ok(Some((claim, epoch)))
5160 })
5161 }
5162 fn refresh_continuation_posture(
5163 &self,
5164 session: &Session,
5165 run: &RunSpec,
5166 expected: &mut Option<ExpectedPermissionPosture>,
5167 ) -> Result<(), AgentError> {
5168 let Some(expected) = expected.as_mut() else {
5169 return Err(plain_actor_unsupported());
5170 };
5171 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5172 .ok_or_else(|| plain_actor_unsupported())?;
5173 if audit.policy_revision != expected.policy_revision
5174 || audit.resolution != expected.resolution
5175 || audit.executor_mapping != expected.executor_mapping
5176 {
5177 return Err(plain_actor_unsupported());
5178 }
5179 match (&self.permission_config, &run.permission_policy) {
5180 (Some(config), Some(policy)) => {
5181 ensure_no_active_scoped_session_denies(config, &session.id)?;
5182 if config.policy_revision() != policy.revision
5183 || policy.revision != audit.policy_revision
5184 || policy.session_id != session.id
5185 || policy.workspace_path != session.workspace
5186 || policy.inherit_session_grants
5187 || serde_json::to_value(config.to_serializable())
5188 .map_err(|_| plain_actor_unsupported())?
5189 != policy.policy
5190 {
5191 return Err(plain_actor_unsupported());
5192 }
5193 }
5194 (None, None) => {}
5195 _ => return Err(plain_actor_unsupported()),
5196 }
5197 expected.expected_audit_revision = Some(audit.audit_revision);
5198 Ok(())
5199 }
5200 fn release_initial_input<'a>(
5201 &'a self,
5202 binding: &'a SessionInboxRuntimeBinding,
5203 session: &'a Session,
5204 run: &'a RunSpec,
5205 epoch: u64,
5206 claim: &'a SessionInboxOwnedClaim,
5207 request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5208 expected: &'a mut Option<ExpectedPermissionPosture>,
5209 previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5210 ) -> std::pin::Pin<
5211 Box<
5212 impl std::future::Future<
5213 Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5214 > + Send
5215 + 'a,
5216 >,
5217 > {
5218 Box::pin(async move {
5219 if !self.initial_release_required {
5220 return Err(plain_actor_unsupported());
5221 }
5222 let delivery = SessionMessageDelivery {
5223 target_session_id: session.id.clone(),
5224 envelope: claim.claim.envelope.clone(),
5225 canonical_claim_generation: claim.claim.generation,
5226 activation_run_id: self.fence.run_id.clone(),
5227 activation_policy: claim.claim.activation_policy,
5228 };
5229 let mut current_run = run.clone();
5230 current_run.execution_epoch = epoch;
5231 let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5232 ¤t_run,
5233 &delivery,
5234 request.nonce.clone(),
5235 )
5236 .map_err(|_| plain_actor_unsupported())?;
5237 if &exact != request
5238 || request.child_id != session.id
5239 || request.created_at != session.created_at
5240 || request.spawn_depth != session.spawn_depth
5241 || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5242 || request.root_id != session.root_session_id
5243 || request.project_id != project_id_for_actor_run(session)?
5244 || !binding
5245 .router
5246 .owns_run(&session.id, &self.fence.run_id)
5247 .await
5248 {
5249 return Err(plain_actor_unsupported());
5250 }
5251 let deadline = self
5252 .actor_deadline
5253 .min(self.input_deadline)
5254 .min(claim.lease.expires_at);
5255 if chrono::Utc::now() >= deadline {
5256 return Err(plain_actor_unsupported());
5257 }
5258 self.store
5259 .validate_fence(&self.fence, chrono::Utc::now())
5260 .await
5261 .map_err(|_| plain_actor_unsupported())?;
5262 let actual =
5263 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5264 .await
5265 .map_err(|_| plain_actor_unsupported())?
5266 .ok_or_else(|| plain_actor_unsupported())?;
5267 if actual.created_at != session.created_at
5268 || actual.root_session_id != session.root_session_id
5269 || actual.parent_session_id != session.parent_session_id
5270 || actual.spawn_depth != session.spawn_depth
5271 || project_id_for_actor_run(&actual)? != request.project_id
5272 || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5273 != serde_json::to_value(&session.messages)
5274 .map_err(|_| plain_actor_unsupported())?
5275 || serde_json::to_value(&actual.provider_transcript)
5276 .map_err(|_| plain_actor_unsupported())?
5277 != serde_json::to_value(&session.provider_transcript)
5278 .map_err(|_| plain_actor_unsupported())?
5279 || serde_json::to_value(actual.session_inbox_admission())
5280 .map_err(|_| plain_actor_unsupported())?
5281 != serde_json::to_value(session.session_inbox_admission())
5282 .map_err(|_| plain_actor_unsupported())?
5283 {
5284 return Err(plain_actor_unsupported());
5285 }
5286 self.refresh_continuation_posture(&actual, ¤t_run, expected)?;
5287 if let Some(previous) = previous {
5288 if !previous.permits(request, chrono::Utc::now())
5289 || previous.expires_at != deadline
5290 || !self
5291 .input_inbox
5292 .was_admitted(&session.id, &claim.claim.envelope.id)
5293 .await
5294 .map_err(|_| plain_actor_unsupported())?
5295 {
5296 return Err(plain_actor_unsupported());
5297 }
5298 } else {
5299 let checkpoint = bamboo_storage::ActorInputCheckpoint {
5302 fence: self.fence.clone(),
5303 expected_created_at: self.created_at,
5304 claim: claim.clone(),
5305 expected_messages: actual.messages.clone(),
5306 expected_provider_transcript: actual.provider_transcript.clone(),
5307 expected_admission: actual.session_inbox_admission().cloned(),
5308 };
5309 let mut retries = 0;
5310 loop {
5311 let result = self
5312 .input_inbox
5313 .checkpoint_actor_input(checkpoint.clone())
5314 .await;
5315 if matches!(
5316 &result,
5317 Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5318 ) && retries < 3
5319 {
5320 tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5324 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5325 retries += 1;
5326 continue;
5327 }
5328 result.map_err(|error| {
5329 tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5330 plain_actor_unsupported()
5331 })?;
5332 break;
5333 }
5334 self.confirm_input(
5335 binding,
5336 &actual,
5337 &self.fence.run_id,
5338 claim,
5339 &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5340 target_session_id: request.child_id.clone(),
5341 envelope_id: request.envelope_id.clone(),
5342 canonical_claim_generation: request.generation,
5343 activation_run_id: request.activation_run_id.clone(),
5344 },
5345 )
5346 .await?;
5347 }
5348 if chrono::Utc::now() >= deadline
5351 || !binding
5352 .router
5353 .owns_run(&session.id, &self.fence.run_id)
5354 .await
5355 {
5356 return Err(plain_actor_unsupported());
5357 }
5358 self.store
5359 .validate_fence(&self.fence, chrono::Utc::now())
5360 .await
5361 .map_err(|_| plain_actor_unsupported())?;
5362 let final_session =
5363 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5364 .await
5365 .map_err(|_| plain_actor_unsupported())?
5366 .ok_or_else(|| plain_actor_unsupported())?;
5367 self.store
5368 .validate_fence(&self.fence, chrono::Utc::now())
5369 .await
5370 .map_err(|_| plain_actor_unsupported())?;
5371 self.refresh_continuation_posture(&final_session, ¤t_run, expected)?;
5372 if chrono::Utc::now() >= deadline {
5373 return Err(plain_actor_unsupported());
5374 }
5375 Ok(bamboo_subagent::proto::InitialInputRelease {
5376 request: request.clone(),
5377 expires_at: deadline,
5378 })
5379 })
5380 }
5381 async fn confirm_input(
5382 &self,
5383 binding: &SessionInboxRuntimeBinding,
5384 session: &Session,
5385 run_id: &str,
5386 claim: &SessionInboxOwnedClaim,
5387 confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5388 ) -> Result<(), AgentError> {
5389 if confirmation.target_session_id != session.id
5390 || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5391 || confirmation.canonical_claim_generation != claim.claim.generation
5392 || confirmation.activation_run_id != run_id
5393 || run_id != self.fence.run_id
5394 || !binding.router.owns_run(&session.id, run_id).await
5395 {
5396 return Err(plain_actor_unsupported());
5397 }
5398 self.input_inbox
5399 .ack_owned(&session.id, claim, chrono::Utc::now())
5400 .await
5401 .map_err(|error| {
5402 tracing::warn!(%error, "Actor correction ACK unresolved");
5403 AgentError::LLM(
5404 "Actor correction ACK unresolved; durable input is preserved".into(),
5405 )
5406 })
5407 }
5408 async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5409 self.finish_with_observer(outcome, None).await
5410 }
5411
5412 async fn finish_with_observer(
5413 &self,
5414 outcome: ActorActivationFinish,
5415 observer: Option<&dyn ActorEventObserver>,
5416 ) -> Result<(), AgentError> {
5417 let committed = self
5418 .store
5419 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5420 .await
5421 .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5422 let router = self.completion_router.lock().recover_poison().take();
5425 if let (Some(observer), Some(router)) = (observer, router) {
5426 match self.store.inspect_actor(&self.fence.actor_id).await {
5430 Ok(entry) => match router.into_host_completion(&entry, &committed) {
5431 Ok(event) => observer.publish(event),
5432 Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5433 "Actor completion notification superseded"),
5434 },
5435 Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5436 "Actor completion committed; notification snapshot unavailable"),
5437 }
5438 }
5439 Ok(())
5440 }
5441}
5442fn local_tool_history_unsupported() -> AgentError {
5443 AgentError::LLM("local_tool_history_unsupported".into())
5444}
5445
5446#[derive(Clone, Copy)]
5447enum LocalToolTerminal<'a> {
5448 Completed(Option<&'a str>),
5449 Cancelled,
5452}
5453
5454impl LocalToolTerminal<'_> {
5455 fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5456 match self {
5457 Self::Completed(_) => !cancel.is_cancelled(),
5458 Self::Cancelled => cancel.is_cancelled(),
5459 }
5460 }
5461}
5462
5463#[derive(Default)]
5464struct LocalToolCollector {
5465 messages: Option<Vec<bamboo_agent_core::Message>>,
5466 starts: HashMap<String, (String, serde_json::Value)>,
5467 outcomes: HashMap<String, (bool, String)>,
5468 ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5471}
5472
5473impl LocalToolCollector {
5474 fn ticket_event(
5475 &self,
5476 value: &serde_json::Value,
5477 committed: &HashMap<String, serde_json::Value>,
5478 ) -> Result<bool, AgentError> {
5479 if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5480 return Ok(true);
5481 }
5482 match serde_json::from_value::<AgentEvent>(value.clone())
5483 .map_err(|_| local_tool_history_unsupported())?
5484 {
5485 AgentEvent::ToolStart { tool_name, .. }
5486 if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5487 {
5488 Err(local_tool_history_unsupported())
5489 }
5490 AgentEvent::ToolComplete {
5491 tool_call_id,
5492 result,
5493 } if result.success => {
5494 let Some((name, args)) = self.starts.get(&tool_call_id) else {
5495 return Err(local_tool_history_unsupported());
5496 };
5497 let valid = if name == "Task" {
5498 committed.get(&tool_call_id) == Some(args)
5499 } else {
5500 committed.get(&tool_call_id).is_some_and(|proof| {
5501 proof["file_tool"] == *name
5502 && proof["arguments"] == *args
5503 && proof["result"] == result.result
5504 })
5505 };
5506 if !valid {
5507 return Err(AgentError::LLM(
5508 "LocalPlan success has no exact Host receipt".into(),
5509 ));
5510 }
5511 Ok(true)
5512 }
5513 AgentEvent::TaskListUpdated { .. } => Ok(false),
5515 _ => Ok(true),
5516 }
5517 }
5518
5519 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5520 use bamboo_subagent::proto::LocalToolMessages;
5521 if self.messages.is_some() {
5522 return Err(local_tool_history_unsupported());
5523 }
5524 if value["type"] == LocalToolMessages::TYPE {
5525 let observation: LocalToolMessages = serde_json::from_value(value.clone())
5526 .map_err(|_| local_tool_history_unsupported())?;
5527 self.messages = Some(
5528 observation
5529 .validate()
5530 .map_err(|_| local_tool_history_unsupported())?,
5531 );
5532 return Ok(false);
5533 }
5534 let event: AgentEvent =
5535 serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5536 match event {
5537 AgentEvent::ToolStart {
5538 tool_call_id,
5539 tool_name,
5540 arguments,
5541 } => {
5542 if !arguments.is_object()
5543 || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5544 || tool_call_id.is_empty()
5545 || tool_call_id.len() > 128
5546 || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5547 || self.outcomes.contains_key(&tool_call_id)
5548 || self
5549 .starts
5550 .insert(tool_call_id, (tool_name, arguments))
5551 .is_some()
5552 {
5553 return Err(local_tool_history_unsupported());
5554 }
5555 }
5556 AgentEvent::ToolComplete {
5557 tool_call_id,
5558 result,
5559 } => {
5560 if !result.images.is_empty() {
5561 return Err(local_tool_history_unsupported());
5562 }
5563 self.outcome(tool_call_id, result.success, result.result)?;
5564 }
5565 AgentEvent::ToolError {
5566 tool_call_id,
5567 error,
5568 } => {
5569 self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5570 }
5571 AgentEvent::ToolToken { tool_call_id, .. } => {
5572 if !self.starts.contains_key(&tool_call_id)
5573 || self.outcomes.contains_key(&tool_call_id)
5574 {
5575 return Err(local_tool_history_unsupported());
5576 }
5577 }
5578 AgentEvent::ToolLifecycle {
5579 tool_call_id,
5580 tool_name,
5581 is_mutating,
5582 auto_approved,
5583 phase,
5584 ..
5585 } => {
5586 if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5587 || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5588 || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5589 || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5590 {
5591 return Err(local_tool_history_unsupported());
5592 }
5593 }
5594 AgentEvent::ReasoningToken { .. } => {
5595 }
5599 AgentEvent::ContextSummarized { .. }
5600 | AgentEvent::ContextArchived { .. }
5601 | AgentEvent::ContextCompressionStatus { .. }
5602 | AgentEvent::NeedClarification { .. }
5603 | AgentEvent::SubAgentStarted { .. }
5604 | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5605 AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5607 return Ok(false)
5608 }
5609 _ => {}
5610 }
5611 Ok(true)
5612 }
5613
5614 fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5615 if id.is_empty()
5616 || id.len() > 128
5617 || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5618 || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5619 || self.outcomes.insert(id, (success, text)).is_some()
5620 {
5621 return Err(local_tool_history_unsupported());
5622 }
5623 Ok(())
5624 }
5625
5626 fn suffix(
5627 self,
5628 host: &Session,
5629 tools: &[String],
5630 read_only: bool,
5631 terminal: Option<&str>,
5632 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5633 self.suffix_for_terminal(
5634 host,
5635 tools,
5636 read_only,
5637 LocalToolTerminal::Completed(terminal),
5638 )
5639 }
5640
5641 fn suffix_for_terminal(
5642 self,
5643 host: &Session,
5644 tools: &[String],
5645 read_only: bool,
5646 terminal: LocalToolTerminal<'_>,
5647 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5648 use bamboo_subagent::proto::LocalToolMessages;
5649 let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5650 if cancelled
5651 && (!read_only
5652 || tools.is_empty()
5653 || tools
5654 .iter()
5655 .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5656 || self.ticket_yield.is_some())
5657 {
5658 return Err(local_tool_history_unsupported());
5659 }
5660 let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5661 let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5662 let prefix: Vec<_> = host
5663 .messages
5664 .iter()
5665 .filter(|m| m.role != Role::System)
5666 .collect();
5667 if !host.provider_transcript.groups().is_empty()
5668 || candidate.len() < prefix.len()
5669 || (!cancelled && candidate.len() == prefix.len())
5670 || serde_json::to_value(&candidate[..prefix.len()])
5671 .map_err(|_| local_tool_history_unsupported())?
5672 != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5673 {
5674 return Err(local_tool_history_unsupported());
5675 }
5676 let mut ids = std::collections::HashSet::new();
5677 let mut calls = std::collections::HashSet::new();
5678 for message in &host.messages {
5679 if !ids.insert(message.id.clone()) {
5680 return Err(local_tool_history_unsupported());
5681 }
5682 for call in message.tool_calls.iter().flatten() {
5683 calls.insert(call.id.clone());
5684 }
5685 if let Some(id) = &message.tool_call_id {
5686 calls.insert(id.clone());
5687 }
5688 }
5689 let suffix: Vec<_> = candidate[prefix.len()..]
5690 .iter()
5691 .map(|message| (**message).clone())
5692 .collect();
5693 let mut pending = HashMap::new();
5694 let mut used = std::collections::HashSet::new();
5695 for message in &suffix {
5696 if !ids.insert(message.id.clone()) || message.never_compress {
5697 return Err(local_tool_history_unsupported());
5698 }
5699 match message.role {
5700 Role::Assistant => {
5701 if !pending.is_empty()
5702 || message.metadata.is_some()
5703 || message.tool_call_id.is_some()
5704 || message.tool_success.is_some()
5705 {
5706 return Err(local_tool_history_unsupported());
5707 }
5708 if let Some(batch) = &message.tool_calls {
5709 if batch.is_empty() {
5710 return Err(local_tool_history_unsupported());
5711 }
5712 for call in batch {
5713 let arguments =
5714 serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5715 .ok()
5716 .filter(|arguments| arguments.is_object())
5717 .ok_or_else(local_tool_history_unsupported)?;
5718 if call.id.is_empty()
5719 || call.id.len() > 128
5720 || call.tool_type != "function"
5721 || !calls.insert(call.id.clone())
5722 || calls.len() > LocalToolMessages::MAX_MESSAGES
5723 || !matches!(
5724 call.function.name.as_str(),
5725 "Read" | "Glob" | "Write" | "Task"
5726 )
5727 || (cancelled
5728 && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5729 || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5730 name != &call.function.name || actual != &arguments
5731 })
5732 {
5733 return Err(local_tool_history_unsupported());
5734 }
5735 pending.insert(call.id.clone(), call.function.name.clone());
5736 }
5737 } else if message.content.trim().is_empty() {
5738 return Err(local_tool_history_unsupported());
5739 }
5740 }
5741 Role::Tool => {
5742 let id = message
5743 .tool_call_id
5744 .as_ref()
5745 .ok_or_else(local_tool_history_unsupported)?;
5746 let name = pending
5747 .remove(id)
5748 .ok_or_else(local_tool_history_unsupported)?;
5749 let success = message
5750 .tool_success
5751 .ok_or_else(local_tool_history_unsupported)?;
5752 if message.tool_calls.is_some()
5753 || message.phase.is_some()
5754 || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5755 || !used.insert(id.clone())
5756 || used.len() > LocalToolMessages::MAX_PAIRS
5757 {
5758 return Err(local_tool_history_unsupported());
5759 }
5760 let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5761 if denied_write {
5762 if success
5763 || !(message.content.contains("native_tool_ceiling_denied")
5764 || message
5765 .content
5766 .contains("not callable at the current conversation position"))
5767 {
5768 return Err(local_tool_history_unsupported());
5769 }
5770 } else if !tools.contains(&name)
5771 || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5772 {
5773 return Err(local_tool_history_unsupported());
5774 }
5775 if let Some(metadata) = &message.metadata {
5776 let object = metadata
5777 .as_object()
5778 .ok_or_else(local_tool_history_unsupported)?;
5779 if object.len() != 5
5780 || metadata["elapsed_ms"].as_u64().is_none()
5781 || metadata["tool_name"].as_str() != Some(name.as_str())
5782 || metadata["is_mutating"].as_bool()
5783 != Some(matches!(name.as_str(), "Write" | "Task"))
5784 || metadata["auto_approved"].as_bool()
5785 != Some(!matches!(name.as_str(), "Write" | "Task"))
5786 || metadata["success"].as_bool() != Some(success)
5787 {
5788 return Err(local_tool_history_unsupported());
5789 }
5790 }
5791 }
5792 _ => return Err(local_tool_history_unsupported()),
5793 }
5794 }
5795 let last = suffix.last();
5796 let completed_text = match terminal {
5797 LocalToolTerminal::Completed(text) => text,
5798 LocalToolTerminal::Cancelled => None,
5799 };
5800 let question_terminal =
5801 self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5802 last.is_some_and(|last| last.role == Role::Tool
5803 && last.tool_call_id.as_ref() == Some(id)
5804 && last.tool_success == Some(true)
5805 && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5806 && completed_text.is_none_or(str::is_empty)
5807 && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5808 == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5809 });
5810 let report_terminal = last.is_some_and(|last| {
5811 last.role == Role::Assistant
5812 && last.tool_calls.is_none()
5813 && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5814 && completed_text == Some(last.content.as_str())
5815 });
5816 if !pending.is_empty()
5817 || used.len() != self.outcomes.len()
5818 || self.starts.keys().any(|id| !used.contains(id))
5819 || !(cancelled || report_terminal || question_terminal)
5820 {
5821 return Err(local_tool_history_unsupported());
5822 }
5823 Ok(suffix)
5824 }
5825}
5826
5827async fn validate_local_tool_checkpoint(
5828 session: &Session,
5829 binding: &SessionInboxRuntimeBinding,
5830 run_id: &str,
5831 terminal: LocalToolTerminal<'_>,
5832 cancel: &CancellationToken,
5833) -> Result<(), AgentError> {
5834 if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5835 return Err(local_tool_history_unsupported());
5836 }
5837 if matches!(terminal, LocalToolTerminal::Cancelled) {
5838 let backlog = binding
5839 .inbox
5840 .inspect(&session.id)
5841 .await
5842 .map_err(|_| local_tool_history_unsupported())?;
5843 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5844 return Err(local_tool_history_unsupported());
5847 }
5848 }
5849 Ok(())
5850}
5851
5852async fn commit_local_tool_history(
5853 collector: LocalToolCollector,
5854 session: &mut Session,
5855 binding: &SessionInboxRuntimeBinding,
5856 run_id: &str,
5857 selection: (&[String], bool),
5858 terminal: LocalToolTerminal<'_>,
5859 cancel: &CancellationToken,
5860) -> Result<(), AgentError> {
5861 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5862 let latest = binding
5863 .storage
5864 .load_session(&session.id)
5865 .await
5866 .map_err(|_| local_tool_history_unsupported())?
5867 .ok_or_else(local_tool_history_unsupported)?;
5868 if latest.id != session.id
5869 || latest.kind != session.kind
5870 || latest.created_at != session.created_at
5871 || latest.root_session_id != session.root_session_id
5872 || latest.parent_session_id != session.parent_session_id
5873 || latest.spawn_depth != session.spawn_depth
5874 || latest.project_id_meta() != session.project_id_meta()
5875 || serde_json::to_value(&latest.provider_transcript)
5876 .map_err(|_| local_tool_history_unsupported())?
5877 != serde_json::to_value(&session.provider_transcript)
5878 .map_err(|_| local_tool_history_unsupported())?
5879 {
5880 return Err(local_tool_history_unsupported());
5881 }
5882 let suffix = match terminal {
5883 LocalToolTerminal::Completed(text) => {
5884 collector.suffix(&latest, selection.0, selection.1, text)?
5885 }
5886 LocalToolTerminal::Cancelled => {
5887 collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5888 }
5889 };
5890 let before = session.clone();
5891 let mut staged = session.clone();
5892 staged.messages = latest.messages.clone();
5893 staged.messages.extend(suffix.iter().cloned());
5894 staged.updated_at = chrono::Utc::now();
5895 let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5896 Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5897 } else {
5898 None
5899 };
5900 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5901 binding
5902 .persistence
5903 .checkpoint_runtime_session(&mut staged)
5904 .await
5905 .map_err(|_| local_tool_history_unsupported())?;
5906 let saved = binding
5907 .storage
5908 .load_session(&session.id)
5909 .await
5910 .map_err(|_| local_tool_history_unsupported())?
5911 .ok_or_else(local_tool_history_unsupported)?;
5912 if serde_json::to_value(&saved.provider_transcript)
5913 .map_err(|_| local_tool_history_unsupported())?
5914 != serde_json::to_value(&latest.provider_transcript)
5915 .map_err(|_| local_tool_history_unsupported())?
5916 {
5917 return Err(local_tool_history_unsupported());
5918 }
5919 let start = match suffix.first() {
5920 Some(first) => saved
5921 .messages
5922 .iter()
5923 .position(|message| message.id == first.id)
5924 .ok_or_else(local_tool_history_unsupported)?,
5925 None => latest.messages.len(),
5926 };
5927 if cancelled_messages.as_ref().is_some_and(|expected| {
5928 serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5929 }) {
5930 return Err(local_tool_history_unsupported());
5933 }
5934 if saved.messages.len() < latest.messages.len()
5935 || serde_json::to_value(&saved.messages[..latest.messages.len()])
5936 .map_err(|_| local_tool_history_unsupported())?
5937 != serde_json::to_value(&latest.messages)
5938 .map_err(|_| local_tool_history_unsupported())?
5939 || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5940 .map_err(|_| local_tool_history_unsupported())?
5941 != serde_json::to_value(Some(&suffix[..]))
5942 .map_err(|_| local_tool_history_unsupported())?
5943 {
5944 return Err(local_tool_history_unsupported());
5945 }
5946 let expected = latest.messages.iter().chain(&suffix);
5949 for message in expected {
5950 let matches: Vec<_> = saved
5951 .messages
5952 .iter()
5953 .filter(|current| current.id == message.id)
5954 .collect();
5955 if matches.len() != 1
5956 || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5957 != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5958 {
5959 *session = before;
5960 return Err(local_tool_history_unsupported());
5961 }
5962 }
5963 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5964 staged.messages = saved.messages;
5965 *session = staged;
5966 Ok(())
5967}
5968
5969#[derive(Default)]
5970struct ReadOnlyActorCollector {
5971 start: Option<(String, serde_json::Value)>,
5972 result: Option<String>,
5973 messages: Option<Vec<bamboo_agent_core::Message>>,
5974}
5975impl ReadOnlyActorCollector {
5976 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5977 if self.messages.is_some() {
5978 return Err(plain_actor_unsupported());
5979 }
5980 if value["type"] == "owned_readonly_transcript" {
5981 let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5982 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5983 tail.validate().map_err(|_| plain_actor_unsupported())?;
5984 let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5985 let typed: Vec<bamboo_agent_core::Message> = messages
5986 .iter()
5987 .map(|raw| {
5988 let message: bamboo_agent_core::Message =
5989 serde_json::from_value(raw.clone())
5990 .map_err(|_| plain_actor_unsupported())?;
5991 if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
5992 != *raw
5993 {
5994 return Err(plain_actor_unsupported());
5995 }
5996 Ok(message)
5997 })
5998 .collect::<Result<_, _>>()?;
5999 let (id, args) = self
6000 .start
6001 .as_ref()
6002 .ok_or_else(|| plain_actor_unsupported())?;
6003 let call = typed[0]
6004 .tool_calls
6005 .as_ref()
6006 .and_then(|calls| {
6007 if calls.len() == 1 {
6008 calls.first()
6009 } else {
6010 None
6011 }
6012 })
6013 .ok_or_else(|| plain_actor_unsupported())?;
6014 if call.id != *id
6015 || call.function.name != "Glob"
6016 || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
6017 .map_err(|_| plain_actor_unsupported())?
6018 != *args
6019 || typed[1].tool_call_id.as_ref() != Some(id)
6020 || typed[1].tool_success != Some(true)
6021 || self.result.as_ref() != Some(&typed[1].content)
6022 {
6023 return Err(plain_actor_unsupported());
6024 }
6025 self.messages = Some(typed);
6026 return Ok(false);
6027 }
6028 let event: AgentEvent =
6029 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6030 match event {
6031 AgentEvent::ToolStart {
6032 tool_call_id,
6033 tool_name,
6034 arguments,
6035 } => {
6036 if tool_name != "Glob"
6037 || tool_call_id.trim().is_empty()
6038 || self.start.is_some()
6039 || !arguments.is_object()
6040 {
6041 return Err(plain_actor_unsupported());
6042 }
6043 self.start = Some((tool_call_id, arguments));
6044 Ok(true)
6045 }
6046 AgentEvent::ToolComplete {
6047 tool_call_id,
6048 result,
6049 } => {
6050 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6051 || self.result.is_some()
6052 || !result.success
6053 || !result.images.is_empty()
6054 {
6055 return Err(plain_actor_unsupported());
6056 }
6057 self.result = Some(result.result);
6058 Ok(true)
6059 }
6060 AgentEvent::ToolToken { tool_call_id, .. } => {
6061 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6062 {
6063 return Err(plain_actor_unsupported());
6064 }
6065 Ok(true)
6066 }
6067 AgentEvent::ToolLifecycle {
6068 tool_call_id,
6069 tool_name,
6070 is_mutating,
6071 phase,
6072 error,
6073 ..
6074 } => {
6075 if tool_name != "Glob"
6076 || is_mutating
6077 || !matches!(phase.as_str(), "begin" | "finished")
6078 || error.is_some()
6079 || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6080 {
6081 return Err(plain_actor_unsupported());
6082 }
6083 Ok(true)
6084 }
6085 _ => plain_actor_event(value),
6086 }
6087 }
6088 fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6089 let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6090 if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6091 return Err(plain_actor_unsupported());
6092 }
6093 Ok(messages)
6094 }
6095}
6096
6097fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6098 let event: AgentEvent =
6099 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6100 match event {
6101 AgentEvent::ReasoningToken { .. }
6102 | AgentEvent::ToolToken { .. }
6103 | AgentEvent::ToolStart { .. }
6104 | AgentEvent::ToolComplete { .. }
6105 | AgentEvent::ToolError { .. }
6106 | AgentEvent::ToolLifecycle { .. }
6107 | AgentEvent::ContextSummarized { .. }
6108 | AgentEvent::ContextArchived { .. }
6109 | AgentEvent::ContextCompressionStatus { .. }
6110 | AgentEvent::NeedClarification { .. }
6111 | AgentEvent::SubAgentStarted { .. }
6112 | AgentEvent::SubAgentEvent { .. }
6113 | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6114 AgentEvent::MessageAppended { .. }
6117 | AgentEvent::SessionHistoryCommitted { .. }
6118 | AgentEvent::VisibleMessageStart { .. }
6119 | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6120 event if event.is_durable_change() => Ok(false),
6121 _ => Ok(true),
6122 }
6123}
6124
6125fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6131 let value = match placement {
6134 Placement::Local => return None,
6135 Placement::Remote { endpoint } => serde_json::json!({
6136 "kind": "remote",
6137 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6138 }),
6139 Placement::Schedulable { pool } => serde_json::json!({
6140 "kind": "remote",
6141 "host": host_label.unwrap_or(pool),
6142 }),
6143 };
6144 serde_json::to_string(&value).ok()
6145}
6146
6147fn host_of_endpoint(endpoint: &str) -> String {
6149 endpoint
6150 .trim()
6151 .trim_start_matches("wss://")
6152 .trim_start_matches("ws://")
6153 .split(['/', ':'])
6154 .next()
6155 .unwrap_or(endpoint)
6156 .to_string()
6157}
6158
6159async fn reconcile_already_admitted_claim(
6160 binding: &SessionInboxRuntimeBinding,
6161 session: &mut Session,
6162 claim: &SessionInboxClaim,
6163) -> crate::runtime::runner::Result<()> {
6164 let latest = binding
6165 .storage
6166 .load_session(&session.id)
6167 .await
6168 .map_err(|error| {
6169 AgentError::LLM(format!(
6170 "load canonical SessionInbox checkpoint for {}: {error}",
6171 session.id
6172 ))
6173 })?
6174 .ok_or_else(|| {
6175 AgentError::LLM(format!(
6176 "canonical SessionInbox target disappeared: {}",
6177 session.id
6178 ))
6179 })?;
6180 let Some(message) = latest
6181 .messages
6182 .iter()
6183 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6184 .cloned()
6185 else {
6186 return Err(AgentError::LLM(format!(
6187 "canonical admitted receipt for {} exists without transcript message {}",
6188 session.id, claim.envelope.id
6189 )));
6190 };
6191 if let Some(existing) = session
6192 .messages
6193 .iter_mut()
6194 .find(|existing| existing.id == message.id)
6195 {
6196 *existing = message;
6197 } else {
6198 session.add_message(message);
6199 }
6200 bamboo_domain::merge_session_inbox_admission(session, &latest);
6201 binding
6202 .inbox
6203 .ack(&session.id, claim)
6204 .await
6205 .map_err(|error| {
6206 AgentError::LLM(format!(
6207 "ack recovered canonical SessionInbox claim {}: {error}",
6208 claim.envelope.id
6209 ))
6210 })
6211}
6212
6213async fn checkpoint_and_ack_canonical_claim(
6217 binding: &SessionInboxRuntimeBinding,
6218 session: &mut Session,
6219 claim: &SessionInboxClaim,
6220 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6221) -> crate::runtime::runner::Result<()> {
6222 if claim.envelope.target_session_id != session.id {
6223 return Err(AgentError::LLM(format!(
6224 "canonical SessionInbox claim target {} does not match active logical session {}",
6225 claim.envelope.target_session_id, session.id
6226 )));
6227 }
6228 if binding
6229 .inbox
6230 .was_admitted(&session.id, &claim.envelope.id)
6231 .await
6232 .map_err(|error| {
6233 AgentError::LLM(format!(
6234 "inspect canonical admitted receipt {}: {error}",
6235 claim.envelope.id
6236 ))
6237 })?
6238 {
6239 return reconcile_already_admitted_claim(binding, session, claim).await;
6240 }
6241
6242 if let Some((store, fence)) = fenced {
6243 let committed = store
6244 .confirm_actor_claim_context(
6245 concrete_file_inbox(binding)?,
6246 bamboo_storage::ActorClaimContextSeed {
6247 fence: fence.clone(),
6248 expected_created_at: session.created_at,
6249 expected_messages: session.messages.clone(),
6250 expected_provider_transcript: session.provider_transcript.clone(),
6251 expected_admission: session.session_inbox_admission().cloned(),
6252 claims: vec![claim.clone()],
6253 },
6254 )
6255 .await;
6256 let committed = match committed {
6257 Ok(committed) => committed,
6258 Err(error) => {
6259 adopt_latest_actor_context(store, session).await;
6263 return Err(AgentError::LLM(format!(
6264 "confirm fenced Actor claim context unconfirmed: {error}"
6265 )));
6266 }
6267 };
6268 bamboo_domain::merge_session_inbox_admission(session, &committed);
6269 session.messages = committed.messages;
6270 session.provider_transcript = committed.provider_transcript;
6271 session.updated_at = committed.updated_at;
6272 if !session
6273 .messages
6274 .iter()
6275 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6276 || !session
6277 .session_inbox_admission()
6278 .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6279 {
6280 return Err(AgentError::LLM(
6281 "fenced Actor claim confirmation lost durable proof".into(),
6282 ));
6283 }
6284 return binding
6285 .inbox
6286 .ack(&session.id, claim)
6287 .await
6288 .map_err(|error| {
6289 AgentError::LLM(format!(
6290 "ack fenced Actor claim {} after confirmation: {error}",
6291 claim.envelope.id
6292 ))
6293 });
6294 }
6295
6296 let transcript_has_id = session
6297 .messages
6298 .iter()
6299 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6300 if session
6301 .messages
6302 .iter()
6303 .any(|message| message.id == claim.envelope.id.as_str())
6304 && !transcript_has_id
6305 {
6306 return Err(AgentError::LLM(format!(
6307 "canonical SessionInbox id {} collides with a non-matching transcript message",
6308 claim.envelope.id
6309 )));
6310 }
6311 let cursor_has_id = session
6312 .session_inbox_admission()
6313 .is_some_and(|state| state.contains(&claim.envelope.id));
6314 if cursor_has_id && !transcript_has_id {
6315 return Err(AgentError::LLM(format!(
6316 "canonical SessionInbox cursor exists without transcript message {}",
6317 claim.envelope.id
6318 )));
6319 }
6320 let before = session.clone();
6321 if !transcript_has_id {
6322 let message = claim.envelope.to_provider_message().map_err(|error| {
6323 AgentError::LLM(format!(
6324 "translate canonical SessionInbox envelope {}: {error}",
6325 claim.envelope.id
6326 ))
6327 })?;
6328 session.add_message(message);
6329 }
6330 session
6331 .session_inbox_admission_mut()
6332 .record(claim.envelope.id.clone(), claim.generation);
6333 session.updated_at = chrono::Utc::now();
6334
6335 if let Err(error) = binding
6336 .persistence
6337 .checkpoint_runtime_session(session)
6338 .await
6339 {
6340 *session = before;
6341 return Err(AgentError::LLM(format!(
6342 "checkpoint canonical SessionInbox claim {}: {error}",
6343 claim.envelope.id
6344 )));
6345 }
6346 if !session
6347 .messages
6348 .iter()
6349 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6350 {
6351 *session = before;
6352 return Err(AgentError::LLM(format!(
6353 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6354 claim.envelope.id
6355 )));
6356 }
6357 binding
6358 .inbox
6359 .ack(&session.id, claim)
6360 .await
6361 .map_err(|error| {
6362 AgentError::LLM(format!(
6363 "ack canonical SessionInbox claim {} after checkpoint: {error}",
6364 claim.envelope.id
6365 ))
6366 })
6367}
6368
6369fn concrete_file_inbox(
6370 binding: &SessionInboxRuntimeBinding,
6371) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6372 binding
6373 .inbox
6374 .as_any()
6375 .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6376 .cloned()
6377 .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6378}
6379
6380async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6381 if let Ok(Some(latest)) = store.load_session(&session.id).await {
6382 if latest.created_at == session.created_at {
6383 bamboo_domain::merge_session_inbox_admission(session, &latest);
6384 session.messages = latest.messages;
6385 session.provider_transcript = latest.provider_transcript;
6386 session.updated_at = latest.updated_at;
6387 }
6388 }
6389}
6390
6391async fn checkpoint_claim_context_before_dispatch(
6402 binding: &SessionInboxRuntimeBinding,
6403 session: &mut Session,
6404 claims: &[SessionInboxClaim],
6405 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6406) -> crate::runtime::runner::Result<()> {
6407 if claims.is_empty() {
6408 return Ok(());
6409 }
6410 let before = session.clone();
6411 let mut additions = Vec::new();
6412 for claim in claims {
6413 if claim.envelope.target_session_id != session.id {
6414 return Err(AgentError::LLM(format!(
6415 "canonical SessionInbox claim target {} does not match actor session {}",
6416 claim.envelope.target_session_id, session.id
6417 )));
6418 }
6419 let matching = session
6420 .messages
6421 .iter()
6422 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6423 if session
6424 .messages
6425 .iter()
6426 .any(|message| message.id == claim.envelope.id.as_str())
6427 && !matching
6428 {
6429 return Err(AgentError::LLM(format!(
6430 "canonical SessionInbox id {} collides before actor dispatch",
6431 claim.envelope.id
6432 )));
6433 }
6434 if session
6435 .session_inbox_admission()
6436 .is_some_and(|state| state.contains(&claim.envelope.id))
6437 && !matching
6438 {
6439 return Err(AgentError::LLM(format!(
6440 "canonical SessionInbox cursor exists without transcript proof for {}",
6441 claim.envelope.id
6442 )));
6443 }
6444 if !matching {
6445 let message = claim.envelope.to_provider_message().map_err(|error| {
6446 AgentError::LLM(format!(
6447 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6448 claim.envelope.id
6449 ))
6450 })?;
6451 additions.push(message);
6452 }
6453 }
6454 let fenced_seed = fenced.is_some();
6455 if let Some((store, fence)) = fenced {
6456 let committed = store
6457 .seed_actor_claim_context(
6458 concrete_file_inbox(binding)?,
6459 bamboo_storage::ActorClaimContextSeed {
6460 fence: fence.clone(),
6461 expected_created_at: session.created_at,
6462 expected_messages: session.messages.clone(),
6463 expected_provider_transcript: session.provider_transcript.clone(),
6464 expected_admission: session.session_inbox_admission().cloned(),
6465 claims: claims.to_vec(),
6466 },
6467 )
6468 .await;
6469 let committed = match committed {
6470 Ok(committed) => committed,
6471 Err(error) => {
6472 adopt_latest_actor_context(store, session).await;
6476 return Err(AgentError::LLM(format!(
6477 "seed fenced Actor claim context unconfirmed: {error}"
6478 )));
6479 }
6480 };
6481 bamboo_domain::merge_session_inbox_admission(session, &committed);
6482 session.messages = committed.messages;
6483 session.provider_transcript = committed.provider_transcript;
6484 session.updated_at = committed.updated_at;
6485 } else {
6486 for message in additions {
6487 session.add_message(message);
6488 }
6489 session.updated_at = chrono::Utc::now();
6490 if let Err(error) = binding
6491 .persistence
6492 .checkpoint_runtime_session(session)
6493 .await
6494 {
6495 *session = before;
6496 return Err(AgentError::LLM(format!(
6497 "checkpoint canonical SessionInbox actor context: {error}"
6498 )));
6499 }
6500 }
6501 for claim in claims {
6502 if !session
6503 .messages
6504 .iter()
6505 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6506 {
6507 if !fenced_seed {
6508 *session = before;
6509 }
6510 return Err(AgentError::LLM(format!(
6511 "actor context checkpoint lost typed transcript proof for {}",
6512 claim.envelope.id
6513 )));
6514 }
6515 }
6516 Ok(())
6517}
6518
6519async fn claim_canonical_deliveries(
6520 binding: &SessionInboxRuntimeBinding,
6521 session: &mut Session,
6522 activation_run_id: &str,
6523 limit: usize,
6524 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6525) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6526 let claims = binding
6527 .inbox
6528 .claim_for_turn(&session.id, limit, Some(activation_run_id))
6529 .await
6530 .map_err(|error| {
6531 AgentError::LLM(format!(
6532 "claim canonical SessionInbox for active actor {}: {error}",
6533 session.id
6534 ))
6535 })?;
6536 if claims.is_empty() {
6537 return Ok(Vec::new());
6538 }
6539 let mut unconfirmed = Vec::with_capacity(claims.len());
6540 for claim in claims {
6541 if binding
6542 .inbox
6543 .was_admitted(&session.id, &claim.envelope.id)
6544 .await
6545 .map_err(|error| {
6546 AgentError::LLM(format!(
6547 "inspect canonical SessionInbox claim {}: {error}",
6548 claim.envelope.id
6549 ))
6550 })?
6551 {
6552 reconcile_already_admitted_claim(binding, session, &claim).await?;
6553 continue;
6554 }
6555 unconfirmed.push(claim);
6556 }
6557 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6558
6559 let mut deliveries = Vec::with_capacity(unconfirmed.len());
6560 for claim in unconfirmed {
6561 if session
6566 .session_inbox_admission()
6567 .is_some_and(|state| state.contains(&claim.envelope.id))
6568 {
6569 binding
6570 .inbox
6571 .ack(&session.id, &claim)
6572 .await
6573 .map_err(|error| {
6574 AgentError::LLM(format!(
6575 "finish confirmed canonical SessionInbox ack {}: {error}",
6576 claim.envelope.id
6577 ))
6578 })?;
6579 continue;
6580 }
6581 let delivery = SessionMessageDelivery {
6582 target_session_id: session.id.clone(),
6583 envelope: claim.envelope.clone(),
6584 canonical_claim_generation: claim.generation,
6585 activation_run_id: activation_run_id.to_string(),
6586 activation_policy: claim.activation_policy,
6587 };
6588 deliveries.push((claim, delivery));
6589 }
6590 Ok(deliveries)
6591}
6592
6593async fn forward_next_canonical_claim(
6594 client: &mut dyn bamboo_subagent::ChildLink,
6595 binding: &SessionInboxRuntimeBinding,
6596 session: &mut Session,
6597 activation_run_id: &str,
6598 inflight: &mut VecDeque<SessionInboxClaim>,
6599 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6600) -> crate::runtime::runner::Result<()> {
6601 if !inflight.is_empty() {
6602 return Ok(());
6603 }
6604 let Some((claim, delivery)) =
6605 claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6606 .await?
6607 .pop()
6608 else {
6609 return Ok(());
6610 };
6611 client
6612 .send(ParentFrame::SessionMessage { delivery })
6613 .await
6614 .map_err(|error| {
6615 AgentError::LLM(format!(
6616 "forward canonical SessionInbox claim {} to active actor: {error}",
6617 claim.envelope.id
6618 ))
6619 })?;
6620 inflight.push_back(claim);
6621 Ok(())
6622}
6623
6624struct ActorDriveContext<'a> {
6626 client: &'a mut dyn bamboo_subagent::ChildLink,
6627 parent_session_id: &'a str,
6628 child_session_id: &'a str,
6629 child_attempt: u32,
6630 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6631 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6632 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6633 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6634 event_tx: &'a mpsc::Sender<AgentEvent>,
6635 cancel_token: &'a CancellationToken,
6636 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6637 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6638 logical_session: &'a mut Session,
6639 expected_permission_posture: Option<ExpectedPermissionPosture>,
6640 expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6641 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6642 actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6643 canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6644 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6645 activation_run_id: Option<&'a str>,
6646 execution_epoch: u64,
6647 expected_source_actor_id: &'a str,
6648 initial_inflight_claims: VecDeque<SessionInboxClaim>,
6649 plain_actor: bool,
6650 remote_environment_lease: bool,
6651 readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6652 local_history_tools: Option<&'a [String]>,
6653 local_history_read_only: bool,
6654 strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6655 plain_input: Option<&'a PlainActorActivation>,
6656 canonical_activation: Option<&'a ActorActivationFence>,
6657 canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6658 actor_event_observer: Option<&'a dyn ActorEventObserver>,
6659 plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6660 first_frame_timeout: Option<Duration>,
6661}
6662
6663#[derive(Debug, Clone, PartialEq, Eq)]
6664struct ExpectedPermissionPosture {
6665 policy_revision: u64,
6666 resolution: bamboo_domain::PermissionModeResolution,
6667 expected_audit_revision: Option<u64>,
6668 executor_mapping: String,
6669}
6670
6671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6672enum PermissionPostureHandshake {
6673 NotRequired,
6675 Awaiting,
6678 Confirmed,
6680}
6681
6682impl PermissionPostureHandshake {
6683 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6684 if expected.is_some() {
6685 Self::Awaiting
6686 } else {
6687 Self::NotRequired
6688 }
6689 }
6690
6691 fn is_awaiting(self) -> bool {
6692 self == Self::Awaiting
6693 }
6694
6695 fn posture_was_confirmed(self) -> bool {
6696 self == Self::Confirmed
6697 }
6698}
6699
6700fn permission_posture_seed_from_event(
6701 session: &Session,
6702 event: &AgentEvent,
6703) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6704 let AgentEvent::PermissionPostureActivated {
6705 session_id,
6706 policy_revision,
6707 requested_mode,
6708 effective_mode,
6709 executor_mapping,
6710 } = event
6711 else {
6712 return Ok(None);
6713 };
6714 if session_id != &session.id {
6715 return Err("permission posture event targets a different logical session".to_string());
6716 }
6717 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6718 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6719 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6720 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6721 let resolution = bamboo_domain::PermissionModeResolution {
6722 requested,
6723 effective,
6724 };
6725 if !resolution.is_consistent() {
6726 return Err("permission posture event has an inconsistent mode pair".to_string());
6727 }
6728 let current_requested = session
6729 .agent_runtime_state
6730 .as_ref()
6731 .map(|state| state.effective_permission_mode())
6732 .unwrap_or_default();
6733 if current_requested != requested {
6734 return Err("permission posture event is stale for the host typed mode".to_string());
6735 }
6736 let mapping_chars = executor_mapping.chars().count();
6737 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6738 return Err("permission posture event has an invalid executor mapping".to_string());
6739 }
6740 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6741 *policy_revision,
6742 resolution,
6743 executor_mapping,
6744 )))
6745}
6746
6747fn actor_event_logical_identity(
6748 session: &Session,
6749 parent_session_id: &str,
6750 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6751) -> LogicalSessionIdentity {
6752 LogicalSessionIdentity {
6753 creation: creation.cloned(),
6754 session_id: session.id.clone(),
6755 parent_session_id: session
6756 .parent_session_id
6757 .clone()
6758 .or_else(|| Some(parent_session_id.to_string())),
6759 root_session_id: if session.root_session_id.trim().is_empty() {
6760 parent_session_id.to_string()
6761 } else {
6762 session.root_session_id.clone()
6763 },
6764 }
6765}
6766
6767fn validate_strict_history_terminal(
6768 watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6769 session: &Session,
6770 parent_session_id: &str,
6771 activation_run_id: Option<&str>,
6772 execution_epoch: u64,
6773 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6774 consumed_seq: u64,
6775) -> Result<(), AgentError> {
6776 let identity = actor_event_logical_identity(session, parent_session_id, creation);
6777 if watermark
6778 .zip(activation_run_id)
6779 .is_some_and(|(watermark, run_id)| {
6780 watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6781 })
6782 {
6783 Ok(())
6784 } else {
6785 Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6786 }
6787}
6788
6789fn validate_actor_event_batch(
6790 batch: &ActorEventBatch,
6791 logical_session: &Session,
6792 parent_session_id: &str,
6793 activation_run_id: Option<&str>,
6794 execution_epoch: u64,
6795 expected_source_actor_id: &str,
6796 expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6797) -> Result<(), AgentError> {
6798 batch
6799 .validate()
6800 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6801 let identity = batch.logical_session.as_ref().ok_or_else(|| {
6802 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6803 })?;
6804 let expected_identity =
6809 actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6810 if identity != &expected_identity {
6811 return Err(AgentError::LLM(
6812 "actor event batch targets a different logical session".to_string(),
6813 ));
6814 }
6815 if batch.activation_id.as_deref() != activation_run_id {
6816 return Err(AgentError::LLM(
6817 "actor event batch belongs to a stale activation".to_string(),
6818 ));
6819 }
6820 if batch.execution_epoch != execution_epoch {
6821 return Err(AgentError::LLM(
6822 "actor event batch belongs to a stale execution epoch".to_string(),
6823 ));
6824 }
6825 if batch
6826 .source_actor_id
6827 .as_deref()
6828 .is_some_and(|source| source != expected_source_actor_id)
6829 {
6830 return Err(AgentError::LLM(
6831 "actor event batch declares a different physical actor".to_string(),
6832 ));
6833 }
6834 Ok(())
6835}
6836
6837async fn validate_directory_actor_event(
6841 store: &bamboo_storage::SessionStoreV2,
6842 fence: &ActorActivationFence,
6843 session: &Session,
6844 run_id: Option<&str>,
6845) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6846 store
6847 .validate_fence(fence, chrono::Utc::now())
6848 .await
6849 .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6850 let entry = store
6851 .inspect_actor(&fence.actor_id)
6852 .await
6853 .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6854 if !entry.actor.matches_session(session)
6855 || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6856 || !entry.activation.as_ref().is_some_and(|current| {
6857 current.matches_fence(fence)
6858 && current.status == bamboo_domain::ActorActivationStatus::Running
6859 })
6860 || run_id.is_some_and(|id| id != fence.run_id)
6861 {
6862 return Err(AgentError::LLM("actor event authority changed".into()));
6863 }
6864 Ok(entry)
6865}
6866
6867async fn inspect_active_owned_tree(
6870 store: &bamboo_storage::SessionStoreV2,
6871 binding: &SessionInboxRuntimeBinding,
6872 run_id: &str,
6873 child_id: &str,
6874 parent_id: &str,
6875 logical_session: &Session,
6876 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6877 plain_input: Option<&PlainActorActivation>,
6878 cursor: Option<&str>,
6879) -> Option<serde_json::Value> {
6880 use bamboo_agent_core::storage::Storage;
6881 let current = match store.load_session(child_id).await {
6882 Ok(Some(current)) => current,
6883 _ => {
6884 tracing::warn!("owned tree denied: canonical caller unavailable");
6885 return None;
6886 }
6887 };
6888 let current_project = match project_id_for_actor_run(¤t) {
6889 Ok(project) => project,
6890 Err(_) => {
6891 tracing::warn!("owned tree denied: invalid canonical Project");
6892 return None;
6893 }
6894 };
6895 let logical_project = match project_id_for_actor_run(logical_session) {
6896 Ok(project) => project,
6897 Err(_) => {
6898 tracing::warn!("owned tree denied: invalid logical Project");
6899 return None;
6900 }
6901 };
6902 if current.id != child_id
6903 || current.kind != bamboo_domain::SessionKind::Child
6904 || current.parent_session_id.as_deref() != Some(parent_id)
6905 || current.parent_session_id != logical_session.parent_session_id
6906 || current.root_session_id != logical_session.root_session_id
6907 || current.created_at != creation.created_at
6908 || current.created_at != logical_session.created_at
6909 || current.spawn_depth != creation.spawn_depth
6910 || current.spawn_depth != logical_session.spawn_depth
6911 || current_project != logical_project
6912 {
6913 tracing::warn!("owned tree denied: caller lifetime or Project changed");
6914 return None;
6915 }
6916 if let Some(activation) = plain_input {
6917 validate_directory_actor_event(
6918 activation.store.as_ref(),
6919 &activation.fence,
6920 ¤t,
6921 Some(run_id),
6922 )
6923 .await
6924 .ok()?;
6925 } else if !binding.router.owns_run(child_id, run_id).await {
6926 tracing::warn!("owned tree denied: activation owner changed");
6927 return None;
6928 }
6929 let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6930 store, child_id, cursor,
6931 )
6932 .await
6933 {
6934 Ok(page) => page,
6935 Err(error) => {
6936 tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6937 return None;
6938 }
6939 };
6940 let again = match store.load_session(child_id).await {
6941 Ok(Some(again)) => again,
6942 _ => {
6943 tracing::warn!("owned tree denied: canonical caller disappeared");
6944 return None;
6945 }
6946 };
6947 if again.created_at != current.created_at
6948 || again.parent_session_id != current.parent_session_id
6949 || again.root_session_id != current.root_session_id
6950 || again.spawn_depth != current.spawn_depth
6951 || project_id_for_actor_run(&again).ok()? != current_project
6952 {
6953 tracing::warn!("owned tree denied: caller changed during inspection");
6954 return None;
6955 }
6956 if let Some(activation) = plain_input {
6957 validate_directory_actor_event(
6958 activation.store.as_ref(),
6959 &activation.fence,
6960 &again,
6961 Some(run_id),
6962 )
6963 .await
6964 .ok()?;
6965 } else if !binding.router.owns_run(child_id, run_id).await {
6966 tracing::warn!("owned tree denied: activation owner changed after inspection");
6967 return None;
6968 }
6969 serde_json::to_vec(&page)
6970 .ok()
6971 .filter(|bytes| bytes.len() <= 8192)?;
6972 Some(page)
6973}
6974
6975async fn load_active_subagent_caller(
6979 store: &bamboo_storage::SessionStoreV2,
6980 binding: &SessionInboxRuntimeBinding,
6981 run_id: &str,
6982 child_id: &str,
6983 parent_id: &str,
6984 logical_session: &Session,
6985 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6986 plain_input: Option<&PlainActorActivation>,
6987) -> Option<Session> {
6988 use bamboo_agent_core::storage::Storage;
6989 let current = store.load_session(child_id).await.ok().flatten()?;
6990 let current_project = project_id_for_actor_run(¤t).ok()?;
6991 let logical_project = project_id_for_actor_run(logical_session).ok()?;
6992 if current.id != child_id
6993 || current.kind != bamboo_domain::SessionKind::Child
6994 || current.parent_session_id.as_deref() != Some(parent_id)
6995 || current.parent_session_id != logical_session.parent_session_id
6996 || current.root_session_id != logical_session.root_session_id
6997 || current.created_at != creation.created_at
6998 || current.created_at != logical_session.created_at
6999 || current.spawn_depth != creation.spawn_depth
7000 || current.spawn_depth != logical_session.spawn_depth
7001 || current_project != logical_project
7002 || bamboo_domain::ActorSession::from_session(¤t).is_err()
7003 {
7004 return None;
7005 }
7006 if let Some(activation) = plain_input {
7007 validate_directory_actor_event(
7008 activation.store.as_ref(),
7009 &activation.fence,
7010 ¤t,
7011 Some(run_id),
7012 )
7013 .await
7014 .ok()?;
7015 } else if !binding.router.owns_run(child_id, run_id).await {
7016 return None;
7017 }
7018 Some(current)
7019}
7020
7021async fn invoke_active_subagent(
7022 tool: &dyn bamboo_agent_core::tools::Tool,
7023 caller: &Session,
7024 args: serde_json::Value,
7025 tool_call_id: &str,
7026) -> serde_json::Value {
7027 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7028 let mut ctx = ToolCtx::none(tool_call_id.to_owned());
7029 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7030 match tool.invoke(args, ctx).await {
7031 Ok(ToolOutcome::Completed(result))
7032 if result.images.is_empty()
7033 && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
7034 {
7035 serde_json::json!({"result":result})
7036 }
7037 _ => serde_json::json!({
7038 "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
7039 }),
7040 }
7041}
7042
7043#[allow(clippy::too_many_arguments)]
7046async fn checkpoint_active_parent_question(
7047 store: &bamboo_storage::SessionStoreV2,
7048 binding: &SessionInboxRuntimeBinding,
7049 run_id: &str,
7050 child_id: &str,
7051 parent_id: &str,
7052 logical_session: &mut Session,
7053 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7054 plain_input: Option<&PlainActorActivation>,
7055 observation: ParentQuestionCheckpointV1,
7056) -> Option<String> {
7057 use bamboo_agent_core::storage::Storage;
7058 if plain_input.is_some() || observation.validate_shape().is_err() {
7059 return None;
7060 }
7061 let _session_guard = binding
7065 .parent_question_lock
7066 .as_ref()?
7067 .acquire_lock(child_id)
7068 .await;
7069 let caller = load_active_subagent_caller(
7070 store,
7071 binding,
7072 run_id,
7073 child_id,
7074 parent_id,
7075 logical_session,
7076 creation,
7077 plain_input,
7078 )
7079 .await?;
7080 let parent = store.load_session(parent_id).await.ok().flatten()?;
7081 if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7082 return None;
7083 }
7084 let (saved, question) = store
7085 .checkpoint_parent_question(&caller, &parent, &observation)
7086 .await
7087 .ok()
7088 .flatten()?;
7089 logical_session.messages = saved.messages;
7092 logical_session.pending_question = saved.pending_question;
7093 if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7094 logical_session
7095 .metadata
7096 .insert("runtime.suspend_reason".into(), reason.clone());
7097 }
7098 if let Some(request) = saved
7099 .metadata
7100 .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7101 {
7102 logical_session.metadata.insert(
7103 bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7104 request.clone(),
7105 );
7106 }
7107 logical_session.metadata.insert(
7108 "runtime.actor_parent_question_handoff".into(),
7109 "true".into(),
7110 );
7111 Some(question.id.as_str().to_owned())
7112}
7113
7114async fn ensure_active_nested_wait(
7119 tool: &dyn bamboo_agent_core::tools::Tool,
7120 store: &bamboo_storage::SessionStoreV2,
7121 binding: &SessionInboxRuntimeBinding,
7122 run_id: &str,
7123 child_id: &str,
7124 parent_id: &str,
7125 logical_session: &Session,
7126 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7127 plain_input: Option<&PlainActorActivation>,
7128) -> Result<bool, AgentError> {
7129 use bamboo_agent_core::storage::Storage;
7130 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7131
7132 let caller = load_active_subagent_caller(
7133 store,
7134 binding,
7135 run_id,
7136 child_id,
7137 parent_id,
7138 logical_session,
7139 creation,
7140 plain_input,
7141 )
7142 .await
7143 .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7144 if caller
7145 .agent_runtime_state
7146 .as_ref()
7147 .and_then(|state| state.waiting_for_children.as_ref())
7148 .is_some()
7149 {
7150 return Ok(true);
7151 }
7152
7153 let active = store
7154 .list_child_run_statuses(child_id)
7155 .await
7156 .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?
7157 .into_iter()
7158 .filter(|(_, status)| {
7159 !status.as_deref().is_some_and(|status| {
7160 matches!(
7161 status,
7162 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7163 )
7164 })
7165 })
7166 .map(|(id, _)| id)
7167 .collect::<Vec<_>>();
7168 if active.is_empty() {
7169 return Ok(false);
7170 }
7171
7172 let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7173 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7174 let outcome = tokio::time::timeout(
7175 Duration::from_secs(30),
7176 tool.invoke(
7177 serde_json::json!({"action":"wait", "child_session_ids":active}),
7178 ctx,
7179 ),
7180 )
7181 .await
7182 .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7183 .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7184 let wait_registered = match outcome {
7185 ToolOutcome::Completed(result) if result.success => {
7186 let payload: serde_json::Value = serde_json::from_str(&result.result)
7187 .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7188 payload["status"] == "waiting"
7189 }
7190 _ => {
7191 return Err(AgentError::LLM(
7192 "nested wait registration was not completed".into(),
7193 ))
7194 }
7195 };
7196
7197 let latest = load_active_subagent_caller(
7200 store,
7201 binding,
7202 run_id,
7203 child_id,
7204 parent_id,
7205 logical_session,
7206 creation,
7207 plain_input,
7208 )
7209 .await
7210 .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7211 if latest
7212 .agent_runtime_state
7213 .as_ref()
7214 .and_then(|state| state.waiting_for_children.as_ref())
7215 .is_some()
7216 {
7217 return Ok(true);
7218 }
7219 let still_active = store
7220 .list_child_run_statuses(child_id)
7221 .await
7222 .map_err(|error| {
7223 AgentError::LLM(format!("nested child wait verification failed: {error}"))
7224 })?
7225 .into_iter()
7226 .any(|(_, status)| {
7227 !status.as_deref().is_some_and(|status| {
7228 matches!(
7229 status,
7230 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7231 )
7232 })
7233 });
7234 if still_active {
7235 return Err(AgentError::LLM(
7236 "nested child remains active without a durable parent wait".into(),
7237 ));
7238 }
7239 Ok(wait_registered)
7244}
7245
7246fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7247 let message = match error {
7248 ActorEventRouteError::SequenceGap { .. } => {
7249 "actor event sequence gap; authoritative session snapshot is required"
7250 }
7251 ActorEventRouteError::LifecycleMissing => {
7252 "actor emitted live content before its activation lifecycle event"
7253 }
7254 ActorEventRouteError::ForgedSession => {
7255 "actor event payload targets a different canonical session"
7256 }
7257 ActorEventRouteError::ConflictingReplay => {
7258 "actor replay reused an event sequence with different content"
7259 }
7260 ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7261 ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7262 };
7263 AgentError::LLM(message.into())
7264}
7265
7266const MAX_DISPLAY_CALLS: usize = 4_096;
7267const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7268const MAX_DISPLAY_ID_BYTES: usize = 256;
7269const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7270
7271fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7272 if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7273 return None;
7274 }
7275 Some(match result {
7276 HookResult::Continue => HookResult::Continue,
7277 HookResult::Mutated => HookResult::Mutated,
7278 HookResult::Allow => HookResult::Allow,
7279 HookResult::Ask => HookResult::Ask,
7280 HookResult::Deny { .. } => HookResult::Deny {
7281 reason: "Hook denied".into(),
7282 },
7283 HookResult::InjectContext { .. } => HookResult::InjectContext {
7284 text: "Hook context hidden".into(),
7285 },
7286 HookResult::WithContext { result, .. } => HookResult::WithContext {
7287 result: Box::new(hook_result_for_display(*result, depth + 1)?),
7288 text: "Hook context hidden".into(),
7289 },
7290 HookResult::Suspend { .. } => HookResult::Suspend {
7291 reason: "Hook suspended".into(),
7292 },
7293 HookResult::Abort { .. } => HookResult::Abort {
7294 reason: "Hook aborted".into(),
7295 },
7296 })
7297}
7298
7299#[derive(Clone, Copy, PartialEq, Eq)]
7300enum DisplayCallKind {
7301 Ordinary,
7302 PrivateBrowser,
7303 Reused,
7304}
7305
7306#[derive(Clone, Copy)]
7307struct DisplayCall {
7308 kind: DisplayCallKind,
7309 started: bool,
7310}
7311
7312#[derive(Default)]
7313struct ActorEventDisplay {
7314 calls: HashMap<String, DisplayCall>,
7315 hook_names: HashMap<String, usize>,
7316 children: HashMap<(String, String), Box<ActorEventDisplay>>,
7317 identities: Arc<AtomicUsize>,
7320 overflowed: bool,
7321 nested_overflowed: bool,
7322}
7323
7324fn is_browser_tool_name(tool_name: &str) -> bool {
7325 tool_name
7326 .trim()
7327 .rsplit("::")
7328 .next()
7329 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7330}
7331
7332fn known_browser_action(args: &serde_json::Value) -> bool {
7333 matches!(
7334 args.get("action").and_then(serde_json::Value::as_str),
7335 Some(
7336 "tabs"
7337 | "new_tab"
7338 | "activate_tab"
7339 | "close_tab"
7340 | "navigate"
7341 | "history"
7342 | "viewport"
7343 | "snapshot"
7344 | "click"
7345 | "click_at"
7346 | "hover"
7347 | "drag"
7348 | "fill"
7349 | "select_option"
7350 | "set_file_input"
7351 | "type"
7352 | "press"
7353 | "key"
7354 | "scroll"
7355 | "download"
7356 | "screenshot"
7357 | "dialog_respond"
7358 )
7359 )
7360}
7361
7362fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7363 is_browser_tool_name(tool_name)
7364 && (!known_browser_action(args)
7365 || args
7366 .get("action")
7367 .and_then(serde_json::Value::as_str)
7368 .is_some_and(|action| {
7369 action.eq_ignore_ascii_case("download") || action == "set_file_input"
7370 })
7371 || args.get("data_base64").is_some())
7372}
7373
7374fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7375 if is_browser_tool_name(tool_name) {
7376 if !known_browser_action(args) {
7379 return serde_json::json!({"details":"[redacted]"});
7380 }
7381 }
7382 tool_start_arguments_for_display(tool_name, args)
7383}
7384
7385impl ActorEventDisplay {
7386 fn fail_closed(&mut self) {
7387 self.identities
7388 .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7389 self.overflowed = true;
7390 self.calls.clear();
7391 self.hook_names.clear();
7392 self.nested_overflowed = true;
7393 self.children.clear();
7394 }
7395
7396 fn reserve_identity(&mut self) -> bool {
7397 if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7398 true
7399 } else {
7400 self.fail_closed();
7401 false
7402 }
7403 }
7404
7405 fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7406 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7407 return DisplayCallKind::Reused;
7408 }
7409 let kind = if private {
7410 DisplayCallKind::PrivateBrowser
7411 } else {
7412 DisplayCallKind::Ordinary
7413 };
7414 if let Some(call) = self.calls.get_mut(id) {
7415 if call.started || call.kind != kind {
7416 call.kind = DisplayCallKind::Reused;
7417 }
7418 call.started = true;
7419 return call.kind;
7420 }
7421 if !self.reserve_identity() {
7422 return DisplayCallKind::Reused;
7423 }
7424 self.calls.insert(
7425 id.to_string(),
7426 DisplayCall {
7427 kind,
7428 started: true,
7429 },
7430 );
7431 kind
7432 }
7433
7434 fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7435 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7436 return DisplayCallKind::Reused;
7437 }
7438 let kind = if private {
7439 DisplayCallKind::PrivateBrowser
7440 } else {
7441 DisplayCallKind::Ordinary
7442 };
7443 if let Some(call) = self.calls.get_mut(id) {
7444 if call.kind != kind {
7445 call.kind = DisplayCallKind::Reused;
7446 }
7447 return call.kind;
7448 }
7449 if !self.reserve_identity() {
7450 return DisplayCallKind::Reused;
7451 }
7452 self.calls.insert(
7453 id.to_string(),
7454 DisplayCall {
7455 kind,
7456 started: false,
7457 },
7458 );
7459 kind
7460 }
7461
7462 fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7463 if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7464 return None;
7465 }
7466 self.calls
7467 .get(id)
7468 .filter(|call| call.started)
7469 .map(|call| call.kind)
7470 }
7471
7472 fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7473 if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7474 self.fail_closed();
7475 return None;
7476 }
7477 if let Some(index) = self.hook_names.get(hook_name) {
7478 return Some(format!("actor-hook-{index}"));
7479 }
7480 if !self.reserve_identity() {
7481 return None;
7482 }
7483 let index = self.hook_names.len() + 1;
7484 self.hook_names.insert(hook_name.to_string(), index);
7485 Some(format!("actor-hook-{index}"))
7486 }
7487
7488 fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7489 let call_id = match &event {
7490 AgentEvent::ToolStart { tool_call_id, .. }
7491 | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7492 | AgentEvent::ToolToken { tool_call_id, .. }
7493 | AgentEvent::ToolComplete { tool_call_id, .. }
7494 | AgentEvent::ToolError { tool_call_id, .. }
7495 | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7496 _ => None,
7497 };
7498 if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7499 self.fail_closed();
7500 return None;
7501 }
7502 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7503 self.fail_closed();
7504 }
7505 Some(match event {
7506 AgentEvent::ToolStart {
7507 tool_call_id,
7508 tool_name,
7509 arguments,
7510 } => {
7511 let private = is_private_browser_call(&tool_name, &arguments);
7512 let kind = self.remember_start(&tool_call_id, private);
7513 let arguments = if kind == DisplayCallKind::Reused {
7514 serde_json::json!({"details":"[redacted]"})
7515 } else {
7516 actor_arguments_for_display(&tool_name, &arguments)
7517 };
7518 AgentEvent::ToolStart {
7519 tool_call_id,
7520 tool_name: match kind {
7521 DisplayCallKind::Reused => "unknown".into(),
7522 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7523 _ => tool_name,
7524 },
7525 arguments,
7526 }
7527 }
7528 AgentEvent::ToolApprovalRequested {
7529 tool_call_id,
7530 tool_name,
7531 parameters,
7532 } => {
7533 let private = is_private_browser_call(&tool_name, ¶meters);
7534 let kind = self.remember_approval(&tool_call_id, private);
7535 let parameters = if kind == DisplayCallKind::Reused {
7536 serde_json::json!({"details":"[redacted]"})
7537 } else {
7538 actor_arguments_for_display(&tool_name, ¶meters)
7539 };
7540 AgentEvent::ToolApprovalRequested {
7541 tool_call_id,
7542 tool_name: match kind {
7543 DisplayCallKind::Reused => "unknown".into(),
7544 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7545 _ => tool_name,
7546 },
7547 parameters,
7548 }
7549 }
7550 AgentEvent::ToolToken {
7551 tool_call_id,
7552 content,
7553 } => {
7554 let content = match self.known_kind(&tool_call_id) {
7555 Some(DisplayCallKind::Ordinary) => content,
7556 Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7557 _ => "Tool output hidden".into(),
7558 };
7559 AgentEvent::ToolToken {
7560 tool_call_id,
7561 content,
7562 }
7563 }
7564 AgentEvent::ToolComplete {
7565 tool_call_id,
7566 result,
7567 } => {
7568 let result = match self.known_kind(&tool_call_id) {
7569 Some(DisplayCallKind::Ordinary) => result,
7570 Some(DisplayCallKind::PrivateBrowser) => {
7571 bamboo_agent_core::tools::ToolResult::text(
7572 result.success,
7573 "Browser tool result hidden",
7574 )
7575 }
7576 _ => bamboo_agent_core::tools::ToolResult::text(
7577 result.success,
7578 "Tool result hidden",
7579 ),
7580 };
7581 AgentEvent::ToolComplete {
7582 tool_call_id,
7583 result,
7584 }
7585 }
7586 AgentEvent::ToolError {
7587 tool_call_id,
7588 error,
7589 } => {
7590 let error = match self.known_kind(&tool_call_id) {
7591 Some(DisplayCallKind::Ordinary) => error,
7592 Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7593 _ => "Tool failed".into(),
7594 };
7595 AgentEvent::ToolError {
7596 tool_call_id,
7597 error,
7598 }
7599 }
7600 AgentEvent::ToolLifecycle {
7601 tool_call_id,
7602 tool_name,
7603 phase,
7604 elapsed_ms,
7605 is_mutating,
7606 auto_approved,
7607 summary,
7608 error,
7609 } => {
7610 let kind = self.known_kind(&tool_call_id);
7611 let (summary, error) = match kind {
7612 Some(DisplayCallKind::Ordinary) => (summary, error),
7613 Some(DisplayCallKind::PrivateBrowser) => (
7614 summary.map(|_| "Browser tool activity hidden".into()),
7615 error.map(|_| "Browser tool failed".into()),
7616 ),
7617 _ => (
7618 summary.map(|_| "Tool activity hidden".into()),
7619 error.map(|_| "Tool failed".into()),
7620 ),
7621 };
7622 AgentEvent::ToolLifecycle {
7623 tool_call_id,
7624 tool_name: match kind {
7625 Some(DisplayCallKind::Ordinary) => tool_name,
7626 Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7627 _ => "unknown".into(),
7628 },
7629 phase: if kind == Some(DisplayCallKind::Ordinary)
7630 || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7631 {
7632 phase
7633 } else {
7634 "event".into()
7635 },
7636 elapsed_ms,
7637 is_mutating,
7638 auto_approved,
7639 summary,
7640 error,
7641 }
7642 }
7643 AgentEvent::HookLifecycle {
7644 hook_name,
7645 point,
7646 phase,
7647 duration_ms,
7648 decision,
7649 } => {
7650 let Some(decision) = hook_result_for_display(decision, 0) else {
7651 self.fail_closed();
7652 return None;
7653 };
7654 let hook_name = self.display_hook_name(&hook_name)?;
7655 let phase = match phase.as_str() {
7656 "completed" | "started" | "error" => phase,
7657 _ => "event".into(),
7658 };
7659 AgentEvent::HookLifecycle {
7660 hook_name,
7661 point,
7662 phase,
7663 duration_ms,
7664 decision,
7665 }
7666 }
7667 AgentEvent::ChildApprovalRequested {
7668 child_session_id,
7669 request_id,
7670 tool_name,
7671 permission,
7672 resource,
7673 } => {
7674 let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7675 (
7676 "browser".into(),
7677 "Browser interaction approval".into(),
7678 "[redacted]".into(),
7679 )
7680 } else {
7681 (tool_name, permission, resource)
7682 };
7683 AgentEvent::ChildApprovalRequested {
7684 child_session_id,
7685 request_id,
7686 tool_name,
7687 permission,
7688 resource,
7689 }
7690 }
7691 AgentEvent::ChildApprovalChanged { tool_name, .. }
7692 if is_browser_tool_name(&tool_name) =>
7693 {
7694 return None;
7698 }
7699 AgentEvent::SubAgentEvent {
7700 parent_session_id,
7701 child_session_id,
7702 event,
7703 } => {
7704 if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7705 return None;
7706 }
7707 if parent_session_id.is_empty()
7708 || child_session_id.is_empty()
7709 || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7710 || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7711 {
7712 self.fail_closed();
7713 return None;
7714 }
7715 let key = (parent_session_id.clone(), child_session_id.clone());
7716 if !self.children.contains_key(&key) && !self.reserve_identity() {
7717 return None;
7718 }
7719 let identities = self.identities.clone();
7720 let child = self.children.entry(key).or_insert_with(|| {
7721 Box::new(ActorEventDisplay {
7722 identities,
7723 ..Default::default()
7724 })
7725 });
7726 let event = child.project(*event, depth + 1)?;
7727 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7728 self.fail_closed();
7729 return None;
7730 }
7731 AgentEvent::SubAgentEvent {
7732 parent_session_id,
7733 child_session_id,
7734 event: Box::new(event),
7735 }
7736 }
7737 other => other,
7738 })
7739 }
7740}
7741
7742#[allow(clippy::too_many_arguments)]
7743async fn process_actor_event(
7744 event: serde_json::Value,
7745 strict_permission_events: bool,
7746 permission_handshake: &mut PermissionPostureHandshake,
7747 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7748 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7749 logical_session: &mut Session,
7750 event_tx: &mpsc::Sender<AgentEvent>,
7751 display: &mut ActorEventDisplay,
7752) -> crate::runtime::runner::Result<()> {
7753 let event = match serde_json::from_value::<AgentEvent>(event) {
7754 Ok(event) => event,
7755 Err(_) if strict_permission_events => {
7756 return Err(AgentError::LLM(
7760 "actor emitted malformed AgentEvent under a typed permission posture contract"
7761 .to_string(),
7762 ));
7763 }
7764 Err(_) => {
7765 display.fail_closed();
7769 return Ok(());
7770 }
7771 };
7772 process_typed_actor_event(
7773 event,
7774 permission_handshake,
7775 expected_permission_posture,
7776 session_inbox_runtime,
7777 logical_session,
7778 event_tx,
7779 display,
7780 )
7781 .await
7782}
7783
7784#[allow(clippy::too_many_arguments)]
7785async fn process_typed_actor_event(
7786 event: AgentEvent,
7787 permission_handshake: &mut PermissionPostureHandshake,
7788 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7789 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7790 logical_session: &mut Session,
7791 event_tx: &mpsc::Sender<AgentEvent>,
7792 display: &mut ActorEventDisplay,
7793) -> crate::runtime::runner::Result<()> {
7794 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7795 if permission_handshake.posture_was_confirmed() {
7796 return Err(AgentError::LLM(
7797 "actor emitted a duplicate permission posture activation".to_string(),
7798 ));
7799 }
7800 let seed = permission_posture_seed_from_event(logical_session, &event)
7801 .map_err(AgentError::LLM)?
7802 .ok_or_else(|| {
7803 AgentError::LLM(
7804 "actor permission posture event did not decode as a posture".to_string(),
7805 )
7806 })?;
7807 if let Some(expected) = expected_permission_posture {
7808 if seed.policy_revision != expected.policy_revision
7809 || seed.resolution != expected.resolution
7810 {
7811 return Err(AgentError::LLM(
7812 "permission posture event does not match the host-dispatched policy"
7813 .to_string(),
7814 ));
7815 }
7816 if seed.executor_mapping() != expected.executor_mapping {
7817 return Err(AgentError::LLM(
7818 "permission posture event does not match the host-dispatched executor mapping"
7819 .to_string(),
7820 ));
7821 }
7822 }
7823 if let Some(binding) = session_inbox_runtime {
7824 let saved = binding
7825 .persistence
7826 .record_permission_posture_activation(
7827 &logical_session.id,
7828 expected_permission_posture
7829 .and_then(|expected| expected.expected_audit_revision),
7830 &seed,
7831 )
7832 .await
7833 .map_err(|error| {
7834 AgentError::LLM(format!(
7835 "persist child permission posture bootstrap: {error}"
7836 ))
7837 })?
7838 .ok_or_else(|| {
7839 AgentError::LLM(
7840 "persist child permission posture bootstrap: session not found".to_string(),
7841 )
7842 })?;
7843 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7844 .ok_or_else(|| {
7845 AgentError::LLM(
7846 "persisted child permission posture audit is incomplete".to_string(),
7847 )
7848 })?;
7849 snapshot.write_to(&mut logical_session.metadata);
7850 } else {
7851 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7854 .map_err(|error| {
7855 AgentError::LLM(format!(
7856 "record in-memory child permission posture: {error}"
7857 ))
7858 })?;
7859 }
7860 *permission_handshake = PermissionPostureHandshake::Confirmed;
7863 } else if permission_handshake.is_awaiting() {
7864 return Err(AgentError::LLM(
7865 "actor emitted an execution event before permission posture confirmation".to_string(),
7866 ));
7867 }
7868 if let Some(event) = display.project(event, 0) {
7869 let _ = event_tx.send(event).await;
7870 }
7871 Ok(())
7872}
7873
7874async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7885 let ActorDriveContext {
7886 client,
7887 parent_session_id,
7888 child_session_id,
7889 child_attempt,
7890 approval_registry,
7891 approval_decider,
7892 approval_reviewer,
7893 escalation_bridge,
7894 event_tx,
7895 cancel_token,
7896 live_rx,
7897 delivery_rx,
7898 logical_session,
7899 mut expected_permission_posture,
7900 expected_creation,
7901 session_inbox_runtime,
7902 actor_directory_store,
7903 canonical_subagent_tool,
7904 ticket_service,
7905 activation_run_id,
7906 execution_epoch,
7907 expected_source_actor_id,
7908 initial_inflight_claims,
7909 first_frame_timeout,
7910 plain_actor,
7911 remote_environment_lease,
7912 mut readonly_output,
7913 local_history_tools,
7914 local_history_read_only,
7915 mut strict_history_output,
7916 plain_input,
7917 canonical_activation,
7918 canonical_placement_ref,
7919 actor_event_observer,
7920 plain_run,
7921 } = context;
7922
7923 logical_session
7927 .metadata
7928 .remove("runtime.actor_nested_wait_handoff");
7929
7930 let mut got_first_frame = false;
7937 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7938 let mut inflight_claims = initial_inflight_claims;
7939 let mut owned_inputs = plain_input
7940 .map(|activation| VecDeque::from(activation.initial_inputs.clone()))
7941 .unwrap_or_default();
7942 let continuation_limit = if owned_inputs.is_empty() { 2 } else { 1 };
7945 let mut continuations = usize::from(!owned_inputs.is_empty());
7946 let mut released_inputs: Vec<(
7947 SessionInboxOwnedClaim,
7948 bamboo_subagent::proto::InitialInputRelease,
7949 )> = Vec::new();
7950 let mut current_epoch = execution_epoch;
7951 let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7952 let approval_cancel = cancel_token.child_token();
7953 let _approval_lifetime = approval_cancel.clone().drop_guard();
7954 let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7955 HashMap::new();
7956 let mut owned_tree_requests = 0usize;
7957 let mut canonical_subagent_requests = 0usize;
7958 let mut committed_plan_calls = HashMap::new();
7959 let mut nested_wait_registered_this_run = false;
7963 let mut parent_question_checkpoint_id: Option<String> = None;
7964 let strict_permission_events = expected_permission_posture.is_some();
7965 let mut permission_handshake =
7966 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
7967 let mut next_actor_event_seq = 1u64;
7968 let mut canonical_router: Option<ActorEventRouter> = None;
7969 let canonical_fence = plain_input
7970 .map(|activation| &activation.fence)
7971 .or(canonical_activation);
7972 let canonical_store = plain_input
7973 .map(|activation| activation.store.as_ref())
7974 .or(actor_directory_store);
7975 let remote_claim_fence = actor_directory_store.zip(canonical_activation);
7976 let workflow_usage_requested = logical_session
7977 .metadata
7978 .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
7979 .is_some_and(|v| v == "true");
7980 if workflow_usage_requested {
7981 logical_session
7982 .metadata
7983 .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
7984 logical_session
7985 .metadata
7986 .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
7987 }
7988 let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
7989 let mut display = ActorEventDisplay::default();
7990 let mut readonly = readonly_output
7991 .as_ref()
7992 .map(|_| ReadOnlyActorCollector::default());
7993 let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
7994 loop {
7995 tokio::select! {
7996 _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
7997 if remote_claim_fence.is_some() || workflow_usage_requested {
7998 let _ = client.send(ParentFrame::Cancel).await;
8004 remote_cancel_deadline = Some(
8005 tokio::time::Instant::now() + Duration::from_secs(10)
8006 );
8007 continue;
8008 }
8009 break;
8011 }
8012 _ = async {
8013 if let Some(deadline) = remote_cancel_deadline {
8014 tokio::time::sleep_until(deadline).await;
8015 } else {
8016 std::future::pending::<()>().await;
8017 }
8018 } => {
8019 return Err(AgentError::Cancelled);
8020 }
8021 _ = async {
8022 match first_frame_watch.as_mut() {
8023 Some(s) => s.as_mut().await,
8024 None => std::future::pending::<()>().await,
8025 }
8026 }, if !got_first_frame => {
8027 return Err(AgentError::WorkerUnresponsive(format!(
8028 "child {child_session_id} produced no frame within {:?}",
8029 first_frame_timeout.unwrap_or_default()
8030 )));
8031 }
8032 Some(_generation) = delivery_rx.recv(),
8033 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
8034 {
8035 if plain_input.is_some() {
8036 continue;
8039 }
8040 forward_next_canonical_claim(
8041 client,
8042 session_inbox_runtime.expect("guarded"),
8043 logical_session,
8044 activation_run_id.expect("guarded"),
8045 &mut inflight_claims,
8046 remote_claim_fence,
8047 )
8048 .await?;
8049 }
8050 Some(frame) = live_rx.recv() => {
8051 if plain_actor { return Err(plain_actor_unsupported()); }
8052 if client.send(frame).await.is_err() {
8054 tracing::warn!("live steering frame could not be sent; connection failing");
8055 }
8056 }
8057 frame = client.next_frame() => {
8058 got_first_frame = true;
8061 first_frame_watch = None;
8062 match frame {
8063 Ok(Some(ChildFrame::Event { event })) => {
8064 if canonical_activation.is_some() {
8065 return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8066 }
8067 if local_history.is_some() {
8068 return Err(local_tool_history_unsupported());
8069 }
8070 if event.get("initial_input_control").is_some() {
8071 let bamboo_subagent::proto::InitialInputControl::Request { request } =
8072 bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8073 else { return Err(plain_actor_unsupported()); };
8074 if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8075 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8076 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8077 let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8078 let previous = released_inputs.iter()
8082 .find(|(_, release)| release.request == request);
8083 let (claim, previous) = match previous {
8084 Some((claim, release)) => (claim.clone(), Some(release.clone())),
8085 None => (owned_inputs.front().cloned()
8086 .ok_or_else(|| plain_actor_unsupported())?, None),
8087 };
8088 let release = activation.release_initial_input(binding, logical_session, run, current_epoch, &claim,
8089 &request, &mut expected_permission_posture, previous.as_ref()).await?;
8090 if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8091 client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8092 .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8093 if previous.is_none() {
8094 let _ = owned_inputs.pop_front();
8095 released_inputs.push((claim, release));
8096 }
8097 continue;
8098 }
8099 if expected_creation.is_some() {
8100 return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8101 }
8102 if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8103 if event["type"] == "owned_readonly_transcript" {
8104 if plain_actor { return Err(plain_actor_unsupported()); }
8105 continue;
8106 }
8107 if plain_actor && !plain_actor_event(&event)? { continue; }
8108 if let Some(activation) = plain_input {
8109 validate_directory_actor_event(
8110 activation.store.as_ref(),
8111 &activation.fence,
8112 logical_session,
8113 None,
8114 ).await?;
8115 }
8116 process_actor_event(
8120 event,
8121 strict_permission_events,
8122 &mut permission_handshake,
8123 expected_permission_posture.as_ref(),
8124 session_inbox_runtime,
8125 logical_session,
8126 event_tx,
8127 &mut display,
8128 )
8129 .await?;
8130 }
8131 Ok(Some(ChildFrame::EventBatch { batch })) => {
8132 validate_actor_event_batch(
8133 &batch,
8134 logical_session,
8135 parent_session_id,
8136 activation_run_id,
8137 current_epoch,
8138 expected_source_actor_id,
8139 expected_creation,
8140 )?;
8141 let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8142 let entry = validate_directory_actor_event(
8143 store, fence, logical_session, batch.activation_id.as_deref(),
8144 ).await?;
8145 if canonical_placement_ref.is_some_and(|expected| {
8146 entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8147 }) {
8148 return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8149 }
8150 Some(entry)
8151 } else { None };
8152 if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8153 if canonical_router.is_none() {
8154 canonical_router = Some(
8155 ActorEventRouter::new(
8156 entry,
8157 fence,
8158 current_epoch,
8159 strict_permission_events && permission_handshake.is_awaiting(),
8160 )
8161 .map_err(actor_event_route_error)?,
8162 );
8163 }
8164 }
8165 if local_history_tools.is_some()
8166 && (batch.first_seq != next_actor_event_seq
8167 || batch.qos != bamboo_subagent::ActorEventQos::Durable
8168 || batch.last_seq == u64::MAX) {
8169 return Err(local_tool_history_unsupported());
8170 }
8171 if batch.last_seq < next_actor_event_seq {
8172 continue;
8173 }
8174 if batch.first_seq > next_actor_event_seq {
8175 if plain_actor {
8176 return Err(AgentError::LLM(
8177 "actor event sequence gap; authoritative session snapshot is required".into(),
8178 ));
8179 }
8180 tracing::warn!(
8181 child_session_id,
8182 expected_seq = next_actor_event_seq,
8183 received_seq = batch.first_seq,
8184 "actor event sequence gap; authoritative session snapshot is required"
8185 );
8186 display.fail_closed();
8190 }
8191 let skip = next_actor_event_seq
8192 .saturating_sub(batch.first_seq)
8193 .min(batch.events.len() as u64) as usize;
8194 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8195 let seq = batch.first_seq + offset as u64;
8196 let mut publish = true;
8197 if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8198 if workflow_usage_requested {
8199 if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8200 let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8201 let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8202 if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8203 if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8204 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8205 }
8206 publish = false;
8207 }
8208 if publish { if let Some(collector) = local_history.as_mut() {
8209 if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8210 return Err(local_tool_history_unsupported());
8211 }
8212 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8213 publish = collector.ticket_event(&event, &committed_plan_calls)?;
8214 }
8215 publish &= collector.event(&event)?;
8216 } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8217 publish = false;
8219 }}
8220 if publish { publish = if let Some(collector) = readonly.as_mut() {
8221 if permission_handshake.is_awaiting()
8222 && event["type"] != "permission_posture_activated" {
8223 return Err(plain_actor_unsupported());
8224 }
8225 collector.event(&event)?
8226 } else if event["type"] == "owned_readonly_transcript" {
8227 if plain_actor { return Err(plain_actor_unsupported()); }
8228 false } else if plain_actor { plain_actor_event(&event)? } else { true };
8230 }
8231 if let (Some(router), Some(entry), Some(fence)) =
8232 (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8233 {
8234 let typed = if publish {
8235 Some(serde_json::from_value::<AgentEvent>(event.clone())
8236 .map_err(|_| plain_actor_unsupported())?)
8237 } else {
8238 None
8239 };
8240 match router.route(entry, fence, current_epoch, seq, typed)
8241 .map_err(actor_event_route_error)?
8242 {
8243 ActorEventRoute::Publish(envelope) => {
8244 let public_event = actor_event_observer
8245 .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8246 .map(|_| PublicActorEvent::from(envelope.as_ref()));
8247 tracing::trace!(
8248 actor_id = %envelope.actor_id,
8249 event_id = %envelope.event_id,
8250 class = ?envelope.class,
8251 "publishing canonical Actor event"
8252 );
8253 process_typed_actor_event(
8254 envelope.payload,
8255 &mut permission_handshake,
8256 expected_permission_posture.as_ref(),
8257 session_inbox_runtime,
8258 logical_session,
8259 event_tx,
8260 &mut display,
8261 ).await?;
8262 if let (Some(observer), Some(public_event)) =
8263 (actor_event_observer, public_event)
8264 {
8265 observer.publish(public_event);
8266 }
8267 }
8268 ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8269 }
8270 next_actor_event_seq = seq.saturating_add(1);
8271 continue;
8272 }
8273 if !publish {
8274 next_actor_event_seq = seq.saturating_add(1);
8275 continue;
8276 }
8277 process_actor_event(
8278 event,
8279 strict_permission_events,
8280 &mut permission_handshake,
8281 expected_permission_posture.as_ref(),
8282 session_inbox_runtime,
8283 logical_session,
8284 event_tx,
8285 &mut display,
8286 )
8287 .await?;
8288 next_actor_event_seq = seq.saturating_add(1);
8289 }
8290 }
8291 Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8292 owned_tree_requests += 1;
8293 if owned_tree_requests > 16 {
8294 return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8295 }
8296 let page = if id.len() <= 128
8297 && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8298 && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8299 && !cancel_token.is_cancelled()
8300 {
8301 match (actor_directory_store, session_inbox_runtime,
8302 activation_run_id, expected_creation) {
8303 (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8304 tokio::time::timeout(
8305 Duration::from_secs(8),
8306 inspect_active_owned_tree(
8307 store, binding, run_id, child_session_id,
8308 parent_session_id, logical_session, creation,
8309 plain_input, cursor.as_deref(),
8310 ),
8311 ).await.ok().flatten()
8312 }
8313 _ => {
8314 tracing::warn!("owned tree denied: canonical Host scope unavailable");
8315 None
8316 },
8317 }
8318 } else {
8319 tracing::warn!("owned tree denied: request bounds or cancellation");
8320 None
8321 };
8322 if cancel_token.is_cancelled() {
8323 return Err(AgentError::Cancelled);
8324 }
8325 client.send(ParentFrame::OwnedTreeReply { id, page }).await
8326 .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8327 }
8328 Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8329 canonical_subagent_requests += 1;
8330 if canonical_subagent_requests > 64 {
8331 return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8332 }
8333 let valid_shape = !id.is_empty() && id.len() <= 128
8334 && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8335 && args.is_object()
8336 && serde_json::to_vec(&args)
8337 .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8338 let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8339 Some("wait"))
8340 || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8341 && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8342 let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8343 let ticket_bootstrap_pending = valid_shape
8344 && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8345 && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8346 && permission_handshake.is_awaiting()
8347 && !cancel_token.is_cancelled();
8348 let result = if ticket_bootstrap_pending {
8349 serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8353 } else if valid_shape
8354 && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8355 || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8356 || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8357 && !cancel_token.is_cancelled()
8358 && !permission_handshake.is_awaiting()
8359 {
8360 if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8361 match (ticket_service.as_deref(), actor_directory_store,
8362 session_inbox_runtime, activation_run_id, expected_creation) {
8363 (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8364 if let Some(caller) = load_active_subagent_caller(
8365 store, binding, run_id, child_session_id,
8366 parent_session_id, logical_session, creation, plain_input,
8367 ).await {
8368 match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8369 Ok(projection) => {
8370 logical_session.task_list = projection.task_list.clone();
8371 logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8372 if let Some(question) = &projection.question {
8373 let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8374 logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8375 if let Some(collector) = local_history.as_mut() {
8376 collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8377 }
8378 }
8379 if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8380 committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8381 if let Some(task_list) = projection.task_list.clone() {
8382 let _ = event_tx.send(AgentEvent::TaskListUpdated {
8383 task_list,
8384 version: Some(projection.plan_revision),
8385 }).await;
8386 }
8387 }
8388 serde_json::json!({"result":projection})
8389 }
8390 Err(error) => serde_json::json!({"error":error.to_string()}),
8391 }
8392 } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8393 }
8394 _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8395 }
8396 } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8397 match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8398 activation_run_id, expected_creation, local_history_tools) {
8399 (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8400 if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8401 match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8402 Ok(reply)=>{
8403 let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8404 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())?}));
8405 serde_json::json!({"result":reply})
8406 },
8407 Err(error)=>serde_json::json!({"error":error.to_string()}),
8408 }
8409 } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8410 },
8411 _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8412 }
8413 } else if let Some(payload) = checkpoint_payload {
8414 let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8415 .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8416 .flatten()
8417 .filter(|observation| observation.tool_call_id == tool_call_id);
8418 match (parsed, actor_directory_store, session_inbox_runtime,
8419 activation_run_id, expected_creation) {
8420 (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8421 let receipt = tokio::time::timeout(Duration::from_secs(25),
8422 checkpoint_active_parent_question(
8423 store, binding, run_id, child_session_id,
8424 parent_session_id, logical_session, creation,
8425 plain_input, observation,
8426 )).await.ok().flatten();
8427 if let Some(request_id) = receipt {
8428 parent_question_checkpoint_id = Some(request_id.clone());
8429 serde_json::json!({"result":{"request_id":request_id}})
8430 } else {
8431 serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8432 }
8433 }
8434 _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8435 }
8436 } else {
8437 match (canonical_subagent_tool.as_deref(), actor_directory_store,
8438 session_inbox_runtime, activation_run_id, expected_creation) {
8439 (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8440 if let Some(caller) = load_active_subagent_caller(
8441 store, binding, run_id, child_session_id,
8442 parent_session_id, logical_session, creation,
8443 plain_input,
8444 ).await {
8445 tokio::time::timeout(
8446 Duration::from_secs(30),
8447 invoke_active_subagent(tool, &caller, args, &tool_call_id),
8448 ).await.unwrap_or_else(|_| serde_json::json!({
8449 "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8450 }))
8451 } else {
8452 serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8453 }
8454 }
8455 _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8456 }
8457 }
8458 } else {
8459 serde_json::json!({"error":"canonical SubAgent request rejected"})
8460 };
8461 if registers_wait
8462 && result["result"]["success"] == true
8463 && result["result"]["result"]
8464 .as_str()
8465 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8466 .is_some_and(|value| value["status"] == "waiting")
8467 {
8468 nested_wait_registered_this_run = true;
8469 }
8470 if cancel_token.is_cancelled() {
8471 return Err(AgentError::Cancelled);
8472 }
8473 client.send(ParentFrame::SubAgentReply { id, result }).await
8474 .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8475 }
8476 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8477 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8478 return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8479 }
8480 if plain_actor { return Err(plain_actor_unsupported()); }
8481 if permission_handshake.is_awaiting() {
8482 return Err(AgentError::LLM(
8483 "actor requested approval before permission posture confirmation"
8484 .to_string(),
8485 ));
8486 }
8487 if let Some(reviewer) = approval_reviewer
8493 .cloned()
8494 .or_else(child_approval_reviewer)
8495 {
8496 let child = child_session_id.to_string();
8504 let parent = parent_session_id.to_string();
8505 let req_id = id.clone();
8506 let body = body.clone();
8507 let registry = approval_registry.cloned();
8508 let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8509 tokio::spawn(async move {
8512 let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8513 reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8514 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8515 child_attempt, &req_id, approved);
8516 });
8517 continue;
8518 };
8519 let generation = body.pointer("/permission_request/request_generation")
8520 .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8521 let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8522 if original != &body || scope.stamp()["reply"] != req_id {
8523 return Err(AgentError::LLM("approval generation changed in live scope".into()));
8524 }
8525 scope.clone()
8526 } else {
8527 if approval_scopes.len() >= 64 {
8528 return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8529 }
8530 let scope = ChildApprovalScope::new(&parent, &child,
8531 (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8532 approval_cancel.clone(), approval_epoch.clone());
8533 approval_scopes.insert(generation, (body.clone(), scope.clone()));
8534 scope
8535 };
8536 tokio::spawn(async move {
8537 let result = tokio::time::timeout(
8538 CHILD_APPROVAL_TIMEOUT,
8539 reviewer.review_scoped(&parent, &child, &body, &scope),
8540 ).await.unwrap_or(ChildApprovalReview::Reply(false));
8541 if let ChildApprovalReview::Reply(approved) = result {
8542 if scope.is_current(&parent, &child).await {
8543 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8544 child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8545 }
8546 }
8547 });
8548 } else if approval_decider.is_some() {
8549 let approved =
8553 decide_child_approval(approval_decider, child_session_id, &body)
8554 .await;
8555 if client
8556 .send(ParentFrame::ApprovalReply { id, approved })
8557 .await
8558 .is_err()
8559 {
8560 tracing::warn!(
8561 "failed to answer approval_request; connection failing"
8562 );
8563 }
8564 } else if let Some(host) = escalation_bridge.clone() {
8565 let child = child_session_id.to_string();
8572 let req_id = id.clone();
8573 let body = body.clone();
8574 let registry = approval_registry.cloned();
8575 tokio::spawn(async move {
8576 let approved = match tokio::time::timeout(
8577 CHILD_APPROVAL_TIMEOUT,
8578 host.approval_call(body),
8579 )
8580 .await
8581 {
8582 Ok(Ok(reply)) => reply
8583 .get("approved")
8584 .and_then(|v| v.as_bool())
8585 .unwrap_or(false),
8586 _ => false,
8588 };
8589 super::live::deliver_approval_scoped(
8590 registry.as_ref(),
8591 &child,
8592 child_attempt,
8593 &req_id,
8594 approved,
8595 );
8596 });
8597 } else {
8598 tracing::warn!(
8602 parent_session_id,
8603 child_session_id,
8604 request_id = %id,
8605 "forced-ask request has no parent-agent reviewer; denying"
8606 );
8607 if client
8608 .send(ParentFrame::ApprovalReply {
8609 id,
8610 approved: false,
8611 })
8612 .await
8613 .is_err()
8614 {
8615 tracing::warn!(
8616 "failed to send fail-closed approval reply; connection failing"
8617 );
8618 }
8619 }
8620 }
8621 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8622 if let Some(activation) = plain_input {
8623 if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8624 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8625 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8626 let claim = owned_inputs.front().ok_or_else(|| plain_actor_unsupported())?;
8627 activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8628 let _ = owned_inputs.pop_front();
8629 continue;
8630 }
8631 let Some(binding) = session_inbox_runtime else {
8632 tracing::warn!(
8633 child_session_id,
8634 "ignoring SessionInbox confirmation without a runtime binding"
8635 );
8636 continue;
8637 };
8638 let Some(bound_run_id) = activation_run_id else {
8639 tracing::warn!(
8640 child_session_id,
8641 "ignoring SessionInbox confirmation without an activation owner"
8642 );
8643 continue;
8644 };
8645 let Some(claim) = inflight_claims.front() else {
8646 tracing::warn!(
8647 child_session_id,
8648 envelope_id = %confirmation.envelope_id,
8649 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8650 );
8651 continue;
8652 };
8653 let exact = confirmation.target_session_id == logical_session.id
8654 && confirmation.envelope_id == claim.envelope.id.as_str()
8655 && confirmation.canonical_claim_generation == claim.generation
8656 && confirmation.activation_run_id == bound_run_id;
8657 if !exact
8658 || !binding
8659 .router
8660 .owns_run(&logical_session.id, bound_run_id)
8661 .await
8662 {
8663 tracing::warn!(
8664 child_session_id,
8665 expected_target = %logical_session.id,
8666 received_target = %confirmation.target_session_id,
8667 expected_envelope_id = %claim.envelope.id,
8668 received_envelope_id = %confirmation.envelope_id,
8669 expected_generation = claim.generation,
8670 received_generation = confirmation.canonical_claim_generation,
8671 expected_run_id = bound_run_id,
8672 received_run_id = %confirmation.activation_run_id,
8673 "rejecting stale or mismatched SessionInbox admission confirmation"
8674 );
8675 continue;
8676 }
8677 let claim = inflight_claims
8678 .pop_front()
8679 .expect("validated in-flight canonical claim");
8680 checkpoint_and_ack_canonical_claim(
8683 binding,
8684 logical_session,
8685 &claim,
8686 remote_claim_fence,
8687 )
8688 .await?;
8689 if inflight_claims.is_empty() {
8693 forward_next_canonical_claim(
8694 client,
8695 binding,
8696 logical_session,
8697 bound_run_id,
8698 &mut inflight_claims,
8699 remote_claim_fence,
8700 )
8701 .await?;
8702 }
8703 }
8704 Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8705 if local_history_tools.is_some() {
8706 validate_strict_history_terminal(
8709 final_event_watermark.as_ref(), logical_session, parent_session_id,
8710 activation_run_id, current_epoch, expected_creation,
8711 next_actor_event_seq - 1,
8712 )?;
8713 }
8714 if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8715 if let Some(tools) = local_history_tools {
8716 if permission_handshake.is_awaiting()
8720 || !owned_inputs.is_empty()
8721 || !inflight_claims.is_empty()
8722 || parent_question_checkpoint_id.is_some()
8723 || nested_wait_registered_this_run
8724 || strict_history_output.is_none()
8725 {
8726 return Err(local_tool_history_unsupported());
8727 }
8728 commit_local_tool_history(
8729 local_history.take().ok_or_else(local_tool_history_unsupported)?,
8730 logical_session,
8731 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8732 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8733 (tools, local_history_read_only),
8734 LocalToolTerminal::Cancelled,
8735 cancel_token,
8736 ).await?;
8737 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8738 logical_session,
8739 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8740 current_epoch,
8741 next_actor_event_seq - 1,
8742 ).map_err(|_| local_tool_history_unsupported())?;
8743 *strict_history_output.as_deref_mut()
8744 .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8745 proof,
8746 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8747 });
8748 }
8749 client.accept_durable_terminal(status);
8755 if workflow_usage_requested {
8756 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8757 }
8758 return Err(AgentError::Cancelled);
8759 }
8760 if parent_question_checkpoint_id.is_some()
8761 && status != TerminalStatus::Suspended
8762 {
8763 return Err(AgentError::LLM(
8764 "Child question checkpoint must end in a suspended Run".into(),
8765 ));
8766 }
8767 if permission_handshake.is_awaiting() {
8768 if remote_environment_lease && status == TerminalStatus::Error {
8769 if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8770 client.accept_durable_terminal(status);
8777 if workflow_usage_requested {
8778 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8779 }
8780 return Err(AgentError::LLM(code.to_owned()));
8781 }
8782 }
8783 return Err(AgentError::LLM(
8784 "actor terminated before permission posture confirmation"
8785 .to_string(),
8786 ));
8787 }
8788 if !owned_inputs.is_empty() {
8789 return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8790 }
8791 if let Some(claim) = inflight_claims.front() {
8792 return Err(AgentError::LLM(format!(
8793 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8794 claim.envelope.id
8795 )));
8796 }
8797 if status == TerminalStatus::Completed {
8798 if let Some(collector) = local_history.take() {
8799 commit_local_tool_history(
8800 collector, logical_session,
8801 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8802 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8803 (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8804 LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8805 ).await?;
8806 }
8807 }
8808 if let Some(collector) = readonly.take() {
8809 if status == TerminalStatus::Completed {
8810 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8811 let backlog = activation.input_inbox.inspect(&logical_session.id).await
8812 .map_err(|_| plain_actor_unsupported())?;
8813 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8814 return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8815 }
8816 *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8817 collector.finish(result.as_deref())?;
8818 }
8819 } else if let Some(activation) = plain_input {
8820 if status == TerminalStatus::Completed {
8821 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8822 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8823 if continuations < continuation_limit {
8824 if let Some((claim, epoch)) = activation.continue_input(
8825 client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8826 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8827 ).await? {
8828 owned_inputs = VecDeque::from([claim]);
8829 released_inputs.clear();
8830 continuations += 1;
8831 current_epoch = epoch;
8832 approval_epoch.store(epoch, Ordering::Release);
8833 next_actor_event_seq = 1;
8836 canonical_router = None;
8837 permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8838 display = ActorEventDisplay::default();
8839 continue;
8840 }
8841 } else if activation.input_inbox.inspect(&logical_session.id).await
8842 .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8843 if continuation_limit == 1 {
8844 return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8845 }
8846 activation.append_reply(logical_session,
8851 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8852 event_tx).await?;
8853 return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8854 }
8855 }
8856 }
8857 if status == TerminalStatus::Completed {
8858 if let (Some(tool), Some(creation)) =
8863 (canonical_subagent_tool.as_deref(), expected_creation)
8864 {
8865 let (Some(store), Some(binding), Some(run_id)) =
8866 (actor_directory_store, session_inbox_runtime,
8867 activation_run_id)
8868 else {
8869 return Err(AgentError::LLM(
8870 "nested SubAgent Host authority unavailable at Child terminal"
8871 .into(),
8872 ));
8873 };
8874 let must_suspend = ensure_active_nested_wait(
8875 tool, store, binding, run_id, child_session_id,
8876 parent_session_id, logical_session, creation, plain_input,
8877 ).await?;
8878 if must_suspend || nested_wait_registered_this_run {
8879 logical_session.metadata.insert(
8886 "runtime.actor_nested_wait_handoff".into(),
8887 "true".into(),
8888 );
8889 }
8890 }
8891 }
8892 let terminal_result = match status {
8893 TerminalStatus::Completed => Ok(result),
8894 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8895 TerminalStatus::Error => Err(AgentError::LLM(
8896 error.unwrap_or_else(|| "actor child errored".to_string()),
8897 )),
8898 TerminalStatus::Suspended => {
8899 use bamboo_agent_core::storage::Storage;
8900 let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8901 (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8902 session_inbox_runtime, activation_run_id, expected_creation)
8903 else {
8904 return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8905 };
8906 if load_active_subagent_caller(
8907 store, binding, run_id, child_session_id,
8908 parent_session_id, logical_session, creation, plain_input,
8909 ).await.is_none() {
8910 return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8911 }
8912 let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8913 .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8914 let child = store.load_session(child_session_id).await
8915 .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8916 .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8917 let parent = store.load_session(parent_session_id).await
8918 .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8919 .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8920 let pending = ParentQuestion::for_pending(&parent, &child)
8921 .is_some_and(|question| question.id == id);
8922 let answered = bamboo_domain::ParentQuestionResolution::from_child(
8923 &parent, &child, &id,
8924 ).is_some();
8925 if !pending && !answered {
8926 return Err(AgentError::LLM("canonical Child question proof changed".into()));
8927 }
8928 logical_session.metadata.insert(
8929 "runtime.actor_parent_question_handoff".into(), "true".into(),
8930 );
8931 Ok(None)
8932 }
8933 };
8934 if local_history_tools.is_some() {
8935 if status != TerminalStatus::Completed {
8939 return terminal_result;
8940 }
8941 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8942 logical_session,
8943 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8944 current_epoch,
8945 next_actor_event_seq - 1,
8946 ).map_err(|_| local_tool_history_unsupported())?;
8947 let history = StrictTerminalHistory {
8948 proof,
8949 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8950 };
8951 if let Some(output) = strict_history_output.as_deref_mut() {
8952 *output = Some(history);
8953 }
8954 }
8955 client.accept_durable_terminal(status);
8962 if workflow_usage_requested {
8963 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8964 }
8965 if status == TerminalStatus::Completed {
8966 if let Some(activation) = plain_input {
8967 *activation.completion_router.lock().recover_poison() =
8970 canonical_router.take();
8971 }
8972 }
8973 return terminal_result;
8974 }
8975 Ok(None) => {
8976 return Err(AgentError::LLM(
8977 "actor child closed before terminal".to_string(),
8978 ));
8979 }
8980 Err(e) => {
8981 return Err(AgentError::LLM(format!("actor transport error: {e}")));
8982 }
8983 }
8984 }
8985 }
8986 }
8987
8988 let _ = client.send(ParentFrame::Cancel).await;
8990 Err(AgentError::Cancelled)
8991}
8992
8993fn project_id_for_actor_run(
8995 session: &Session,
8996) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
8997 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
8998 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
8999 Ok(Some(project_id))
9000 }
9001 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
9002 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
9003 Err(AgentError::LLM(format!(
9004 "child session carries an invalid Project identity '{raw}': {message}"
9005 )))
9006 }
9007 }
9008}
9009
9010fn remote_broker_unavailable() -> AgentError {
9011 AgentError::LLM("remote_broker_activation_unavailable".into())
9012}
9013
9014fn remote_environment_error_code(value: &str) -> Option<&'static str> {
9018 const CODES: &[&str] = &[
9019 "remote_environment_lease_invalid",
9020 "remote_environment_snapshot_mismatch",
9021 "remote_environment_checkout_not_clean",
9022 "remote_environment_checkout_unsupported",
9023 "remote_environment_workspace_unavailable",
9024 "remote_environment_workspace_outside_git_root",
9025 "remote_environment_path_unsupported",
9026 "remote_environment_git_unavailable",
9027 "remote_environment_git_inspection_failed",
9028 "remote_environment_git_inspection_timed_out",
9029 "remote_environment_git_output_too_large",
9030 "remote_environment_snapshot_too_large",
9031 "remote_environment_snapshot_changed",
9032 "remote_environment_index_invalid",
9033 "remote_environment_index_unsupported",
9034 "remote_environment_host_path_or_grants_rejected",
9035 "remote_environment_worker_workspace_missing",
9036 "remote_environment_runspec_invalid",
9037 "remote_environment_lease_missing_or_host_path_present",
9038 ];
9039 CODES.iter().copied().find(|code| *code == value)
9040}
9041
9042async fn remote_canonical_lineage(
9045 binding: &SessionInboxRuntimeBinding,
9046 session: &Session,
9047) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
9048 let mut current = bamboo_domain::ActorSession::from_session(session)
9049 .map_err(|_| remote_broker_unavailable())?;
9050 let mut chain = Vec::new();
9051 loop {
9052 if chain.len() > session.spawn_depth as usize {
9053 return Err(remote_broker_unavailable());
9054 }
9055 let saved = binding
9056 .storage
9057 .load_session(¤t.actor_id)
9058 .await
9059 .map_err(|_| remote_broker_unavailable())?
9060 .ok_or_else(remote_broker_unavailable)?;
9061 let observed = bamboo_domain::ActorSession::from_session(&saved)
9062 .map_err(|_| remote_broker_unavailable())?;
9063 if observed.actor_id != current.actor_id
9064 || observed.session_created_at != current.session_created_at
9065 || observed.parent_actor_id != current.parent_actor_id
9066 || observed.root_actor_id != current.root_actor_id
9067 || observed.spawn_depth != current.spawn_depth
9068 || observed.project_id != current.project_id
9069 {
9070 return Err(remote_broker_unavailable());
9071 }
9072 chain.push(observed.clone());
9073 let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9074 if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9075 return Err(remote_broker_unavailable());
9076 }
9077 return Ok(chain);
9078 };
9079 let parent = binding
9080 .storage
9081 .load_session(parent_id)
9082 .await
9083 .map_err(|_| remote_broker_unavailable())?
9084 .ok_or_else(remote_broker_unavailable)?;
9085 let parent = bamboo_domain::ActorSession::from_session(&parent)
9086 .map_err(|_| remote_broker_unavailable())?;
9087 if parent.root_actor_id != observed.root_actor_id
9088 || parent.project_id != observed.project_id
9089 || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9090 || parent.session_created_at > observed.session_created_at
9091 {
9092 return Err(remote_broker_unavailable());
9093 }
9094 current = parent;
9095 }
9096}
9097
9098async fn canonical_child_creation(
9099 binding: &SessionInboxRuntimeBinding,
9100 session: &Session,
9101 job: &SpawnJob,
9102) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9103 let conflict = || {
9104 AgentError::LLM(
9105 "child creation identity unavailable or changed in canonical host Storage".into(),
9106 )
9107 };
9108 let current = binding
9109 .storage
9110 .load_session(&job.child_session_id)
9111 .await
9112 .map_err(|_| conflict())?
9113 .ok_or_else(conflict)?;
9114 if bamboo_domain::ActorSession::from_session(¤t).is_err()
9115 || bamboo_domain::ActorSession::from_session(session).is_err()
9116 || current.kind != bamboo_domain::SessionKind::Child
9117 || session.kind != current.kind
9118 || current.id != session.id
9119 || current.id != job.child_session_id
9120 || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9121 || current.parent_session_id != session.parent_session_id
9122 || current.root_session_id.trim().is_empty()
9123 || current.root_session_id != session.root_session_id
9124 || current.created_at != session.created_at
9125 || current.spawn_depth != session.spawn_depth
9126 || current.spawn_depth == 0
9127 || project_id_for_actor_run(¤t)? != project_id_for_actor_run(session)?
9128 {
9129 return Err(conflict());
9130 }
9131 Ok(bamboo_subagent::proto::ChildCreationIdentity {
9132 created_at: current.created_at,
9133 spawn_depth: current.spawn_depth,
9134 })
9135}
9136
9137fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9138 LogicalSessionIdentity {
9139 creation: None,
9140 session_id: session.id.clone(),
9141 parent_session_id: session
9142 .parent_session_id
9143 .clone()
9144 .or_else(|| Some(job.parent_session_id.clone())),
9145 root_session_id: if session.root_session_id.trim().is_empty() {
9146 job.parent_session_id.clone()
9147 } else {
9148 session.root_session_id.clone()
9149 },
9150 }
9151}
9152
9153fn extract_assignment(session: &Session) -> String {
9154 session
9155 .messages
9156 .iter()
9157 .rev()
9158 .find(|m| matches!(m.role, Role::User))
9159 .map(|m| m.content.clone())
9160 .unwrap_or_else(|| {
9161 session
9162 .metadata
9163 .get("title")
9164 .cloned()
9165 .unwrap_or_else(|| "Execute task".to_string())
9166 })
9167}
9168
9169#[cfg(test)]
9170mod tests {
9171 use super::*;
9172 use crate::SessionActivationRouter;
9173 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9174
9175 include!("expired_plain_owner_tests.rs");
9176
9177 include!("owned_cold_input_tests.rs");
9178
9179 #[test]
9180 fn plain_rejection_logs_branch_and_run_without_changing_error() {
9181 let capture_interest_guard = tracing::Dispatch::new(
9183 tracing_subscriber::fmt()
9184 .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9185 .with_writer(std::io::sink)
9186 .finish(),
9187 );
9188 let output = tempfile::NamedTempFile::new().unwrap();
9189 let subscriber = tracing_subscriber::fmt()
9190 .with_ansi(false)
9191 .without_time()
9192 .with_writer(output.reopen().unwrap())
9193 .finish();
9194 let branch_line = std::cell::Cell::new(0);
9195 let error = tracing::subscriber::with_default(subscriber, || {
9196 std::thread::spawn(|| drop(plain_actor_unsupported()))
9198 .join()
9199 .expect("unscoped rejection warning thread panicked");
9200 let span = tracing::warn_span!(
9201 "actor_child_run",
9202 child_session_id = %"diagnostic-child",
9203 activation_run_id = tracing::field::Empty
9204 );
9205 let _entered = span.enter();
9206 span.record("activation_run_id", "diagnostic-run");
9207 None::<()>
9208 .ok_or_else(|| {
9209 let caller_line = line!() + 1;
9210 let error = plain_actor_unsupported();
9211 branch_line.set(caller_line);
9212 error
9213 })
9214 .unwrap_err()
9215 });
9216 assert!(matches!(&error, AgentError::LLM(message)
9217 if message.starts_with("This Actor Child supports a fresh plain activation,")
9218 && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9219 let log = std::fs::read_to_string(output.path()).unwrap();
9220 assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9221 assert!(
9222 log.contains("activation_run_id=\"diagnostic-run\""),
9223 "{log}"
9224 );
9225 assert!(
9226 log.contains(&format!("process_id={}", std::process::id())),
9227 "{log}"
9228 );
9229 assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9230 assert!(
9231 log.contains(&format!("source_line={}", branch_line.get())),
9232 "{log}"
9233 );
9234 assert!(!error.to_string().contains("diagnostic-run"));
9235 drop(capture_interest_guard);
9236 }
9237
9238 #[tokio::test]
9239 async fn completed_plain_continuation_requires_bounded_inputs_and_current_identity() {
9240 for case in [
9241 "valid",
9242 "over-batch-limit",
9243 "tool-history",
9244 "stale-lineage",
9245 "no-release",
9246 ] {
9247 let temp = tempfile::tempdir().unwrap();
9248 let store = Arc::new(
9249 bamboo_storage::SessionStoreV2::new(temp.path().into())
9250 .await
9251 .unwrap(),
9252 );
9253 let parent = Session::new("continuation-parent", "model");
9254 store.save_session(&parent).await.unwrap();
9255 let mut child =
9256 Session::new_child_of("continuation-child", &parent, "model", "first task");
9257 child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9258 if case == "tool-history" {
9259 let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9260 tool.role = Role::Tool;
9261 child.add_message(tool);
9262 }
9263 child.set_last_run_status("completed");
9264 store.save_session(&child).await.unwrap();
9265 store.ensure_actor(&child.id).await.unwrap();
9266 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9267 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9268 let now = chrono::Utc::now();
9269 let old = store
9270 .claim_activation(&ActorActivationClaim {
9271 actor_id: child.id.clone(),
9272 run_id: "first-run".into(),
9273 lease_owner: "first-owner".into(),
9274 lease_expires_at: now + chrono::Duration::minutes(1),
9275 inbox_generation: 0,
9276 placement_ref: None,
9277 now,
9278 })
9279 .await
9280 .unwrap();
9281 store.start_activation(&old.fence(), now).await.unwrap();
9282 store
9283 .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9284 .await
9285 .unwrap();
9286 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9287 store.clone(),
9288 Default::default(),
9289 ));
9290 for slot in 0..if case == "over-batch-limit" {
9291 bamboo_domain::SessionInboxLimits::default().max_claim_batch + 1
9292 } else {
9293 1
9294 } {
9295 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9296 &child.id,
9297 format!("new task {slot}"),
9298 );
9299 let receipt = inbox.deliver(&envelope).await.unwrap();
9300 inbox
9301 .mark_activation_eligible(
9302 &child.id,
9303 receipt.generation,
9304 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9305 )
9306 .await
9307 .unwrap();
9308 }
9309 let storage: Arc<dyn Storage> = store.clone();
9310 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9311 let binding = actor_binding(store.clone(), inbox, persistence);
9312 let _registration = binding
9313 .router
9314 .register_run(&child.id, "second-run")
9315 .await
9316 .unwrap();
9317 if case == "stale-lineage" {
9318 child.parent_session_id = Some("foreign-parent".into());
9319 }
9320 let result = PlainActorActivation::start(
9321 store.clone(),
9322 &child,
9323 &binding,
9324 Some("second-run"),
9325 Some(policy),
9326 true,
9327 (case != "no-release").then_some("second-worker"),
9328 )
9329 .await;
9330 if case == "valid" {
9331 let activation = result.expect("one new input on a completed plain Child");
9332 assert_eq!(activation.fence.attempt, old.attempt + 1);
9333 assert_eq!(activation.fence.actor_id, child.id);
9334 assert_eq!(activation.inbox_generation, 1);
9335 activation
9336 .finish(ActorActivationFinish::Failed)
9337 .await
9338 .unwrap();
9339 } else {
9340 assert!(
9341 result.is_err(),
9342 "unsupported continuation {case} must fail before claim"
9343 );
9344 assert_eq!(
9345 store
9346 .inspect_actor(&child.id)
9347 .await
9348 .unwrap()
9349 .actor
9350 .current_attempt,
9351 old.attempt
9352 );
9353 }
9354 let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9355 assert_eq!(
9356 canonical.parent_session_id.as_deref(),
9357 Some(parent.id.as_str())
9358 );
9359 assert!(canonical
9360 .messages
9361 .iter()
9362 .any(|message| message.content == "first result"));
9363 }
9364 }
9365
9366 #[tokio::test]
9367 async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9368 let temp = tempfile::tempdir().unwrap();
9369 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9370 .await
9371 .unwrap();
9372 let parent = Session::new("placement-parent", "model");
9373 let child = Session::new_child_of("placement-child", &parent, "model", "task");
9374 store.save_session(&parent).await.unwrap();
9375 store.save_session(&child).await.unwrap();
9376 store.ensure_actor(&child.id).await.unwrap();
9377 store
9378 .bind_control_plane(
9379 &child.id,
9380 bamboo_domain::ActorControlPlaneBinding {
9381 policy_revision: None,
9382 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9383 pool_id: "remote-worker".into(),
9384 }),
9385 },
9386 )
9387 .await
9388 .unwrap();
9389
9390 let claim = |run_id: &str| ActorActivationClaim {
9391 actor_id: child.id.clone(),
9392 run_id: run_id.into(),
9393 lease_owner: format!("host-{run_id}"),
9394 lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9395 inbox_generation: 0,
9396 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9397 class: bamboo_domain::ActorPlacementClass::Remote,
9398 lease_id: format!("slot-{run_id}"),
9399 slot_epoch: Some(1),
9400 }),
9401 now: chrono::Utc::now(),
9402 };
9403 let first = store.claim_activation(&claim("first")).await.unwrap();
9404 store
9405 .start_activation(&first.fence(), chrono::Utc::now())
9406 .await
9407 .unwrap();
9408 assert!(store.claim_activation(&claim("retry")).await.is_err());
9409
9410 fail_pre_dispatch_activation(&store, &first.fence())
9413 .await
9414 .unwrap();
9415 let failed = store.inspect_actor(&child.id).await.unwrap();
9416 assert_eq!(
9417 failed.activation.unwrap().status,
9418 bamboo_domain::ActorActivationStatus::Failed
9419 );
9420 let retry = store.claim_activation(&claim("retry")).await.unwrap();
9421 assert_eq!(retry.attempt, first.attempt + 1);
9422 assert!(fail_pre_dispatch_activation(&store, &first.fence())
9423 .await
9424 .is_err());
9425 assert_eq!(
9426 store.inspect_actor(&child.id).await.unwrap().activation,
9427 Some(retry)
9428 );
9429 }
9430
9431 #[tokio::test]
9432 async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9433 struct AcceptedWithoutReceipt {
9434 published: bool,
9435 }
9436
9437 #[async_trait]
9438 impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9439 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9440 assert!(matches!(frame, ParentFrame::Run(_)));
9441 self.published = true;
9442 Err(bamboo_subagent::TransportError::Protocol(
9443 "broker delivery receipt lost after publication".into(),
9444 ))
9445 }
9446
9447 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9448 std::future::pending().await
9449 }
9450 }
9451
9452 let temp = tempfile::tempdir().unwrap();
9453 let store = Arc::new(
9454 bamboo_storage::SessionStoreV2::new(temp.path().into())
9455 .await
9456 .unwrap(),
9457 );
9458 let parent = Session::new("dispatch-parent", "model");
9459 let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9460 store.save_session(&parent).await.unwrap();
9461 store.save_session(&child).await.unwrap();
9462 store.ensure_actor(&child.id).await.unwrap();
9463 let now = chrono::Utc::now();
9464 let claim = ActorActivationClaim {
9465 actor_id: child.id.clone(),
9466 run_id: "dispatch-run".into(),
9467 lease_owner: "dispatch-host".into(),
9468 lease_expires_at: now + chrono::Duration::seconds(80),
9469 inbox_generation: 0,
9470 placement_ref: None,
9471 now,
9472 };
9473 let activation = store.claim_activation(&claim).await.unwrap();
9474 store
9475 .start_activation(&activation.fence(), chrono::Utc::now())
9476 .await
9477 .unwrap();
9478 let mut link = AcceptedWithoutReceipt { published: false };
9479 let mut send_invoked = false;
9480 let run = RunSpec {
9481 assignment: "task".into(),
9482 logical_session: None,
9483 project_id: None,
9484 reasoning_effort: None,
9485 permission_policy: None,
9486 messages: Vec::new(),
9487 activation_run_id: Some(claim.run_id.clone()),
9488 execution_epoch: 1,
9489 initial_session_messages: Vec::new(),
9490 secrets: Default::default(),
9491 };
9492 assert!(send_actor_run(&mut link, run, &mut send_invoked)
9493 .await
9494 .is_err());
9495 assert!(send_invoked && link.published);
9496 let slot = WorkerSlotLease {
9497 lease_id: "dispatch-slot".into(),
9498 host_ref: "dispatch-worker".into(),
9499 connection_generation: "dispatch-generation".into(),
9500 slot: 0,
9501 epoch: 1,
9502 actor_id: child.id.clone(),
9503 run_id: claim.run_id.clone(),
9504 expires_at: claim.lease_expires_at,
9505 };
9506 let mut lease = ActivePlacementLease {
9507 registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9508 .await
9509 .unwrap(),
9510 actor_store: store.clone(),
9511 route: ScopedPlacementRoute {
9512 endpoint: String::new(),
9513 token: String::new(),
9514 ca_cert_file: PathBuf::new(),
9515 parent: bamboo_subagent::AgentRef {
9516 session_id: "dispatch-parent".into(),
9517 role: Some("host".into()),
9518 },
9519 worker_role: "worker".into(),
9520 pinned_mailbox: None,
9521 requirements: Default::default(),
9522 },
9523 mailbox: "dispatch-worker".into(),
9524 host_ref: "dispatch-worker".into(),
9525 fence: activation.fence(),
9526 placement_ref: bamboo_domain::ActorPlacementRef {
9527 class: bamboo_domain::ActorPlacementClass::Remote,
9528 lease_id: slot.lease_id.clone(),
9529 slot_epoch: Some(slot.epoch),
9530 },
9531 lease: Arc::new(tokio::sync::Mutex::new(slot)),
9532 heartbeat: tokio::spawn(std::future::pending()),
9533 run_cancel: CancellationToken::new(),
9534 lost: Arc::new(AtomicBool::new(false)),
9535 release_on_drop: AtomicBool::new(false),
9536 };
9537 assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9538 assert_eq!(
9539 store
9540 .inspect_actor(&child.id)
9541 .await
9542 .unwrap()
9543 .activation
9544 .unwrap()
9545 .status,
9546 bamboo_domain::ActorActivationStatus::Running
9547 );
9548 let mut retry = claim.clone();
9549 retry.run_id = "dispatch-retry".into();
9550 retry.now = chrono::Utc::now();
9551 retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9552 assert!(matches!(
9553 store.claim_activation(&retry).await,
9554 Err(bamboo_domain::ActorDirectoryError::Busy)
9555 ));
9556 }
9557
9558 #[test]
9559 fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9560 let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9561 assert_eq!(
9562 remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9563 ActorActivationFinish::Cancelled
9564 );
9565 assert_eq!(
9566 remote_activation_finish(&cancelled, None),
9567 ActorActivationFinish::Failed
9568 );
9569 assert_eq!(
9570 remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9571 ActorActivationFinish::Failed
9572 );
9573 }
9574
9575 #[tokio::test]
9576 async fn remote_reply_is_fenced_before_terminal_status_save() {
9577 let temp = tempfile::tempdir().unwrap();
9578 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9579 .await
9580 .unwrap();
9581 let parent = Session::new("remote-reply-parent", "model");
9582 let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9583 child.add_message(bamboo_agent_core::Message::user("task"));
9584 store.save_session(&parent).await.unwrap();
9585 store.save_session(&child).await.unwrap();
9586 store.ensure_actor(&child.id).await.unwrap();
9587 store
9588 .bind_control_plane(
9589 &child.id,
9590 bamboo_domain::ActorControlPlaneBinding {
9591 policy_revision: None,
9592 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9593 pool_id: "remote-worker".into(),
9594 }),
9595 },
9596 )
9597 .await
9598 .unwrap();
9599 let now = chrono::Utc::now();
9600 let activation = store
9601 .claim_activation(&ActorActivationClaim {
9602 actor_id: child.id.clone(),
9603 run_id: "remote-reply-run".into(),
9604 lease_owner: "remote-worker".into(),
9605 lease_expires_at: now + chrono::Duration::seconds(80),
9606 inbox_generation: 0,
9607 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9608 class: bamboo_domain::ActorPlacementClass::Remote,
9609 lease_id: "remote-slot".into(),
9610 slot_epoch: Some(1),
9611 }),
9612 now,
9613 })
9614 .await
9615 .unwrap();
9616 let fence = activation.fence();
9617 store.start_activation(&fence, now).await.unwrap();
9618
9619 append_fenced_remote_reply(&store, &fence, &mut child, "done")
9620 .await
9621 .unwrap();
9622 store
9623 .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9624 .await
9625 .unwrap();
9626 child.set_last_run_status("completed");
9627 store.save_session(&child).await.unwrap();
9628 let saved = store.load_session(&child.id).await.unwrap().unwrap();
9629 assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9630 assert_eq!(saved.messages.len(), 2);
9631 assert_eq!(saved.messages.last().unwrap().content, "done");
9632 assert!(
9633 append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9634 .await
9635 .is_err()
9636 );
9637 assert_eq!(
9638 store
9639 .load_session(&child.id)
9640 .await
9641 .unwrap()
9642 .unwrap()
9643 .messages
9644 .len(),
9645 2
9646 );
9647 }
9648
9649 async fn start_bus() -> (String, tempfile::TempDir) {
9650 let dir = tempfile::tempdir().unwrap();
9651 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9652 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9653 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9654 let addr = listener.local_addr().unwrap();
9655 tokio::spawn(async move {
9656 let _ = server.serve(listener).await;
9657 });
9658 (format!("ws://{addr}"), dir)
9659 }
9660
9661 struct DurableAckProbe {
9662 calls: Arc<AtomicUsize>,
9663 fail: bool,
9664 terminal_status: TerminalStatus,
9665 parent_mailbox: String,
9666 }
9667
9668 #[async_trait]
9669 impl bamboo_subagent::ChildLink for DurableAckProbe {
9670 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9671 Ok(())
9672 }
9673
9674 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9675 Ok(None)
9676 }
9677
9678 fn has_pending_durable_terminal(&self) -> bool {
9679 true
9680 }
9681
9682 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9683 Some(bamboo_subagent::DurableChildDeliveryReceipt {
9684 broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9685 parent_mailbox: self.parent_mailbox.clone(),
9686 correlation_id: "broker-run-1".into(),
9687 message_ids: vec!["broker-outcome-1".into()],
9688 terminal_status: self.terminal_status,
9689 })
9690 }
9691
9692 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9693 self.calls.fetch_add(1, Ordering::SeqCst);
9694 if self.fail {
9695 Err(bamboo_subagent::TransportError::Protocol(
9696 "injected ACK delivery failure".into(),
9697 ))
9698 } else {
9699 Ok(())
9700 }
9701 }
9702 }
9703
9704 async fn insert_ack_probe(
9705 runner: &ActorChildRunner,
9706 session: &Session,
9707 calls: &Arc<AtomicUsize>,
9708 terminal_status: TerminalStatus,
9709 ) {
9710 insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9711 }
9712
9713 async fn insert_ack_probe_with_nested_wait(
9714 runner: &ActorChildRunner,
9715 session: &Session,
9716 calls: &Arc<AtomicUsize>,
9717 terminal_status: TerminalStatus,
9718 host_nested_wait_handoff: bool,
9719 ) {
9720 runner.pending_durable_links.lock().await.insert(
9721 (session.id.clone(), session.created_at, "run-1".into()),
9722 PendingDurableChildLink {
9723 link: Box::new(DurableAckProbe {
9724 calls: calls.clone(),
9725 fail: false,
9726 terminal_status,
9727 parent_mailbox: format!("p-{}", session.id),
9728 }),
9729 host_nested_wait_handoff,
9730 strict_history: None,
9731 strict_history_required: false,
9732 strict_history_committed: false,
9733 },
9734 );
9735 }
9736
9737 #[tokio::test]
9738 async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9739 let (endpoint, broker_dir) = start_bus().await;
9740 let home = tempfile::tempdir().unwrap();
9741 let store = Arc::new(
9742 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9743 .await
9744 .unwrap(),
9745 );
9746 let root = Session::new("receipt-root", "model");
9747 store.save_session(&root).await.unwrap();
9748 let mut sender = bamboo_broker::BrokerClient::connect(
9749 &endpoint,
9750 bamboo_subagent::AgentRef {
9751 session_id: "receipt-worker".into(),
9752 role: None,
9753 },
9754 "t",
9755 )
9756 .await
9757 .unwrap();
9758 let broker_identity = sender.durable_broker_identity().await.unwrap();
9759 let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9760 "00000000-0000-4000-8000-000000000002"
9761 } else {
9762 "00000000-0000-4000-8000-000000000001"
9763 };
9764
9765 for (id, receipt_mailbox, receipt_broker) in [
9766 ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9767 (
9768 "receipt-wrong-mailbox",
9769 "p-another-child",
9770 broker_identity.as_str(),
9771 ),
9772 (
9773 "receipt-wrong-broker",
9774 "p-receipt-wrong-broker",
9775 wrong_broker_identity,
9776 ),
9777 (
9778 "receipt-local-role-changed",
9779 "p-receipt-local-role-changed",
9780 broker_identity.as_str(),
9781 ),
9782 (
9783 "receipt-remote-unproven",
9784 "remote-parent-unproven",
9785 broker_identity.as_str(),
9786 ),
9787 ] {
9788 let mut child = Session::new_child_of(id, &root, "model", "task");
9789 child
9790 .metadata
9791 .insert("runtime.kind".into(), "external".into());
9792 child
9793 .metadata
9794 .insert("external.protocol".into(), "actor".into());
9795 child.metadata.insert(
9796 "external.agent_id".into(),
9797 crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9798 );
9799 if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9800 child
9801 .metadata
9802 .insert("subagent_type".into(), "remote-worker".into());
9803 }
9804 child.add_message(bamboo_agent_core::Message::user("work"));
9805 store.save_session(&child).await.unwrap();
9806 let message_id = bamboo_subagent::MsgId::new();
9807 sender
9808 .deliver(
9809 &format!("p-{id}"),
9810 bamboo_subagent::InboxMessage {
9811 id: message_id.clone(),
9812 from: bamboo_subagent::AgentRef {
9813 session_id: "receipt-worker".into(),
9814 role: None,
9815 },
9816 kind: bamboo_subagent::InboxKind::Outcome,
9817 body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9818 "done",
9819 ))
9820 .unwrap(),
9821 created_at: chrono::Utc::now(),
9822 correlation_id: Some(bamboo_subagent::MsgId::new()),
9823 },
9824 )
9825 .await
9826 .unwrap();
9827 let mailbox =
9830 bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9831 assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9832 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9833 child.set_last_run_status("completed");
9834 if id == "receipt-good" {
9835 let prior_ids = child.messages[..child.messages.len() - 1]
9836 .iter()
9837 .map(|message| message.id.clone())
9838 .collect();
9839 crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9840 assert!(
9841 crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9842 &mut child, "run-1"
9843 )
9844 .unwrap()
9845 );
9846 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9847 &child, "run-1", 7, 1,
9848 )
9849 .unwrap();
9850 store
9851 .prepare_broker_terminal_receipt_with_completeness(
9852 &child,
9853 bamboo_storage::v2::BrokerTerminalRoute {
9854 activation_run_id: "run-1",
9855 broker_identity: receipt_broker,
9856 parent_mailbox: receipt_mailbox,
9857 broker_correlation_id: "correlation-1",
9858 message_ids: &[message_id.as_str().to_owned()],
9859 },
9860 &proof,
9861 )
9862 .await
9863 .unwrap();
9864 let mut staged = child.clone();
9865 staged.set_last_run_status("running");
9866 staged.clear_last_run_error();
9867 store.save_session(&staged).await.unwrap();
9868 store
9869 .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9870 .await
9871 .unwrap();
9872 } else {
9873 store
9874 .prepare_broker_terminal_receipt(
9875 &child,
9876 "run-1",
9877 receipt_broker,
9878 receipt_mailbox,
9879 "correlation-1",
9880 &[message_id.as_str().to_owned()],
9881 )
9882 .await
9883 .unwrap();
9884 store.save_session(&child).await.unwrap();
9885 }
9886 }
9887
9888 let mut config = bamboo_config::Config::default();
9889 config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9890 endpoint,
9891 token: "t".into(),
9892 ..Default::default()
9893 });
9894 config
9895 .subagents_mut()
9896 .remote_placements
9897 .push(bamboo_config::RemoteActorPlacement {
9898 role: "remote-worker".into(),
9899 endpoint: "ws://unscoped.example.invalid".into(),
9900 token_env: None,
9901 ca_cert_file: None,
9902 broker_peer: None,
9903 placement_requirements: None,
9904 });
9905 let cold_store = Arc::new(
9908 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9909 .await
9910 .unwrap(),
9911 );
9912 let before = cold_store
9913 .load_session("receipt-good")
9914 .await
9915 .unwrap()
9916 .unwrap();
9917 assert!(crate::execution::ChildCompletionSource::has_source_record(
9918 &before
9919 ));
9920 assert_eq!(before.last_run_status().as_deref(), Some("running"));
9921 assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9922 let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9923 let report = repair.reconcile_once().await;
9924 assert_eq!(report.candidates, 5);
9925 assert_eq!(report.repaired, 2);
9926 assert_eq!(report.blocked, 3);
9927 let after = cold_store
9928 .load_session("receipt-good")
9929 .await
9930 .unwrap()
9931 .unwrap();
9932 assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9933 assert_eq!(
9934 serde_json::to_value(before.messages).unwrap(),
9935 serde_json::to_value(after.messages).unwrap()
9936 );
9937 let retry = repair.reconcile_once().await;
9938 assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9939
9940 let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9941 let wrong = bamboo_subagent::Mailbox::at(
9942 broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9943 );
9944 let wrong_broker = bamboo_subagent::Mailbox::at(
9945 broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9946 );
9947 let local_role_changed = bamboo_subagent::Mailbox::at(
9948 broker_dir
9949 .path()
9950 .join("mailboxes/p-receipt-local-role-changed"),
9951 );
9952 let remote_unproven = bamboo_subagent::Mailbox::at(
9953 broker_dir
9954 .path()
9955 .join("mailboxes/p-receipt-remote-unproven"),
9956 );
9957 assert_eq!(good.pending_count().await.unwrap(), 0);
9958 assert_eq!(wrong.pending_count().await.unwrap(), 1);
9959 assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9960 assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9961 assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9962 let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9963 let wrong_child = store
9964 .load_session("receipt-wrong-mailbox")
9965 .await
9966 .unwrap()
9967 .unwrap();
9968 let wrong_broker_child = store
9969 .load_session("receipt-wrong-broker")
9970 .await
9971 .unwrap()
9972 .unwrap();
9973 let local_role_changed_child = store
9974 .load_session("receipt-local-role-changed")
9975 .await
9976 .unwrap()
9977 .unwrap();
9978 let remote_unproven_child = store
9979 .load_session("receipt-remote-unproven")
9980 .await
9981 .unwrap()
9982 .unwrap();
9983 assert!(store
9984 .recover_broker_terminal_receipts(&good_child)
9985 .await
9986 .unwrap()
9987 .is_empty());
9988 assert_eq!(
9989 store
9990 .recover_broker_terminal_receipts(&wrong_child)
9991 .await
9992 .unwrap()
9993 .len(),
9994 1
9995 );
9996 assert_eq!(
9997 store
9998 .recover_broker_terminal_receipts(&wrong_broker_child)
9999 .await
10000 .unwrap()
10001 .len(),
10002 1
10003 );
10004 assert!(store
10005 .recover_broker_terminal_receipts(&local_role_changed_child)
10006 .await
10007 .unwrap()
10008 .is_empty());
10009 assert_eq!(
10010 store
10011 .recover_broker_terminal_receipts(&remote_unproven_child)
10012 .await
10013 .unwrap()
10014 .len(),
10015 1
10016 );
10017 }
10018
10019 #[tokio::test]
10020 async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
10021 let runner = ActorChildRunner::new(
10022 "test".into(),
10023 PathBuf::new(),
10024 vec![],
10025 PathBuf::new(),
10026 ExecutorSpec::BambooRuntime,
10027 vec![],
10028 "test".into(),
10029 1,
10030 );
10031 let home = tempfile::tempdir().unwrap();
10032 let store = Arc::new(
10033 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10034 .await
10035 .unwrap(),
10036 );
10037 let root = Session::new("ack-parent", "model");
10038 store.save_session(&root).await.unwrap();
10039 let mut child = Session::new_child_of("ack-child", &root, "model", "task");
10040 child.add_message(bamboo_agent_core::Message::user("work"));
10041 store.save_session(&child).await.unwrap();
10042 child.add_message(bamboo_agent_core::Message::assistant("done", None));
10043 child.set_last_run_status("completed");
10044 store
10045 .prepare_broker_terminal_receipt(
10046 &child,
10047 "run-1",
10048 "00000000-0000-4000-8000-000000000001",
10049 "p-ack-child",
10050 "broker-run-1",
10051 &["broker-outcome-1".into()],
10052 )
10053 .await
10054 .unwrap();
10055 store.save_session(&child).await.unwrap();
10056 runner.set_actor_directory_store(Some(store));
10057 let calls = Arc::new(AtomicUsize::new(0));
10058 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10059 runner
10060 .confirm_durable_child_delivery(&child, "wrong-run", true)
10061 .await
10062 .unwrap();
10063 let mut wrong_birth = child.clone();
10064 wrong_birth.created_at += chrono::Duration::seconds(1);
10065 runner
10066 .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
10067 .await
10068 .unwrap();
10069 assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
10070 child.set_last_run_status("completed");
10071 runner
10072 .confirm_durable_child_delivery(&child, "run-1", false)
10073 .await
10074 .unwrap();
10075 assert_eq!(calls.load(Ordering::SeqCst), 0);
10076
10077 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10078 child.set_last_run_status("error");
10079 runner
10080 .confirm_durable_child_delivery(&child, "run-1", true)
10081 .await
10082 .unwrap();
10083 assert_eq!(calls.load(Ordering::SeqCst), 0);
10084
10085 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10086 child.set_last_run_status("completed");
10087 runner
10088 .confirm_durable_child_delivery(&child, "run-1", true)
10089 .await
10090 .unwrap();
10091 assert_eq!(calls.load(Ordering::SeqCst), 1);
10092 }
10093
10094 #[tokio::test]
10095 async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10096 for commit in [false, true] {
10097 let runner = ActorChildRunner::new(
10098 "test".into(),
10099 PathBuf::new(),
10100 vec![],
10101 PathBuf::new(),
10102 ExecutorSpec::BambooRuntime,
10103 vec![],
10104 "test".into(),
10105 1,
10106 );
10107 let home = tempfile::tempdir().unwrap();
10108 let store = Arc::new(
10109 bamboo_storage::SessionStoreV2::new(home.path().into())
10110 .await
10111 .unwrap(),
10112 );
10113 let root = Session::new("strict-ack-parent", "model");
10114 store.save_session(&root).await.unwrap();
10115 let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10116 child.add_message(bamboo_agent_core::Message::user("work"));
10117 store.save_session(&child).await.unwrap();
10118 child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10119 child.set_last_run_status("completed");
10120 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10121 &child, "run-1", 7, 2,
10122 )
10123 .unwrap();
10124 let creation = bamboo_subagent::proto::ChildCreationIdentity {
10125 created_at: child.created_at,
10126 spawn_depth: child.spawn_depth,
10127 };
10128 let history = StrictTerminalHistory {
10129 proof: proof.clone(),
10130 watermark: bamboo_subagent::ActorEventWatermark {
10131 version: 1,
10132 logical_session: Some(actor_event_logical_identity(
10133 &child,
10134 &root.id,
10135 Some(&creation),
10136 )),
10137 activation_id: Some("run-1".into()),
10138 execution_epoch: 7,
10139 final_seq: 2,
10140 },
10141 };
10142 let parent_mailbox = "p-strict-ack-child";
10143 store
10144 .prepare_broker_terminal_receipt_with_completeness(
10145 &child,
10146 bamboo_storage::v2::BrokerTerminalRoute {
10147 activation_run_id: "run-1",
10148 broker_identity: "00000000-0000-4000-8000-000000000001",
10149 parent_mailbox,
10150 broker_correlation_id: "broker-run-1",
10151 message_ids: &["broker-outcome-1".into()],
10152 },
10153 &proof,
10154 )
10155 .await
10156 .unwrap();
10157 store.save_session(&child).await.unwrap();
10158 runner.set_actor_directory_store(Some(store.clone()));
10159 let calls = Arc::new(AtomicUsize::new(0));
10160 runner.pending_durable_links.lock().await.insert(
10161 (child.id.clone(), child.created_at, "run-1".into()),
10162 PendingDurableChildLink {
10163 link: Box::new(DurableAckProbe {
10164 calls: calls.clone(),
10165 fail: true,
10166 terminal_status: TerminalStatus::Completed,
10167 parent_mailbox: parent_mailbox.into(),
10168 }),
10169 host_nested_wait_handoff: false,
10170 strict_history: Some(history),
10171 strict_history_required: true,
10172 strict_history_committed: false,
10173 },
10174 );
10175 if commit {
10176 runner
10177 .commit_durable_child_delivery(&child, "run-1")
10178 .await
10179 .unwrap();
10180 assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10181 }
10182 assert!(runner
10183 .confirm_durable_child_delivery(&child, "run-1", true)
10184 .await
10185 .is_err());
10186 assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10187 let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10188 .await
10189 .unwrap();
10190 let recovered = cold.recover_broker_terminal_receipts(&child).await;
10191 if commit {
10192 let receipts = recovered.unwrap();
10193 assert_eq!(receipts.len(), 1);
10194 assert_eq!(
10195 receipts[0]
10196 .terminal_completeness
10197 .as_ref()
10198 .unwrap()
10199 .contiguous_applied_seq,
10200 2
10201 );
10202 assert_eq!(
10203 cold.load_session(&child.id)
10204 .await
10205 .unwrap()
10206 .unwrap()
10207 .last_run_status()
10208 .as_deref(),
10209 Some("completed")
10210 );
10211 } else {
10212 assert!(
10213 recovered.is_err(),
10214 "matching canonical history is not proof commitment"
10215 );
10216 }
10217 assert_eq!(
10218 serde_json::to_value(
10219 cold.load_session(&child.id)
10220 .await
10221 .unwrap()
10222 .unwrap()
10223 .messages
10224 )
10225 .unwrap(),
10226 serde_json::to_value(&child.messages).unwrap(),
10227 );
10228 }
10229 }
10230
10231 #[tokio::test]
10232 async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10233 for (terminal_status, status) in [
10234 (TerminalStatus::Error, "error"),
10235 (TerminalStatus::Cancelled, "cancelled"),
10236 ] {
10237 let runner = ActorChildRunner::new(
10238 "test".into(),
10239 PathBuf::new(),
10240 vec![],
10241 PathBuf::new(),
10242 ExecutorSpec::BambooRuntime,
10243 vec![],
10244 "test".into(),
10245 1,
10246 );
10247 let home = tempfile::tempdir().unwrap();
10248 let store = Arc::new(
10249 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10250 .await
10251 .unwrap(),
10252 );
10253 let root = Session::new("failed-ack-parent", "model");
10254 store.save_session(&root).await.unwrap();
10255 let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10256 child.add_message(bamboo_agent_core::Message::user("work"));
10257 store.save_session(&child).await.unwrap();
10258 child.set_last_run_status(status);
10259 child.set_last_run_error(format!("worker {status}"));
10260 store
10261 .prepare_broker_terminal_receipt(
10262 &child,
10263 "run-1",
10264 "00000000-0000-4000-8000-000000000001",
10265 "p-failed-ack-child",
10266 "broker-run-1",
10267 &["broker-outcome-1".into()],
10268 )
10269 .await
10270 .unwrap();
10271 store.save_session(&child).await.unwrap();
10272 runner.set_actor_directory_store(Some(store));
10273 let calls = Arc::new(AtomicUsize::new(0));
10274 insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10275 runner
10276 .confirm_durable_child_delivery(&child, "run-1", true)
10277 .await
10278 .unwrap();
10279 assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10280 }
10281 }
10282
10283 #[tokio::test]
10284 async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10285 assert_eq!(
10286 effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10287 "suspended"
10288 );
10289 for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10290 assert!(effective_broker_terminal_status(raw, true).is_err());
10291 }
10292 let runner = ActorChildRunner::new(
10293 "test".into(),
10294 PathBuf::new(),
10295 vec![],
10296 PathBuf::new(),
10297 ExecutorSpec::BambooRuntime,
10298 vec![],
10299 "test".into(),
10300 1,
10301 );
10302 let home = tempfile::tempdir().unwrap();
10303 let store = Arc::new(
10304 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10305 .await
10306 .unwrap(),
10307 );
10308 let root = Session::new("nested-ack-parent", "model");
10309 store.save_session(&root).await.unwrap();
10310 let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10311 child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10312 store.save_session(&child).await.unwrap();
10313 child.set_last_run_status("suspended");
10314 store
10315 .prepare_broker_terminal_receipt(
10316 &child,
10317 "run-1",
10318 "00000000-0000-4000-8000-000000000001",
10319 "p-nested-ack-child",
10320 "broker-run-1",
10321 &["broker-outcome-1".into()],
10322 )
10323 .await
10324 .unwrap();
10325 store.save_session(&child).await.unwrap();
10326 runner.set_actor_directory_store(Some(store.clone()));
10327 let calls = Arc::new(AtomicUsize::new(0));
10328
10329 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10330 runner
10331 .confirm_durable_child_delivery(&child, "run-1", true)
10332 .await
10333 .unwrap();
10334 assert_eq!(calls.load(Ordering::SeqCst), 0);
10335
10336 insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10337 .await;
10338 runner
10339 .confirm_durable_child_delivery(&child, "run-1", true)
10340 .await
10341 .unwrap();
10342 assert_eq!(calls.load(Ordering::SeqCst), 1);
10343 assert_eq!(
10344 store
10345 .load_session(&child.id)
10346 .await
10347 .unwrap()
10348 .unwrap()
10349 .last_run_status()
10350 .as_deref(),
10351 Some("suspended")
10352 );
10353 assert!(store
10354 .recover_broker_terminal_receipts(&child)
10355 .await
10356 .unwrap()
10357 .is_empty());
10358 }
10359
10360 #[tokio::test]
10361 async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10362 struct CommittedObserver {
10363 authority: PathBuf,
10364 events: std::sync::Mutex<Vec<PublicActorEvent>>,
10365 }
10366 impl ActorEventObserver for CommittedObserver {
10367 fn publish(&self, event: PublicActorEvent) {
10368 let entry: bamboo_domain::ActorDirectoryEntry =
10370 serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10371 let committed = entry.activation.unwrap();
10372 assert_eq!(
10373 committed.status,
10374 bamboo_domain::ActorActivationStatus::Succeeded
10375 );
10376 assert_eq!(committed.activation_id, event.activation_id);
10377 assert!(committed.finished_at.is_some());
10378 self.events.lock().unwrap().push(event);
10379 }
10380 }
10381 struct CompletionLink(VecDeque<ChildFrame>);
10382 #[async_trait]
10383 impl bamboo_subagent::ChildLink for CompletionLink {
10384 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10385 Ok(())
10386 }
10387 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10388 Ok(self.0.pop_front())
10389 }
10390 }
10391 for cut in [
10392 "success",
10393 "no_terminal",
10394 "invalid_terminal",
10395 "stale",
10396 "storage_failure",
10397 "append_failure",
10398 ] {
10399 let temp = tempfile::tempdir().unwrap();
10400 let store = Arc::new(
10401 bamboo_storage::SessionStoreV2::new(temp.path().into())
10402 .await
10403 .unwrap(),
10404 );
10405 let root = Session::new("notify-root", "model");
10406 store.save_session(&root).await.unwrap();
10407 let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10408 store.save_session(&child).await.unwrap();
10409 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10410 store.clone(),
10411 bamboo_domain::SessionInboxLimits::default(),
10412 ));
10413 let binding = actor_binding(
10414 store.clone(),
10415 inbox,
10416 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10417 );
10418 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10419 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10420 let activation = PlainActorActivation::start(
10421 store.clone(),
10422 &child,
10423 &binding,
10424 Some("notify-run"),
10425 Some(policy),
10426 false,
10427 None,
10428 )
10429 .await
10430 .unwrap();
10431 let _owner = binding
10432 .router
10433 .register_run(&child.id, "notify-run")
10434 .await
10435 .unwrap();
10436 let batch = ActorEventBatch {
10437 logical_session: Some(LogicalSessionIdentity {
10438 session_id: child.id.clone(),
10439 parent_session_id: child.parent_session_id.clone(),
10440 root_session_id: child.root_session_id.clone(),
10441 creation: None,
10442 }),
10443 activation_id: Some("notify-run".into()),
10444 execution_epoch: 9,
10445 source_node_id: None,
10446 source_actor_id: Some("notify-worker".into()),
10447 first_seq: 1,
10448 last_seq: 2,
10449 qos: bamboo_subagent::ActorEventQos::Durable,
10450 events: vec![
10451 serde_json::to_value(AgentEvent::Token {
10452 content: "PRIVATE_PROVIDER_TOKEN".into(),
10453 })
10454 .unwrap(),
10455 serde_json::to_value(AgentEvent::message_appended(
10456 &child.id,
10457 &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10458 ))
10459 .unwrap(),
10460 ],
10461 };
10462 let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10463 if cut != "no_terminal" {
10464 let mut terminal = completed_actor_frame();
10465 if cut == "invalid_terminal" {
10466 if let ChildFrame::Terminal { status, .. } = &mut terminal {
10467 *status = TerminalStatus::Suspended;
10468 }
10469 }
10470 frames.push_back(terminal);
10471 }
10472 let mut link = CompletionLink(frames);
10473 let (tx, mut rx) = mpsc::channel(8);
10474 let (_live, mut live_rx) = mpsc::unbounded_channel();
10475 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10476 let cancel = CancellationToken::new();
10477 let outcome = drive(ActorDriveContext {
10478 client: &mut link,
10479 parent_session_id: "notify-root",
10480 child_session_id: "notify-child",
10481 child_attempt: 0,
10482 approval_registry: None,
10483 approval_decider: None,
10484 approval_reviewer: None,
10485 escalation_bridge: None,
10486 event_tx: &tx,
10487 cancel_token: &cancel,
10488 live_rx: &mut live_rx,
10489 delivery_rx: &mut delivery_rx,
10490 logical_session: &mut child,
10491 expected_permission_posture: None,
10492 expected_creation: None,
10493 session_inbox_runtime: Some(&binding),
10494 actor_directory_store: Some(store.as_ref()),
10495 canonical_subagent_tool: None,
10496 ticket_service: None,
10497 activation_run_id: Some("notify-run"),
10498 execution_epoch: 9,
10499 expected_source_actor_id: "notify-worker",
10500 initial_inflight_claims: VecDeque::new(),
10501 plain_actor: true,
10502 remote_environment_lease: false,
10503 readonly_output: None,
10504 local_history_tools: None,
10505 local_history_read_only: false,
10506 strict_history_output: None,
10507 plain_input: Some(&activation),
10508 canonical_activation: None,
10509 canonical_placement_ref: None,
10510 actor_event_observer: None,
10511 plain_run: None,
10512 first_frame_timeout: Some(Duration::from_secs(1)),
10513 })
10514 .await;
10515 let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10516 assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10517 assert_eq!(
10518 activation.completion_router.lock().unwrap().is_some(),
10519 admitted,
10520 "{cut}"
10521 );
10522 while rx.try_recv().is_ok() {}
10523 let authority = temp
10524 .path()
10525 .join(store.resolve_rel_path(&child.id).await.unwrap())
10526 .join("actor-authority.json");
10527 let observer = CommittedObserver {
10528 authority: authority.clone(),
10529 events: Default::default(),
10530 };
10531 if cut == "append_failure" {
10532 child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10533 assert!(activation
10534 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10535 .await
10536 .is_err());
10537 activation
10538 .finish(ActorActivationFinish::Failed)
10539 .await
10540 .unwrap();
10541 assert!(observer.events.lock().unwrap().is_empty());
10542 continue;
10543 }
10544 activation
10545 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10546 .await
10547 .unwrap();
10548 assert!(
10549 rx.try_recv().is_ok(),
10550 "legacy content notification retained"
10551 );
10552 assert!(
10553 observer.events.lock().unwrap().is_empty(),
10554 "reply alone must not publish"
10555 );
10556 if cut == "stale" {
10557 store
10558 .retire_actor(&child.id, chrono::Utc::now())
10559 .await
10560 .unwrap();
10561 } else if cut == "storage_failure" {
10562 std::fs::remove_file(&authority).unwrap();
10565 std::fs::create_dir(&authority).unwrap();
10566 }
10567 let result = activation
10568 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10569 .await;
10570 assert_eq!(
10571 result.is_ok(),
10572 matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10573 "{cut}"
10574 );
10575 let events = observer.events.lock().unwrap().clone();
10576 assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10577 if let Some(event) = events.first() {
10578 assert_eq!(event.actor_id, child.id);
10579 assert_eq!(event.root_actor_id, root.id);
10580 assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10581 assert_eq!(event.source_order.sequence, 3);
10582 let json = serde_json::to_string(event).unwrap();
10583 assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10584 let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10585 assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10586 }
10587 assert!(activation
10588 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10589 .await
10590 .is_err());
10591 assert_eq!(
10592 observer.events.lock().unwrap().len(),
10593 events.len(),
10594 "repeat finish cannot publish"
10595 );
10596 }
10597 }
10598
10599 async fn bind_local_control_plane(
10600 store: &bamboo_storage::SessionStoreV2,
10601 actor_id: &str,
10602 policy: &bamboo_tools::permission::PermissionConfig,
10603 ) {
10604 store
10605 .bind_control_plane(
10606 actor_id,
10607 bamboo_domain::ActorControlPlaneBinding {
10608 policy_revision: Some(policy.policy_revision()),
10609 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10610 },
10611 )
10612 .await
10613 .unwrap();
10614 }
10615
10616 fn local_tool_sample(
10617 host: &Session,
10618 denied_write: bool,
10619 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10620 local_tool_sample_with_reasoning(host, denied_write, None)
10621 }
10622
10623 fn local_tool_sample_with_reasoning(
10624 host: &Session,
10625 denied_write: bool,
10626 reasoning: Option<&str>,
10627 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10628 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10629 let calls = ["Read", "Write"]
10630 .map(|name| ToolCall {
10631 id: format!("local-{name}"),
10632 tool_type: "function".into(),
10633 function: FunctionCall {
10634 name: name.into(),
10635 arguments: "{\"file_path\":\"answer.txt\"}".into(),
10636 },
10637 })
10638 .to_vec();
10639 let mut rows = vec![Message::assistant("checking", Some(calls))];
10640 rows[0].reasoning = reasoning.map(str::to_owned);
10641 let mut collector = LocalToolCollector::default();
10642 if let Some(reasoning) = reasoning {
10643 assert!(collector
10644 .event(
10645 &serde_json::to_value(AgentEvent::ReasoningToken {
10646 content: reasoning.into(),
10647 })
10648 .unwrap()
10649 )
10650 .unwrap());
10651 }
10652 for name in ["Read", "Write"] {
10653 let denied = name == "Write" && denied_write;
10654 let id = format!("local-{name}");
10655 if !denied {
10656 collector
10657 .event(
10658 &serde_json::to_value(AgentEvent::ToolStart {
10659 tool_call_id: id.clone(),
10660 tool_name: name.into(),
10661 arguments: serde_json::json!({"file_path":"answer.txt"}),
10662 })
10663 .unwrap(),
10664 )
10665 .unwrap();
10666 }
10667 let text = if denied {
10668 "Error: native_tool_ceiling_denied: Write"
10669 } else {
10670 "actual file result"
10671 };
10672 let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10673 message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10674 "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10675 rows.push(message);
10676 let event = if denied {
10677 AgentEvent::ToolError {
10678 tool_call_id: id,
10679 error: "native_tool_ceiling_denied: Write".into(),
10680 }
10681 } else {
10682 AgentEvent::ToolComplete {
10683 tool_call_id: id,
10684 result: bamboo_agent_core::tools::ToolResult::text(true, text),
10685 }
10686 };
10687 collector
10688 .event(&serde_json::to_value(event).unwrap())
10689 .unwrap();
10690 }
10691 rows.push(Message::assistant("complete exact report", None));
10692 rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10693 if let Some(reasoning) = reasoning {
10694 assert!(collector
10695 .event(
10696 &serde_json::to_value(AgentEvent::ReasoningToken {
10697 content: reasoning.into(),
10698 })
10699 .unwrap()
10700 )
10701 .unwrap());
10702 }
10703 let mut messages = host.messages.clone();
10704 if let Some(system) = messages
10705 .iter_mut()
10706 .find(|message| message.role == Role::System)
10707 {
10708 system.content = "worker-private system never imported".into();
10709 }
10710 messages.extend(rows.iter().cloned());
10711 let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10712 version: 1,
10713 messages: messages
10714 .iter()
10715 .map(|message| serde_json::to_value(message).unwrap())
10716 .collect(),
10717 };
10718 assert!(!collector
10719 .event(&serde_json::to_value(data).unwrap())
10720 .unwrap());
10721 (collector, rows)
10722 }
10723
10724 #[test]
10725 fn ticket_task_event_requires_exact_host_callback_receipt() {
10726 let mut collector = LocalToolCollector::default();
10727 let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10728 let start = serde_json::to_value(AgentEvent::ToolStart {
10729 tool_call_id: "task-call".into(),
10730 tool_name: "Task".into(),
10731 arguments: args.clone(),
10732 })
10733 .unwrap();
10734 let mut receipts = HashMap::new();
10735 assert!(collector.ticket_event(&start, &receipts).unwrap());
10736 collector.event(&start).unwrap();
10737 let complete = serde_json::to_value(AgentEvent::ToolComplete {
10738 tool_call_id: "task-call".into(),
10739 result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10740 })
10741 .unwrap();
10742 assert!(collector.ticket_event(&complete, &receipts).is_err());
10743 receipts.insert("other-call".into(), args.clone());
10744 assert!(collector.ticket_event(&complete, &receipts).is_err());
10745 receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10746 assert!(collector.ticket_event(&complete, &receipts).is_err());
10747 receipts.insert("task-call".into(), args);
10748 assert!(collector.ticket_event(&complete, &receipts).unwrap());
10749 let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10750 task_list: bamboo_tools::TaskTool::task_list_from_args(
10751 &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10752 "root",
10753 )
10754 .unwrap(),
10755 version: Some(999),
10756 })
10757 .unwrap();
10758 assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10759 }
10760
10761 #[test]
10762 fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10763 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10764 let mut host = Session::new("question-child", "model");
10765 host.messages.push(Message::user("own assignment"));
10766 let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10767 "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10768 "generation":1, "contract_revision":1, "prompt_revision":1,
10769 "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10770 "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10771 })).unwrap();
10772 let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10773 for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10774 let mut returned =
10775 serde_json::json!({"status":"waiting_for_answer", "request":question});
10776 if case == "forged_request" {
10777 returned["request"]["id"] = serde_json::json!("sibling-question");
10778 }
10779 let text = returned.to_string();
10780 let rows = [
10781 Message::assistant(
10782 "",
10783 Some(vec![ToolCall {
10784 id: "own-call".into(),
10785 tool_type: "function".into(),
10786 function: FunctionCall {
10787 name: "Task".into(),
10788 arguments: args.to_string(),
10789 },
10790 }]),
10791 ),
10792 Message::tool_result_with_status("own-call", &text, true),
10793 ];
10794 let collector = LocalToolCollector {
10795 messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10796 starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10797 outcomes: HashMap::from([("own-call".into(), (true, text))]),
10798 ticket_yield: (case != "no_host_receipt")
10799 .then(|| ("own-call".into(), question.clone())),
10800 };
10801 let terminal = if case == "fake_report" {
10802 Some("fake delivery")
10803 } else {
10804 Some("")
10805 };
10806 assert_eq!(
10807 collector
10808 .suffix(&host, &["Task".into()], false, terminal)
10809 .is_ok(),
10810 case == "exact",
10811 "{case}"
10812 );
10813 }
10814 }
10815
10816 #[test]
10817 fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10818 let mut host = Session::new("tool-history-prefix", "model");
10819 host.messages
10820 .push(bamboo_agent_core::Message::user("assignment"));
10821 let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10822 confirmed.metadata = Some(
10823 serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10824 );
10825 host.messages.push(confirmed);
10826 let tools = vec!["Read".into(), "Write".into()];
10827 let (good, rows) = local_tool_sample(&host, false);
10828 assert_eq!(
10829 serde_json::to_value(
10830 good.suffix(&host, &tools, false, Some("complete exact report"))
10831 .unwrap()
10832 )
10833 .unwrap(),
10834 serde_json::to_value(&rows).unwrap()
10835 );
10836 for case in [
10837 "missing",
10838 "changed",
10839 "metadata",
10840 "system",
10841 "user",
10842 "collision",
10843 "dangling",
10844 "result",
10845 "terminal",
10846 "tool_metadata",
10847 "grant",
10848 "start_arguments",
10849 "ledger",
10850 ] {
10851 let (mut collector, _) = local_tool_sample(&host, false);
10852 let mut current = host.clone();
10853 let messages = collector.messages.as_mut().unwrap();
10854 match case {
10855 "missing" => {
10856 messages.remove(host.messages.len() - 1);
10857 }
10858 "changed" => messages[0].content.push('!'),
10859 "metadata" => messages[1].metadata = None,
10860 "system" => messages.last_mut().unwrap().role = Role::System,
10861 "user" => messages.last_mut().unwrap().role = Role::User,
10862 "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10863 "dangling" => {
10864 messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10865 }
10866 "result" => messages[host.messages.len() + 1].content.push('!'),
10867 "tool_metadata" => {
10868 messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10869 true.into()
10870 }
10871 "grant" => {
10872 messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10873 .function
10874 .name = "Bash".into()
10875 }
10876 "start_arguments" => {
10877 collector.starts.get_mut("local-Read").unwrap().1 =
10878 serde_json::json!({"file_path":"different.txt"})
10879 }
10880 "ledger" => {
10881 current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10882 bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10883 bamboo_domain::ProviderFamily::OpenAi,
10884 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10885 bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10886 bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10887 serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10888 ).unwrap(),
10889 ]).unwrap();
10890 }
10891 "terminal" => {}
10892 _ => unreachable!(),
10893 }
10894 let terminal = if case == "terminal" {
10895 "different"
10896 } else {
10897 "complete exact report"
10898 };
10899 assert!(
10900 collector
10901 .suffix(¤t, &tools, false, Some(terminal))
10902 .is_err(),
10903 "{case}"
10904 );
10905 }
10906 let (denied, _) = local_tool_sample(&host, true);
10907 assert!(denied
10908 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10909 .is_ok());
10910 let (mut contradicted, _) = local_tool_sample(&host, true);
10911 contradicted.starts.insert(
10912 "local-Write".into(),
10913 ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10914 );
10915 assert!(contradicted
10916 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10917 .is_err());
10918 let (successful, _) = local_tool_sample(&host, false);
10919 assert!(successful
10920 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10921 .is_err());
10922 let mut empty = LocalToolCollector::default();
10923 assert!(empty
10924 .event(
10925 &serde_json::to_value(AgentEvent::ReasoningToken {
10926 content: "private".into()
10927 })
10928 .unwrap()
10929 )
10930 .unwrap());
10931 assert!(
10932 empty
10933 .suffix(&host, &tools, false, Some("complete exact report"))
10934 .is_err(),
10935 "stream observations alone cannot commit history"
10936 );
10937 let (mut completed, _) = local_tool_sample(&host, false);
10938 assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10939 }
10940
10941 struct LocalToolConcurrentCheckpoint {
10942 real: SessionInboxRuntimeBinding,
10943 claim: SessionInboxClaim,
10944 }
10945 #[async_trait]
10946 impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10947 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10948 self.real.persistence.save_runtime_session(session).await
10949 }
10950 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10951 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10954 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10955 .await
10956 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10957 self.real
10958 .persistence
10959 .checkpoint_runtime_session(session)
10960 .await
10961 }
10962 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10963 self.real.storage.load_session(id).await
10964 }
10965 }
10966
10967 #[tokio::test]
10968 async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
10969 for case in [
10970 "success",
10971 "reasoning",
10972 "concurrent_input",
10973 "checkpoint_error",
10974 "stale_prefix",
10975 "cancelled",
10976 "lost_owner",
10977 ] {
10978 let temp = tempfile::tempdir().unwrap();
10979 let store = Arc::new(
10980 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10981 .await
10982 .unwrap(),
10983 );
10984 let root = Session::new("local-tool-root", "model");
10985 store.save_session(&root).await.unwrap();
10986 let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
10987 let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
10988 system.role = Role::System;
10989 child.messages.push(system);
10990 child
10991 .messages
10992 .push(bamboo_agent_core::Message::user("assignment"));
10993 store.save_session(&child).await.unwrap();
10994 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10995 store.clone(),
10996 bamboo_domain::SessionInboxLimits::default(),
10997 ));
10998 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
10999 let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11000 let mut owner = Some(
11001 binding
11002 .router
11003 .register_run(&child.id, "current-run")
11004 .await
11005 .unwrap(),
11006 );
11007 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
11008 &child.id,
11009 "current confirmed User",
11010 );
11011 let receipt = inbox.deliver(&envelope).await.unwrap();
11012 inbox
11013 .mark_activation_eligible(
11014 &child.id,
11015 receipt.generation,
11016 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11017 )
11018 .await
11019 .unwrap();
11020 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11021 checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
11022 .await
11023 .unwrap();
11024 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
11025 let before = serde_json::to_value(&child).unwrap();
11026 let reasoning = (case == "reasoning")
11027 .then_some("Complete plain reasoning before Read and the final reply 🪷.");
11028 let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
11029 let mut binding = binding;
11030 let late = bamboo_domain::SessionMessageEnvelope::user_input(
11031 &child.id,
11032 "concurrent confirmed User",
11033 );
11034 if case == "concurrent_input" {
11035 let receipt = inbox.deliver(&late).await.unwrap();
11036 inbox
11037 .mark_activation_eligible(
11038 &child.id,
11039 receipt.generation,
11040 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11041 )
11042 .await
11043 .unwrap();
11044 let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11045 binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
11046 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11047 claim: late_claim,
11048 });
11049 }
11050 if case == "checkpoint_error" {
11051 binding.persistence = Arc::new(ActorFaultingPersistence {
11052 inner: locked.clone(),
11053 fail_checkpoint_once: AtomicBool::new(true),
11054 });
11055 }
11056 if case == "stale_prefix" {
11057 let mut latest = child.clone();
11058 latest
11059 .messages
11060 .push(bamboo_agent_core::Message::user("new confirmed prefix"));
11061 store.save_session(&latest).await.unwrap();
11062 }
11063 let cancel = CancellationToken::new();
11064 if case == "cancelled" {
11065 cancel.cancel();
11066 }
11067 if case == "lost_owner" {
11068 owner.take().unwrap().abandon().await;
11069 }
11070 let durable_before =
11071 serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11072 let result = commit_local_tool_history(
11073 collector,
11074 &mut child,
11075 &binding,
11076 "current-run",
11077 (&["Read".into(), "Write".into()], false),
11078 LocalToolTerminal::Completed(Some("complete exact report")),
11079 &cancel,
11080 )
11081 .await;
11082 let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11083 .await
11084 .unwrap()
11085 .load_session(&child.id)
11086 .await
11087 .unwrap()
11088 .unwrap();
11089 if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11090 assert!(result.is_err(), "{case}");
11091 assert_eq!(
11092 serde_json::to_value(&child).unwrap(),
11093 before,
11094 "{case}: no partial mutable success"
11095 );
11096 assert_eq!(
11097 serde_json::to_value(Some(&cold)).unwrap(),
11098 durable_before,
11099 "{case}: no write"
11100 );
11101 continue;
11102 }
11103 result.unwrap();
11104 assert_eq!(
11105 serde_json::to_value(&cold.messages[..3]).unwrap(),
11106 before["messages"]
11107 );
11108 let suffix_start = if case == "concurrent_input" {
11109 assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11110 assert_eq!(
11111 serde_json::to_value(&cold.messages[3]).unwrap(),
11112 serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11113 );
11114 assert!(child
11115 .messages
11116 .iter()
11117 .any(|message| message.id == late.id.as_str()));
11118 4
11119 } else {
11120 3
11121 };
11122 assert_eq!(
11123 serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11124 serde_json::to_value(rows).unwrap()
11125 );
11126 assert_eq!(
11127 cold.messages
11128 .iter()
11129 .filter(|message| message.id == envelope.id.as_str())
11130 .count(),
11131 1
11132 );
11133 assert_eq!(
11134 cold.messages
11135 .iter()
11136 .filter(|message| message.content == "complete exact report")
11137 .count(),
11138 1
11139 );
11140 assert_eq!(cold.metadata, child.metadata);
11141 assert!(cold.provider_transcript.is_empty());
11142 }
11143 }
11144
11145 #[tokio::test]
11146 async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11147 for case in [
11148 "legacy",
11149 "missing",
11150 "gap",
11151 "duplicate",
11152 "epoch",
11153 "source",
11154 "birth",
11155 "qos",
11156 "ticket_forged_success",
11157 ] {
11158 let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11159 session
11160 .messages
11161 .push(bamboo_agent_core::Message::user("original"));
11162 if case == "ticket_forged_success" {
11163 session.metadata.insert(
11164 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11165 "assignment".into(),
11166 );
11167 }
11168 let before = serde_json::to_value(&session).unwrap();
11169 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11170 created_at: session.created_at,
11171 spawn_depth: session.spawn_depth,
11172 };
11173 let mut messages = session.messages.clone();
11174 messages.push(bamboo_agent_core::Message::assistant("complete", None));
11175 let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11176 version: 1,
11177 messages: messages
11178 .iter()
11179 .map(|message| serde_json::to_value(message).unwrap())
11180 .collect(),
11181 })
11182 .unwrap();
11183 let mut batch = ActorEventBatch {
11184 logical_session: Some(LogicalSessionIdentity {
11185 session_id: session.id.clone(),
11186 parent_session_id: session.parent_session_id.clone(),
11187 root_session_id: session.root_session_id.clone(),
11188 creation: Some(creation.clone()),
11189 }),
11190 activation_id: Some("current".into()),
11191 execution_epoch: 7,
11192 source_node_id: None,
11193 source_actor_id: Some("selected".into()),
11194 first_seq: 1,
11195 last_seq: 1,
11196 qos: bamboo_subagent::proto::ActorEventQos::Durable,
11197 events: vec![event.clone()],
11198 };
11199 match case {
11200 "gap" => {
11201 batch.first_seq = 2;
11202 batch.last_seq = 2;
11203 }
11204 "epoch" => batch.execution_epoch = 6,
11205 "source" => batch.source_actor_id = Some("replaced".into()),
11206 "birth" => {
11207 batch
11208 .logical_session
11209 .as_mut()
11210 .unwrap()
11211 .creation
11212 .as_mut()
11213 .unwrap()
11214 .created_at += chrono::Duration::nanoseconds(1)
11215 }
11216 "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11217 "ticket_forged_success" => {
11218 batch.last_seq = 2;
11219 batch.events = vec![
11220 serde_json::to_value(AgentEvent::ToolStart {
11221 tool_call_id: "forged".into(), tool_name: "Task".into(),
11222 arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11223 }).unwrap(),
11224 serde_json::to_value(AgentEvent::ToolComplete {
11225 tool_call_id: "forged".into(),
11226 result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11227 }).unwrap(),
11228 ];
11229 }
11230 _ => {}
11231 }
11232 let terminal = ChildFrame::Terminal {
11233 status: TerminalStatus::Completed,
11234 result: Some("complete".into()),
11235 error: None,
11236 final_event_watermark: None,
11237 transcript: vec![],
11238 };
11239 let frames = match case {
11240 "legacy" => vec![ChildFrame::Event { event }, terminal],
11241 "missing" => vec![terminal],
11242 "duplicate" => vec![
11243 ChildFrame::EventBatch {
11244 batch: batch.clone(),
11245 },
11246 ChildFrame::EventBatch { batch },
11247 terminal,
11248 ],
11249 _ => vec![ChildFrame::EventBatch { batch }, terminal],
11250 };
11251 let mut link = ConfirmationSequenceLink {
11252 frames: frames.into(),
11253 sent: vec![],
11254 };
11255 let (tx, mut rx) = mpsc::channel(32);
11256 let (_live, mut live_rx) = mpsc::unbounded_channel();
11257 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11258 let cancel = CancellationToken::new();
11259 let tools = vec![if case == "ticket_forged_success" {
11260 "Task"
11261 } else {
11262 "Read"
11263 }
11264 .into()];
11265 let outcome = drive(ActorDriveContext {
11266 client: &mut link,
11267 parent_session_id: "frame-root",
11268 child_session_id: "frame-child",
11269 child_attempt: 0,
11270 approval_registry: None,
11271 approval_decider: None,
11272 approval_reviewer: None,
11273 escalation_bridge: None,
11274 event_tx: &tx,
11275 cancel_token: &cancel,
11276 live_rx: &mut live_rx,
11277 delivery_rx: &mut delivery_rx,
11278 logical_session: &mut session,
11279 expected_permission_posture: None,
11280 expected_creation: Some(&creation),
11281 session_inbox_runtime: None,
11282 actor_directory_store: None,
11283 canonical_subagent_tool: None,
11284 ticket_service: None,
11285 activation_run_id: Some("current"),
11286 execution_epoch: 7,
11287 expected_source_actor_id: "selected",
11288 initial_inflight_claims: vec![].into(),
11289 plain_actor: false,
11290 remote_environment_lease: false,
11291 readonly_output: None,
11292 local_history_tools: Some(&tools),
11293 local_history_read_only: true,
11294 strict_history_output: None,
11295 plain_input: None,
11296 canonical_activation: None,
11297 canonical_placement_ref: None,
11298 actor_event_observer: None,
11299 plain_run: None,
11300 first_frame_timeout: Some(Duration::from_secs(1)),
11301 })
11302 .await;
11303 assert!(outcome.is_err(), "{case}: no last-text success fallback");
11304 if case == "ticket_forged_success" {
11305 assert!(outcome
11306 .unwrap_err()
11307 .to_string()
11308 .contains("no exact Host receipt"));
11309 assert!(matches!(
11310 rx.try_recv().unwrap(),
11311 AgentEvent::ToolStart { .. }
11312 ));
11313 }
11314 assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11315 assert!(link.sent.is_empty());
11316 assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11317 }
11318 }
11319
11320 struct HistoryTerminalProbe {
11321 frames: VecDeque<ChildFrame>,
11322 accepted: Option<TerminalStatus>,
11323 acks: usize,
11324 }
11325
11326 #[async_trait]
11327 impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11328 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11329 Ok(())
11330 }
11331 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11332 Ok(self.frames.pop_front())
11333 }
11334 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11335 self.accepted = Some(status);
11336 }
11337 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11338 Some(bamboo_subagent::DurableChildDeliveryReceipt {
11339 broker_identity: "fixture".into(),
11340 parent_mailbox: "parent".into(),
11341 correlation_id: "current".into(),
11342 message_ids: vec!["tail".into(), "terminal".into()],
11343 terminal_status: self.accepted?,
11344 })
11345 }
11346 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11347 self.acks += 1;
11348 Ok(())
11349 }
11350 }
11351
11352 struct CancelConcurrentInputCheckpoint {
11353 real: SessionInboxRuntimeBinding,
11354 envelope: bamboo_domain::SessionMessageEnvelope,
11355 }
11356 #[async_trait]
11357 impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11358 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11359 self.real.persistence.save_runtime_session(session).await
11360 }
11361 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11362 let receipt = self
11363 .real
11364 .inbox
11365 .deliver(&self.envelope)
11366 .await
11367 .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11368 self.real
11369 .inbox
11370 .mark_activation_eligible(
11371 &session.id,
11372 receipt.generation,
11373 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11374 )
11375 .await
11376 .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11377 let claim = self
11378 .real
11379 .inbox
11380 .claim(&session.id, 1)
11381 .await
11382 .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11383 .remove(0);
11384 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11385 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11386 .await
11387 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11388 self.real
11389 .persistence
11390 .checkpoint_runtime_session(session)
11391 .await
11392 }
11393 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11394 self.real.storage.load_session(id).await
11395 }
11396 }
11397
11398 struct CancelHistoryProbe {
11399 inner: HistoryTerminalProbe,
11400 cancel_requested: bool,
11401 }
11402
11403 #[async_trait]
11404 impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11405 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11406 self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11407 Ok(())
11408 }
11409 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11410 if !self.cancel_requested {
11411 std::future::pending().await
11412 } else {
11413 self.inner.next_frame().await
11414 }
11415 }
11416 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11417 self.inner.accept_durable_terminal(status);
11418 }
11419 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11420 self.inner.durable_delivery_receipt()
11421 }
11422 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11423 self.inner.acknowledge_durable_frames().await
11424 }
11425 }
11426
11427 #[tokio::test]
11428 async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11429 use bamboo_subagent::ChildLink;
11430 for case in [
11431 "success",
11432 "empty",
11433 "missing",
11434 "unclosed",
11435 "unobserved",
11436 "prefix",
11437 "metadata",
11438 "write",
11439 "not_read_only",
11440 "watermark",
11441 "epoch",
11442 "birth",
11443 "permission",
11444 "inbox",
11445 "pending",
11446 "pending_eligible",
11447 "claimed",
11448 "inspect_error",
11449 "checkpoint_error",
11450 "lost_owner",
11451 "concurrent_input",
11452 ] {
11453 let temp = tempfile::tempdir().unwrap();
11454 let store = Arc::new(
11455 bamboo_storage::SessionStoreV2::new(temp.path().into())
11456 .await
11457 .unwrap(),
11458 );
11459 let root = Session::new("cancel-history-root", "model");
11460 store.save_session(&root).await.unwrap();
11461 let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11462 child
11463 .messages
11464 .push(bamboo_agent_core::Message::user("exact assignment"));
11465 child.metadata.insert(
11466 bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11467 "true".into(),
11468 );
11469 store.save_session(&child).await.unwrap();
11470 let before = serde_json::to_value(&child.messages).unwrap();
11471 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11472 store.clone(),
11473 bamboo_domain::SessionInboxLimits::default(),
11474 ));
11475 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11476 let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11477 let mut owner = Some(
11478 binding
11479 .router
11480 .register_run(&child.id, "current")
11481 .await
11482 .unwrap(),
11483 );
11484 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11485 created_at: child.created_at,
11486 spawn_depth: child.spawn_depth,
11487 };
11488 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11489 let tool = if case == "write" { "Write" } else { "Read" };
11490 let arguments = serde_json::json!({"file_path":"assigned.txt"});
11491 let call = bamboo_agent_core::ToolCall {
11492 id: "cancel-read".into(),
11493 tool_type: "function".into(),
11494 function: bamboo_agent_core::FunctionCall {
11495 name: tool.into(),
11496 arguments: arguments.to_string(),
11497 },
11498 };
11499 let mut rows = child.messages.clone();
11500 let mut events = Vec::new();
11501 if case != "empty" {
11502 rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11503 events.push(
11504 serde_json::to_value(AgentEvent::ToolStart {
11505 tool_call_id: "cancel-read".into(),
11506 tool_name: tool.into(),
11507 arguments,
11508 })
11509 .unwrap(),
11510 );
11511 if case != "unclosed" {
11512 let mut result = bamboo_agent_core::Message::tool_result_with_status(
11513 "cancel-read",
11514 "exact Read result",
11515 true,
11516 );
11517 if case == "metadata" {
11518 result.metadata = Some(serde_json::json!({"permission":true}));
11519 }
11520 rows.push(result);
11521 events.push(
11522 serde_json::to_value(AgentEvent::ToolComplete {
11523 tool_call_id: "cancel-read".into(),
11524 result: bamboo_agent_core::tools::ToolResult::text(
11525 true,
11526 "exact Read result",
11527 ),
11528 })
11529 .unwrap(),
11530 );
11531 }
11532 }
11533 if case == "unobserved" {
11534 events.clear();
11535 }
11536 if case == "prefix" {
11537 rows[0].content.push('!');
11538 }
11539 events.push(
11540 serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11541 version: 1,
11542 messages: rows
11543 .iter()
11544 .map(|row| serde_json::to_value(row).unwrap())
11545 .collect(),
11546 })
11547 .unwrap(),
11548 );
11549 if case == "missing" {
11550 events.clear();
11551 }
11552 let final_seq = events.len() as u64;
11553 let mut watermark = bamboo_subagent::ActorEventWatermark {
11554 version: 1,
11555 logical_session: Some(identity.clone()),
11556 activation_id: Some("current".into()),
11557 execution_epoch: 7,
11558 final_seq,
11559 };
11560 match case {
11561 "watermark" => watermark.final_seq += 1,
11562 "epoch" => watermark.execution_epoch -= 1,
11563 "birth" => {
11564 watermark
11565 .logical_session
11566 .as_mut()
11567 .unwrap()
11568 .creation
11569 .as_mut()
11570 .unwrap()
11571 .created_at += chrono::Duration::nanoseconds(1)
11572 }
11573 _ => {}
11574 }
11575 let mut frames = VecDeque::new();
11576 if !events.is_empty() {
11577 frames.push_back(ChildFrame::EventBatch {
11578 batch: ActorEventBatch {
11579 logical_session: Some(identity),
11580 activation_id: Some("current".into()),
11581 execution_epoch: 7,
11582 source_node_id: None,
11583 source_actor_id: Some("selected".into()),
11584 first_seq: 1,
11585 last_seq: final_seq,
11586 qos: bamboo_subagent::ActorEventQos::Durable,
11587 events,
11588 },
11589 });
11590 }
11591 frames.push_back(ChildFrame::Terminal {
11592 status: TerminalStatus::Cancelled,
11593 result: None,
11594 error: None,
11595 transcript: vec![],
11596 final_event_watermark: Some(watermark),
11597 });
11598 let mut claims = VecDeque::new();
11599 let pending_input =
11600 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11601 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11602 let receipt = inbox.deliver(&pending_input).await.unwrap();
11603 if case != "pending" {
11604 inbox
11605 .mark_activation_eligible(
11606 &child.id,
11607 receipt.generation,
11608 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11609 )
11610 .await
11611 .unwrap();
11612 }
11613 if matches!(case, "inbox" | "claimed") {
11614 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11615 if case == "inbox" {
11616 claims.push_back(claim);
11617 }
11618 }
11619 }
11620 if case == "concurrent_input" {
11621 binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11622 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11623 envelope: pending_input.clone(),
11624 });
11625 }
11626 if case == "inspect_error" {
11627 let rel = store.resolve_rel_path(&child.id).await.unwrap();
11628 let dir = store.bamboo_home_dir().join(rel).join("inbox");
11629 std::fs::create_dir_all(&dir).unwrap();
11630 std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11631 assert!(inbox.inspect(&child.id).await.is_err());
11632 }
11633 let backlog_before =
11634 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11635 Some(inbox.inspect(&child.id).await.unwrap())
11636 } else {
11637 None
11638 };
11639 if case == "checkpoint_error" {
11640 binding.persistence = Arc::new(ActorFaultingPersistence {
11641 inner: locked.clone(),
11642 fail_checkpoint_once: AtomicBool::new(true),
11643 });
11644 }
11645 if case == "lost_owner" {
11646 owner.take().unwrap().abandon().await;
11647 }
11648 let mut link = CancelHistoryProbe {
11649 inner: HistoryTerminalProbe {
11650 frames,
11651 accepted: None,
11652 acks: 0,
11653 },
11654 cancel_requested: false,
11655 };
11656 let (tx, mut rx) = mpsc::channel(32);
11657 let (_live, mut live_rx) = mpsc::unbounded_channel();
11658 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11659 let cancel = CancellationToken::new();
11660 cancel.cancel();
11661 let tools = vec!["Read".into(), "Glob".into()];
11662 let mut strict_history = None;
11663 let outcome = drive(ActorDriveContext {
11664 client: &mut link,
11665 parent_session_id: &root.id,
11666 child_session_id: &child.id.clone(),
11667 child_attempt: 0,
11668 approval_registry: None,
11669 approval_decider: None,
11670 approval_reviewer: None,
11671 escalation_bridge: None,
11672 event_tx: &tx,
11673 cancel_token: &cancel,
11674 live_rx: &mut live_rx,
11675 delivery_rx: &mut delivery_rx,
11676 logical_session: &mut child,
11677 expected_permission_posture: (case == "permission")
11678 .then(|| expected_default_permission_posture(7)),
11679 expected_creation: Some(&creation),
11680 session_inbox_runtime: Some(&binding),
11681 actor_directory_store: None,
11682 canonical_subagent_tool: None,
11683 ticket_service: None,
11684 activation_run_id: Some("current"),
11685 execution_epoch: 7,
11686 expected_source_actor_id: "selected",
11687 initial_inflight_claims: claims,
11688 plain_actor: false,
11689 remote_environment_lease: false,
11690 readonly_output: None,
11691 local_history_tools: Some(&tools),
11692 local_history_read_only: case != "not_read_only",
11693 strict_history_output: Some(&mut strict_history),
11694 plain_input: None,
11695 canonical_activation: None,
11696 canonical_placement_ref: None,
11697 actor_event_observer: None,
11698 plain_run: None,
11699 first_frame_timeout: None,
11700 })
11701 .await;
11702 let proven = matches!(case, "success" | "empty");
11703 assert_eq!(strict_history.is_some(), proven, "{case}");
11704 assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11705 assert_eq!(
11706 link.inner.acks, 0,
11707 "{case}: pump cannot ACK before SDK save/proof"
11708 );
11709 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11710 .await
11711 .unwrap()
11712 .load_session(&child.id)
11713 .await
11714 .unwrap()
11715 .unwrap();
11716 if proven {
11717 assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11718 assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11719 let history = strict_history.unwrap();
11720 assert_eq!(history.proof.execution_epoch(), 7);
11721 assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11722 assert_eq!(
11723 serde_json::to_value(&cold.messages).unwrap(),
11724 serde_json::to_value(&rows).unwrap()
11725 );
11726 } else {
11727 assert!(outcome.is_err(), "{case}");
11728 if case == "concurrent_input" {
11729 assert!(
11730 cold.messages
11731 .iter()
11732 .any(|message| message.id == pending_input.id.as_str()),
11733 "new input must survive refusal"
11734 );
11735 } else {
11736 assert_eq!(
11737 serde_json::to_value(&cold.messages).unwrap(),
11738 before,
11739 "{case}: no unvalidated history write"
11740 );
11741 }
11742 }
11743 if let Some(backlog) = backlog_before {
11744 assert!(!inbox
11745 .was_admitted(&child.id, &pending_input.id)
11746 .await
11747 .unwrap());
11748 assert_eq!(
11749 inbox.inspect(&child.id).await.unwrap(),
11750 backlog,
11751 "{case}: proof refusal must neither claim nor ACK the input"
11752 );
11753 }
11754 while let Ok(event) = rx.try_recv() {
11755 assert!(
11756 !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11757 "private DATA is not a canonical public barrier"
11758 );
11759 }
11760 }
11761 }
11762
11763 #[tokio::test]
11764 async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11765 use bamboo_subagent::ChildLink;
11766 for case in [
11767 "success",
11768 "tail",
11769 "short",
11770 "missing",
11771 "version",
11772 "activation",
11773 "epoch",
11774 "birth",
11775 "duplicate",
11776 "overlap",
11777 "reorder",
11778 "midgap",
11779 "qos",
11780 "zero_missing",
11781 "zero_valid",
11782 "error_tail",
11783 "cancelled_tail",
11784 "cancelled_valid",
11785 "cancelled_zero_valid",
11786 "error_valid",
11787 "error_zero_valid",
11788 "ordinary_lossy",
11789 ] {
11790 let temp = tempfile::tempdir().unwrap();
11791 let store = Arc::new(
11792 bamboo_storage::SessionStoreV2::new(temp.path().into())
11793 .await
11794 .unwrap(),
11795 );
11796 let root = Session::new("watermark-root", "model");
11797 store.save_session(&root).await.unwrap();
11798 let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11799 child
11800 .messages
11801 .push(bamboo_agent_core::Message::user("assignment"));
11802 store.save_session(&child).await.unwrap();
11803 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11804 store.clone(),
11805 bamboo_domain::SessionInboxLimits::default(),
11806 ));
11807 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11808 let binding = actor_binding(store.clone(), inbox, locked);
11809 let _owner = binding
11810 .router
11811 .register_run(&child.id, "current")
11812 .await
11813 .unwrap();
11814 let before = serde_json::to_value(&child).unwrap();
11815 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11816 created_at: child.created_at,
11817 spawn_depth: child.spawn_depth,
11818 };
11819 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11820 let mut rows = child.messages.clone();
11821 rows.push(bamboo_agent_core::Message::assistant("complete", None));
11822 let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11823 version: 1,
11824 messages: rows
11825 .iter()
11826 .map(|row| serde_json::to_value(row).unwrap())
11827 .collect(),
11828 })
11829 .unwrap();
11830 let batch = |seq, event, qos| ActorEventBatch {
11831 logical_session: Some(identity.clone()),
11832 activation_id: Some("current".into()),
11833 execution_epoch: 7,
11834 source_node_id: None,
11835 source_actor_id: Some("selected".into()),
11836 first_seq: seq,
11837 last_seq: seq,
11838 qos,
11839 events: vec![event],
11840 };
11841 let durable = bamboo_subagent::ActorEventQos::Durable;
11842 let token = serde_json::json!({"type":"token", "content":"observation"});
11843 let mut frames = vec![ChildFrame::EventBatch {
11844 batch: batch(1, data.clone(), durable),
11845 }];
11846 let mut marker = bamboo_subagent::ActorEventWatermark {
11847 version: 1,
11848 logical_session: Some(identity.clone()),
11849 activation_id: Some("current".into()),
11850 execution_epoch: 7,
11851 final_seq: 1,
11852 };
11853 match case {
11854 "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11855 "short" => marker.final_seq = 0,
11856 "version" => marker.version = 99,
11857 "activation" => marker.activation_id = Some("old".into()),
11858 "epoch" => marker.execution_epoch = 6,
11859 "birth" => {
11860 marker
11861 .logical_session
11862 .as_mut()
11863 .unwrap()
11864 .creation
11865 .as_mut()
11866 .unwrap()
11867 .created_at += chrono::Duration::nanoseconds(1)
11868 }
11869 "duplicate" => frames.push(frames[0].clone()),
11870 "overlap" | "midgap" | "qos" => {
11871 frames = vec![
11872 ChildFrame::EventBatch {
11873 batch: batch(
11874 1,
11875 token.clone(),
11876 if case == "qos" {
11877 bamboo_subagent::ActorEventQos::Ephemeral
11878 } else {
11879 durable
11880 },
11881 ),
11882 },
11883 ChildFrame::EventBatch {
11884 batch: batch(
11885 if case == "overlap" {
11886 1
11887 } else if case == "midgap" {
11888 3
11889 } else {
11890 2
11891 },
11892 data,
11893 durable,
11894 ),
11895 },
11896 ];
11897 marker.final_seq = 2;
11898 }
11899 "reorder" => {
11900 frames = vec![
11901 ChildFrame::EventBatch {
11902 batch: batch(2, data, durable),
11903 },
11904 ChildFrame::EventBatch {
11905 batch: batch(1, token, durable),
11906 },
11907 ]
11908 }
11909 "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11910 frames.clear();
11911 marker.final_seq = 0;
11912 }
11913 "ordinary_lossy" => {
11914 frames = vec![ChildFrame::EventBatch {
11915 batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11916 }]
11917 }
11918 _ => {}
11919 }
11920 let status = match case {
11921 "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11922 "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11923 TerminalStatus::Cancelled
11924 }
11925 _ => TerminalStatus::Completed,
11926 };
11927 frames.push(ChildFrame::Terminal {
11928 status,
11929 result: Some("complete".into()),
11930 error: Some("worker failed".into()),
11931 transcript: vec![],
11932 final_event_watermark: (!matches!(
11933 case,
11934 "missing" | "zero_missing" | "ordinary_lossy"
11935 ))
11936 .then_some(marker),
11937 });
11938 let mut link = HistoryTerminalProbe {
11939 frames: frames.into(),
11940 accepted: None,
11941 acks: 0,
11942 };
11943 let (tx, mut rx) = mpsc::channel(32);
11944 let (_live, mut live_rx) = mpsc::unbounded_channel();
11945 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11946 let cancel = CancellationToken::new();
11947 let tools = vec!["Read".into()];
11948 let mut strict_history = None;
11949 let outcome = drive(ActorDriveContext {
11950 client: &mut link,
11951 parent_session_id: &root.id,
11952 child_session_id: "watermark-child",
11953 child_attempt: 0,
11954 approval_registry: None,
11955 approval_decider: None,
11956 approval_reviewer: None,
11957 escalation_bridge: None,
11958 event_tx: &tx,
11959 cancel_token: &cancel,
11960 live_rx: &mut live_rx,
11961 delivery_rx: &mut delivery_rx,
11962 logical_session: &mut child,
11963 expected_permission_posture: None,
11964 expected_creation: Some(&creation),
11965 session_inbox_runtime: Some(&binding),
11966 actor_directory_store: None,
11967 canonical_subagent_tool: None,
11968 ticket_service: None,
11969 activation_run_id: Some("current"),
11970 execution_epoch: 7,
11971 expected_source_actor_id: "selected",
11972 initial_inflight_claims: vec![].into(),
11973 plain_actor: false,
11974 remote_environment_lease: false,
11975 readonly_output: None,
11976 local_history_tools: (case != "ordinary_lossy").then_some(&tools),
11977 local_history_read_only: true,
11978 strict_history_output: Some(&mut strict_history),
11979 plain_input: None,
11980 canonical_activation: None,
11981 canonical_placement_ref: None,
11982 actor_event_observer: None,
11983 plain_run: None,
11984 first_frame_timeout: Some(Duration::from_secs(1)),
11985 })
11986 .await;
11987 assert_eq!(strict_history.is_some(), case == "success", "{case}");
11988 if let Some(history) = strict_history {
11989 assert_eq!(history.proof.execution_epoch(), 7);
11990 assert_eq!(history.proof.contiguous_applied_seq(), 1);
11991 assert_eq!(history.watermark.final_seq, 1);
11992 }
11993 let cold = store.load_session(&child.id).await.unwrap().unwrap();
11994 if matches!(case, "success" | "ordinary_lossy") {
11995 assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
11996 } else {
11997 let error = outcome.unwrap_err();
11998 if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
11999 assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
12000 }
12001 let error = error.to_string();
12002 if !matches!(
12003 case,
12004 "duplicate"
12005 | "overlap"
12006 | "reorder"
12007 | "midgap"
12008 | "qos"
12009 | "zero_valid"
12010 | "error_valid"
12011 | "error_zero_valid"
12012 | "cancelled_valid"
12013 | "cancelled_zero_valid"
12014 ) {
12015 assert!(
12016 error.contains("actor_history_tail_incomplete"),
12017 "{case}: {error}"
12018 );
12019 }
12020 }
12021 if case == "success" {
12022 assert_eq!(
12023 serde_json::to_value(&cold.messages).unwrap(),
12024 serde_json::to_value(rows).unwrap()
12025 );
12026 } else {
12027 assert_eq!(
12028 serde_json::to_value(&cold).unwrap(),
12029 before,
12030 "{case}: no cold history commit"
12031 );
12032 assert_eq!(
12033 serde_json::to_value(&child).unwrap(),
12034 before,
12035 "{case}: no mutable history commit"
12036 );
12037 }
12038 let accepted = matches!(case, "success" | "ordinary_lossy");
12039 assert_eq!(link.accepted.is_some(), accepted, "{case}");
12040 assert_eq!(
12041 link.durable_delivery_receipt().is_some(),
12042 accepted,
12043 "{case}"
12044 );
12045 assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
12046 while let Ok(event) = rx.try_recv() {
12047 assert!(
12048 matches!(event, AgentEvent::Token { .. }),
12049 "typed DATA cannot leak"
12050 );
12051 }
12052 }
12053 }
12054
12055 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
12056 PooledWorker {
12057 worker: SpawnedChild::remote(AgentRecord {
12058 agent_id: mailbox_id.to_string(),
12059 role: "test".to_string(),
12060 labels: Vec::new(),
12061 endpoint: "ws://127.0.0.1:1".to_string(),
12062 pid: 0,
12063 version: String::new(),
12064 started_at: chrono::Utc::now(),
12065 lease_expires_at: chrono::Utc::now(),
12066 }),
12067 mailbox_id: mailbox_id.to_string(),
12068 parked_at: None,
12069 }
12070 }
12071
12072 #[test]
12073 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12074 let now = Instant::now();
12075 let timeout = Duration::from_secs(300);
12076 assert!(!parked_worker_expired(None, now, timeout));
12077 assert!(!parked_worker_expired(
12078 Some(now - timeout + Duration::from_millis(1)),
12079 now,
12080 timeout
12081 ));
12082 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12083 }
12084
12085 #[test]
12086 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12087 let mut pool = WorkerPool::new();
12088 pool.insert(
12089 "fingerprint-a".to_string(),
12090 vec![processless_pool_worker("a")],
12091 );
12092 pool.insert(
12093 "fingerprint-b".to_string(),
12094 vec![processless_pool_worker("b")],
12095 );
12096
12097 assert_eq!(
12098 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12099 Some("pool_global_limit")
12100 );
12101 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12102 assert_eq!(
12103 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12104 Some("pool_per_key_limit")
12105 );
12106 }
12107
12108 #[test]
12109 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12110 let mut pool = WorkerPool::new();
12111 pool.insert(
12112 "fingerprint-a".to_string(),
12113 vec![processless_pool_worker("dead")],
12114 );
12115
12116 let reaped = take_reapable_workers(
12117 &mut pool,
12118 Instant::now(),
12119 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12120 );
12121
12122 assert!(pool.is_empty());
12123 assert_eq!(reaped.len(), 1);
12124 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12125 }
12126
12127 #[test]
12128 fn actor_preflight_counts_only_current_session_scoped_denies() {
12129 let config = bamboo_tools::permission::PermissionConfig::new();
12130 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12131 config.deny_scoped_session_permission(
12132 "target-session",
12133 bamboo_tools::permission::PermissionType::ExecuteCommand,
12134 secret_matcher,
12135 );
12136 config.deny_scoped_session_permission(
12137 "other-session",
12138 bamboo_tools::permission::PermissionType::WriteFile,
12139 "/other/**",
12140 );
12141
12142 assert_eq!(
12143 active_scoped_session_deny_count(&config, "target-session"),
12144 1
12145 );
12146 assert_eq!(
12147 active_scoped_session_deny_count(&config, "clean-session"),
12148 0
12149 );
12150 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12151 .unwrap_err()
12152 .to_string();
12153 assert!(!error.contains(secret_matcher));
12154 ensure_no_active_scoped_session_denies(&config, "clean-session")
12155 .expect("another session's deny must not block this activation");
12156 }
12157
12158 #[test]
12159 fn permission_posture_event_rejects_oversized_executor_mapping() {
12160 let session = Session::new("mapping-bound", "model");
12161 let event = AgentEvent::PermissionPostureActivated {
12162 session_id: session.id.clone(),
12163 policy_revision: 1,
12164 requested_mode: "default".to_string(),
12165 effective_mode: "default".to_string(),
12166 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12167 };
12168
12169 assert!(permission_posture_seed_from_event(&session, &event)
12170 .unwrap_err()
12171 .contains("executor mapping"));
12172 }
12173
12174 #[test]
12175 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12176 let mut session = Session::new("host-resigns-audit", "model");
12177 let hostile_timestamp = "9".repeat(1024);
12178 let event: AgentEvent = serde_json::from_value(serde_json::json!({
12179 "type": "permission_posture_activated",
12180 "session_id": session.id.clone(),
12181 "policy_revision": 31,
12182 "requested_mode": "default",
12183 "effective_mode": "default",
12184 "executor_mapping": "codex_exec:approval_policy=never",
12185 "audit_revision": u64::MAX,
12186 "transitioned_at": hostile_timestamp,
12187 }))
12188 .expect("unknown remote audit fields are ignored by the typed event");
12189 let seed = permission_posture_seed_from_event(&session, &event)
12190 .unwrap()
12191 .expect("permission event");
12192
12193 let host_revision =
12194 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12195 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12196 .expect("host-generated complete audit");
12197 assert_eq!(host_audit.audit_revision, host_revision);
12198 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12199 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12200 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12201 }
12202
12203 struct ActorFaultingPersistence {
12204 inner: Arc<bamboo_storage::LockedSessionStore>,
12205 fail_checkpoint_once: std::sync::atomic::AtomicBool,
12206 }
12207
12208 #[async_trait]
12209 impl RuntimeSessionPersistence for ActorFaultingPersistence {
12210 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12211 self.inner.merge_save_runtime(session).await
12212 }
12213
12214 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12215 if self
12216 .fail_checkpoint_once
12217 .swap(false, std::sync::atomic::Ordering::SeqCst)
12218 {
12219 return Err(std::io::Error::other("injected actor checkpoint failure"));
12220 }
12221 self.inner.checkpoint_runtime_session(session).await
12222 }
12223
12224 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12225 self.inner.storage().load_session(session_id).await
12226 }
12227 }
12228
12229 struct ActorFailBeforeAckInbox {
12230 inner: Arc<dyn SessionInboxPort>,
12231 fail_once: std::sync::atomic::AtomicBool,
12232 }
12233
12234 #[async_trait]
12235 impl SessionInboxPort for ActorFailBeforeAckInbox {
12236 async fn deliver(
12237 &self,
12238 envelope: &bamboo_domain::SessionMessageEnvelope,
12239 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12240 self.inner.deliver(envelope).await
12241 }
12242
12243 async fn mark_activation_eligible(
12244 &self,
12245 target_session_id: &str,
12246 generation: u64,
12247 policy: bamboo_domain::SessionActivationPolicy,
12248 ) -> Result<(), bamboo_domain::SessionInboxError> {
12249 self.inner
12250 .mark_activation_eligible(target_session_id, generation, policy)
12251 .await
12252 }
12253
12254 async fn claim(
12255 &self,
12256 target_session_id: &str,
12257 limit: usize,
12258 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12259 self.inner.claim(target_session_id, limit).await
12260 }
12261
12262 async fn was_admitted(
12263 &self,
12264 target_session_id: &str,
12265 id: &bamboo_domain::SessionMessageId,
12266 ) -> Result<bool, bamboo_domain::SessionInboxError> {
12267 self.inner.was_admitted(target_session_id, id).await
12268 }
12269
12270 async fn ack(
12271 &self,
12272 target_session_id: &str,
12273 claim: &SessionInboxClaim,
12274 ) -> Result<(), bamboo_domain::SessionInboxError> {
12275 if self
12276 .fail_once
12277 .swap(false, std::sync::atomic::Ordering::SeqCst)
12278 {
12279 return Err(bamboo_domain::SessionInboxError::Storage(
12280 "injected actor pre-ack failure".to_string(),
12281 ));
12282 }
12283 self.inner.ack(target_session_id, claim).await
12284 }
12285
12286 async fn inspect(
12287 &self,
12288 target_session_id: &str,
12289 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12290 self.inner.inspect(target_session_id).await
12291 }
12292 }
12293
12294 async fn actor_inbox_fixture(
12295 session_id: &str,
12296 ) -> (
12297 tempfile::TempDir,
12298 Arc<bamboo_storage::SessionStoreV2>,
12299 Arc<bamboo_storage::LockedSessionStore>,
12300 Arc<dyn SessionInboxPort>,
12301 Session,
12302 SessionInboxClaim,
12303 ) {
12304 let temp = tempfile::tempdir().unwrap();
12305 let store = Arc::new(
12306 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12307 .await
12308 .unwrap(),
12309 );
12310 let storage: Arc<dyn Storage> = store.clone();
12311 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12312 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12313 store.clone(),
12314 bamboo_domain::SessionInboxLimits::default(),
12315 ));
12316 let session = Session::new(session_id, "model");
12317 store.save_session(&session).await.unwrap();
12318 let mut envelope =
12319 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12320 envelope.id =
12321 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12322 let receipt = inbox.deliver(&envelope).await.unwrap();
12323 inbox
12324 .mark_activation_eligible(
12325 session_id,
12326 receipt.generation,
12327 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12328 )
12329 .await
12330 .unwrap();
12331 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12332 (temp, store, locked, inbox, session, claim)
12333 }
12334
12335 fn actor_binding(
12336 store: Arc<bamboo_storage::SessionStoreV2>,
12337 inbox: Arc<dyn SessionInboxPort>,
12338 persistence: Arc<dyn RuntimeSessionPersistence>,
12339 ) -> SessionInboxRuntimeBinding {
12340 let storage: Arc<dyn Storage> = store;
12341 SessionInboxRuntimeBinding {
12342 router: SessionActivationRouter::new(),
12343 inbox,
12344 storage,
12345 persistence,
12346 parent_question_lock: None,
12347 }
12348 }
12349
12350 #[tokio::test]
12351 async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12352 let (_temp, store, locked, inbox, mut session, claim) =
12353 actor_inbox_fixture("unsupported-remote-inbox").await;
12354 let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12355 inner: inbox.clone(),
12356 fail_once: std::sync::atomic::AtomicBool::new(false),
12357 });
12358 let binding = actor_binding(store.clone(), wrapped, locked);
12359 let fence = ActorActivationFence {
12360 schema_version: 1,
12361 actor_id: session.id.clone(),
12362 activation_id: "unsupported-inbox-activation".into(),
12363 attempt: 1,
12364 run_id: "unsupported-inbox-run".into(),
12365 lease_owner: "unsupported-inbox-host".into(),
12366 lease_epoch: 1,
12367 };
12368 let before = session.clone();
12369
12370 let error = checkpoint_claim_context_before_dispatch(
12371 &binding,
12372 &mut session,
12373 &[claim],
12374 Some((store.as_ref(), &fence)),
12375 )
12376 .await
12377 .unwrap_err();
12378 assert!(error
12379 .to_string()
12380 .contains("requires canonical FileSessionInbox"));
12381 assert_eq!(
12382 serde_json::to_value(&session.messages).unwrap(),
12383 serde_json::to_value(&before.messages).unwrap()
12384 );
12385 assert_eq!(
12386 serde_json::to_value(
12387 &store
12388 .load_session(&session.id)
12389 .await
12390 .unwrap()
12391 .unwrap()
12392 .messages
12393 )
12394 .unwrap(),
12395 serde_json::to_value(&before.messages).unwrap()
12396 );
12397 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12398 }
12399
12400 #[tokio::test]
12401 async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12402 let (_temp, store, locked, inbox, parent, _claim) =
12403 actor_inbox_fixture("remote-parent").await;
12404 let child = Session::new_child_of("remote-child", &parent, "model", "child");
12405 store.save_session(&child).await.unwrap();
12406 let binding = actor_binding(store.clone(), inbox, locked);
12407 let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12408 assert_eq!(first.len(), 2);
12409 let mut changed = child.clone();
12410 changed.set_project_id_meta("different-project");
12411 assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12412 store.delete_session(&parent.id).await.unwrap();
12413 assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12414 }
12415
12416 #[tokio::test]
12417 async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12418 let (_temp, store, locked, inbox, root, _claim) =
12419 actor_inbox_fixture("deep-remote-root").await;
12420 let mut parent = root;
12421 for depth in 1..=6 {
12422 let child =
12423 Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12424 store.save_session(&child).await.unwrap();
12425 parent = child;
12426 }
12427 let binding = actor_binding(store, inbox, locked);
12428 let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12429 assert_eq!(chain.len(), 7);
12430 assert_eq!(chain.last().unwrap().spawn_depth, 0);
12431 }
12432
12433 #[tokio::test]
12434 async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12435 let (_temp, store, locked, inbox, parent, _claim) =
12436 actor_inbox_fixture("creation-parent").await;
12437 let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12438 child.set_project_id_meta("creation-project");
12439 store.save_session(&child).await.unwrap();
12440 let binding = actor_binding(store.clone(), inbox, locked);
12441 let job = SpawnJob {
12442 parent_session_id: parent.id.clone(),
12443 child_session_id: child.id.clone(),
12444 model: "model".into(),
12445 disabled_tools: None,
12446 };
12447 let creation = canonical_child_creation(&binding, &child, &job)
12448 .await
12449 .unwrap();
12450 assert_eq!(creation.created_at, child.created_at);
12451 assert_eq!(creation.spawn_depth, child.spawn_depth);
12452 for damage in 0..7 {
12453 let mut held = child.clone();
12454 match damage {
12455 0 => held.created_at += chrono::Duration::nanoseconds(1),
12456 1 => held.parent_session_id = Some("foreign-parent".into()),
12457 2 => held.root_session_id = "foreign-root".into(),
12458 3 => held.spawn_depth += 1,
12459 4 => held.set_project_id_meta("foreign-project"),
12460 5 => held.kind = bamboo_domain::SessionKind::Root,
12461 _ => held.id = " creation-child ".into(),
12462 }
12463 assert!(
12464 canonical_child_creation(&binding, &held, &job)
12465 .await
12466 .is_err(),
12467 "damage {damage}"
12468 );
12469 }
12470 store.delete_session(&child.id).await.unwrap();
12471 assert!(canonical_child_creation(&binding, &child, &job)
12472 .await
12473 .is_err());
12474 let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12475 replacement.set_project_id_meta("creation-project");
12476 assert_ne!(replacement.created_at, child.created_at);
12477 store.save_session(&replacement).await.unwrap();
12478 assert!(
12479 canonical_child_creation(&binding, &child, &job)
12480 .await
12481 .is_err(),
12482 "old held spec cannot adopt replacement birth"
12483 );
12484 canonical_child_creation(&binding, &replacement, &job)
12485 .await
12486 .unwrap();
12487 }
12488
12489 #[tokio::test]
12490 async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12491 use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12492 for policies in [
12493 [
12494 SessionActivationPolicy::RespectSpecificWait,
12495 SessionActivationPolicy::InterruptSpecificWait,
12496 ],
12497 [
12498 SessionActivationPolicy::InterruptSpecificWait,
12499 SessionActivationPolicy::RespectSpecificWait,
12500 ],
12501 ] {
12502 let temp = tempfile::tempdir().unwrap();
12503 let store = Arc::new(
12504 bamboo_storage::SessionStoreV2::new(temp.path().into())
12505 .await
12506 .unwrap(),
12507 );
12508 let session_id = "actor-mixed-intent-policy";
12509 store
12510 .save_session(&Session::new(session_id, "model"))
12511 .await
12512 .unwrap();
12513 let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12514 let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12515 let staged_receipt = inbox.deliver(&staged).await.unwrap();
12516 let mut sent = Vec::new();
12517 for policy in policies {
12518 let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12519 let receipt = inbox
12520 .deliver_with_activation_intent(&envelope, policy, None)
12521 .await
12522 .unwrap();
12523 sent.push((envelope, policy, receipt));
12524 }
12525 let restarted = Arc::new(
12526 bamboo_storage::SessionStoreV2::new(temp.path().into())
12527 .await
12528 .unwrap(),
12529 );
12530 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12531 restarted.clone(),
12532 SessionInboxLimits::default(),
12533 ));
12534 let binding = actor_binding(
12535 restarted.clone(),
12536 inbox.clone(),
12537 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12538 );
12539 let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12540 let pairs =
12541 claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12542 .await
12543 .unwrap();
12544 assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12545 for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12546 assert_eq!(claim.envelope.id, envelope.id);
12547 assert_eq!(claim.generation, receipt.generation);
12548 assert_eq!(claim.activation_policy, *policy);
12549 assert_eq!(delivery.activation_policy, *policy);
12550 checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12551 .await
12552 .unwrap();
12553 }
12554 let restarted = Arc::new(
12555 bamboo_storage::SessionStoreV2::new(temp.path().into())
12556 .await
12557 .unwrap(),
12558 );
12559 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12560 restarted.clone(),
12561 SessionInboxLimits::default(),
12562 ));
12563 let binding = actor_binding(
12564 restarted.clone(),
12565 inbox.clone(),
12566 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12567 );
12568 session = restarted.load_session(session_id).await.unwrap().unwrap();
12569 for (envelope, policy, receipt) in &sent {
12570 assert_eq!(
12571 inbox
12572 .deliver_with_activation_intent(envelope, *policy, None)
12573 .await
12574 .unwrap(),
12575 *receipt
12576 );
12577 }
12578 assert!(
12579 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12580 .await
12581 .unwrap()
12582 .is_empty()
12583 );
12584 inbox
12585 .mark_activation_eligible(
12586 session_id,
12587 staged_receipt.generation,
12588 SessionActivationPolicy::RespectSpecificWait,
12589 )
12590 .await
12591 .unwrap();
12592 let pairs =
12593 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12594 .await
12595 .unwrap();
12596 assert_eq!(pairs.len(), 1);
12597 assert_eq!(pairs[0].0.envelope.id, staged.id);
12598 assert_eq!(
12599 pairs[0].1.activation_policy,
12600 SessionActivationPolicy::RespectSpecificWait
12601 );
12602 }
12603 }
12604
12605 #[tokio::test]
12606 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12607 let (_temp, store, locked, inbox, mut session, claim) =
12608 actor_inbox_fixture("actor-live-id-collision").await;
12609 let mut forged = claim.envelope.to_provider_message().unwrap();
12610 forged.metadata = Some(serde_json::json!({
12611 "session_message": {
12612 "id": claim.envelope.id,
12613 "target_session_id": "different-session"
12614 }
12615 }));
12616 session.add_message(forged);
12617 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12618 let binding = actor_binding(store, inbox.clone(), persistence);
12619
12620 assert!(
12621 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12622 .await
12623 .is_err()
12624 );
12625 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12626 assert!(!inbox
12627 .was_admitted(&session.id, &claim.envelope.id)
12628 .await
12629 .unwrap());
12630 }
12631
12632 #[tokio::test]
12633 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12634 let (_temp, store, locked, inbox, mut session, claim) =
12635 actor_inbox_fixture("actor-durable-id-collision").await;
12636 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12637 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12638 forged.id = claim.envelope.id.to_string();
12639 concurrent.add_message(forged);
12640 store.save_session(&concurrent).await.unwrap();
12641 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12642 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12643
12644 assert!(
12645 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12646 .await
12647 .is_err()
12648 );
12649 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12650 assert!(!inbox
12651 .was_admitted(&session.id, &claim.envelope.id)
12652 .await
12653 .unwrap());
12654 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12655 assert!(!durable.messages.iter().any(|message| {
12656 bamboo_domain::is_matching_session_message(message, &claim.envelope)
12657 }));
12658 }
12659
12660 #[tokio::test]
12661 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12662 let (_temp, store, locked, inbox, mut session, claim) =
12663 actor_inbox_fixture("actor-durable-typed-body-collision").await;
12664 let mut different = claim.envelope.clone();
12665 different.body = bamboo_domain::SessionMessageBody::Content(
12666 bamboo_domain::SessionMessageContent::text("forged actor body"),
12667 );
12668 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12669 concurrent.add_message(different.to_provider_message().unwrap());
12670 store.save_session(&concurrent).await.unwrap();
12671 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12672 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12673
12674 assert!(
12675 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12676 .await
12677 .is_err()
12678 );
12679 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12680 assert!(!inbox
12681 .was_admitted(&session.id, &claim.envelope.id)
12682 .await
12683 .unwrap());
12684 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12685 assert!(!durable
12686 .messages
12687 .iter()
12688 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12689 }
12690
12691 #[tokio::test]
12692 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12693 let (_temp, store, locked, inbox, mut session, claim) =
12694 actor_inbox_fixture("actor-checkpoint-failure").await;
12695 let envelope_id = claim.envelope.id.clone();
12696 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12697 inner: locked.clone(),
12698 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12699 });
12700 let binding = actor_binding(store.clone(), inbox.clone(), fault);
12701
12702 assert!(
12703 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12704 .await
12705 .is_err()
12706 );
12707 assert!(!session
12708 .messages
12709 .iter()
12710 .any(|message| message.id == envelope_id.as_str()));
12711 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12712 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12713
12714 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12715 store.clone(),
12716 bamboo_domain::SessionInboxLimits::default(),
12717 ));
12718 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12719 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12720 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12721 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12722 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12723 .await
12724 .unwrap();
12725 assert_eq!(
12726 restarted
12727 .messages
12728 .iter()
12729 .filter(|message| message.id == envelope_id.as_str())
12730 .count(),
12731 1
12732 );
12733 assert!(reopened
12734 .was_admitted(&session.id, &envelope_id)
12735 .await
12736 .unwrap());
12737 let backlog = reopened.inspect(&session.id).await.unwrap();
12738 assert_eq!(backlog.pending + backlog.claimed, 0);
12739 }
12740
12741 #[tokio::test]
12742 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12743 let (_temp, store, locked, real_inbox, mut session, claim) =
12744 actor_inbox_fixture("actor-pre-ack-failure").await;
12745 let envelope_id = claim.envelope.id.clone();
12746 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12747 inner: real_inbox.clone(),
12748 fail_once: std::sync::atomic::AtomicBool::new(true),
12749 });
12750 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12751 let binding = actor_binding(store.clone(), faulted, persistence);
12752
12753 assert!(
12754 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12755 .await
12756 .is_err()
12757 );
12758 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12759 assert_eq!(
12760 durable
12761 .messages
12762 .iter()
12763 .filter(|message| message.id == envelope_id.as_str())
12764 .count(),
12765 1
12766 );
12767 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12768 assert!(!real_inbox
12769 .was_admitted(&session.id, &envelope_id)
12770 .await
12771 .unwrap());
12772
12773 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12774 store.clone(),
12775 bamboo_domain::SessionInboxLimits::default(),
12776 ));
12777 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12778 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12779 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12780 let mut restarted = durable;
12781 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12782 .await
12783 .unwrap();
12784 assert_eq!(
12785 restarted
12786 .messages
12787 .iter()
12788 .filter(|message| message.id == envelope_id.as_str())
12789 .count(),
12790 1
12791 );
12792 assert!(reopened
12793 .was_admitted(&session.id, &envelope_id)
12794 .await
12795 .unwrap());
12796 let backlog = reopened.inspect(&session.id).await.unwrap();
12797 assert_eq!(backlog.pending + backlog.claimed, 0);
12798 }
12799
12800 struct ConfirmationSequenceLink {
12801 frames: VecDeque<ChildFrame>,
12802 sent: Vec<ParentFrame>,
12803 }
12804
12805 #[async_trait]
12806 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12807 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12808 self.sent.push(frame);
12809 Ok(())
12810 }
12811
12812 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12813 match self.frames.pop_front() {
12814 Some(frame) => Ok(Some(frame)),
12815 None => std::future::pending().await,
12816 }
12817 }
12818 }
12819
12820 #[tokio::test]
12821 async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12822 for case in [
12823 "accepted",
12824 "readonly-refusal",
12825 "prefix-refusal",
12826 "old-claim",
12827 "old-checkpoint",
12828 "succeeded",
12829 "changed-birth",
12830 "oversize",
12831 ] {
12832 let temp = tempfile::tempdir().unwrap();
12833 let store = Arc::new(
12834 bamboo_storage::SessionStoreV2::new(temp.path().into())
12835 .await
12836 .unwrap(),
12837 );
12838 let parent = Session::new("retry-parent", "model");
12839 store.save_session(&parent).await.unwrap();
12840 let mut child =
12841 Session::new_child_of("retry-child", &parent, "model", "original assignment");
12842 child.add_message(bamboo_agent_core::Message::user("original assignment"));
12843 store.save_session(&child).await.unwrap();
12844 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12845 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12846 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12847 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12848 store.clone(),
12849 bamboo_domain::SessionInboxLimits::default(),
12850 ));
12851 let binding = actor_binding(store.clone(), inbox, locked);
12852 let registration = binding
12853 .router
12854 .register_run(&child.id, "first-run")
12855 .await
12856 .unwrap();
12857 let first = PlainActorActivation::start(
12858 store.clone(),
12859 &child,
12860 &binding,
12861 Some("first-run"),
12862 Some(policy.clone()),
12863 true,
12864 None,
12865 )
12866 .await
12867 .unwrap();
12868 if matches!(case, "old-claim" | "old-checkpoint") {
12869 let old = bamboo_domain::SessionMessageEnvelope::user_input(
12870 &child.id,
12871 "unconfirmed old input",
12872 );
12873 let receipt = first.input_inbox.deliver(&old).await.unwrap();
12874 first
12875 .input_inbox
12876 .mark_activation_eligible(
12877 &child.id,
12878 receipt.generation,
12879 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12880 )
12881 .await
12882 .unwrap();
12883 let claim = first
12884 .claim_input(&binding, &child, "first-run")
12885 .await
12886 .unwrap()
12887 .unwrap();
12888 if case == "old-checkpoint" {
12889 first
12890 .checkpoint_input(&mut child, "first-run", &claim)
12891 .await
12892 .unwrap();
12893 }
12894 }
12895 first
12896 .finish(if case == "succeeded" {
12897 ActorActivationFinish::Succeeded
12898 } else {
12899 ActorActivationFinish::Failed
12900 })
12901 .await
12902 .unwrap();
12903 drop(registration);
12904 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12905 &child.id,
12906 if case == "oversize" {
12907 "x".repeat(8193)
12908 } else {
12909 "NEW_CORRECTION".into()
12910 },
12911 );
12912 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12913 first
12914 .input_inbox
12915 .mark_activation_eligible(
12916 &child.id,
12917 receipt.generation,
12918 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12919 )
12920 .await
12921 .unwrap();
12922 let before = store.load_session(&child.id).await.unwrap().unwrap();
12923 let expected_messages = serde_json::to_value(&before.messages).unwrap();
12924 child = before.clone();
12925 if case == "prefix-refusal" {
12926 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12927 }
12928 if case == "changed-birth" {
12929 child.created_at += chrono::Duration::milliseconds(1);
12930 }
12931 let _retry_registration = binding
12932 .router
12933 .register_run(&child.id, "retry-run")
12934 .await
12935 .unwrap();
12936 let result = PlainActorActivation::start_and_prepare(
12937 store.clone(),
12938 &mut child,
12939 &binding,
12940 Some("retry-run"),
12941 Some(policy.clone()),
12942 case != "readonly-refusal",
12943 None,
12944 )
12945 .await;
12946 if case != "accepted" {
12947 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12948 let actual = store.load_session(&before.id).await.unwrap().unwrap();
12949 assert_eq!(
12950 serde_json::to_value(&actual.messages).unwrap(),
12951 expected_messages,
12952 "{case}: failed preparation preserves Main history"
12953 );
12954 assert!(
12955 !first
12956 .input_inbox
12957 .was_admitted(&before.id, &envelope.id)
12958 .await
12959 .unwrap(),
12960 "{case}: no ACK"
12961 );
12962 if case == "readonly-refusal" {
12963 let entry = store.inspect_actor(&before.id).await.unwrap();
12964 assert_eq!(entry.actor.current_attempt, 1);
12965 assert_eq!(
12966 entry.activation.unwrap().activation_id,
12967 first.fence.activation_id
12968 );
12969 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12970 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12971 }
12972 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
12973 assert_eq!(
12974 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
12975 1
12976 );
12977 assert_eq!(
12978 store
12979 .inspect_actor(&before.id)
12980 .await
12981 .unwrap()
12982 .activation
12983 .unwrap()
12984 .status,
12985 bamboo_domain::ActorActivationStatus::Failed
12986 );
12987 }
12988 if case == "oversize" {
12989 let reopened = Arc::new(
12990 bamboo_storage::SessionStoreV2::new(temp.path().into())
12991 .await
12992 .unwrap(),
12993 );
12994 let cold_inbox = bamboo_storage::FileSessionInbox::new(
12995 reopened,
12996 bamboo_domain::SessionInboxLimits::default(),
12997 );
12998 let now = chrono::Utc::now();
12999 let leases = cold_inbox
13000 .inspect_owned_leases(&before.id, 2, now)
13001 .await
13002 .unwrap();
13003 assert_eq!(leases.len(), 1);
13004 assert_eq!(leases[0].failure_count, 1);
13005 assert_eq!(
13006 leases[0].last_error_code.as_deref(),
13007 Some("unsupported_actor_input")
13008 );
13009 let retry_after = leases[0].retry_after.unwrap();
13010 assert!(retry_after > now);
13011 let readiness = cold_inbox
13012 .inspect_wake_readiness(&before.id, now)
13013 .await
13014 .unwrap();
13015 assert!(
13016 readiness.ready.is_none(),
13017 "failed correction must not hot-loop"
13018 );
13019 assert_eq!(readiness.next_due_at, Some(retry_after));
13020 }
13021 continue;
13022 }
13023 let (retry, prepared) = result.unwrap();
13024 let (prefix, mut deliveries) = prepared.unwrap();
13025 assert_eq!(deliveries.len(), 1);
13026 let delivery = deliveries.pop().unwrap();
13027 assert_eq!(
13028 prefix,
13029 serde_json::to_value(&before.messages)
13030 .unwrap()
13031 .as_array()
13032 .unwrap()
13033 .clone()
13034 );
13035 assert_eq!(delivery.envelope, envelope);
13036 assert_eq!(delivery.activation_run_id, "retry-run");
13037 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13038 assert!(store
13039 .validate_fence(&first.fence, chrono::Utc::now())
13040 .await
13041 .is_err());
13042 let running = store.inspect_actor(&child.id).await.unwrap();
13043 assert_eq!(running.actor.current_attempt, 2);
13044 assert_eq!(
13045 running.activation.as_ref().unwrap().status,
13046 bamboo_domain::ActorActivationStatus::Running
13047 );
13048 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13049 let claim = retry.initial_inputs.first().unwrap();
13050 assert!(child
13051 .session_inbox_admission()
13052 .unwrap()
13053 .contains(&envelope.id));
13054 assert!(
13055 !retry
13056 .input_inbox
13057 .was_admitted(&child.id, &envelope.id)
13058 .await
13059 .unwrap(),
13060 "checkpoint is not startup confirmation"
13061 );
13062 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13063 confirmation.activation_run_id = "first-run".into();
13064 assert!(retry
13065 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13066 .await
13067 .is_err());
13068 assert!(!retry
13069 .input_inbox
13070 .was_admitted(&child.id, &envelope.id)
13071 .await
13072 .unwrap());
13073 confirmation.activation_run_id = "retry-run".into();
13074 retry
13075 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13076 .await
13077 .unwrap();
13078 let (tx, mut rx) = mpsc::channel(1);
13079 retry
13080 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13081 .await
13082 .unwrap();
13083 rx.recv().await.unwrap();
13084 retry
13085 .finish(ActorActivationFinish::Succeeded)
13086 .await
13087 .unwrap();
13088 let reopened = Arc::new(
13089 bamboo_storage::SessionStoreV2::new(temp.path().into())
13090 .await
13091 .unwrap(),
13092 );
13093 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13094 assert_eq!(cold.created_at, before.created_at);
13095 assert_eq!(cold.messages.len(), before.messages.len() + 2);
13096 assert_eq!(
13097 cold.messages[before.messages.len()].id,
13098 envelope.id.as_str()
13099 );
13100 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13101 assert_eq!(
13102 cold.messages
13103 .iter()
13104 .filter(|m| m.id == envelope.id.as_str())
13105 .count(),
13106 1
13107 );
13108 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13109 reopened,
13110 bamboo_domain::SessionInboxLimits::default(),
13111 );
13112 assert!(cold_inbox
13113 .was_admitted(&cold.id, &envelope.id)
13114 .await
13115 .unwrap());
13116 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13117 }
13118 }
13119
13120 #[tokio::test]
13121 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13122 for case in [
13123 "accepted",
13124 "actor-live",
13125 "claim-live",
13126 "legacy",
13127 "ack",
13128 "readonly",
13129 "prefix",
13130 "duplicate",
13131 "missing-marker",
13132 "mixed",
13133 "project",
13134 "birth",
13135 ] {
13136 let temp = tempfile::tempdir().unwrap();
13137 let store = Arc::new(
13138 bamboo_storage::SessionStoreV2::new(temp.path().into())
13139 .await
13140 .unwrap(),
13141 );
13142 let parent = Session::new("recovery-parent", "model");
13143 store.save_session(&parent).await.unwrap();
13144 let mut child =
13145 Session::new_child_of("recovery-child", &parent, "model", "original task");
13146 child.add_message(bamboo_agent_core::Message::user("original task"));
13147 store.save_session(&child).await.unwrap();
13148 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13149 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13150 let inbox = bamboo_storage::FileSessionInbox::new(
13151 store.clone(),
13152 bamboo_domain::SessionInboxLimits::default(),
13153 );
13154 let envelope =
13156 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13157 let receipt = inbox.deliver(&envelope).await.unwrap();
13158 inbox
13159 .mark_activation_eligible(
13160 &child.id,
13161 receipt.generation,
13162 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13163 )
13164 .await
13165 .unwrap();
13166 let now = chrono::Utc::now();
13167 let cutoff = now + chrono::Duration::seconds(10);
13168 let consumer = SessionInboxConsumerId::new();
13169 let old = store
13170 .claim_activation(&ActorActivationClaim {
13171 actor_id: child.id.clone(),
13172 run_id: "old-run".into(),
13173 lease_owner: consumer.as_str().into(),
13174 lease_expires_at: if case == "actor-live" {
13175 now + chrono::Duration::minutes(5)
13176 } else {
13177 cutoff
13178 },
13179 inbox_generation: 0,
13180 placement_ref: if case == "legacy" {
13181 None
13182 } else {
13183 Some(bamboo_domain::ActorPlacementRef {
13184 class: bamboo_domain::ActorPlacementClass::Local,
13185 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13186 slot_epoch: None,
13187 })
13188 },
13189 now,
13190 })
13191 .await
13192 .unwrap();
13193 store
13194 .start_activation(&old.fence(), chrono::Utc::now())
13195 .await
13196 .unwrap();
13197 let claim_now = chrono::Utc::now();
13198 let claim = inbox
13199 .claim_owned(
13200 &child.id,
13201 1,
13202 Some("old-run"),
13203 &SessionInboxLeaseRequest {
13204 consumer,
13205 now: claim_now,
13206 duration: if case == "claim-live" {
13207 chrono::Duration::minutes(5)
13208 } else {
13209 cutoff - claim_now
13210 },
13211 },
13212 )
13213 .await
13214 .unwrap()
13215 .pop()
13216 .unwrap();
13217 child = inbox
13218 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13219 fence: old.fence(),
13220 expected_created_at: child.created_at,
13221 claim: claim.clone(),
13222 expected_messages: child.messages.clone(),
13223 expected_provider_transcript: child.provider_transcript.clone(),
13224 expected_admission: None,
13225 })
13226 .await
13227 .unwrap()
13228 .session;
13229 store
13231 .validate_fence(&old.fence(), chrono::Utc::now())
13232 .await
13233 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13234 let setup_leases = inbox
13235 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13236 .await
13237 .unwrap();
13238 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13239 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13240 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13241 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13242 let setup_at = chrono::Utc::now();
13243 assert!(
13244 setup_at < old.lease_expires_at,
13245 "{case}: setup Actor expired"
13246 );
13247 assert!(
13248 setup_at < claim.lease.expires_at,
13249 "{case}: setup claim expired"
13250 );
13251 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13252 if case == "ack" {
13253 inbox
13254 .ack_owned(&child.id, &claim, chrono::Utc::now())
13255 .await
13256 .unwrap();
13257 }
13258 if case == "mixed" {
13259 inbox
13260 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13261 &child.id,
13262 "different input",
13263 ))
13264 .await
13265 .unwrap();
13266 }
13267 let directory = store
13268 .bamboo_home_dir()
13269 .join(store.resolve_rel_path(&child.id).await.unwrap());
13270 let main = std::fs::read(directory.join("session.json")).unwrap();
13271 let canonical = child.clone();
13272 match case {
13273 "prefix" => child.messages[0].content.push_str(" stale"),
13274 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13275 "missing-marker" => {
13276 child
13277 .messages
13278 .last_mut()
13279 .unwrap()
13280 .metadata
13281 .as_mut()
13282 .unwrap()
13283 .as_object_mut()
13284 .unwrap()
13285 .remove("_bamboo_owned_input_checkpoint");
13286 }
13287 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13288 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13289 _ => {}
13290 }
13291 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13294 tokio::time::sleep(delay + Duration::from_millis(10)).await;
13295 let recovery_at = chrono::Utc::now();
13296 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13297 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13298 let original = before_recovery.activation.unwrap();
13299 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13300 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13301 assert_eq!(
13302 store
13303 .validate_fence(&old.fence(), recovery_at)
13304 .await
13305 .is_ok(),
13306 case == "actor-live",
13307 "{case}: Actor expiry control"
13308 );
13309 let recovery_leases = inbox
13310 .inspect_owned_leases(&child.id, 2, recovery_at)
13311 .await
13312 .unwrap();
13313 if case == "ack" {
13314 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13315 } else {
13316 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13317 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13318 assert_eq!(
13319 recovery_leases[0].expires_at, claim.lease.expires_at,
13320 "{case}"
13321 );
13322 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13323 }
13324 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13325 let second = Arc::new(
13326 bamboo_storage::SessionStoreV2::new(temp.path().into())
13327 .await
13328 .unwrap(),
13329 );
13330 let binding = actor_binding(
13331 second.clone(),
13332 Arc::new(bamboo_storage::FileSessionInbox::new(
13333 second.clone(),
13334 bamboo_domain::SessionInboxLimits::default(),
13335 )),
13336 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13337 );
13338 let _owner = binding
13339 .router
13340 .register_run(&child.id, "replacement-run")
13341 .await
13342 .unwrap();
13343 let prepared = PlainActorActivation::start_and_prepare(
13344 second.clone(),
13345 &mut child,
13346 &binding,
13347 Some("replacement-run"),
13348 Some(policy.clone()),
13349 case != "readonly",
13350 Some("probed-new-worker"),
13351 )
13352 .await;
13353 assert_eq!(
13354 std::fs::read(directory.join("session.json")).unwrap(),
13355 main,
13356 "{case}: Already or refusal never rewrites Main"
13357 );
13358 if case != "accepted" {
13359 assert!(prepared.is_err(), "{case}");
13360 assert_eq!(
13361 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13362 case == "ack"
13363 );
13364 continue;
13365 }
13366 let (replacement, delivery) = prepared.unwrap();
13367 assert!(replacement.recovering_pre_ack);
13368 let (messages, mut deliveries) = delivery.unwrap();
13369 assert_eq!(deliveries.len(), 1);
13370 let delivery = deliveries.pop().unwrap();
13371 let new_claim = replacement.initial_inputs.first().unwrap();
13372 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13373 assert_eq!(new_claim.claim.generation, claim.claim.generation);
13374 assert_eq!(
13375 new_claim.claim.activation_policy,
13376 claim.claim.activation_policy
13377 );
13378 assert!(new_claim.lease.epoch > claim.lease.epoch);
13379 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13380 assert_eq!(
13381 replacement.inbox_generation, 1,
13382 "old Actor generation was zero"
13383 );
13384 assert_eq!(
13385 second
13386 .inspect_actor(&child.id)
13387 .await
13388 .unwrap()
13389 .actor
13390 .current_attempt,
13391 2
13392 );
13393 assert!(second
13394 .validate_fence(&old.fence(), chrono::Utc::now())
13395 .await
13396 .is_err());
13397 assert!(inbox
13398 .ack_owned(&child.id, &claim, chrono::Utc::now())
13399 .await
13400 .is_err());
13401 let authority = second.inspect_actor(&child.id).await.unwrap();
13405 let leases = inbox
13406 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13407 .await
13408 .unwrap();
13409 let delayed = inbox
13410 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13411 fence: old.fence(),
13412 expected_created_at: child.created_at,
13413 claim: claim.clone(),
13414 expected_messages: child.messages.clone(),
13415 expected_provider_transcript: child.provider_transcript.clone(),
13416 expected_admission: child.session_inbox_admission().cloned(),
13417 })
13418 .await;
13419 assert!(matches!(
13420 delayed,
13421 Err(bamboo_storage::ActorInputCheckpointError::Actor(
13422 bamboo_domain::ActorDirectoryError::StaleFence
13423 ))
13424 ));
13425 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13426 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13427 assert_eq!(
13428 inbox
13429 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13430 .await
13431 .unwrap(),
13432 leases
13433 );
13434 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13435 assert_eq!(
13436 serde_json::to_value(&child.messages).unwrap(),
13437 serde_json::to_value(&canonical.messages).unwrap()
13438 );
13439 let mut worker: Vec<bamboo_agent_core::Message> = messages
13440 .iter()
13441 .cloned()
13442 .map(|v| serde_json::from_value(v).unwrap())
13443 .collect();
13444 let target = worker.last_mut().unwrap();
13445 assert!(target
13446 .metadata
13447 .as_ref()
13448 .unwrap()
13449 .get("_bamboo_owned_input_checkpoint")
13450 .is_none());
13451 assert!(bamboo_domain::is_matching_session_message(
13452 target, &envelope
13453 ));
13454 let mut wanted = canonical.messages.last().unwrap().clone();
13455 wanted
13456 .metadata
13457 .as_mut()
13458 .unwrap()
13459 .as_object_mut()
13460 .unwrap()
13461 .remove("_bamboo_owned_input_checkpoint");
13462 assert_eq!(
13463 serde_json::to_value(target).unwrap(),
13464 serde_json::to_value(wanted).unwrap()
13465 );
13466 let run = RunSpec {
13467 assignment: "original task".into(),
13468 logical_session: Some(LogicalSessionIdentity {
13469 session_id: child.id.clone(),
13470 parent_session_id: child.parent_session_id.clone(),
13471 root_session_id: child.root_session_id.clone(),
13472 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13473 created_at: child.created_at,
13474 spawn_depth: child.spawn_depth,
13475 }),
13476 }),
13477 project_id: None,
13478 reasoning_effort: None,
13479 permission_policy: Some(PermissionPolicyContext {
13480 revision: policy.policy_revision(),
13481 requested_mode: "default".into(),
13482 effective_mode: "default".into(),
13483 bypass_permissions: false,
13484 auto_approve_permissions: false,
13485 session_id: child.id.clone(),
13486 workspace_path: child.workspace.clone(),
13487 environment_lease: None,
13488 inherit_session_grants: false,
13489 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13490 }),
13491 messages,
13492 activation_run_id: Some("replacement-run".into()),
13493 execution_epoch: 2,
13494 initial_session_messages: vec![delivery.clone()],
13495 secrets: Default::default(),
13496 };
13497 let policy_revision = policy.policy_revision();
13498 let mut expected = Some(expected_default_permission_posture(policy_revision));
13499 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13500 else {
13501 unreachable!()
13502 };
13503 let (events, mut rx) = mpsc::channel(1);
13504 process_actor_event(
13505 event,
13506 true,
13507 &mut PermissionPostureHandshake::Awaiting,
13508 expected.as_ref(),
13509 Some(&binding),
13510 &mut child,
13511 &events,
13512 &mut ActorEventDisplay::default(),
13513 )
13514 .await
13515 .unwrap();
13516 rx.recv().await.unwrap();
13517 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13518 &run,
13519 &delivery,
13520 uuid::Uuid::new_v4().to_string(),
13521 )
13522 .unwrap();
13523 replacement
13524 .release_initial_input(
13525 &binding,
13526 &child,
13527 &run,
13528 2,
13529 new_claim,
13530 &request,
13531 &mut expected,
13532 None,
13533 )
13534 .await
13535 .unwrap();
13536 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13537 replacement
13538 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13539 .await
13540 .unwrap();
13541 rx.recv().await.unwrap();
13542 replacement
13543 .finish(ActorActivationFinish::Succeeded)
13544 .await
13545 .unwrap();
13546 let cold = second.load_session(&child.id).await.unwrap().unwrap();
13547 assert_eq!(cold.created_at, canonical.created_at);
13548 assert_eq!(
13549 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13550 serde_json::to_value(&canonical.messages).unwrap()
13551 );
13552 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13553 assert_eq!(
13554 cold.messages
13555 .iter()
13556 .filter(|m| m.id == envelope.id.as_str())
13557 .count(),
13558 1
13559 );
13560 }
13561 }
13562
13563 #[cfg(unix)]
13564 #[tokio::test]
13565 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13566 use bamboo_subagent::provision::*;
13567 use std::os::unix::fs::PermissionsExt;
13568 let temp = tempfile::tempdir().unwrap();
13569 let worker = temp.path().join("mixed-worker");
13570 let spawned = temp.path().join("spawned");
13571 let run = temp.path().join("run");
13572 let probes = temp.path().join("probes");
13573 let fabric = temp.path().join("fabric");
13574 let mut report = WorkerCapabilityReport::current();
13575 report
13576 .capabilities
13577 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13578 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13579 let report = serde_json::to_string(&report).unwrap();
13580 std::fs::write(&worker, format!(
13581 "#!/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"
13582 )).unwrap();
13583 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13584 let args = vec![
13585 spawned.to_str().unwrap().into(),
13586 run.to_str().unwrap().into(),
13587 probes.to_str().unwrap().into(),
13588 ];
13589 for capability in [
13591 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13592 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13593 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13594 TYPED_READ_ONLY_WORKER_CAPABILITY,
13595 ] {
13596 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13597 .await
13598 .unwrap();
13599 }
13600 let runner = ActorChildRunner::new(
13601 "mixed".into(),
13602 worker,
13603 args,
13604 fabric.clone(),
13605 ExecutorSpec::BambooRuntime,
13606 vec![],
13607 "openai".into(),
13608 1,
13609 );
13610 let error = runner
13612 .require_initial_input_release_worker()
13613 .await
13614 .unwrap_err();
13615 assert!(error
13616 .to_string()
13617 .contains("owned initial release worker capability unconfirmed"));
13618 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13619 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13620 assert!(runner.pool.lock().await.is_empty());
13621 }
13622
13623 #[tokio::test]
13624 async fn ordinary_glob_only_observes_own_initialization_signals() {
13625 let temp = tempfile::tempdir().unwrap();
13626 let home = temp.path().canonicalize().unwrap();
13627 let store = bamboo_storage::SessionStoreV2::new(home.clone())
13628 .await
13629 .unwrap();
13630 let parent = Session::new("glob-standard-parent", "model");
13631 store.save_session(&parent).await.unwrap();
13632 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13633 store.save_session(&child).await.unwrap();
13634 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13635 let unrelated = directory.parent().unwrap().join("unrelated");
13638 std::fs::create_dir(&unrelated).unwrap();
13639 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13640 require_unowned_glob(&store, &child).await.unwrap();
13641 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13642 let path = directory.join(name);
13643 std::fs::write(&path, b"invalid prior signal").unwrap();
13644 assert!(require_unowned_glob(&store, &child).await.is_err());
13645 std::fs::remove_file(&path).unwrap();
13646 }
13647 #[cfg(unix)]
13648 {
13649 let marker = directory.join("actor-authority.initialized.json");
13650 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13651 assert!(require_unowned_glob(&store, &child).await.is_err());
13652 }
13653 }
13654
13655 #[test]
13656 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13657 let call = bamboo_domain::ToolCall {
13658 id: "glob".into(),
13659 tool_type: "function".into(),
13660 function: bamboo_domain::FunctionCall {
13661 name: "Glob".into(),
13662 arguments: r#"{"pattern":"marker.txt"}"#.into(),
13663 },
13664 };
13665 let messages = vec![
13666 bamboo_agent_core::Message::assistant("", Some(vec![call])),
13667 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13668 bamboo_agent_core::Message::assistant("done", None),
13669 ];
13670 let start = serde_json::to_value(AgentEvent::ToolStart {
13671 tool_call_id: "glob".into(),
13672 tool_name: "Glob".into(),
13673 arguments: serde_json::json!({"pattern":"marker.txt"}),
13674 })
13675 .unwrap();
13676 let complete = serde_json::to_value(AgentEvent::ToolComplete {
13677 tool_call_id: "glob".into(),
13678 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13679 })
13680 .unwrap();
13681 let tail =
13682 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13683 messages: messages
13684 .iter()
13685 .map(serde_json::to_value)
13686 .collect::<Result<_, _>>()
13687 .unwrap(),
13688 })
13689 .unwrap();
13690 let mut ordered = ReadOnlyActorCollector::default();
13691 assert!(ordered.event(&start).unwrap());
13692 assert!(ordered.event(&complete).unwrap());
13693 assert!(!ordered.event(&tail).unwrap());
13694 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13695 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13696 assert!(ReadOnlyActorCollector::default()
13697 .finish(Some("done"))
13698 .is_err());
13699 let mut unfinished = ReadOnlyActorCollector::default();
13700 unfinished.event(&start).unwrap();
13701 unfinished.event(&complete).unwrap();
13702 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13703 let mut closed = tail.clone();
13704 closed["grant"] = true.into();
13705 assert!(
13706 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13707 .is_err()
13708 );
13709 let mut oversized = tail.clone();
13710 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13711 assert!(
13712 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13713 .unwrap()
13714 .validate()
13715 .is_err()
13716 );
13717 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13718 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13719 let mut bad = start.clone();
13720 bad["tool_name"] = "Bash".into();
13721 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13722 let mut duplicate = ReadOnlyActorCollector::default();
13723 duplicate.event(&start).unwrap();
13724 assert!(duplicate.event(&start).is_err());
13725 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13726 content: "hidden".into(),
13727 })
13728 .unwrap();
13729 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13730 let mut mismatched = ReadOnlyActorCollector::default();
13731 mismatched.event(&start).unwrap();
13732 let mut bad = complete.clone();
13733 bad["tool_call_id"] = "foreign".into();
13734 assert!(mismatched.event(&bad).is_err());
13735 mismatched.event(&complete).unwrap();
13736 let mut changed = tail.clone();
13737 changed["messages"][1]["content"] = "fabricated".into();
13738 assert!(mismatched.event(&changed).is_err());
13739 }
13740
13741 #[cfg(unix)]
13742 #[tokio::test]
13743 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13744 let temp = tempfile::tempdir().unwrap();
13745 let store = Arc::new(
13746 bamboo_storage::SessionStoreV2::new(temp.path().into())
13747 .await
13748 .unwrap(),
13749 );
13750 let parent = Session::new("release-host-parent", "model");
13751 store.save_session(&parent).await.unwrap();
13752 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13753 store.save_session(&child).await.unwrap();
13754 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13755 store.clone(),
13756 bamboo_domain::SessionInboxLimits::default(),
13757 ));
13758 let binding = actor_binding(
13759 store.clone(),
13760 inbox,
13761 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13762 );
13763 let _owner = binding
13764 .router
13765 .register_run(&child.id, "release-host-run")
13766 .await
13767 .unwrap();
13768 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13769 let revision = policy.policy_revision();
13770 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13771 let activation = PlainActorActivation::start(
13772 store.clone(),
13773 &child,
13774 &binding,
13775 Some("release-host-run"),
13776 Some(policy.clone()),
13777 true,
13778 Some("probed-physical-worker"),
13779 )
13780 .await
13781 .unwrap();
13782 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13783 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13784 activation
13785 .input_inbox
13786 .mark_activation_eligible(
13787 &child.id,
13788 receipt.generation,
13789 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13790 )
13791 .await
13792 .unwrap();
13793 let claim = activation
13794 .claim_input(&binding, &child, "release-host-run")
13795 .await
13796 .unwrap()
13797 .unwrap();
13798 let (prefix, delivery) = activation
13799 .checkpoint_input(&mut child, "release-host-run", &claim)
13800 .await
13801 .unwrap();
13802 let mut expected = Some(expected_default_permission_posture(revision));
13803 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13804 unreachable!()
13805 };
13806 let (events, mut events_rx) = mpsc::channel(1);
13807 process_actor_event(
13808 event,
13809 true,
13810 &mut PermissionPostureHandshake::Awaiting,
13811 expected.as_ref(),
13812 Some(&binding),
13813 &mut child,
13814 &events,
13815 &mut ActorEventDisplay::default(),
13816 )
13817 .await
13818 .unwrap();
13819 events_rx.recv().await.unwrap();
13820 let run = RunSpec {
13821 assignment: "assignment".into(),
13822 logical_session: Some(LogicalSessionIdentity {
13823 session_id: child.id.clone(),
13824 parent_session_id: child.parent_session_id.clone(),
13825 root_session_id: child.root_session_id.clone(),
13826 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13827 created_at: child.created_at,
13828 spawn_depth: child.spawn_depth,
13829 }),
13830 }),
13831 project_id: None,
13832 reasoning_effort: None,
13833 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13834 revision,
13835 requested_mode: "default".into(),
13836 effective_mode: "default".into(),
13837 bypass_permissions: false,
13838 auto_approve_permissions: false,
13839 session_id: child.id.clone(),
13840 workspace_path: child.workspace.clone(),
13841 environment_lease: None,
13842 inherit_session_grants: false,
13843 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13844 }),
13845 messages: prefix,
13846 activation_run_id: Some("release-host-run".into()),
13847 execution_epoch: 9,
13848 initial_session_messages: vec![delivery.clone()],
13849 secrets: Default::default(),
13850 };
13851 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13852 &run,
13853 &delivery,
13854 uuid::Uuid::new_v4().to_string(),
13855 )
13856 .unwrap();
13857 let mut stale = child.clone();
13858 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13859 assert!(activation
13860 .release_initial_input(
13861 &binding,
13862 &stale,
13863 &run,
13864 9,
13865 &claim,
13866 &request,
13867 &mut expected,
13868 None
13869 )
13870 .await
13871 .is_err());
13872 assert!(!activation
13873 .input_inbox
13874 .was_admitted(&child.id, &envelope.id)
13875 .await
13876 .unwrap());
13877 use std::os::unix::fs::PermissionsExt;
13880 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13881 impl Drop for RestorePermissions {
13882 fn drop(&mut self) {
13883 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13884 }
13885 }
13886 let admitted = store
13887 .bamboo_home_dir()
13888 .join(store.resolve_rel_path(&child.id).await.unwrap())
13889 .join("inbox/admitted");
13890 std::fs::create_dir_all(&admitted).unwrap();
13891 let restore = RestorePermissions(
13892 admitted.clone(),
13893 std::fs::metadata(&admitted).unwrap().permissions(),
13894 );
13895 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13896 let verified = activation
13897 .input_inbox
13898 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13899 fence: activation.fence.clone(),
13900 expected_created_at: child.created_at,
13901 claim: claim.clone(),
13902 expected_messages: child.messages.clone(),
13903 expected_provider_transcript: child.provider_transcript.clone(),
13904 expected_admission: child.session_inbox_admission().cloned(),
13905 })
13906 .await
13907 .unwrap();
13908 assert_eq!(
13909 verified.status,
13910 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13911 );
13912 let error = activation
13913 .release_initial_input(
13914 &binding,
13915 &child,
13916 &run,
13917 9,
13918 &claim,
13919 &request,
13920 &mut expected,
13921 None,
13922 )
13923 .await
13924 .unwrap_err();
13925 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13926 drop(restore);
13927 assert!(
13928 error.to_string().contains("ACK unresolved"),
13929 "actual release error (<=512B): {}",
13930 error.to_string().chars().take(128).collect::<String>()
13931 );
13932 let release = activation
13933 .release_initial_input(
13934 &binding,
13935 &child,
13936 &run,
13937 9,
13938 &claim,
13939 &request,
13940 &mut expected,
13941 None,
13942 )
13943 .await
13944 .unwrap();
13945 assert!(activation
13946 .input_inbox
13947 .was_admitted(&child.id, &envelope.id)
13948 .await
13949 .unwrap());
13950 assert_eq!(
13951 release.expires_at,
13952 activation
13953 .actor_deadline
13954 .min(claim.lease.expires_at)
13955 .min(activation.input_deadline)
13956 );
13957 assert_eq!(
13958 activation
13959 .release_initial_input(
13960 &binding,
13961 &child,
13962 &run,
13963 9,
13964 &claim,
13965 &request,
13966 &mut expected,
13967 Some(&release)
13968 )
13969 .await
13970 .unwrap(),
13971 release
13972 );
13973 let mut wrong = request.clone();
13974 wrong.nonce = uuid::Uuid::new_v4().to_string();
13975 assert!(activation
13976 .release_initial_input(
13977 &binding,
13978 &child,
13979 &run,
13980 9,
13981 &claim,
13982 &wrong,
13983 &mut expected,
13984 Some(&release)
13985 )
13986 .await
13987 .is_err());
13988 assert!(activation
13989 .release_initial_input(
13990 &binding,
13991 &child,
13992 &run,
13993 10,
13994 &claim,
13995 &request,
13996 &mut expected,
13997 Some(&release)
13998 )
13999 .await
14000 .is_err());
14001 let deadlines = (
14003 activation.fence.clone(),
14004 activation.actor_deadline,
14005 activation.input_deadline,
14006 );
14007 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14008 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14009 activation
14010 .input_inbox
14011 .mark_activation_eligible(
14012 &child.id,
14013 receipt.generation,
14014 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14015 )
14016 .await
14017 .unwrap();
14018 let mut link = ConfirmationSequenceLink {
14019 frames: VecDeque::new(),
14020 sent: Vec::new(),
14021 };
14022 let epochs = AtomicU64::new(9);
14023 let (claim2, epoch2) = activation
14024 .continue_input(
14025 &mut link,
14026 &binding,
14027 &mut child,
14028 "release-host-run",
14029 Some((&run, &epochs)),
14030 &mut expected,
14031 "SECOND_REPLY".into(),
14032 &events,
14033 )
14034 .await
14035 .unwrap()
14036 .unwrap();
14037 events_rx.recv().await.unwrap();
14038 assert_eq!(epoch2, 10);
14039 let ParentFrame::Run(next) = &link.sent[0] else {
14040 panic!("second correlated Run")
14041 };
14042 assert_eq!(next.logical_session, run.logical_session);
14043 assert_eq!(next.activation_run_id, run.activation_run_id);
14044 assert_eq!(next.initial_session_messages.len(), 1);
14045 assert!(next
14046 .messages
14047 .iter()
14048 .any(|m| m["id"] == envelope.id.as_str()));
14049 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14050 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14051 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14052 next,
14053 &next.initial_session_messages[0],
14054 uuid::Uuid::new_v4().to_string(),
14055 )
14056 .unwrap();
14057 assert_ne!(request2.nonce, request.nonce);
14058 macro_rules! release_second {
14059 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14060 activation
14061 .release_initial_input(
14062 &binding,
14063 $candidate,
14064 next,
14065 $epoch,
14066 &claim2,
14067 $request,
14068 &mut expected,
14069 $previous,
14070 )
14071 .await
14072 };
14073 }
14074 assert!(release_second!(&child, &request, epoch2, None).is_err());
14075 assert!(release_second!(&child, &request2, 9, None).is_err());
14076 let mut stale2 = child.clone();
14077 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14078 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14079 assert!(!activation
14080 .input_inbox
14081 .was_admitted(&child.id, &second.id)
14082 .await
14083 .unwrap());
14084 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14085 assert_eq!(
14086 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14087 release2
14088 );
14089 let mut wrong2 = request2.clone();
14090 wrong2.nonce = request.nonce.clone();
14091 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14092 assert!(activation
14093 .input_inbox
14094 .was_admitted(&child.id, &second.id)
14095 .await
14096 .unwrap());
14097 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14098 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14099 activation
14100 .input_inbox
14101 .mark_activation_eligible(
14102 &child.id,
14103 receipt.generation,
14104 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14105 )
14106 .await
14107 .unwrap();
14108 let now = chrono::Utc::now();
14109 let expired_claim = activation
14110 .input_inbox
14111 .claim_owned(
14112 &child.id,
14113 1,
14114 Some("release-host-run"),
14115 &SessionInboxLeaseRequest {
14116 consumer: activation.input_consumer.clone(),
14117 now,
14118 duration: chrono::Duration::seconds(1),
14119 },
14120 )
14121 .await
14122 .unwrap()
14123 .pop()
14124 .unwrap();
14125 let (prefix, delivery3) = activation
14126 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14127 .await
14128 .unwrap();
14129 let mut expired_run = next.clone();
14130 expired_run.execution_epoch = 11;
14131 expired_run.messages = prefix;
14132 expired_run.initial_session_messages = vec![delivery3.clone()];
14133 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14134 &expired_run,
14135 &delivery3,
14136 uuid::Uuid::new_v4().to_string(),
14137 )
14138 .unwrap();
14139 tokio::time::sleep(
14140 (expired_claim.lease.expires_at - chrono::Utc::now())
14141 .to_std()
14142 .unwrap_or_default(),
14143 )
14144 .await;
14145 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14146 assert!(activation
14147 .release_initial_input(
14148 &binding,
14149 &child,
14150 &expired_run,
14151 11,
14152 &expired_claim,
14153 &expired_request,
14154 &mut expected,
14155 None
14156 )
14157 .await
14158 .is_err());
14159 assert!(!activation
14160 .input_inbox
14161 .was_admitted(&child.id, &expired.id)
14162 .await
14163 .unwrap());
14164 assert_eq!(
14165 (
14166 activation.fence.clone(),
14167 activation.actor_deadline,
14168 activation.input_deadline
14169 ),
14170 deadlines
14171 );
14172 assert_eq!(
14173 link.sent.len(),
14174 1,
14175 "no dispatch from rejected release checks"
14176 );
14177 let cold = store.load_session(&child.id).await.unwrap().unwrap();
14178 for id in [&envelope.id, &second.id] {
14179 assert_eq!(
14180 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14181 1
14182 );
14183 assert!(cold.session_inbox_admission().unwrap().contains(id));
14184 }
14185 policy.deny_scoped_session_permission(
14187 &child.id,
14188 bamboo_tools::permission::PermissionType::ExecuteCommand,
14189 "blocked-action",
14190 );
14191 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14192 assert!(activation
14193 .input_inbox
14194 .was_admitted(&child.id, &second.id)
14195 .await
14196 .unwrap());
14197 activation
14198 .finish(ActorActivationFinish::Failed)
14199 .await
14200 .unwrap();
14201 activation
14203 .input_inbox
14204 .ack_owned(&child.id, &claim, chrono::Utc::now())
14205 .await
14206 .unwrap();
14207 assert!(activation
14208 .release_initial_input(
14209 &binding,
14210 &child,
14211 &run,
14212 9,
14213 &claim,
14214 &request,
14215 &mut expected,
14216 Some(&release)
14217 )
14218 .await
14219 .is_err());
14220 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14221 .await
14222 .unwrap()
14223 .load_session(&child.id)
14224 .await
14225 .unwrap()
14226 .unwrap();
14227 assert_eq!(
14228 cold.messages
14229 .iter()
14230 .filter(|m| m.id == envelope.id.as_str())
14231 .count(),
14232 1
14233 );
14234 }
14235
14236 #[tokio::test]
14237 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14238 for reject_checkpoint in [false, true] {
14239 let temp = tempfile::tempdir().unwrap();
14240 let store = Arc::new(
14241 bamboo_storage::SessionStoreV2::new(temp.path().into())
14242 .await
14243 .unwrap(),
14244 );
14245 let parent = Session::new("owned-input-parent", "model");
14246 store.save_session(&parent).await.unwrap();
14247 let mut child =
14248 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14249 store.save_session(&child).await.unwrap();
14250 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14251 let raw_storage: Arc<dyn Storage> = store.clone();
14252 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14253 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14254 store.clone(),
14255 bamboo_domain::SessionInboxLimits::default(),
14256 ));
14257 let binding = actor_binding(store.clone(), inbox, locked);
14258 let run_id = "owned-input-run";
14259 let _registration = binding
14260 .router
14261 .register_run(&child.id, run_id)
14262 .await
14263 .unwrap();
14264 assert!(PlainActorActivation::start(
14265 store.clone(),
14266 &child,
14267 &binding,
14268 Some(run_id),
14269 None,
14270 true,
14271 None,
14272 )
14273 .await
14274 .is_err());
14275 assert!(PlainActorActivation::start(
14276 store.clone(),
14277 &child,
14278 &binding,
14279 Some(run_id),
14280 Some(policy.clone()),
14281 true,
14282 None,
14283 )
14284 .await
14285 .is_err());
14286 assert_eq!(
14287 store
14288 .inspect_actor(&child.id)
14289 .await
14290 .unwrap()
14291 .actor
14292 .current_attempt,
14293 0
14294 );
14295 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14296 let activation = PlainActorActivation::start(
14297 store.clone(),
14298 &child,
14299 &binding,
14300 Some(run_id),
14301 Some(policy.clone()),
14302 true,
14303 None,
14304 )
14305 .await
14306 .unwrap();
14307 let envelope =
14308 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14309 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14310 activation
14311 .input_inbox
14312 .mark_activation_eligible(
14313 &child.id,
14314 receipt.generation,
14315 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14316 )
14317 .await
14318 .unwrap();
14319 let mut link = ConfirmationSequenceLink {
14320 frames: VecDeque::new(),
14321 sent: Vec::new(),
14322 };
14323 let policy_revision = policy.policy_revision();
14324 let run = RunSpec {
14325 assignment: "one plain task".into(),
14326 logical_session: None,
14327 project_id: None,
14328 reasoning_effort: None,
14329 permission_policy: Some(PermissionPolicyContext {
14330 revision: policy_revision,
14331 requested_mode: "default".into(),
14332 effective_mode: "default".into(),
14333 bypass_permissions: false,
14334 auto_approve_permissions: false,
14335 session_id: child.id.clone(),
14336 workspace_path: child.workspace.clone(),
14337 environment_lease: None,
14338 inherit_session_grants: false,
14339 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14340 }),
14341 messages: vec![],
14342 activation_run_id: Some(run_id.into()),
14343 execution_epoch: 1,
14344 initial_session_messages: vec![],
14345 secrets: Default::default(),
14346 };
14347 let epochs = AtomicU64::new(1);
14348 let (tx, mut rx) = mpsc::channel(1);
14349 let mut expected = Some(expected_default_permission_posture(policy_revision));
14350 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14351 else {
14352 unreachable!()
14353 };
14354 process_actor_event(
14355 event,
14356 true,
14357 &mut PermissionPostureHandshake::Awaiting,
14358 expected.as_ref(),
14359 Some(&binding),
14360 &mut child,
14361 &tx,
14362 &mut ActorEventDisplay::default(),
14363 )
14364 .await
14365 .unwrap();
14366 rx.recv().await.unwrap();
14367 let first_audit =
14368 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14369 .unwrap()
14370 .audit_revision;
14371 if reject_checkpoint {
14372 tx.send(AgentEvent::Complete {
14375 usage: Default::default(),
14376 })
14377 .await
14378 .unwrap();
14379 }
14380 let mut operation = activation.continue_input(
14381 &mut link,
14382 &binding,
14383 &mut child,
14384 run_id,
14385 Some((&run, &epochs)),
14386 &mut expected,
14387 "FIRST_PLAIN_REPLY".into(),
14388 &tx,
14389 );
14390 if reject_checkpoint {
14391 tokio::select! {
14392 result = &mut operation => panic!("publication barrier released early: {result:?}"),
14393 _ = async {
14394 tokio::time::timeout(Duration::from_secs(5), async {
14395 loop {
14396 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14397 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14398 tokio::task::yield_now().await;
14399 }
14400 }).await.unwrap();
14401 activation.finish(ActorActivationFinish::Failed).await.unwrap();
14402 rx.recv().await.unwrap();
14403 } => {}
14404 }
14405 }
14406 let result = operation.await;
14407 if reject_checkpoint {
14408 assert!(result
14409 .unwrap_err()
14410 .to_string()
14411 .contains("checkpoint unconfirmed"));
14412 assert!(
14413 link.sent.is_empty(),
14414 "zero worker/provider correction dispatch"
14415 );
14416 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14417 assert!(durable
14418 .messages
14419 .iter()
14420 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14421 assert!(!durable
14422 .messages
14423 .iter()
14424 .any(|m| m.id == envelope.id.as_str()));
14425 assert!(!activation
14426 .input_inbox
14427 .was_admitted(&child.id, &envelope.id)
14428 .await
14429 .unwrap());
14430 assert_eq!(
14431 activation
14432 .input_inbox
14433 .inspect(&child.id)
14434 .await
14435 .unwrap()
14436 .claimed,
14437 1
14438 );
14439 continue;
14440 }
14441 let (claim, epoch) = result.unwrap().unwrap();
14442 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
14444 assert_eq!(link.sent.len(), 1);
14445 let ParentFrame::Run(next) = &link.sent[0] else {
14446 panic!("correlated continuation Run")
14447 };
14448 assert_eq!(next.execution_epoch, 2);
14449 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14450 assert!(next
14451 .messages
14452 .iter()
14453 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14454 assert!(!next
14455 .messages
14456 .iter()
14457 .any(|m| m["id"] == envelope.id.as_str()));
14458 let delivery = &next.initial_session_messages[0];
14459 assert_eq!(delivery.envelope, envelope);
14460 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14461 assert!(durable
14462 .session_inbox_admission()
14463 .unwrap()
14464 .contains(&envelope.id));
14465 assert_eq!(
14466 durable
14467 .messages
14468 .iter()
14469 .filter(|m| m.id == envelope.id.as_str())
14470 .count(),
14471 1
14472 );
14473 assert!(
14474 !activation
14475 .input_inbox
14476 .was_admitted(&child.id, &envelope.id)
14477 .await
14478 .unwrap(),
14479 "checkpoint is not a worker admission receipt"
14480 );
14481 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14482 confirmation.canonical_claim_generation += 1;
14483 assert!(activation
14484 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14485 .await
14486 .is_err());
14487 assert!(!activation
14488 .input_inbox
14489 .was_admitted(&child.id, &envelope.id)
14490 .await
14491 .unwrap());
14492 confirmation.canonical_claim_generation = claim.claim.generation;
14493 activation
14494 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14495 .await
14496 .unwrap();
14497 assert!(activation
14498 .input_inbox
14499 .was_admitted(&child.id, &envelope.id)
14500 .await
14501 .unwrap());
14502 assert_eq!(
14503 expected.as_ref().unwrap().expected_audit_revision,
14504 Some(first_audit)
14505 );
14506 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14507 else {
14508 unreachable!()
14509 };
14510 process_actor_event(
14511 event,
14512 true,
14513 &mut PermissionPostureHandshake::Awaiting,
14514 expected.as_ref(),
14515 Some(&binding),
14516 &mut child,
14517 &tx,
14518 &mut ActorEventDisplay::default(),
14519 )
14520 .await
14521 .unwrap();
14522 rx.recv().await.unwrap();
14523 assert!(
14524 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14525 .unwrap()
14526 .audit_revision
14527 > first_audit
14528 );
14529 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14530 .await
14531 .unwrap();
14532 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14533 assert_eq!(
14534 cold.messages
14535 .iter()
14536 .filter(|m| m.id == envelope.id.as_str())
14537 .count(),
14538 1
14539 );
14540 assert!(cold
14541 .session_inbox_admission()
14542 .unwrap()
14543 .contains(&envelope.id));
14544 }
14545 }
14546
14547 fn admission_confirmation(
14548 session_id: &str,
14549 claim: &SessionInboxClaim,
14550 run_id: &str,
14551 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14552 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14553 target_session_id: session_id.to_string(),
14554 envelope_id: claim.envelope.id.to_string(),
14555 canonical_claim_generation: claim.generation,
14556 activation_run_id: run_id.to_string(),
14557 }
14558 }
14559
14560 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14561 ExpectedPermissionPosture {
14562 policy_revision,
14563 resolution: bamboo_domain::PermissionModeResolution {
14564 requested: bamboo_domain::SessionPermissionMode::Default,
14565 effective: bamboo_domain::PermissionMode::Default,
14566 },
14567 expected_audit_revision: None,
14568 executor_mapping: "test_actor:permission_mode=default".to_string(),
14569 }
14570 }
14571
14572 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14573 ChildFrame::Event {
14574 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14575 session_id: session_id.to_string(),
14576 policy_revision,
14577 requested_mode: "default".to_string(),
14578 effective_mode: "default".to_string(),
14579 executor_mapping: "test_actor:permission_mode=default".to_string(),
14580 })
14581 .expect("serialize permission posture event"),
14582 }
14583 }
14584
14585 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14586 ChildFrame::Event {
14587 event: serde_json::to_value(event).expect("serialize actor event"),
14588 }
14589 }
14590
14591 #[test]
14592 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14593 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14594 let mut batch = ActorEventBatch {
14595 logical_session: Some(LogicalSessionIdentity {
14596 creation: None,
14597 session_id: session.id.clone(),
14598 parent_session_id: session.parent_session_id.clone(),
14599 root_session_id: session.root_session_id.clone(),
14600 }),
14601 activation_id: Some("activation-7".into()),
14602 execution_epoch: 11,
14603 source_node_id: Some("node-a".into()),
14604 source_actor_id: Some("worker-a".into()),
14605 first_seq: 1,
14606 last_seq: 1,
14607 qos: bamboo_subagent::ActorEventQos::Ephemeral,
14608 events: vec![serde_json::json!({"type":"token","content":"ok"})],
14609 };
14610 validate_actor_event_batch(
14611 &batch,
14612 &session,
14613 "logical-parent",
14614 Some("activation-7"),
14615 11,
14616 "worker-a",
14617 None,
14618 )
14619 .unwrap();
14620
14621 let creation = bamboo_subagent::proto::ChildCreationIdentity {
14622 created_at: session.created_at,
14623 spawn_depth: session.spawn_depth,
14624 };
14625 assert!(validate_actor_event_batch(
14626 &batch,
14627 &session,
14628 "logical-parent",
14629 Some("activation-7"),
14630 11,
14631 "worker-a",
14632 Some(&creation)
14633 )
14634 .is_err());
14635 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14636 validate_actor_event_batch(
14637 &batch,
14638 &session,
14639 "logical-parent",
14640 Some("activation-7"),
14641 11,
14642 "worker-a",
14643 Some(&creation),
14644 )
14645 .unwrap();
14646 batch
14647 .logical_session
14648 .as_mut()
14649 .unwrap()
14650 .creation
14651 .as_mut()
14652 .unwrap()
14653 .created_at += chrono::Duration::nanoseconds(1);
14654 assert!(validate_actor_event_batch(
14655 &batch,
14656 &session,
14657 "logical-parent",
14658 Some("activation-7"),
14659 11,
14660 "worker-a",
14661 Some(&creation)
14662 )
14663 .is_err());
14664 batch.logical_session.as_mut().unwrap().creation = None;
14665
14666 batch.execution_epoch = 10;
14667 assert!(validate_actor_event_batch(
14668 &batch,
14669 &session,
14670 "logical-parent",
14671 Some("activation-7"),
14672 11,
14673 "worker-a",
14674 None,
14675 )
14676 .unwrap_err()
14677 .to_string()
14678 .contains("execution epoch"));
14679 batch.execution_epoch = 11;
14680 batch.activation_id = Some("replaced-activation".into());
14681 assert!(validate_actor_event_batch(
14682 &batch,
14683 &session,
14684 "logical-parent",
14685 Some("activation-7"),
14686 11,
14687 "worker-a",
14688 None,
14689 )
14690 .unwrap_err()
14691 .to_string()
14692 .contains("stale activation"));
14693 batch.activation_id = Some("activation-7".into());
14694 batch
14695 .logical_session
14696 .as_mut()
14697 .expect("logical identity")
14698 .session_id = "foreign-child".into();
14699 assert!(validate_actor_event_batch(
14700 &batch,
14701 &session,
14702 "logical-parent",
14703 Some("activation-7"),
14704 11,
14705 "worker-a",
14706 None,
14707 )
14708 .unwrap_err()
14709 .to_string()
14710 .contains("different logical session"));
14711 batch
14712 .logical_session
14713 .as_mut()
14714 .expect("logical identity")
14715 .session_id = session.id.clone();
14716 batch.source_actor_id = Some("worker-b".into());
14717 assert!(validate_actor_event_batch(
14718 &batch,
14719 &session,
14720 "logical-parent",
14721 Some("activation-7"),
14722 11,
14723 "worker-a",
14724 None,
14725 )
14726 .unwrap_err()
14727 .to_string()
14728 .contains("different physical actor"));
14729 }
14730
14731 #[tokio::test]
14732 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14733 use bamboo_domain::Storage;
14734
14735 let temp = tempfile::tempdir().unwrap();
14736 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14737 .await
14738 .unwrap();
14739 let parent = Session::new("event-parent", "model");
14740 let child = Session::new_child_of("event-child", &parent, "model", "task");
14741 store.save_session(&parent).await.unwrap();
14742 store.save_session(&child).await.unwrap();
14743 store.ensure_actor(&child.id).await.unwrap();
14744 let now = chrono::Utc::now();
14745 let claim = |run_id: &str| ActorActivationClaim {
14746 actor_id: child.id.clone(),
14747 run_id: run_id.into(),
14748 lease_owner: "event-owner".into(),
14749 lease_expires_at: now + chrono::Duration::minutes(2),
14750 inbox_generation: 0,
14751 placement_ref: None,
14752 now,
14753 };
14754 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14755 store.start_activation(&first.fence(), now).await.unwrap();
14756 let first_fence = first.fence();
14757 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14758 .await
14759 .unwrap();
14760 validate_directory_actor_event(&store, &first_fence, &child, None)
14761 .await
14762 .unwrap(); assert!(
14764 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14765 .await
14766 .is_err()
14767 );
14768 let mut forged = child.clone();
14769 forged.root_session_id = "foreign-root".into();
14770 assert!(
14771 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14772 .await
14773 .is_err()
14774 );
14775
14776 store
14777 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14778 .await
14779 .unwrap();
14780 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14781 store.start_activation(&second.fence(), now).await.unwrap();
14782 assert!(
14783 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14784 .await
14785 .is_err()
14786 );
14787 assert!(
14788 validate_directory_actor_event(&store, &first_fence, &child, None)
14789 .await
14790 .is_err()
14791 );
14792 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14793 .await
14794 .unwrap();
14795 }
14796
14797 fn completed_actor_frame() -> ChildFrame {
14798 ChildFrame::Terminal {
14799 status: TerminalStatus::Completed,
14800 result: Some("done".to_string()),
14801 error: None,
14802 final_event_watermark: None,
14803 transcript: Vec::new(),
14804 }
14805 }
14806
14807 async fn drive_permission_handshake_frames(
14808 session_id: &str,
14809 frames: impl IntoIterator<Item = ChildFrame>,
14810 expected: ExpectedPermissionPosture,
14811 ) -> (
14812 crate::runtime::runner::Result<Option<String>>,
14813 Session,
14814 Vec<AgentEvent>,
14815 Vec<ParentFrame>,
14816 ) {
14817 let mut link = ConfirmationSequenceLink {
14818 frames: frames.into_iter().collect(),
14819 sent: Vec::new(),
14820 };
14821 let (event_tx, mut event_rx) = mpsc::channel(128);
14822 let cancel = CancellationToken::new();
14823 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14824 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14825 let mut session = Session::new(session_id, "model");
14826 let result = drive(ActorDriveContext {
14827 expected_creation: None,
14828 client: &mut link,
14829 parent_session_id: "permission-parent",
14830 child_session_id: session_id,
14831 child_attempt: 0,
14832 approval_registry: None,
14833 approval_decider: None,
14834 approval_reviewer: None,
14835 escalation_bridge: None,
14836 event_tx: &event_tx,
14837 cancel_token: &cancel,
14838 live_rx: &mut live_rx,
14839 delivery_rx: &mut delivery_rx,
14840 logical_session: &mut session,
14841 expected_permission_posture: Some(expected),
14842 session_inbox_runtime: None,
14843 actor_directory_store: None,
14844 canonical_subagent_tool: None,
14845 ticket_service: None,
14846 activation_run_id: None,
14847 execution_epoch: 0,
14848 expected_source_actor_id: session_id,
14849 initial_inflight_claims: VecDeque::new(),
14850 plain_actor: false,
14851 remote_environment_lease: false,
14852 readonly_output: None,
14853 local_history_tools: None,
14854 local_history_read_only: false,
14855 strict_history_output: None,
14856 plain_input: None,
14857 canonical_activation: None,
14858 canonical_placement_ref: None,
14859 actor_event_observer: None,
14860 plain_run: None,
14861 first_frame_timeout: Some(Duration::from_secs(1)),
14862 })
14863 .await;
14864 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14865 (result, session, events, link.sent)
14866 }
14867
14868 #[tokio::test]
14869 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14870 let session_id = "permission-missing";
14871 let (result, session, events, _) = drive_permission_handshake_frames(
14872 session_id,
14873 [completed_actor_frame()],
14874 expected_default_permission_posture(7),
14875 )
14876 .await;
14877
14878 assert!(result
14879 .unwrap_err()
14880 .to_string()
14881 .contains("terminated before permission posture confirmation"));
14882 assert!(events.is_empty());
14883 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14884 }
14885
14886 #[tokio::test]
14887 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14888 struct TerminalProofProbe {
14889 frame: Option<ChildFrame>,
14890 accepted: bool,
14891 }
14892
14893 #[async_trait]
14894 impl bamboo_subagent::ChildLink for TerminalProofProbe {
14895 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14896 Ok(())
14897 }
14898
14899 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14900 Ok(self.frame.take())
14901 }
14902
14903 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14904 self.accepted = status == TerminalStatus::Error;
14905 }
14906
14907 fn has_pending_durable_terminal(&self) -> bool {
14908 self.accepted
14909 }
14910 }
14911
14912 for (status, error, expected_proof) in [
14913 (
14914 TerminalStatus::Error,
14915 Some("remote_environment_checkout_not_clean"),
14916 true,
14917 ),
14918 (TerminalStatus::Error, Some("untrusted worker error"), false),
14919 (
14920 TerminalStatus::Cancelled,
14921 Some("remote_environment_checkout_not_clean"),
14922 false,
14923 ),
14924 ] {
14925 let mut link = TerminalProofProbe {
14926 frame: Some(ChildFrame::Terminal {
14927 status,
14928 result: None,
14929 error: error.map(str::to_owned),
14930 final_event_watermark: None,
14931 transcript: Vec::new(),
14932 }),
14933 accepted: false,
14934 };
14935 let (event_tx, _event_rx) = mpsc::channel(8);
14936 let cancel = CancellationToken::new();
14937 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14938 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14939 let mut session = Session::new("remote-terminal-proof", "model");
14940 let result = drive(ActorDriveContext {
14941 client: &mut link,
14942 parent_session_id: "proof-parent",
14943 child_session_id: "remote-terminal-proof",
14944 child_attempt: 0,
14945 approval_registry: None,
14946 approval_decider: None,
14947 approval_reviewer: None,
14948 escalation_bridge: None,
14949 event_tx: &event_tx,
14950 cancel_token: &cancel,
14951 live_rx: &mut live_rx,
14952 delivery_rx: &mut delivery_rx,
14953 logical_session: &mut session,
14954 expected_permission_posture: Some(expected_default_permission_posture(7)),
14955 expected_creation: None,
14956 session_inbox_runtime: None,
14957 actor_directory_store: None,
14958 canonical_subagent_tool: None,
14959 ticket_service: None,
14960 activation_run_id: None,
14961 execution_epoch: 0,
14962 expected_source_actor_id: "remote-terminal-proof",
14963 initial_inflight_claims: VecDeque::new(),
14964 plain_actor: false,
14965 remote_environment_lease: true,
14966 readonly_output: None,
14967 local_history_tools: None,
14968 local_history_read_only: false,
14969 strict_history_output: None,
14970 plain_input: None,
14971 canonical_activation: None,
14972 canonical_placement_ref: None,
14973 actor_event_observer: None,
14974 plain_run: None,
14975 first_frame_timeout: Some(Duration::from_secs(1)),
14976 })
14977 .await;
14978 assert!(result.is_err());
14979 assert_eq!(
14980 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14981 expected_proof
14982 );
14983 }
14984 }
14985
14986 #[tokio::test(start_paused = true)]
14987 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
14988 struct CancelOutcomeLink {
14989 cancel_sent: bool,
14990 settle: bool,
14991 accepted: bool,
14992 }
14993
14994 #[async_trait]
14995 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
14996 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14997 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
14998 Ok(())
14999 }
15000
15001 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15002 if !self.cancel_sent || !self.settle {
15003 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15004 .await
15005 } else {
15006 self.cancel_sent = false;
15007 Ok(Some(ChildFrame::Terminal {
15008 status: TerminalStatus::Cancelled,
15009 result: None,
15010 error: None,
15011 final_event_watermark: None,
15012 transcript: Vec::new(),
15013 }))
15014 }
15015 }
15016
15017 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15018 self.accepted = status == TerminalStatus::Cancelled;
15019 }
15020
15021 fn has_pending_durable_terminal(&self) -> bool {
15022 self.accepted
15023 }
15024 }
15025
15026 let temp = tempfile::tempdir().unwrap();
15027 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15028 .await
15029 .unwrap();
15030 let fence = ActorActivationFence {
15031 schema_version: 2,
15032 actor_id: "cancelled-remote-child".into(),
15033 activation_id: "cancelled-remote-activation".into(),
15034 attempt: 1,
15035 run_id: "cancelled-remote-run".into(),
15036 lease_owner: "cancelled-remote-host".into(),
15037 lease_epoch: 1,
15038 };
15039 for settle in [true, false] {
15040 let mut link = CancelOutcomeLink {
15041 cancel_sent: false,
15042 settle,
15043 accepted: false,
15044 };
15045 let (event_tx, _event_rx) = mpsc::channel(8);
15046 let cancel = CancellationToken::new();
15047 cancel.cancel();
15048 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15049 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15050 let mut session = Session::new("cancelled-remote-child", "model");
15051 let result = drive(ActorDriveContext {
15052 expected_creation: None,
15053 client: &mut link,
15054 parent_session_id: "cancelled-remote-parent",
15055 child_session_id: "cancelled-remote-child",
15056 child_attempt: 1,
15057 approval_registry: None,
15058 approval_decider: None,
15059 approval_reviewer: None,
15060 escalation_bridge: None,
15061 event_tx: &event_tx,
15062 cancel_token: &cancel,
15063 live_rx: &mut live_rx,
15064 delivery_rx: &mut delivery_rx,
15065 logical_session: &mut session,
15066 expected_permission_posture: Some(expected_default_permission_posture(7)),
15067 session_inbox_runtime: None,
15068 actor_directory_store: Some(&store),
15069 canonical_subagent_tool: None,
15070 ticket_service: None,
15071 activation_run_id: Some("cancelled-remote-run"),
15072 execution_epoch: 0,
15073 expected_source_actor_id: "cancelled-remote-worker",
15074 initial_inflight_claims: VecDeque::new(),
15075 plain_actor: false,
15076 remote_environment_lease: true,
15077 readonly_output: None,
15078 local_history_tools: None,
15079 local_history_read_only: false,
15080 strict_history_output: None,
15081 plain_input: None,
15082 canonical_activation: Some(&fence),
15083 canonical_placement_ref: None,
15084 actor_event_observer: None,
15085 plain_run: None,
15086 first_frame_timeout: None,
15087 })
15088 .await;
15089 assert!(matches!(result, Err(AgentError::Cancelled)));
15090 assert_eq!(
15091 link.accepted, settle,
15092 "only a consumed exact terminal can release the remote fence"
15093 );
15094 assert_eq!(
15095 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15096 settle
15097 );
15098 }
15099 }
15100
15101 #[tokio::test]
15102 async fn actor_permission_handshake_rejects_malformed_agent_event() {
15103 let session_id = "permission-malformed";
15104 let (result, session, events, _) = drive_permission_handshake_frames(
15105 session_id,
15106 [ChildFrame::Event {
15107 event: serde_json::json!({"type": "token", "content": 42}),
15108 }],
15109 expected_default_permission_posture(7),
15110 )
15111 .await;
15112
15113 assert!(result
15114 .unwrap_err()
15115 .to_string()
15116 .contains("malformed AgentEvent"));
15117 assert!(events.is_empty());
15118 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15119
15120 let (result, _, events, _) = drive_permission_handshake_frames(
15121 session_id,
15122 [
15123 permission_posture_frame(session_id, 7),
15124 ChildFrame::Event {
15125 event: serde_json::json!({"type":"private-download-secret"}),
15126 },
15127 ],
15128 expected_default_permission_posture(7),
15129 )
15130 .await;
15131 assert!(!result
15132 .unwrap_err()
15133 .to_string()
15134 .contains("private-download-secret"));
15135 assert_eq!(
15136 events.len(),
15137 1,
15138 "only the confirmed posture may be forwarded"
15139 );
15140 }
15141
15142 #[tokio::test]
15143 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15144 let session_id = "permission-early-event";
15145 let early_events = [
15146 (
15147 "progress",
15148 AgentEvent::RunnerProgress {
15149 session_id: session_id.to_string(),
15150 round_count: 1,
15151 },
15152 ),
15153 (
15154 "token",
15155 AgentEvent::Token {
15156 content: "must-not-forward".to_string(),
15157 },
15158 ),
15159 (
15160 "tool",
15161 AgentEvent::ToolStart {
15162 tool_call_id: "early-tool".to_string(),
15163 tool_name: "Read".to_string(),
15164 arguments: serde_json::json!({"file_path": "README.md"}),
15165 },
15166 ),
15167 ];
15168 for (kind, event) in early_events {
15169 let (result, session, events, _) = drive_permission_handshake_frames(
15170 session_id,
15171 [
15172 actor_event_frame(event),
15173 permission_posture_frame(session_id, 7),
15174 completed_actor_frame(),
15175 ],
15176 expected_default_permission_posture(7),
15177 )
15178 .await;
15179
15180 assert!(
15181 result
15182 .unwrap_err()
15183 .to_string()
15184 .contains("execution event before permission posture confirmation"),
15185 "{kind} must fail closed before posture"
15186 );
15187 assert!(events.is_empty(), "{kind} must not be forwarded");
15188 assert!(
15189 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15190 "{kind} must not advance the permission audit"
15191 );
15192 }
15193 }
15194
15195 #[tokio::test]
15196 async fn actor_permission_handshake_rejects_approval_before_posture() {
15197 let session_id = "permission-early-approval";
15198 let (result, session, events, sent) = drive_permission_handshake_frames(
15199 session_id,
15200 [ChildFrame::ApprovalRequest {
15201 id: "approval-before-posture".to_string(),
15202 body: serde_json::json!({
15203 "tool_name": "Bash",
15204 "permission": "execute",
15205 "resource": "echo must-not-run"
15206 }),
15207 }],
15208 expected_default_permission_posture(7),
15209 )
15210 .await;
15211
15212 assert!(result
15213 .unwrap_err()
15214 .to_string()
15215 .contains("requested approval before permission posture confirmation"));
15216 assert!(events.is_empty());
15217 assert!(
15218 sent.is_empty(),
15219 "an unconfirmed actor must receive no approval reply"
15220 );
15221 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15222 }
15223
15224 #[tokio::test]
15225 async fn actor_permission_handshake_rejects_mismatched_posture() {
15226 let session_id = "permission-mismatch";
15227 let (result, session, events, _) = drive_permission_handshake_frames(
15228 session_id,
15229 [permission_posture_frame(session_id, 8)],
15230 expected_default_permission_posture(7),
15231 )
15232 .await;
15233
15234 assert!(result
15235 .unwrap_err()
15236 .to_string()
15237 .contains("does not match the host-dispatched policy"));
15238 assert!(events.is_empty());
15239 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15240 }
15241
15242 #[tokio::test]
15243 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15244 let session_id = "permission-hostile-mapping";
15245 for hostile_mapping in [
15246 "wrong_executor:permission_mode=default",
15247 "test_actor:permission_mode=default;credential=must-not-persist",
15248 ] {
15249 let frame = ChildFrame::Event {
15250 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15251 session_id: session_id.to_string(),
15252 policy_revision: 7,
15253 requested_mode: "default".to_string(),
15254 effective_mode: "default".to_string(),
15255 executor_mapping: hostile_mapping.to_string(),
15256 })
15257 .expect("serialize hostile posture fixture"),
15258 };
15259 let (result, session, events, _) = drive_permission_handshake_frames(
15260 session_id,
15261 [frame],
15262 expected_default_permission_posture(7),
15263 )
15264 .await;
15265
15266 let error = result.unwrap_err().to_string();
15267 assert!(error.contains("host-dispatched executor mapping"));
15268 assert!(
15269 !error.contains(hostile_mapping),
15270 "untrusted mapping must not be reflected in host errors"
15271 );
15272 assert!(events.is_empty());
15273 assert!(
15274 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15275 "untrusted mapping must not reach durable or in-memory audit state"
15276 );
15277 }
15278 }
15279
15280 #[tokio::test]
15281 async fn actor_permission_handshake_rejects_duplicate_posture() {
15282 let session_id = "permission-duplicate";
15283 let (result, session, events, _) = drive_permission_handshake_frames(
15284 session_id,
15285 [
15286 permission_posture_frame(session_id, 7),
15287 permission_posture_frame(session_id, 7),
15288 completed_actor_frame(),
15289 ],
15290 expected_default_permission_posture(7),
15291 )
15292 .await;
15293
15294 assert!(result
15295 .unwrap_err()
15296 .to_string()
15297 .contains("duplicate permission posture activation"));
15298 assert_eq!(
15299 events.len(),
15300 1,
15301 "only the confirmed posture may be forwarded"
15302 );
15303 assert!(matches!(
15304 events[0],
15305 AgentEvent::PermissionPostureActivated { .. }
15306 ));
15307 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15308 .expect("the first matching posture must be recorded");
15309 assert_eq!(audit.policy_revision, 7);
15310 }
15311
15312 #[tokio::test]
15313 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15314 let session_id = "permission-happy";
15315 let (result, session, events, _) = drive_permission_handshake_frames(
15316 session_id,
15317 [
15318 permission_posture_frame(session_id, 7),
15319 actor_event_frame(AgentEvent::RunnerProgress {
15320 session_id: session_id.to_string(),
15321 round_count: 1,
15322 }),
15323 actor_event_frame(AgentEvent::Token {
15324 content: "working".to_string(),
15325 }),
15326 actor_event_frame(AgentEvent::ToolStart {
15327 tool_call_id: "tool-1".to_string(),
15328 tool_name: "Read".to_string(),
15329 arguments: serde_json::json!({"file_path": "README.md"}),
15330 }),
15331 completed_actor_frame(),
15332 ],
15333 expected_default_permission_posture(7),
15334 )
15335 .await;
15336
15337 assert_eq!(result.unwrap().as_deref(), Some("done"));
15338 assert!(matches!(
15339 events.as_slice(),
15340 [
15341 AgentEvent::PermissionPostureActivated { .. },
15342 AgentEvent::RunnerProgress { .. },
15343 AgentEvent::Token { .. },
15344 AgentEvent::ToolStart { .. }
15345 ]
15346 ));
15347 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15348 .expect("matching posture must be recorded before execution events are accepted");
15349 assert_eq!(audit.policy_revision, 7);
15350 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15351 }
15352
15353 #[tokio::test]
15354 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15355 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15356 .into_iter()
15357 .enumerate()
15358 {
15359 let session_id = format!("actor-download-{index}");
15360 let args = serde_json::json!({
15361 "action":"download",
15362 "selector":"a[data-secret='private-selector']",
15363 "expected_epoch":17,
15364 "data_base64":"private-extra-bytes",
15365 "extra":{"url":"https://private.test/file"},
15366 });
15367 let original = args.clone();
15368 let read_args = serde_json::json!({"file_path":"README.md"});
15369 let (result, _session, events, _) = drive_permission_handshake_frames(
15370 &session_id,
15371 [
15372 permission_posture_frame(&session_id, 7),
15373 actor_event_frame(AgentEvent::ToolStart {
15374 tool_call_id: "download-call".to_string(),
15375 tool_name: tool_name.to_string(),
15376 arguments: args.clone(),
15377 }),
15378 actor_event_frame(AgentEvent::ToolStart {
15379 tool_call_id: "read-call".to_string(),
15380 tool_name: "Read".to_string(),
15381 arguments: read_args.clone(),
15382 }),
15383 completed_actor_frame(),
15384 ],
15385 expected_default_permission_posture(7),
15386 )
15387 .await;
15388 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15389 assert_eq!(args, original);
15390 let starts: Vec<_> = events
15391 .iter()
15392 .filter_map(|event| match event {
15393 AgentEvent::ToolStart {
15394 tool_name,
15395 arguments,
15396 ..
15397 } => Some((tool_name.as_str(), arguments)),
15398 _ => None,
15399 })
15400 .collect();
15401 assert_eq!(starts.len(), 2);
15402 assert_eq!(starts[0].0, "browser");
15403 assert_eq!(
15404 starts[0].1,
15405 &serde_json::json!({"action":"download","expected_epoch":17})
15406 );
15407 assert_eq!(starts[1], ("Read", &read_args));
15408 assert!(!format!("{events:?}").contains("private"));
15409 }
15410 }
15411
15412 #[tokio::test]
15413 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15414 let session_id = "actor-hook-display";
15415 let private = "private-selector-url-filename-response";
15416 let hook_name = format!("audit-{private}");
15417 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15418 hook_name: hook_name.clone(),
15419 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15420 phase: phase.into(),
15421 duration_ms,
15422 decision,
15423 };
15424 let decisions = [
15425 HookResult::Deny {
15426 reason: private.into(),
15427 },
15428 HookResult::InjectContext {
15429 text: private.into(),
15430 },
15431 HookResult::WithContext {
15432 result: Box::new(HookResult::WithContext {
15433 result: Box::new(HookResult::Deny {
15434 reason: private.into(),
15435 }),
15436 text: private.into(),
15437 }),
15438 text: private.into(),
15439 },
15440 HookResult::Suspend {
15441 reason: private.into(),
15442 },
15443 HookResult::Abort {
15444 reason: private.into(),
15445 },
15446 ];
15447 let originals = decisions.to_vec();
15448 let batch_events = [
15449 hook(2, "completed", decisions[1].clone()),
15450 hook(3, "completed", decisions[2].clone()),
15451 hook(4, "completed", decisions[3].clone()),
15452 ]
15453 .into_iter()
15454 .map(|event| serde_json::to_value(event).unwrap())
15455 .collect::<Vec<_>>();
15456 let batch = ActorEventBatch {
15457 logical_session: Some(LogicalSessionIdentity {
15458 creation: None,
15459 session_id: session_id.into(),
15460 parent_session_id: Some("permission-parent".into()),
15461 root_session_id: session_id.into(),
15462 }),
15463 activation_id: None,
15464 execution_epoch: 0,
15465 source_node_id: None,
15466 source_actor_id: Some(session_id.into()),
15467 first_seq: 1,
15468 last_seq: batch_events.len() as u64,
15469 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15470 events: batch_events,
15471 };
15472 let nested = AgentEvent::SubAgentEvent {
15473 parent_session_id: "parent".into(),
15474 child_session_id: "child".into(),
15475 event: Box::new(hook(5, "completed", decisions[4].clone())),
15476 };
15477 let raw = serde_json::to_string(&originals).unwrap();
15478 assert!(raw.contains(private));
15479
15480 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15481 session_id,
15482 [
15483 permission_posture_frame(session_id, 7),
15484 actor_event_frame(hook(1, private, decisions[0].clone())),
15485 ChildFrame::EventBatch { batch },
15486 actor_event_frame(nested),
15487 completed_actor_frame(),
15488 ],
15489 expected_default_permission_posture(7),
15490 )
15491 .await;
15492 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15493 let wire = serde_json::to_string(&forwarded).unwrap();
15494 assert!(
15495 !wire.contains(private),
15496 "private hook text reached SSE: {wire}"
15497 );
15498
15499 let hooks = forwarded
15500 .iter()
15501 .filter_map(|event| match event {
15502 AgentEvent::HookLifecycle {
15503 hook_name,
15504 point,
15505 phase,
15506 duration_ms,
15507 decision,
15508 } => Some((hook_name, point, phase, duration_ms, decision)),
15509 _ => None,
15510 })
15511 .collect::<Vec<_>>();
15512 assert_eq!(hooks.len(), 4);
15513 assert!(hooks.iter().all(|(name, point, _, _, _)| {
15514 name == &&"actor-hook-1".to_string()
15515 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15516 }));
15517 assert_eq!(hooks[0].2, "event");
15518 assert_eq!(hooks[1].2, "completed");
15519 assert_eq!(*hooks[0].3, 1);
15520 assert_eq!(*hooks[3].3, 4);
15521 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15522 assert!(
15523 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15524 );
15525 assert!(
15526 matches!(hooks[2].4, HookResult::WithContext { result, text }
15527 if text == "Hook context hidden" && matches!(result.as_ref(),
15528 HookResult::WithContext { result, text }
15529 if text == "Hook context hidden" && matches!(result.as_ref(),
15530 HookResult::Deny { reason } if reason == "Hook denied")))
15531 );
15532 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15533 assert!(forwarded.iter().any(|event| matches!(event,
15534 AgentEvent::SubAgentEvent { event, .. }
15535 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15536 hook_name, duration_ms: 5,
15537 decision: HookResult::Abort { reason }, ..
15538 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15539 )));
15540 }
15541
15542 #[test]
15543 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15544 let mut display = ActorEventDisplay::default();
15545 let mut deep = HookResult::Allow;
15546 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15547 deep = HookResult::WithContext {
15548 result: Box::new(deep),
15549 text: "private-download-bytes".into(),
15550 };
15551 }
15552 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15553 hook_name,
15554 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15555 phase: "completed".into(),
15556 duration_ms: 7,
15557 decision,
15558 };
15559 assert!(display.project(event("deep".into(), deep), 0).is_none());
15560 assert!(display.overflowed);
15561 assert!(display
15562 .project(
15563 event(
15564 "private-download-bytes".into(),
15565 HookResult::Deny {
15566 reason: "private-download-bytes".into(),
15567 },
15568 ),
15569 0,
15570 )
15571 .is_none());
15572
15573 let mut oversized = ActorEventDisplay::default();
15574 assert!(oversized
15575 .project(
15576 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15577 0,
15578 )
15579 .is_none());
15580 assert!(oversized.overflowed);
15581
15582 let mut reconnected = ActorEventDisplay::default();
15586 let private = "private-download-bytes";
15587 for _ in 0..2 {
15588 let projected = reconnected
15589 .project(event(private.into(), HookResult::Allow), 0)
15590 .unwrap();
15591 assert!(
15592 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15593 if hook_name == "actor-hook-1")
15594 );
15595 }
15596 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15597 for index in 1..MAX_DISPLAY_CALLS {
15598 assert!(reconnected
15599 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15600 .is_some());
15601 }
15602 assert!(reconnected
15603 .project(event("overflow-private".into(), HookResult::Allow), 0)
15604 .is_none());
15605 assert!(reconnected.overflowed);
15606 assert!(reconnected.hook_names.is_empty());
15607 }
15608
15609 #[test]
15610 fn actor_hook_display_preserves_nontext_decision_kinds() {
15611 for result in [
15612 HookResult::Continue,
15613 HookResult::Mutated,
15614 HookResult::Allow,
15615 HookResult::Ask,
15616 ] {
15617 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15618 }
15619 }
15620
15621 #[tokio::test]
15622 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15623 let session_id = "actor-malformed-hook";
15624 let private = "private-download-selector";
15625 let original = AgentEvent::HookLifecycle {
15626 hook_name: "audit".into(),
15627 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15628 phase: "completed".into(),
15629 duration_ms: 9,
15630 decision: HookResult::Deny {
15631 reason: private.into(),
15632 },
15633 };
15634 let mut invalid = serde_json::to_value(&original).unwrap();
15635 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15636 let nested = AgentEvent::SubAgentEvent {
15637 parent_session_id: "parent".into(),
15638 child_session_id: "child".into(),
15639 event: Box::new(original),
15640 };
15641 let mut invalid_nested = serde_json::to_value(nested).unwrap();
15642 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15643 let batch = ActorEventBatch {
15644 logical_session: Some(LogicalSessionIdentity {
15645 creation: None,
15646 session_id: session_id.into(),
15647 parent_session_id: Some("permission-parent".into()),
15648 root_session_id: session_id.into(),
15649 }),
15650 activation_id: None,
15651 execution_epoch: 0,
15652 source_node_id: None,
15653 source_actor_id: Some(session_id.into()),
15654 first_seq: 1,
15655 last_seq: 1,
15656 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15657 events: vec![invalid.clone()],
15658 };
15659 for frame in [
15660 ChildFrame::Event {
15661 event: invalid.clone(),
15662 },
15663 ChildFrame::EventBatch { batch },
15664 ChildFrame::Event {
15665 event: invalid_nested,
15666 },
15667 ] {
15668 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15669 session_id,
15670 [permission_posture_frame(session_id, 7), frame],
15671 expected_default_permission_posture(7),
15672 )
15673 .await;
15674 let error = outcome.unwrap_err().to_string();
15675 assert!(error.contains("malformed AgentEvent"), "{error}");
15676 assert!(!error.contains(private));
15677 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15678 }
15679
15680 let (event_tx, mut event_rx) = mpsc::channel(8);
15681 let mut session = Session::new("legacy-hook", "model");
15682 let mut handshake = PermissionPostureHandshake::NotRequired;
15683 let mut display = ActorEventDisplay::default();
15684 process_actor_event(
15685 invalid,
15686 false,
15687 &mut handshake,
15688 None,
15689 None,
15690 &mut session,
15691 &event_tx,
15692 &mut display,
15693 )
15694 .await
15695 .unwrap();
15696 assert!(display.overflowed);
15697 assert!(event_rx.try_recv().is_err());
15698 }
15699
15700 #[tokio::test]
15701 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15702 let session_id = "actor-download-batch";
15703 let private = "private-download-bytes-and-url";
15704 let events = vec![
15705 AgentEvent::ToolStart {
15706 tool_call_id: "download-call".into(),
15707 tool_name: "default::browser".into(),
15708 arguments: serde_json::json!({
15709 "action":"download", "selector": private, "expected_epoch": 17,
15710 }),
15711 },
15712 AgentEvent::ToolApprovalRequested {
15713 tool_call_id: "download-call".into(),
15714 tool_name: format!("{private}::browser"),
15715 parameters: serde_json::json!({
15716 "action":"download", "selector": private, "expected_epoch": 17,
15717 }),
15718 },
15719 AgentEvent::ToolStart {
15720 tool_call_id: "read-call".into(),
15721 tool_name: "Read".into(),
15722 arguments: serde_json::json!({"file_path":"README.md"}),
15723 },
15724 ];
15725 let batch = ActorEventBatch {
15726 logical_session: Some(LogicalSessionIdentity {
15727 creation: None,
15728 session_id: session_id.into(),
15729 parent_session_id: Some("permission-parent".into()),
15730 root_session_id: session_id.into(),
15731 }),
15732 activation_id: None,
15733 execution_epoch: 0,
15734 source_node_id: None,
15735 source_actor_id: Some(session_id.into()),
15736 first_seq: 1,
15737 last_seq: events.len() as u64,
15738 qos: bamboo_subagent::ActorEventQos::Durable,
15739 events: events
15740 .into_iter()
15741 .map(|event| serde_json::to_value(event).unwrap())
15742 .collect(),
15743 };
15744 let result = bamboo_agent_core::tools::ToolResult {
15745 success: true,
15746 result: format!("{{\"data_base64\":\"{private}\"}}"),
15747 display_preference: Some(private.into()),
15748 images: vec![bamboo_agent_core::tools::ToolResultImage {
15749 mime_type: "image/png".into(),
15750 data: private.into(),
15751 }],
15752 };
15753 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15754 session_id,
15755 [
15756 permission_posture_frame(session_id, 7),
15757 ChildFrame::EventBatch { batch },
15758 actor_event_frame(AgentEvent::ToolToken {
15759 tool_call_id: "download-call".into(),
15760 content: private.into(),
15761 }),
15762 actor_event_frame(AgentEvent::ToolToken {
15763 tool_call_id: "read-call".into(),
15764 content: "public live output".into(),
15765 }),
15766 actor_event_frame(AgentEvent::ToolComplete {
15767 tool_call_id: "download-call".into(),
15768 result,
15769 }),
15770 actor_event_frame(AgentEvent::ToolLifecycle {
15771 tool_call_id: "download-call".into(),
15772 tool_name: "browser".into(),
15773 phase: "finished".into(),
15774 elapsed_ms: Some(1),
15775 is_mutating: false,
15776 auto_approved: false,
15777 summary: Some(private.into()),
15778 error: Some(private.into()),
15779 }),
15780 actor_event_frame(AgentEvent::ToolError {
15781 tool_call_id: "download-call".into(),
15782 error: private.into(),
15783 }),
15784 actor_event_frame(AgentEvent::ToolComplete {
15785 tool_call_id: "read-call".into(),
15786 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15787 }),
15788 completed_actor_frame(),
15789 ],
15790 expected_default_permission_posture(7),
15791 )
15792 .await;
15793 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15794 let wire = serde_json::to_string(&forwarded).unwrap();
15795 assert!(
15796 !wire.contains(private),
15797 "private content reached actor SSE: {wire}"
15798 );
15799 assert!(wire.contains("public README text"));
15800 assert!(wire.contains("public live output"));
15801 assert!(forwarded.iter().any(|event| matches!(event,
15802 AgentEvent::ToolComplete { tool_call_id, result }
15803 if tool_call_id == "download-call"
15804 && result.result == "Browser tool result hidden"
15805 && result.images.is_empty() && result.display_preference.is_none()
15806 )));
15807 }
15808
15809 #[tokio::test]
15810 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15811 let private = "private-download-content";
15812 let nested = |event| AgentEvent::SubAgentEvent {
15813 parent_session_id: "parent".into(),
15814 child_session_id: "nested-child".into(),
15815 event: Box::new(event),
15816 };
15817 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15818 "actor-reorder",
15819 [
15820 permission_posture_frame("actor-reorder", 7),
15821 actor_event_frame(AgentEvent::ToolComplete {
15822 tool_call_id: "missing".into(),
15823 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15824 }),
15825 actor_event_frame(AgentEvent::ToolLifecycle {
15826 tool_call_id: "missing".into(),
15827 tool_name: "browser".into(),
15828 phase: private.into(),
15829 elapsed_ms: None,
15830 is_mutating: false,
15831 auto_approved: false,
15832 summary: Some(private.into()),
15833 error: Some(private.into()),
15834 }),
15835 actor_event_frame(AgentEvent::ToolApprovalRequested {
15836 tool_call_id: "approval-only".into(),
15837 tool_name: "browser".into(),
15838 parameters: serde_json::json!({"action":"download","selector":private}),
15839 }),
15840 actor_event_frame(AgentEvent::ToolToken {
15841 tool_call_id: "approval-only".into(),
15842 content: private.into(),
15843 }),
15844 actor_event_frame(AgentEvent::ToolStart {
15845 tool_call_id: "malformed-browser".into(),
15846 tool_name: format!("{private}::browser"),
15847 arguments: serde_json::json!({"selector":private}),
15848 }),
15849 actor_event_frame(AgentEvent::ToolComplete {
15850 tool_call_id: "malformed-browser".into(),
15851 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15852 }),
15853 actor_event_frame(AgentEvent::ToolStart {
15854 tool_call_id: "reused".into(),
15855 tool_name: "Read".into(),
15856 arguments: serde_json::json!({"file_path":"README.md"}),
15857 }),
15858 actor_event_frame(AgentEvent::ToolStart {
15859 tool_call_id: "reused".into(),
15860 tool_name: "browser".into(),
15861 arguments: serde_json::json!({"action":"download","selector":private}),
15862 }),
15863 actor_event_frame(AgentEvent::ToolToken {
15864 tool_call_id: "reused".into(),
15865 content: private.into(),
15866 }),
15867 actor_event_frame(nested(AgentEvent::ToolStart {
15868 tool_call_id: "nested".into(),
15869 tool_name: "browser".into(),
15870 arguments: serde_json::json!({"action":"download","selector":private}),
15871 })),
15872 actor_event_frame(nested(AgentEvent::ToolComplete {
15873 tool_call_id: "nested".into(),
15874 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15875 })),
15876 actor_event_frame(AgentEvent::ChildApprovalRequested {
15877 child_session_id: "nested-child".into(),
15878 request_id: "approval".into(),
15879 tool_name: format!("{private}::browser"),
15880 permission: private.into(),
15881 resource: private.into(),
15882 }),
15883 actor_event_frame(AgentEvent::ChildApprovalChanged {
15884 parent_session_id: "parent".into(),
15885 child_session_id: "nested-child".into(),
15886 child_attempt: 1,
15887 request_id: "approval".into(),
15888 version: 1,
15889 status: "pending".into(),
15890 reason: Some(private.into()),
15891 tool_name: "browser".into(),
15892 permission: private.into(),
15893 resource: private.into(),
15894 created_at: "2026-01-01T00:00:00Z".into(),
15895 resolved_at: None,
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("Tool result hidden"));
15909 assert!(wire.contains("Browser tool result hidden"));
15910 assert!(wire.contains("Browser interaction approval"));
15911 assert!(!forwarded
15912 .iter()
15913 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15914 }
15915
15916 #[test]
15917 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15918 let mut display = ActorEventDisplay::default();
15919 display
15920 .project(
15921 AgentEvent::ToolStart {
15922 tool_call_id: "private-call".into(),
15923 tool_name: "browser".into(),
15924 arguments: serde_json::json!({"action":"download","selector":"private"}),
15925 },
15926 0,
15927 )
15928 .unwrap();
15929 for index in 0..MAX_DISPLAY_CALLS {
15930 display
15931 .project(
15932 AgentEvent::ToolStart {
15933 tool_call_id: format!("ordinary-{index}"),
15934 tool_name: "Read".into(),
15935 arguments: serde_json::json!({"file_path":"README.md"}),
15936 },
15937 0,
15938 )
15939 .unwrap();
15940 }
15941 assert!(display.overflowed);
15942 let forwarded = display
15943 .project(
15944 AgentEvent::ToolComplete {
15945 tool_call_id: "private-call".into(),
15946 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15947 },
15948 0,
15949 )
15950 .unwrap();
15951 assert!(!serde_json::to_string(&forwarded)
15952 .unwrap()
15953 .contains("private-bytes"));
15954 }
15955
15956 #[test]
15957 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15958 let mut display = ActorEventDisplay::default();
15959 let nested = |index: usize| AgentEvent::SubAgentEvent {
15960 parent_session_id: "parent".into(),
15961 child_session_id: format!("child-{index}"),
15962 event: Box::new(AgentEvent::ToolStart {
15963 tool_call_id: format!("read-{index}"),
15964 tool_name: "Read".into(),
15965 arguments: serde_json::json!({"file_path":"README.md"}),
15966 }),
15967 };
15968 for index in 0..(MAX_DISPLAY_CALLS / 2) {
15969 assert!(display.project(nested(index), 0).is_some());
15970 }
15971 assert_eq!(
15972 display.identities.load(Ordering::Relaxed),
15973 MAX_DISPLAY_CALLS
15974 );
15975 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
15976 assert!(display.overflowed);
15977 assert!(display.children.is_empty());
15978
15979 let mut oversized = ActorEventDisplay::default();
15980 assert!(oversized
15981 .project(
15982 AgentEvent::SubAgentEvent {
15983 parent_session_id: "parent".into(),
15984 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15985 event: Box::new(AgentEvent::ToolComplete {
15986 tool_call_id: "call".into(),
15987 result: bamboo_agent_core::tools::ToolResult::text(
15988 true,
15989 "private-download-bytes",
15990 ),
15991 }),
15992 },
15993 0,
15994 )
15995 .is_none());
15996 assert!(oversized.overflowed);
15997 assert!(oversized
15998 .project(
15999 AgentEvent::ToolStart {
16000 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16001 tool_name: "Read".into(),
16002 arguments: serde_json::json!({"file_path":"README.md"}),
16003 },
16004 0,
16005 )
16006 .is_none());
16007 }
16008
16009 #[tokio::test]
16010 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16011 let session_id = "actor-gap";
16012 let batch = |seq, event: AgentEvent| {
16013 let event = serde_json::to_value(event).unwrap();
16014 ChildFrame::EventBatch {
16015 batch: ActorEventBatch {
16016 logical_session: Some(LogicalSessionIdentity {
16017 creation: None,
16018 session_id: session_id.into(),
16019 parent_session_id: Some("permission-parent".into()),
16020 root_session_id: session_id.into(),
16021 }),
16022 activation_id: None,
16023 execution_epoch: 0,
16024 source_node_id: None,
16025 source_actor_id: Some(session_id.into()),
16026 first_seq: seq,
16027 last_seq: seq,
16028 qos: bamboo_subagent::ActorEventQos::classify(&event),
16029 events: vec![event],
16030 },
16031 }
16032 };
16033 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16034 session_id,
16035 [
16036 permission_posture_frame(session_id, 7),
16037 batch(
16038 1,
16039 AgentEvent::ToolStart {
16040 tool_call_id: "reused-call".into(),
16041 tool_name: "Read".into(),
16042 arguments: serde_json::json!({"file_path":"README.md"}),
16043 },
16044 ),
16045 batch(
16048 3,
16049 AgentEvent::ToolComplete {
16050 tool_call_id: "reused-call".into(),
16051 result: bamboo_agent_core::tools::ToolResult::text(
16052 true,
16053 "private-download-bytes",
16054 ),
16055 },
16056 ),
16057 completed_actor_frame(),
16058 ],
16059 expected_default_permission_posture(7),
16060 )
16061 .await;
16062 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16063 let wire = serde_json::to_string(&forwarded).unwrap();
16064 assert!(!wire.contains("private-download-bytes"), "{wire}");
16065 assert!(wire.contains("Tool result hidden"));
16066 }
16067
16068 #[tokio::test]
16069 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16070 let (event_tx, mut event_rx) = mpsc::channel(8);
16071 let mut session = Session::new("legacy-malformed-private", "model");
16072 let mut handshake = PermissionPostureHandshake::NotRequired;
16073 let mut display = ActorEventDisplay::default();
16074 let read_start = serde_json::to_value(AgentEvent::ToolStart {
16075 tool_call_id: "reused-call".into(),
16076 tool_name: "Read".into(),
16077 arguments: serde_json::json!({"file_path":"README.md"}),
16078 })
16079 .unwrap();
16080 process_actor_event(
16081 read_start,
16082 false,
16083 &mut handshake,
16084 None,
16085 None,
16086 &mut session,
16087 &event_tx,
16088 &mut display,
16089 )
16090 .await
16091 .unwrap();
16092
16093 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16096 tool_call_id: "reused-call".into(),
16097 tool_name: "browser".into(),
16098 arguments: serde_json::json!({
16099 "action":"download", "selector":"private-download-selector"
16100 }),
16101 })
16102 .unwrap();
16103 malformed["tool_name"] = serde_json::json!(7);
16104 process_actor_event(
16105 malformed,
16106 false,
16107 &mut handshake,
16108 None,
16109 None,
16110 &mut session,
16111 &event_tx,
16112 &mut display,
16113 )
16114 .await
16115 .unwrap();
16116 process_actor_event(
16117 serde_json::to_value(AgentEvent::ToolComplete {
16118 tool_call_id: "reused-call".into(),
16119 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16120 })
16121 .unwrap(),
16122 false,
16123 &mut handshake,
16124 None,
16125 None,
16126 &mut session,
16127 &event_tx,
16128 &mut display,
16129 )
16130 .await
16131 .unwrap();
16132 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16133 assert_eq!(forwarded.len(), 2);
16134 let wire = serde_json::to_string(&forwarded).unwrap();
16135 assert!(!wire.contains("private-download"), "{wire}");
16136 assert!(wire.contains("Tool result hidden"));
16137 }
16138
16139 #[tokio::test]
16140 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16141 let temp = tempfile::tempdir().unwrap();
16142 let store = Arc::new(
16143 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16144 .await
16145 .unwrap(),
16146 );
16147 let storage: Arc<dyn Storage> = store.clone();
16148 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16149 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16150 store.clone(),
16151 bamboo_domain::SessionInboxLimits::default(),
16152 ));
16153 let session_id = "actor-confirmation-order";
16154 let run_id = "actor-run-current";
16155 let mut session = Session::new(session_id, "model");
16156 store.save_session(&session).await.unwrap();
16157 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16158 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16159 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16160 inbox.deliver(&envelope).await.unwrap();
16161 }
16162 inbox
16163 .mark_activation_eligible(
16164 session_id,
16165 2,
16166 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16167 )
16168 .await
16169 .unwrap();
16170 let router = SessionActivationRouter::new();
16171 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16172 let binding = SessionInboxRuntimeBinding {
16173 router,
16174 inbox: inbox.clone(),
16175 storage,
16176 persistence: locked,
16177 parent_question_lock: None,
16178 };
16179 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16180 .await
16181 .unwrap();
16182 assert_eq!(
16183 pairs
16184 .iter()
16185 .map(|(claim, _)| claim.envelope.id.as_str())
16186 .collect::<Vec<_>>(),
16187 vec!["actor-first", "actor-second"]
16188 );
16189 let seeded = store.load_session(session_id).await.unwrap().unwrap();
16190 assert_eq!(
16191 seeded
16192 .messages
16193 .iter()
16194 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16195 .map(|message| message.id.as_str())
16196 .collect::<Vec<_>>(),
16197 vec!["actor-first", "actor-second"],
16198 "host context must be durable before actor dispatch"
16199 );
16200 for (claim, _) in &pairs {
16201 assert_eq!(
16202 seeded
16203 .messages
16204 .iter()
16205 .filter(|message| bamboo_domain::is_matching_session_message(
16206 message,
16207 &claim.envelope
16208 ))
16209 .count(),
16210 1,
16211 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16212 claim.envelope.id
16213 );
16214 }
16215 assert!(
16216 seeded.session_inbox_admission().is_none_or(|cursor| {
16217 !cursor.contains(&pairs[0].0.envelope.id)
16218 && !cursor.contains(&pairs[1].0.envelope.id)
16219 }),
16220 "pre-dispatch transcript seeding must not forge worker confirmation"
16221 );
16222 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16223 let claims = pairs
16224 .into_iter()
16225 .map(|(claim, _)| claim)
16226 .collect::<VecDeque<_>>();
16227 let first = claims[0].clone();
16228 let second = claims[1].clone();
16229 let mut stale = admission_confirmation(session_id, &first, "stale-run");
16230 stale.canonical_claim_generation = second.generation;
16231 let mut link = ConfirmationSequenceLink {
16232 frames: VecDeque::from([
16233 ChildFrame::SessionMessageAdmitted {
16234 confirmation: stale,
16235 },
16236 ChildFrame::SessionMessageAdmitted {
16237 confirmation: admission_confirmation(session_id, &first, run_id),
16238 },
16239 ChildFrame::SessionMessageAdmitted {
16240 confirmation: admission_confirmation(session_id, &second, run_id),
16241 },
16242 ChildFrame::Terminal {
16243 status: TerminalStatus::Completed,
16244 result: Some("done".to_string()),
16245 error: None,
16246 final_event_watermark: None,
16247 transcript: Vec::new(),
16248 },
16249 ]),
16250 sent: Vec::new(),
16251 };
16252 let (event_tx, _event_rx) = mpsc::channel(8);
16253 let cancel = CancellationToken::new();
16254 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16255 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16256 let result = drive(ActorDriveContext {
16257 expected_creation: None,
16258 client: &mut link,
16259 parent_session_id: "parent",
16260 child_session_id: session_id,
16261 child_attempt: 0,
16262 approval_registry: None,
16263 approval_decider: None,
16264 approval_reviewer: None,
16265 escalation_bridge: None,
16266 event_tx: &event_tx,
16267 cancel_token: &cancel,
16268 live_rx: &mut live_rx,
16269 delivery_rx: &mut delivery_rx,
16270 logical_session: &mut session,
16271 expected_permission_posture: None,
16272 session_inbox_runtime: Some(&binding),
16273 actor_directory_store: None,
16274 canonical_subagent_tool: None,
16275 ticket_service: None,
16276 activation_run_id: Some(run_id),
16277 execution_epoch: 0,
16278 expected_source_actor_id: session_id,
16279 initial_inflight_claims: claims,
16280 plain_actor: false,
16281 remote_environment_lease: false,
16282 readonly_output: None,
16283 local_history_tools: None,
16284 local_history_read_only: false,
16285 strict_history_output: None,
16286 plain_input: None,
16287 canonical_activation: None,
16288 canonical_placement_ref: None,
16289 actor_event_observer: None,
16290 plain_run: None,
16291 first_frame_timeout: Some(Duration::from_secs(1)),
16292 })
16293 .await
16294 .unwrap();
16295 assert_eq!(result.as_deref(), Some("done"));
16296 assert_eq!(
16297 session
16298 .messages
16299 .iter()
16300 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16301 .map(|message| message.id.as_str())
16302 .collect::<Vec<_>>(),
16303 vec!["actor-first", "actor-second"]
16304 );
16305 let backlog = inbox.inspect(session_id).await.unwrap();
16306 assert_eq!(backlog.pending + backlog.claimed, 0);
16307 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16308 let cursor = confirmed
16309 .session_inbox_admission()
16310 .expect("exact worker confirmation must checkpoint the admission cursor");
16311 assert!(cursor.contains(&first.envelope.id));
16312 assert!(cursor.contains(&second.envelope.id));
16313 for claim in [&first, &second] {
16314 assert_eq!(
16315 confirmed
16316 .messages
16317 .iter()
16318 .filter(|message| bamboo_domain::is_matching_session_message(
16319 message,
16320 &claim.envelope
16321 ))
16322 .count(),
16323 1,
16324 "confirmation must retain one exact canonical marker for {}",
16325 claim.envelope.id
16326 );
16327 }
16328 assert!(inbox
16329 .was_admitted(session_id, &first.envelope.id)
16330 .await
16331 .unwrap());
16332 assert!(inbox
16333 .was_admitted(session_id, &second.envelope.id)
16334 .await
16335 .unwrap());
16336 owner_registration.begin_finalization().await;
16337 owner_registration.finish(2).await.unwrap();
16338 }
16339
16340 #[derive(Default)]
16341 struct RecordingCodexTokenAuthority {
16342 issued_for: std::sync::Mutex<Vec<String>>,
16343 revoked: std::sync::Mutex<Vec<String>>,
16344 }
16345
16346 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16347 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16348 self.issued_for
16349 .lock()
16350 .expect("issued fixture lock")
16351 .push(session_id.to_string());
16352 Ok(IssuedCodexRunToken {
16353 token_id: format!("id-{session_id}"),
16354 token: format!("bcx1_secret-{session_id}"),
16355 })
16356 }
16357
16358 fn revoke(&self, token_id: &str) {
16359 self.revoked
16360 .lock()
16361 .expect("revoked fixture lock")
16362 .push(token_id.to_string());
16363 }
16364 }
16365
16366 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16367 let bamboo_mode = auth_mode == Some("bamboo")
16368 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16369 ExecutorSpec::Codex {
16370 binary: None,
16371 model: None,
16372 mode: None,
16373 sandbox: None,
16374 inherit_user_config,
16375 auth_mode: auth_mode.map(str::to_string),
16376 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16377 wire_api: Some("responses".to_string()),
16378 provider_key_ref: None,
16379 forward_env: None,
16380 approval_policy: None,
16381 network_access: None,
16382 allow_danger_bypass: None,
16383 permission_profile: None,
16384 workspace_owned: None,
16385 }
16386 }
16387
16388 fn permission_resolution(
16389 requested: bamboo_domain::SessionPermissionMode,
16390 effective: bamboo_domain::PermissionMode,
16391 ) -> bamboo_domain::PermissionModeResolution {
16392 bamboo_domain::PermissionModeResolution {
16393 requested,
16394 effective,
16395 }
16396 }
16397
16398 #[test]
16399 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16400 use bamboo_domain::{PermissionMode, SessionPermissionMode};
16401
16402 let default =
16403 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16404 assert_eq!(
16405 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16406 .unwrap()
16407 .as_deref(),
16408 Some("bamboo_runtime:default")
16409 );
16410 assert_eq!(
16411 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16412 None,
16413 "transport-only Echo must not claim the typed permission contract"
16414 );
16415 assert_eq!(
16416 expected_permission_executor_mapping(
16417 &ExecutorSpec::CliAdapter {
16418 command: "must-not-appear-in-contract".to_string(),
16419 args: vec!["credential-like-argument".to_string()],
16420 },
16421 default,
16422 false,
16423 )
16424 .unwrap(),
16425 None,
16426 "unimplemented CliAdapter must not leak command data into a contract"
16427 );
16428
16429 let claude = ExecutorSpec::ClaudeCode {
16430 binary: None,
16431 model: None,
16432 permission_mode: Some("default".to_string()),
16433 inherit_user_config: None,
16434 forward_env: None,
16435 };
16436 for (resolution, mapping) in [
16437 (
16438 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16439 "claude_code:permission_mode=plan",
16440 ),
16441 (
16442 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16443 "claude_code:permission_mode=bypassPermissions",
16444 ),
16445 (
16446 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16447 "claude_code:permission_mode=acceptEdits",
16448 ),
16449 (
16450 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16451 "claude_code:permission_mode=dontAsk",
16452 ),
16453 (
16454 permission_resolution(
16455 SessionPermissionMode::Bypass,
16456 PermissionMode::BypassPermissions,
16457 ),
16458 "claude_code:permission_mode=default",
16459 ),
16460 ] {
16461 assert_eq!(
16462 expected_permission_executor_mapping(&claude, resolution, false)
16463 .unwrap()
16464 .as_deref(),
16465 Some(mapping)
16466 );
16467 }
16468 assert_eq!(
16469 expected_permission_executor_mapping(&claude, default, true)
16470 .unwrap()
16471 .as_deref(),
16472 Some("claude_code:blocked_explicit_deny")
16473 );
16474
16475 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16476 if let ExecutorSpec::Codex {
16477 approval_policy, ..
16478 } = &mut codex_exec
16479 {
16480 *approval_policy = Some("on-failure".to_string());
16481 }
16482 assert_eq!(
16483 expected_permission_executor_mapping(&codex_exec, default, false)
16484 .unwrap()
16485 .as_deref(),
16486 Some("codex_exec:approval_policy=on-failure")
16487 );
16488 assert_eq!(
16489 expected_permission_executor_mapping(
16490 &codex_exec,
16491 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16492 false,
16493 )
16494 .unwrap()
16495 .as_deref(),
16496 Some("codex_exec:approval_policy=never")
16497 );
16498 assert_eq!(
16499 expected_permission_executor_mapping(&codex_exec, default, true)
16500 .unwrap()
16501 .as_deref(),
16502 Some("codex_exec:blocked_explicit_deny")
16503 );
16504
16505 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16506 if let ExecutorSpec::Codex {
16507 mode,
16508 approval_policy,
16509 ..
16510 } = &mut codex_app_server
16511 {
16512 *mode = Some("app_server".to_string());
16513 *approval_policy = Some("on-request".to_string());
16514 }
16515 assert_eq!(
16516 expected_permission_executor_mapping(&codex_app_server, default, false)
16517 .unwrap()
16518 .as_deref(),
16519 Some("codex_app_server:approvalPolicy=on-request")
16520 );
16521 assert_eq!(
16522 expected_permission_executor_mapping(
16523 &codex_app_server,
16524 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16525 false,
16526 )
16527 .unwrap()
16528 .as_deref(),
16529 Some("codex_app_server:approvalPolicy=never")
16530 );
16531 assert_eq!(
16532 expected_permission_executor_mapping(&codex_app_server, default, true)
16533 .unwrap()
16534 .as_deref(),
16535 Some("codex_app_server:blocked_explicit_deny")
16536 );
16537 }
16538
16539 #[test]
16540 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16541 let project = tempfile::tempdir().unwrap();
16542 let managed = project.path().join(".bamboo/worktree/child-571");
16543 std::fs::create_dir_all(&managed).unwrap();
16544 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16545 let marker = project
16546 .path()
16547 .join(".bamboo/worktree/.bamboo-owned/child-571");
16548 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16549 std::fs::write(&marker, "bamboo/child-571").unwrap();
16550 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16551 let nested = managed.join("nested/path");
16552 std::fs::create_dir_all(&nested).unwrap();
16553 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16554
16555 let arbitrary = tempfile::tempdir().unwrap();
16556 assert!(!workspace_is_bamboo_owned(
16557 arbitrary.path().to_str().unwrap()
16558 ));
16559 }
16560
16561 #[test]
16562 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16563 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16564 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16565
16566 let (secrets, guard) = build_codex_run_secrets(
16567 &codex_executor(Some("bamboo"), None),
16568 Some(authority_dyn),
16569 "child-570",
16570 )
16571 .unwrap();
16572
16573 let token = secrets
16574 .codex_provider_token
16575 .as_ref()
16576 .expect("bamboo mode mints a token");
16577 assert_eq!(token.expose(), "bcx1_secret-child-570");
16578 assert!(!format!("{token:?}").contains("secret-child-570"));
16579 assert_eq!(
16580 authority.issued_for.lock().unwrap().as_slice(),
16581 ["child-570"]
16582 );
16583 assert!(authority.revoked.lock().unwrap().is_empty());
16584
16585 drop(guard);
16586 assert_eq!(
16587 authority.revoked.lock().unwrap().as_slice(),
16588 ["id-child-570"]
16589 );
16590 }
16591
16592 #[test]
16593 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16594 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16595 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16596 let (secrets, guard) = build_codex_run_secrets(
16597 &codex_executor(Some("custom"), None),
16598 Some(authority_dyn),
16599 "child-custom",
16600 )
16601 .unwrap();
16602 assert!(secrets.codex_provider_token.is_none());
16603 assert!(guard.is_none());
16604 assert!(authority.issued_for.lock().unwrap().is_empty());
16605
16606 let error = build_codex_run_secrets(
16607 &codex_executor(Some("bamboo"), None),
16608 None,
16609 "child-no-authority",
16610 )
16611 .err()
16612 .expect("bamboo mode without an authority must fail closed");
16613 assert!(error.to_string().contains("per-run token authority"));
16614 }
16615
16616 #[test]
16617 fn codex_provisioning_never_leaks_the_session_provider_credential() {
16618 let credentials = vec![ScopedCredential {
16619 provider: "openai".to_string(),
16620 api_key: "upstream-secret-must-not-cross".to_string(),
16621 base_url: None,
16622 provider_type: Some("openai".to_string()),
16623 credential_ref: Some("provider.openai.api_key".to_string()),
16624 }];
16625
16626 for (mode, label) in [
16627 (Some("inherit"), "inherit"),
16628 (Some("api_key"), "api_key"),
16629 (Some("bamboo"), "bamboo"),
16630 (None, "default-bamboo"),
16631 ] {
16632 let runner = ActorChildRunner::new(
16633 format!("codex-{label}-test"),
16634 PathBuf::from("/bin/false"),
16635 Vec::new(),
16636 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16637 codex_executor(mode, None),
16638 credentials.clone(),
16639 "openai".to_string(),
16640 1,
16641 );
16642 let mut session = Session::new(format!("child-{label}"), "model");
16643 session.add_message(bamboo_agent_core::Message::user("test"));
16644 let spec = runner.build_spec(
16645 &session,
16646 &crate::runtime::execution::SpawnJob {
16647 parent_session_id: "parent".to_string(),
16648 child_session_id: format!("child-{label}"),
16649 model: "gpt-5.4".to_string(),
16650 disabled_tools: None,
16651 },
16652 );
16653 assert!(
16654 spec.secrets.provider_credentials.is_empty(),
16655 "{label} Codex must not receive the session provider key"
16656 );
16657 }
16658 }
16659
16660 #[test]
16661 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16662 let credentials = vec![
16663 ScopedCredential {
16664 provider: "openai".to_string(),
16665 api_key: "selected-openai-secret".to_string(),
16666 base_url: None,
16667 provider_type: Some("openai".to_string()),
16668 credential_ref: Some("provider.openai.api_key".to_string()),
16669 },
16670 ScopedCredential {
16671 provider: "other".to_string(),
16672 api_key: "unrelated-secret".to_string(),
16673 base_url: None,
16674 provider_type: Some("openai".to_string()),
16675 credential_ref: Some("provider.other.api_key".to_string()),
16676 },
16677 ];
16678 let runner = ActorChildRunner::new(
16679 "echo-test".to_string(),
16680 PathBuf::from("/bin/false"),
16681 Vec::new(),
16682 std::env::temp_dir().join("bamboo-echo-provider-570"),
16683 ExecutorSpec::Echo,
16684 credentials,
16685 "openai".to_string(),
16686 1,
16687 );
16688 let spec = runner.build_spec(
16689 &Session::new("child-echo", "model"),
16690 &crate::runtime::execution::SpawnJob {
16691 parent_session_id: "parent".to_string(),
16692 child_session_id: "child-echo".to_string(),
16693 model: "gpt-5.4".to_string(),
16694 disabled_tools: None,
16695 },
16696 );
16697
16698 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16699 assert_eq!(
16700 spec.secrets.provider_credentials[0].api_key,
16701 "selected-openai-secret"
16702 );
16703 }
16704
16705 #[tokio::test]
16706 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16707 fn provider_instance(
16708 provider_type: &str,
16709 api_key: &str,
16710 ) -> bamboo_config::ProviderInstanceConfig {
16711 bamboo_config::ProviderInstanceConfig {
16712 provider_type: provider_type.to_string(),
16713 label: None,
16714 api_key: api_key.to_string(),
16715 api_key_encrypted: None,
16716 credential_ref: None,
16717 base_url: None,
16718 model: None,
16719 fast_model: None,
16720 vision_model: None,
16721 reasoning_effort: None,
16722 responses_only_models: Vec::new(),
16723 request_overrides: None,
16724 enabled: true,
16725 extra: Default::default(),
16726 }
16727 }
16728
16729 let mut initial = bamboo_llm::Config::default();
16730 initial.provider_instances.insert(
16731 "old-provider".to_string(),
16732 provider_instance("openai", "old-key"),
16733 );
16734 initial.default_provider_instance = Some("old-provider".to_string());
16735 let live = Arc::new(tokio::sync::RwLock::new(initial));
16736
16737 let runner = ActorChildRunner::new(
16738 "echo-live-config-test".to_string(),
16739 PathBuf::from("/bin/false"),
16740 Vec::new(),
16741 std::env::temp_dir().join("bamboo-echo-live-config"),
16742 ExecutorSpec::Echo,
16743 Vec::new(),
16744 "stale-provider".to_string(),
16745 1,
16746 )
16747 .with_live_provider_config(live.clone());
16748 let job = crate::runtime::execution::SpawnJob {
16749 parent_session_id: "parent".to_string(),
16750 child_session_id: "child".to_string(),
16751 model: "model".to_string(),
16752 disabled_tools: None,
16753 };
16754
16755 let first = runner
16756 .build_live_spec(&Session::new("child", "model"), &job)
16757 .await;
16758 assert_eq!(
16759 first.model.as_ref().map(|model| model.provider.as_str()),
16760 Some("old-provider")
16761 );
16762 assert_eq!(first.secrets.provider_credentials.len(), 1);
16763 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16764
16765 let mut reloaded = bamboo_llm::Config::default();
16766 reloaded.provider_instances.insert(
16767 "new-provider".to_string(),
16768 provider_instance("openai", "new-key"),
16769 );
16770 reloaded.default_provider_instance = Some("new-provider".to_string());
16771 *live.write().await = reloaded;
16772
16773 let second = runner
16774 .build_live_spec(&Session::new("child", "model"), &job)
16775 .await;
16776 assert_eq!(
16777 second.model.as_ref().map(|model| model.provider.as_str()),
16778 Some("new-provider")
16779 );
16780 assert_eq!(second.secrets.provider_credentials.len(), 1);
16781 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16782 }
16783
16784 #[test]
16785 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16786 let mut executor = codex_executor(Some("custom"), None);
16787 if let ExecutorSpec::Codex {
16788 base_url,
16789 provider_key_ref,
16790 ..
16791 } = &mut executor
16792 {
16793 *base_url = Some("https://provider.example/v1".to_string());
16794 *provider_key_ref = Some("provider.custom.api_key".to_string());
16795 }
16796 let credentials = vec![
16797 ScopedCredential {
16798 provider: "openai".to_string(),
16799 api_key: "session-provider-secret".to_string(),
16800 base_url: None,
16801 provider_type: Some("openai".to_string()),
16802 credential_ref: Some("provider.openai.api_key".to_string()),
16803 },
16804 ScopedCredential {
16805 provider: "custom".to_string(),
16806 api_key: "selected-secret".to_string(),
16807 base_url: None,
16808 provider_type: Some("openai".to_string()),
16809 credential_ref: Some("provider.custom.api_key".to_string()),
16810 },
16811 ScopedCredential {
16812 provider: "other".to_string(),
16813 api_key: "unrelated-secret".to_string(),
16814 base_url: None,
16815 provider_type: Some("openai".to_string()),
16816 credential_ref: Some("provider.other.api_key".to_string()),
16817 },
16818 ];
16819 let runner = ActorChildRunner::new(
16820 "codex-test".to_string(),
16821 PathBuf::from("/bin/false"),
16822 Vec::new(),
16823 std::env::temp_dir().join("bamboo-codex-570"),
16824 executor,
16825 credentials,
16826 "openai".to_string(),
16827 1,
16828 );
16829 let mut session = Session::new("child-custom", "model");
16830 session.add_message(bamboo_agent_core::Message::user("test"));
16831 let spec = runner.build_spec(
16832 &session,
16833 &crate::runtime::execution::SpawnJob {
16834 parent_session_id: "parent".to_string(),
16835 child_session_id: "child-custom".to_string(),
16836 model: "gpt-5.4".to_string(),
16837 disabled_tools: None,
16838 },
16839 );
16840
16841 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16842 assert_eq!(
16843 spec.secrets.provider_credentials[0]
16844 .credential_ref
16845 .as_deref(),
16846 Some("provider.custom.api_key")
16847 );
16848 assert_eq!(
16849 spec.secrets.provider_credentials[0].api_key,
16850 "selected-secret"
16851 );
16852 }
16853
16854 fn spec_with(
16855 role: &str,
16856 provider: &str,
16857 model: &str,
16858 workspace: Option<&str>,
16859 disabled: Option<Vec<&str>>,
16860 ) -> ProvisionSpec {
16861 let mut spec = ProvisionSpec::new(
16862 ChildIdentity {
16863 child_id: "c".into(),
16864 parent_id: None,
16865 project_key: None,
16866 role: role.into(),
16867 depth: 0,
16868 },
16869 ExecutorSpec::Echo,
16870 "/tmp/fab".into(),
16871 );
16872 spec.workspace = workspace.map(|w| w.to_string());
16873 spec.model = Some(ModelRefSpec {
16874 provider: provider.into(),
16875 model: model.into(),
16876 });
16877 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16878 spec
16879 }
16880
16881 #[test]
16882 fn fingerprint_matches_interchangeable_children() {
16883 let a = spec_with(
16886 "explorer",
16887 "p",
16888 "m",
16889 Some("/ws"),
16890 Some(vec!["Bash", "Edit"]),
16891 );
16892 let mut b = spec_with(
16893 "explorer",
16894 "p",
16895 "m",
16896 Some("/ws"),
16897 Some(vec!["Edit", "Bash"]),
16898 );
16899 b.identity.child_id = "other".into();
16900 assert_eq!(
16901 ActorChildRunner::fingerprint(&a),
16902 ActorChildRunner::fingerprint(&b)
16903 );
16904 }
16905
16906 #[test]
16907 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16908 let mut session =
16909 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16910 session.root_session_id = "logical-root-681".to_string();
16911 let job = SpawnJob {
16912 parent_session_id: "logical-parent-681".to_string(),
16913 child_session_id: "logical-child-681".to_string(),
16914 model: "model".to_string(),
16915 disabled_tools: None,
16916 };
16917 let expected = LogicalSessionIdentity {
16918 creation: None,
16919 session_id: "logical-child-681".to_string(),
16920 parent_session_id: Some("logical-parent-681".to_string()),
16921 root_session_id: "logical-root-681".to_string(),
16922 };
16923
16924 let placements_and_transport_ids = [
16925 (Placement::Local, "local-mailbox-first"),
16926 (
16927 Placement::Remote {
16928 endpoint: "wss://remote.example/actor".to_string(),
16929 },
16930 "remote-process-44",
16931 ),
16932 (
16933 Placement::Schedulable {
16934 pool: "gpu-pool".to_string(),
16935 },
16936 "scheduled-mailbox-9",
16937 ),
16938 (Placement::Local, "warm-mailbox-reused-77"),
16940 ];
16941 for (placement, transport_id) in placements_and_transport_ids {
16942 let mut provision = spec_with("worker", "provider", "model", None, None);
16943 provision.placement = placement;
16944 provision.identity.child_id = transport_id.to_string();
16945 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16946 assert_ne!(
16947 provision.identity.child_id, expected.session_id,
16948 "test fixture must prove transport identity is independent"
16949 );
16950 }
16951 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16952 original.executor = ExecutorSpec::BambooRuntime;
16953 original.identity.depth = session.spawn_depth;
16954 original.capabilities.required_child_context = true;
16955 original.capabilities.child_creation_identity = true;
16956 original.capabilities.enforce_permissions = true;
16957 original.capabilities.native_tool_ceiling_required = true;
16958 original.capabilities.native_tool_ceiling =
16959 Some(bamboo_subagent::proto::NativeToolCeiling {
16960 version: 1,
16961 child_session_id: session.id.clone(),
16962 parent_session_id: job.parent_session_id.clone(),
16963 root_session_id: session.root_session_id.clone(),
16964 created_at: session.created_at,
16965 spawn_depth: session.spawn_depth,
16966 project_id: None,
16967 tools: vec![],
16968 });
16969 original.validate().unwrap();
16970 let first = ActorChildRunner::fresh_required_worker_spec(&original);
16971 let second = ActorChildRunner::fresh_required_worker_spec(&original);
16972 assert_ne!(first.identity.child_id, second.identity.child_id);
16973 assert_ne!(first.identity.child_id, original.identity.child_id);
16974 for physical in [first, second] {
16975 physical.validate().unwrap();
16976 let mut actual = serde_json::to_value(&physical).unwrap();
16977 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
16978 assert_eq!(actual, serde_json::to_value(&original).unwrap());
16979 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16980 }
16981 }
16982
16983 #[test]
16984 fn fingerprint_separates_distinct_runtimes() {
16985 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
16986 let base_fp = ActorChildRunner::fingerprint(&base);
16987 assert_ne!(
16989 base_fp,
16990 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
16991 );
16992 assert_ne!(
16993 base_fp,
16994 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
16995 );
16996 assert_ne!(
16997 base_fp,
16998 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
16999 );
17000 assert_ne!(
17001 base_fp,
17002 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17003 );
17004 assert_ne!(
17005 base_fp,
17006 ActorChildRunner::fingerprint(&spec_with(
17007 "explorer",
17008 "p",
17009 "m",
17010 Some("/ws"),
17011 Some(vec!["Bash"])
17012 ))
17013 );
17014 }
17015
17016 #[test]
17017 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17018 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17019 before.secrets.provider_credentials.push(ScopedCredential {
17020 provider: "provider".to_string(),
17021 api_key: "old-secret-key".to_string(),
17022 base_url: Some("https://provider.example/v1".to_string()),
17023 provider_type: Some("openai".to_string()),
17024 credential_ref: None,
17025 });
17026 let mut after = before.clone();
17027 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17028
17029 let before_fingerprint = ActorChildRunner::fingerprint(&before);
17030 let after_fingerprint = ActorChildRunner::fingerprint(&after);
17031 assert_ne!(before_fingerprint, after_fingerprint);
17032 assert!(!before_fingerprint.contains("old-secret-key"));
17033 assert!(!after_fingerprint.contains("new-secret-key"));
17034 }
17035
17036 #[test]
17037 fn fingerprint_splits_on_baked_capabilities() {
17038 let base_fp =
17043 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17044
17045 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17046 creation.capabilities.child_creation_identity = true;
17047 let typed = ActorChildRunner::fingerprint(&creation);
17048 assert_ne!(
17049 base_fp, typed,
17050 "legacy worker pool cannot satisfy birth-required Run"
17051 );
17052 creation.identity.child_id = "different-logical-child".into();
17053 assert_eq!(
17054 typed,
17055 ActorChildRunner::fingerprint(&creation),
17056 "birth-required mode retains cross-Child pool reuse"
17057 );
17058
17059 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17060 depth.identity.depth = 2;
17061 assert_ne!(
17062 base_fp,
17063 ActorChildRunner::fingerprint(&depth),
17064 "depth must split"
17065 );
17066
17067 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17068 nested.capabilities.nested_spawn = true;
17069 assert_ne!(
17070 base_fp,
17071 ActorChildRunner::fingerprint(&nested),
17072 "nested_spawn must split"
17073 );
17074
17075 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17076 bypass.capabilities.bypass = true;
17077 assert_ne!(
17078 base_fp,
17079 ActorChildRunner::fingerprint(&bypass),
17080 "bypass must split"
17081 );
17082
17083 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17084 auto.capabilities.auto_approve_permissions = true;
17085 assert_ne!(
17086 base_fp,
17087 ActorChildRunner::fingerprint(&auto),
17088 "auto_approve_permissions must split"
17089 );
17090
17091 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17092 global_auto.capabilities.permission_requested_mode = "default".to_string();
17093 global_auto.capabilities.permission_effective_mode = "auto".to_string();
17094 global_auto.capabilities.auto_approve_permissions = true;
17095 let mut explicit_auto = global_auto.clone();
17096 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17097 assert_ne!(
17098 ActorChildRunner::fingerprint(&global_auto),
17099 ActorChildRunner::fingerprint(&explicit_auto),
17100 "permission_requested_mode must split global and explicit Auto"
17101 );
17102
17103 let mut plan_overlay = explicit_auto.clone();
17104 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17105 assert_ne!(
17106 ActorChildRunner::fingerprint(&explicit_auto),
17107 ActorChildRunner::fingerprint(&plan_overlay),
17108 "permission_effective_mode must split Plan overlay from Auto"
17109 );
17110
17111 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17112 enforce.capabilities.enforce_permissions = true;
17113 assert_ne!(
17114 base_fp,
17115 ActorChildRunner::fingerprint(&enforce),
17116 "enforce_permissions must split"
17117 );
17118
17119 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17120 cap.capabilities.max_spawn_depth = Some(8);
17121 assert_ne!(
17122 base_fp,
17123 ActorChildRunner::fingerprint(&cap),
17124 "max_spawn_depth must split"
17125 );
17126
17127 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17131 nha.capabilities.no_human_approver = true;
17132 assert_ne!(
17133 base_fp,
17134 ActorChildRunner::fingerprint(&nha),
17135 "no_human_approver must split"
17136 );
17137
17138 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17141 read_only.capabilities.read_only = true;
17142 assert_ne!(
17143 base_fp,
17144 ActorChildRunner::fingerprint(&read_only),
17145 "read_only must split"
17146 );
17147
17148 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17150 gro.capabilities.guardian_read_only = true;
17151 assert_ne!(
17152 base_fp,
17153 ActorChildRunner::fingerprint(&gro),
17154 "guardian_read_only must split"
17155 );
17156 }
17157
17158 #[test]
17159 fn fingerprint_splits_codex_exec_and_app_server_workers() {
17160 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17161 exec.executor = codex_executor(Some("inherit"), None);
17162 let mut app_server = exec.clone();
17163 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17164 *mode = Some("app_server".to_string());
17165 }
17166 assert_ne!(
17167 ActorChildRunner::fingerprint(&exec),
17168 ActorChildRunner::fingerprint(&app_server)
17169 );
17170 }
17171
17172 struct StaticDecider(bool);
17173
17174 #[async_trait]
17175 impl ChildApprovalDecider for StaticDecider {
17176 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17177 self.0
17178 }
17179 }
17180
17181 struct RecordingReviewer {
17182 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17183 }
17184
17185 #[async_trait]
17186 impl ChildApprovalReviewer for RecordingReviewer {
17187 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17188 let _ = self
17189 .reviewed
17190 .send((parent.to_string(), child.to_string(), request.clone()));
17191 true
17192 }
17193 }
17194
17195 struct SilentLink;
17200 #[async_trait]
17201 impl bamboo_subagent::ChildLink for SilentLink {
17202 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17203 Ok(())
17204 }
17205 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17206 std::future::pending().await
17207 }
17208 }
17209
17210 struct InstantTerminalLink {
17212 done: bool,
17213 }
17214
17215 struct ApprovalRoundTripLink {
17216 step: u8,
17217 approval_reply: Option<(String, bool)>,
17218 }
17219
17220 #[async_trait]
17221 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17222 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17223 if let ParentFrame::ApprovalReply { id, approved } = frame {
17224 self.approval_reply = Some((id, approved));
17225 self.step = 2;
17226 }
17227 Ok(())
17228 }
17229
17230 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17231 match self.step {
17232 0 => {
17233 self.step = 1;
17234 Ok(Some(ChildFrame::ApprovalRequest {
17235 id: "approval-1".into(),
17236 body: serde_json::json!({
17237 "tool_name": "Bash",
17238 "permission": "execute",
17239 "resource": "rm -rf target",
17240 "permission_request": {"reason_code": "hard_dangerous"}
17241 }),
17242 }))
17243 }
17244 1 => std::future::pending().await,
17245 2 => {
17246 self.step = 3;
17247 Ok(Some(ChildFrame::Terminal {
17248 status: TerminalStatus::Completed,
17249 result: Some("done".into()),
17250 error: None,
17251 final_event_watermark: None,
17252 transcript: vec![],
17253 }))
17254 }
17255 _ => std::future::pending().await,
17256 }
17257 }
17258 }
17259
17260 #[tokio::test]
17261 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17262 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17263 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17264 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17265 reviewed: review_tx,
17266 });
17267 let cancel = CancellationToken::new();
17268 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17269 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17270 let mut logical_session = Session::new("child-reviewer", "model");
17271 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17272 let mut link = ApprovalRoundTripLink {
17273 step: 0,
17274 approval_reply: None,
17275 };
17276
17277 let result = tokio::time::timeout(
17278 Duration::from_secs(1),
17279 drive(ActorDriveContext {
17280 expected_creation: None,
17281 client: &mut link,
17282 parent_session_id: "parent-reviewer",
17283 child_session_id: "child-reviewer",
17284 child_attempt: 0,
17285 approval_registry: None,
17286 approval_decider: None,
17287 approval_reviewer: Some(&reviewer),
17288 escalation_bridge: None,
17289 event_tx: &event_tx,
17290 cancel_token: &cancel,
17291 live_rx: &mut live_rx,
17292 delivery_rx: &mut delivery_rx,
17293 logical_session: &mut logical_session,
17294 expected_permission_posture: None,
17295 session_inbox_runtime: None,
17296 actor_directory_store: None,
17297 canonical_subagent_tool: None,
17298 ticket_service: None,
17299 activation_run_id: None,
17300 execution_epoch: 0,
17301 expected_source_actor_id: "child-reviewer",
17302 initial_inflight_claims: VecDeque::new(),
17303 plain_actor: false,
17304 remote_environment_lease: false,
17305 readonly_output: None,
17306 local_history_tools: None,
17307 local_history_read_only: false,
17308 strict_history_output: None,
17309 plain_input: None,
17310 canonical_activation: None,
17311 canonical_placement_ref: None,
17312 actor_event_observer: None,
17313 plain_run: None,
17314 first_frame_timeout: None,
17315 }),
17316 )
17317 .await
17318 .expect("worker must receive the reviewer verdict before terminating");
17319
17320 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17321 assert_eq!(
17322 link.approval_reply,
17323 Some(("approval-1".to_string(), true)),
17324 "reviewer verdict must traverse the live route back to the worker"
17325 );
17326 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17327 .await
17328 .expect("reviewer should be invoked off-loop")
17329 .expect("review channel should remain open");
17330 assert_eq!(parent, "parent-reviewer");
17331 assert_eq!(child, "child-reviewer");
17332 assert_eq!(
17333 body.pointer("/permission_request/reason_code")
17334 .and_then(serde_json::Value::as_str),
17335 Some("hard_dangerous")
17336 );
17337 assert!(
17338 event_rx.try_recv().is_err(),
17339 "must not emit a human-review event"
17340 );
17341 drop(live_guard);
17342 }
17343
17344 #[tokio::test]
17345 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17346 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17347 let cancel = CancellationToken::new();
17348 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17349 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17350 let mut logical_session = Session::new("child-no-reviewer", "model");
17351 let mut link = ApprovalRoundTripLink {
17352 step: 0,
17353 approval_reply: None,
17354 };
17355
17356 let result = tokio::time::timeout(
17357 Duration::from_secs(1),
17358 drive(ActorDriveContext {
17359 expected_creation: None,
17360 client: &mut link,
17361 parent_session_id: "parent-no-reviewer",
17362 child_session_id: "child-no-reviewer",
17363 child_attempt: 0,
17364 approval_registry: None,
17365 approval_decider: None,
17366 approval_reviewer: None,
17367 escalation_bridge: None,
17368 event_tx: &event_tx,
17369 cancel_token: &cancel,
17370 live_rx: &mut live_rx,
17371 delivery_rx: &mut delivery_rx,
17372 logical_session: &mut logical_session,
17373 expected_permission_posture: None,
17374 session_inbox_runtime: None,
17375 actor_directory_store: None,
17376 canonical_subagent_tool: None,
17377 ticket_service: None,
17378 activation_run_id: None,
17379 execution_epoch: 0,
17380 expected_source_actor_id: "child-no-reviewer",
17381 initial_inflight_claims: VecDeque::new(),
17382 plain_actor: false,
17383 remote_environment_lease: false,
17384 readonly_output: None,
17385 local_history_tools: None,
17386 local_history_read_only: false,
17387 strict_history_output: None,
17388 plain_input: None,
17389 canonical_activation: None,
17390 canonical_placement_ref: None,
17391 actor_event_observer: None,
17392 plain_run: None,
17393 first_frame_timeout: None,
17394 }),
17395 )
17396 .await
17397 .expect("fail-closed reply must unblock the child immediately");
17398
17399 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17400 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17401 assert!(
17402 event_rx.try_recv().is_err(),
17403 "missing parent review must not surface a manual approval event"
17404 );
17405 }
17406 #[async_trait]
17407 impl bamboo_subagent::ChildLink for InstantTerminalLink {
17408 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17409 Ok(())
17410 }
17411 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17412 if self.done {
17413 std::future::pending().await
17414 } else {
17415 self.done = true;
17416 Ok(Some(ChildFrame::Terminal {
17417 status: TerminalStatus::Completed,
17418 result: Some("done".into()),
17419 error: None,
17420 final_event_watermark: None,
17421 transcript: vec![],
17422 }))
17423 }
17424 }
17425 }
17426
17427 #[tokio::test]
17428 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17429 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17430 let cancel = CancellationToken::new();
17431 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17432 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17433 let mut logical_session = Session::new("child-x", "model");
17434 let mut link = SilentLink;
17435 let r = drive(ActorDriveContext {
17436 expected_creation: None,
17437 client: &mut link,
17438 parent_session_id: "parent-x",
17439 child_session_id: "child-x",
17440 child_attempt: 0,
17441 approval_registry: None,
17442 approval_decider: None,
17443 approval_reviewer: None,
17444 escalation_bridge: None,
17445 event_tx: &event_tx,
17446 cancel_token: &cancel,
17447 live_rx: &mut live_rx,
17448 delivery_rx: &mut delivery_rx,
17449 logical_session: &mut logical_session,
17450 expected_permission_posture: None,
17451 session_inbox_runtime: None,
17452 actor_directory_store: None,
17453 canonical_subagent_tool: None,
17454 ticket_service: None,
17455 activation_run_id: None,
17456 execution_epoch: 0,
17457 expected_source_actor_id: "child-x",
17458 initial_inflight_claims: VecDeque::new(),
17459 plain_actor: false,
17460 remote_environment_lease: false,
17461 readonly_output: None,
17462 local_history_tools: None,
17463 local_history_read_only: false,
17464 strict_history_output: None,
17465 plain_input: None,
17466 canonical_activation: None,
17467 canonical_placement_ref: None,
17468 actor_event_observer: None,
17469 plain_run: None,
17470 first_frame_timeout: Some(Duration::from_millis(100)),
17471 })
17472 .await;
17473 assert!(
17474 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17475 "a silent worker must trip the first-frame watchdog, got {r:?}"
17476 );
17477 }
17478
17479 #[tokio::test]
17480 async fn drive_does_not_trip_when_a_frame_arrives() {
17481 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17482 let cancel = CancellationToken::new();
17483 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17484 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17485 let mut logical_session = Session::new("child-y", "model");
17486 let mut link = InstantTerminalLink { done: false };
17487 let r = drive(ActorDriveContext {
17490 expected_creation: None,
17491 client: &mut link,
17492 parent_session_id: "parent-y",
17493 child_session_id: "child-y",
17494 child_attempt: 0,
17495 approval_registry: None,
17496 approval_decider: None,
17497 approval_reviewer: None,
17498 escalation_bridge: None,
17499 event_tx: &event_tx,
17500 cancel_token: &cancel,
17501 live_rx: &mut live_rx,
17502 delivery_rx: &mut delivery_rx,
17503 logical_session: &mut logical_session,
17504 expected_permission_posture: None,
17505 session_inbox_runtime: None,
17506 actor_directory_store: None,
17507 canonical_subagent_tool: None,
17508 ticket_service: None,
17509 activation_run_id: None,
17510 execution_epoch: 0,
17511 expected_source_actor_id: "child-y",
17512 initial_inflight_claims: VecDeque::new(),
17513 plain_actor: false,
17514 remote_environment_lease: false,
17515 readonly_output: None,
17516 local_history_tools: None,
17517 local_history_read_only: false,
17518 strict_history_output: None,
17519 plain_input: None,
17520 canonical_activation: None,
17521 canonical_placement_ref: None,
17522 actor_event_observer: None,
17523 plain_run: None,
17524 first_frame_timeout: Some(Duration::from_millis(50)),
17525 })
17526 .await;
17527 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17528 }
17529
17530 #[tokio::test]
17531 async fn child_approval_fails_closed_without_decider() {
17532 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17534 assert!(!decide_child_approval(None, "child-1", &body).await);
17535 }
17536
17537 #[tokio::test]
17538 async fn child_approval_honors_wired_decider() {
17539 let body =
17540 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17541 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17542 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17543 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17544 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17545 }
17546
17547 use crate::runtime::execution::SpawnJob;
17550 use bamboo_agent_core::Session;
17551
17552 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17555 ActorChildRunner::new(
17556 "test-actor".into(),
17557 PathBuf::from("/bin/false"),
17558 vec![],
17559 std::env::temp_dir().join("bamboo-test-fab-193"),
17560 ExecutorSpec::Echo,
17561 vec![],
17562 "anthropic".into(),
17563 4,
17564 )
17565 .with_remote_placements(placements)
17566 }
17567
17568 fn session_of_role(role: &str, assignment: &str) -> Session {
17571 let mut s = Session::new("child-1", "test-model");
17572 s.metadata
17573 .insert("subagent_type".to_string(), role.to_string());
17574 s.add_message(bamboo_agent_core::Message::user(assignment));
17575 s
17576 }
17577
17578 fn job_for(child: &str) -> SpawnJob {
17579 SpawnJob {
17580 parent_session_id: "parent-1".into(),
17581 child_session_id: child.into(),
17582 model: String::new(),
17583 disabled_tools: None,
17584 }
17585 }
17586
17587 #[tokio::test]
17588 async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17589 let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17590 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17591 let mut remote = HashMap::new();
17592 remote.insert(
17593 "remote-role".into(),
17594 ResolvedRemotePlacement {
17595 endpoint: "wss://worker.example:8443".into(),
17596 token: None,
17597 ca_cert_file: None,
17598 host_label: None,
17599 broker_peer: None,
17600 requirements: None,
17601 },
17602 );
17603 let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17604 let root = Session::new("provision-root", "model");
17605 for role in ["local-role", "remote-role"] {
17606 let mut child =
17607 Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17608 child.metadata.insert("subagent_type".into(), role.into());
17609 let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17610 assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17611 assert!(!first.capabilities.nested_spawn);
17612 assert_eq!(
17613 matches!(first.placement, Placement::Remote { .. }),
17614 role == "remote-role"
17615 );
17616 live.write().await.subagents_mut().max_spawn_depth = Some(7);
17617 let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17618 assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17619 assert!(second.capabilities.nested_spawn);
17620 assert_ne!(
17621 ActorChildRunner::fingerprint(&first),
17622 ActorChildRunner::fingerprint(&second)
17623 );
17624 assert_eq!(second.identity.depth, 1);
17625 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17626 }
17627 let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17628 let spec = fallback.build_spec(&root, &job_for(&root.id));
17629 assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17630 assert!(!spec.capabilities.nested_spawn);
17631 }
17632
17633 struct RecordingChildSessionPort {
17634 parent: Session,
17635 saved: std::sync::Mutex<Option<Session>>,
17636 }
17637
17638 impl RecordingChildSessionPort {
17639 fn saved_child(&self) -> Session {
17640 self.saved
17641 .lock()
17642 .expect("saved-child fixture lock")
17643 .clone()
17644 .expect("create_child_action must save the child")
17645 }
17646 }
17647
17648 #[async_trait]
17649 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17650 async fn load_root_session(
17651 &self,
17652 root_id: &str,
17653 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17654 assert_eq!(root_id, self.parent.id);
17655 Ok(self.parent.clone())
17656 }
17657
17658 async fn load_child_for_parent(
17659 &self,
17660 _parent_id: &str,
17661 _child_id: &str,
17662 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17663 unreachable!("create_child_action does not reload the child")
17664 }
17665
17666 async fn save_child_session(
17667 &self,
17668 child: &mut Session,
17669 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17670 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17671 Ok(())
17672 }
17673
17674 async fn save_child_session_authoritative_flags(
17675 &self,
17676 _child: &mut Session,
17677 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17678 unreachable!("new-child creation uses the ordinary save")
17679 }
17680
17681 async fn is_child_running(&self, _child_id: &str) -> bool {
17682 false
17683 }
17684
17685 async fn list_children(
17686 &self,
17687 _parent_id: &str,
17688 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17689 Vec::new()
17690 }
17691
17692 async fn enqueue_child_run(
17693 &self,
17694 _parent: &Session,
17695 _child: &Session,
17696 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17697 unreachable!("fixture creates the child with auto_run=false")
17698 }
17699
17700 async fn cancel_child_run_and_wait(
17701 &self,
17702 _child_id: &str,
17703 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17704 unreachable!("create_child_action does not cancel")
17705 }
17706
17707 async fn delete_child_session(
17708 &self,
17709 _parent_id: &str,
17710 _child_id: &str,
17711 ) -> Result<
17712 crate::session_app::child_session::DeleteChildResult,
17713 crate::session_app::child_session::ChildSessionError,
17714 > {
17715 unreachable!("create_child_action does not delete")
17716 }
17717
17718 async fn get_child_runner_info(
17719 &self,
17720 _child_id: &str,
17721 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17722 None
17723 }
17724
17725 async fn register_parent_wait_for_child(
17726 &self,
17727 _parent_session_id: &str,
17728 _child_session_id: &str,
17729 _tool_call_id: Option<&str>,
17730 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17731 unreachable!("create_child_action does not register a wait")
17732 }
17733
17734 async fn register_parent_wait_for_children(
17735 &self,
17736 _parent_session_id: &str,
17737 _child_session_ids: &[String],
17738 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17739 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17740 unreachable!("create_child_action does not register a wait")
17741 }
17742
17743 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17744 Vec::new()
17745 }
17746
17747 async fn find_resident_child(
17748 &self,
17749 _root_session_id: &str,
17750 _resident_name: &str,
17751 ) -> Option<String> {
17752 None
17753 }
17754
17755 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17756 }
17757
17758 #[test]
17759 fn build_spec_sets_remote_placement_for_matching_role() {
17760 let mut placements = HashMap::new();
17761 placements.insert(
17762 "explorer".to_string(),
17763 ResolvedRemotePlacement {
17764 endpoint: "wss://gpu-host:8443".into(),
17765 token: Some("T-secret".into()),
17766 ca_cert_file: None,
17767 host_label: None,
17768 broker_peer: None,
17769 requirements: None,
17770 },
17771 );
17772 let runner = bogus_runner(placements);
17773
17774 let s = session_of_role("explorer", "do the thing");
17776 let spec = runner.build_spec(&s, &job_for("child-1"));
17777 match &spec.placement {
17778 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17779 other => panic!("expected Remote, got {other:?}"),
17780 }
17781 assert!(spec.secrets.worker_auth_token.is_none());
17782 }
17783
17784 #[test]
17785 fn build_spec_leaves_local_for_unmatched_role() {
17786 let mut placements = HashMap::new();
17787 placements.insert(
17788 "explorer".to_string(),
17789 ResolvedRemotePlacement {
17790 endpoint: "wss://gpu-host:8443".into(),
17791 token: Some("T".into()),
17792 ca_cert_file: None,
17793 host_label: None,
17794 broker_peer: None,
17795 requirements: None,
17796 },
17797 );
17798 let runner = bogus_runner(placements);
17799
17800 let s = session_of_role("writer", "do the thing");
17802 let spec = runner.build_spec(&s, &job_for("child-1"));
17803 assert_eq!(spec.placement, Placement::Local);
17804 assert!(spec.secrets.worker_auth_token.is_none());
17805 }
17806
17807 #[test]
17808 fn build_spec_local_when_no_placements() {
17809 let runner = bogus_runner(HashMap::new());
17810 let s = session_of_role("explorer", "do the thing");
17811 let spec = runner.build_spec(&s, &job_for("child-1"));
17812 assert_eq!(spec.placement, Placement::Local);
17813 assert!(spec.secrets.worker_auth_token.is_none());
17814 }
17815
17816 #[test]
17817 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17818 for mode in [
17819 bamboo_domain::SessionPermissionMode::Auto,
17820 bamboo_domain::SessionPermissionMode::Bypass,
17821 ] {
17822 let runner = bogus_runner(HashMap::new());
17823 let mut child = session_of_role("planner", "inspect and plan");
17824 let runtime = child
17825 .agent_runtime_state
17826 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17827 runtime.set_permission_mode(mode);
17828 runtime.read_only = true;
17829
17830 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17831
17832 assert!(spec.capabilities.read_only);
17833 assert!(spec.capabilities.read_only_enforced());
17834 assert!(
17835 spec.capabilities.guardian_read_only,
17836 "the compatibility alias must protect planners on older workers"
17837 );
17838 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17839 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17840 assert!(
17841 !spec.capabilities.bypass,
17842 "read-only must win over requested {mode:?}"
17843 );
17844 assert_eq!(
17845 spec.capabilities.auto_approve_permissions,
17846 mode == bamboo_domain::SessionPermissionMode::Auto,
17847 "Auto may suppress prompts but must not widen Plan authorization"
17848 );
17849 let disabled = spec
17850 .disabled_tools
17851 .as_ref()
17852 .expect("read-only state must provision a host-owned denylist");
17853 for tool_name in [
17854 "Edit",
17855 "Write",
17856 "Plan",
17857 "SubAgent",
17858 "session_control",
17859 "request_permissions",
17860 ] {
17861 assert!(
17862 disabled.iter().any(|name| name == tool_name),
17863 "read-only provisioning must disable {tool_name}"
17864 );
17865 }
17866 }
17867
17868 let runner = bogus_runner(HashMap::new());
17869 let cosmetic_planner = runner.build_spec(
17870 &session_of_role("planner", "label only"),
17871 &job_for("cosmetic"),
17872 );
17873 assert!(
17874 !cosmetic_planner.capabilities.read_only_enforced(),
17875 "a cosmetic role label must not change authority"
17876 );
17877 assert!(cosmetic_planner.disabled_tools.is_none());
17878
17879 let cosmetic_guardian = runner.build_spec(
17880 &session_of_role("guardian", "label only"),
17881 &job_for("cosmetic-guardian"),
17882 );
17883 assert!(
17884 !cosmetic_guardian.capabilities.read_only_enforced(),
17885 "a Guardian label without typed state or the legacy denylist is cosmetic"
17886 );
17887
17888 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17889 let mut legacy_job = job_for("legacy-guardian");
17890 legacy_job.disabled_tools = Some(vec![
17891 "Edit".to_string(),
17892 "Write".to_string(),
17893 "SubAgent".to_string(),
17894 "WebFetch".to_string(),
17895 ]);
17896 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17897 assert!(
17898 legacy_spec.capabilities.read_only_enforced(),
17899 "an in-flight pre-upgrade Guardian keeps its read-only authority"
17900 );
17901
17902 let mut differently_named = session_of_role("explorer", "typed authority");
17903 differently_named
17904 .agent_runtime_state
17905 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17906 .read_only = true;
17907 let typed = runner.build_spec(&differently_named, &job_for("typed"));
17908 assert!(
17909 typed.capabilities.read_only_enforced(),
17910 "typed state must enforce read-only independently of role"
17911 );
17912 }
17913
17914 #[test]
17915 fn typed_read_only_forces_native_external_executor_profiles() {
17916 let mut planner = session_of_role("planner", "inspect and plan");
17917 planner
17918 .agent_runtime_state
17919 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17920 .read_only = true;
17921
17922 let claude_runner = ActorChildRunner::new(
17923 "test-actor".into(),
17924 PathBuf::from("/bin/false"),
17925 vec![],
17926 std::env::temp_dir().join("bamboo-test-plan-claude"),
17927 ExecutorSpec::ClaudeCode {
17928 binary: None,
17929 model: None,
17930 permission_mode: Some("bypassPermissions".to_string()),
17931 inherit_user_config: Some(true),
17932 forward_env: None,
17933 },
17934 vec![],
17935 "anthropic".into(),
17936 4,
17937 );
17938 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17939 let disabled = claude_spec
17940 .disabled_tools
17941 .as_ref()
17942 .expect("typed read-only child must receive a host-owned denylist");
17943 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17944 assert!(
17945 disabled.iter().any(|name| name == tool),
17946 "typed read-only child must not advertise {tool}"
17947 );
17948 }
17949 assert!(matches!(
17950 claude_spec.executor,
17951 ExecutorSpec::ClaudeCode {
17952 permission_mode: Some(ref mode),
17953 inherit_user_config: Some(false),
17954 ..
17955 } if mode == "plan"
17956 ));
17957 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17958
17959 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17960 if let ExecutorSpec::Codex {
17961 sandbox,
17962 approval_policy,
17963 network_access,
17964 allow_danger_bypass,
17965 permission_profile,
17966 ..
17967 } = &mut codex_exec
17968 {
17969 *sandbox = Some("danger-full-access".to_string());
17970 *approval_policy = Some("on-failure".to_string());
17971 *network_access = Some(true);
17972 *allow_danger_bypass = Some(true);
17973 *permission_profile = Some("full-access".to_string());
17974 }
17975 let codex_runner = ActorChildRunner::new(
17976 "test-actor".into(),
17977 PathBuf::from("/bin/false"),
17978 vec![],
17979 std::env::temp_dir().join("bamboo-test-plan-codex"),
17980 codex_exec,
17981 vec![],
17982 "openai".into(),
17983 4,
17984 );
17985 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
17986 assert_eq!(
17987 expected_permission_executor_mapping(
17988 &codex_spec.executor,
17989 codex_spec.capabilities.permission_resolution().unwrap(),
17990 false,
17991 )
17992 .unwrap()
17993 .as_deref(),
17994 Some("codex_exec:approval_policy=never")
17995 );
17996 assert!(matches!(
17997 codex_spec.executor,
17998 ExecutorSpec::Codex {
17999 sandbox: Some(ref sandbox),
18000 inherit_user_config: Some(false),
18001 approval_policy: Some(ref approval_policy),
18002 network_access: Some(false),
18003 allow_danger_bypass: Some(false),
18004 permission_profile: Some(ref profile),
18005 ..
18006 } if sandbox == "read-only"
18007 && approval_policy == "never"
18008 && profile == "read-only"
18009 ));
18010
18011 let mut app_server = codex_executor(Some("inherit"), Some(true));
18012 if let ExecutorSpec::Codex {
18013 mode,
18014 sandbox,
18015 approval_policy,
18016 network_access,
18017 allow_danger_bypass,
18018 permission_profile,
18019 ..
18020 } = &mut app_server
18021 {
18022 *mode = Some("app_server".to_string());
18023 *sandbox = Some("danger-full-access".to_string());
18024 *approval_policy = Some("on-request".to_string());
18025 *network_access = Some(true);
18026 *allow_danger_bypass = Some(true);
18027 *permission_profile = Some("full-access".to_string());
18028 }
18029 let app_server_runner = ActorChildRunner::new(
18030 "test-actor".into(),
18031 PathBuf::from("/bin/false"),
18032 vec![],
18033 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18034 app_server,
18035 vec![],
18036 "openai".into(),
18037 4,
18038 );
18039 let app_server_spec =
18040 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18041 assert_eq!(
18042 expected_permission_executor_mapping(
18043 &app_server_spec.executor,
18044 app_server_spec
18045 .capabilities
18046 .permission_resolution()
18047 .unwrap(),
18048 false,
18049 )
18050 .unwrap()
18051 .as_deref(),
18052 Some("codex_app_server:approvalPolicy=never")
18053 );
18054 assert!(matches!(
18055 app_server_spec.executor,
18056 ExecutorSpec::Codex {
18057 mode: Some(ref mode),
18058 sandbox: Some(ref sandbox),
18059 inherit_user_config: Some(false),
18060 approval_policy: Some(ref approval_policy),
18061 network_access: Some(false),
18062 allow_danger_bypass: Some(false),
18063 permission_profile: Some(ref profile),
18064 ..
18065 } if mode == "app_server"
18066 && sandbox == "read-only"
18067 && approval_policy == "on-request"
18068 && profile == "read-only"
18069 ));
18070 }
18071
18072 #[test]
18073 fn typed_read_only_rejects_both_codex_executor_modes() {
18074 let mut planner = session_of_role("planner", "inspect and plan");
18075 planner
18076 .agent_runtime_state
18077 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18078 .read_only = true;
18079
18080 for mode in ["exec", "app_server"] {
18081 let mut executor = codex_executor(Some("inherit"), Some(true));
18082 if let ExecutorSpec::Codex {
18083 mode: configured_mode,
18084 ..
18085 } = &mut executor
18086 {
18087 *configured_mode = Some(mode.to_string());
18088 }
18089 let runner = ActorChildRunner::new(
18090 "test-actor".into(),
18091 PathBuf::from("/bin/false"),
18092 vec![],
18093 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18094 executor,
18095 vec![],
18096 "openai".into(),
18097 4,
18098 );
18099 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18100 let error = ensure_read_only_activation_is_enforceable(&spec)
18101 .expect_err("Codex command execution must fail closed for read-only activations");
18102 let message = error.to_string();
18103 assert!(message.contains("Codex executor"), "{message}");
18104 assert!(message.contains("command execution"), "{message}");
18105 }
18106 }
18107
18108 #[tokio::test]
18109 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18110 let mut planner = session_of_role("planner", "inspect and plan");
18111 planner
18112 .agent_runtime_state
18113 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18114 .read_only = true;
18115
18116 let runner = ActorChildRunner::new(
18117 "test-actor".into(),
18118 PathBuf::from("/bin/false"),
18119 vec![],
18120 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18121 codex_executor(Some("inherit"), Some(true)),
18122 vec![],
18123 "openai".into(),
18124 4,
18125 );
18126 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18127 let error = runner
18128 .execute_external_child(
18129 &mut planner,
18130 &job_for("planner-codex-no-spawn"),
18131 event_tx,
18132 CancellationToken::new(),
18133 )
18134 .await
18135 .expect_err("typed read-only Codex must fail before invoking /bin/false");
18136 let message = error.to_string();
18137 assert!(message.contains("Codex executor"), "{message}");
18138 assert!(message.contains("command execution"), "{message}");
18139 }
18140
18141 #[test]
18142 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18143 let runner = bogus_runner(HashMap::new());
18144 let ordinary = session_of_role("worker", "ordinary work");
18145 for placement in [
18146 Placement::Local,
18147 Placement::Remote {
18148 endpoint: "wss://fixture.invalid/actor".into(),
18149 },
18150 Placement::Schedulable {
18151 pool: "fixture-pool".into(),
18152 },
18153 ] {
18154 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18155 spec.placement = placement;
18156 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18157 for marker in [
18158 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18159 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18160 ] {
18161 let mut ticket = ordinary.clone();
18162 ticket.metadata.insert(marker.into(), "fixture".into());
18163 assert_eq!(
18164 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18165 matches!(spec.placement, Placement::Local)
18166 );
18167 }
18168 }
18169 }
18170
18171 #[tokio::test]
18172 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18173 for marker in [
18174 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18175 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18176 ] {
18177 for schedulable in [false, true] {
18178 let mut session = session_of_role("worker", "Ticket work");
18179 session.metadata.insert(marker.into(), "fixture".into());
18180 let before = serde_json::to_value(&session).unwrap();
18181 let remote = if schedulable {
18182 HashMap::new()
18183 } else {
18184 HashMap::from([(
18185 "worker".into(),
18186 ResolvedRemotePlacement {
18187 endpoint: "wss://fixture.invalid/actor".into(),
18188 ..Default::default()
18189 },
18190 )])
18191 };
18192 let sched = if schedulable {
18193 HashMap::from([(
18194 "worker".into(),
18195 sched_placement("fixture-pool", "wss://fixture.invalid"),
18196 )])
18197 } else {
18198 HashMap::new()
18199 };
18200 let runner = bogus_sched_runner(remote, sched);
18201 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18202 let error = runner
18203 .execute_external_child(
18204 &mut session,
18205 &job_for("ticket-no-remote"),
18206 events,
18207 CancellationToken::new(),
18208 )
18209 .await
18210 .unwrap_err();
18211 assert!(
18212 error
18213 .to_string()
18214 .contains("Ticket execution requires local placement"),
18215 "{error}"
18216 );
18217 assert_eq!(serde_json::to_value(&session).unwrap(), before);
18218 assert!(observations.try_recv().is_err());
18219 }
18220 }
18221 }
18222
18223 #[test]
18224 fn typed_read_only_rejects_resident_actor_placements() {
18225 let runner = bogus_runner(HashMap::new());
18226 let mut planner = session_of_role("planner", "inspect and plan");
18227 planner
18228 .agent_runtime_state
18229 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18230 .read_only = true;
18231
18232 for placement in [
18233 Placement::Remote {
18234 endpoint: "wss://resident.example/actor".to_string(),
18235 },
18236 Placement::Schedulable {
18237 pool: "resident-pool".to_string(),
18238 },
18239 ] {
18240 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18241 spec.placement = placement;
18242
18243 let error = ensure_read_only_activation_is_enforceable(&spec)
18244 .expect_err("resident placement must fail closed for read-only activation");
18245 let message = error.to_string();
18246 assert!(message.contains("requires local placement"), "{message}");
18247 assert!(message.contains("read-only tool boundary"), "{message}");
18248 }
18249
18250 let local = runner.build_spec(&planner, &job_for("read-only-local"));
18251 ensure_read_only_activation_is_enforceable(&local)
18252 .expect("a locally provisioned read-only worker is enforceable");
18253 }
18254
18255 #[tokio::test]
18256 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18257 for (label, mode) in [
18261 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18262 ("auto", bamboo_domain::SessionPermissionMode::Auto),
18263 ] {
18264 let runner = bogus_runner(HashMap::new());
18265 let mut parent = Session::new(format!("parent-{label}"), "test-model");
18266 parent
18267 .agent_runtime_state
18268 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18269 .set_permission_mode(mode);
18270 let workspace = tempfile::tempdir().expect("workspace fixture");
18271 let port = RecordingChildSessionPort {
18272 parent: parent.clone(),
18273 saved: Default::default(),
18274 };
18275 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18276 crate::session_app::child_session::create_child_action(
18277 &port,
18278 crate::session_app::child_session::CreateChildInput {
18279 parent_session: parent,
18280 child_id: child_id.clone(),
18281 title: format!("{label} child"),
18282 responsibility: "Run ordinary commands".to_string(),
18283 assignment_prompt: "run an ordinary command".to_string(),
18284 subagent_type: "explorer".to_string(),
18285 workspace: workspace.path().to_string_lossy().into_owned(),
18286 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18287 model_override: None,
18288 model_ref_override: None,
18289 runtime_metadata: HashMap::new(),
18290 read_only: false,
18291 auto_run: false,
18292 reasoning_effort: None,
18293 lifecycle: None,
18294 resident_name: None,
18295 resident_context: None,
18296 disabled_tools: None,
18297 context_fork: None,
18298 },
18299 )
18300 .await
18301 .expect("create inherited-permission child");
18302 let child = port.saved_child();
18303
18304 assert_eq!(
18305 child
18306 .agent_runtime_state
18307 .as_ref()
18308 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18309 Some(mode),
18310 "create_child_action must inherit {label} from the parent"
18311 );
18312
18313 let spec = runner.build_spec(&child, &job_for(&child_id));
18314
18315 assert_eq!(
18316 spec.capabilities.bypass,
18317 mode == bamboo_domain::SessionPermissionMode::Bypass
18318 );
18319 assert_eq!(
18320 spec.capabilities.auto_approve_permissions,
18321 mode == bamboo_domain::SessionPermissionMode::Auto
18322 );
18323 assert!(
18324 spec.capabilities.enforce_permissions,
18325 "policy evaluation must remain active under {label}"
18326 );
18327 }
18328 }
18329
18330 #[tokio::test]
18331 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18332 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18333 let workspace = tempfile::tempdir().expect("workspace fixture");
18334
18335 for (role, lifecycle, resident_name) in [
18336 ("explorer", None, None),
18337 ("resident", Some("resident"), Some("stable-reviewer")),
18338 ("guardian", None, None),
18339 ] {
18340 let mut parent = Session::new(format!("parent-{role}"), "test-model");
18341 parent.set_project_id_meta(project_id.to_string());
18342 let port = RecordingChildSessionPort {
18343 parent: parent.clone(),
18344 saved: Default::default(),
18345 };
18346 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18347 crate::session_app::child_session::create_child_action(
18348 &port,
18349 crate::session_app::child_session::CreateChildInput {
18350 parent_session: parent,
18351 child_id: child_id.clone(),
18352 title: format!("{role} child"),
18353 responsibility: "Review the assigned work".to_string(),
18354 assignment_prompt: "inspect the change".to_string(),
18355 subagent_type: role.to_string(),
18356 workspace: workspace.path().to_string_lossy().into_owned(),
18357 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18358 model_override: None,
18359 model_ref_override: None,
18360 runtime_metadata: HashMap::new(),
18361 read_only: false,
18362 auto_run: false,
18363 reasoning_effort: None,
18364 lifecycle: lifecycle.map(str::to_string),
18365 resident_name: resident_name.map(str::to_string),
18366 resident_context: None,
18367 disabled_tools: None,
18368 context_fork: None,
18369 },
18370 )
18371 .await
18372 .expect("create Project-inheriting child");
18373 let child = port.saved_child();
18374 assert_eq!(
18375 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18376 Some(project_id.clone()),
18377 "{role} child must inherit its parent's Project"
18378 );
18379
18380 assert_eq!(
18381 project_id_for_actor_run(&child).expect("valid actor Project identity"),
18382 Some(project_id.clone()),
18383 "{role} actor RunSpec must preserve inherited Project identity"
18384 );
18385 }
18386 }
18387
18388 #[test]
18389 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18390 assert_eq!(placement_metadata(&Placement::Local, None), None);
18392
18393 let r = placement_metadata(
18395 &Placement::Remote {
18396 endpoint: "wss://10.0.0.5:8443/stream".into(),
18397 },
18398 None,
18399 )
18400 .unwrap();
18401 assert!(r.contains(r#""kind":"remote""#), "{r}");
18402 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18403
18404 let labeled = placement_metadata(
18406 &Placement::Remote {
18407 endpoint: "ws://169.254.230.101:8899".into(),
18408 },
18409 Some("mini"),
18410 )
18411 .unwrap();
18412 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18413
18414 let s = placement_metadata(
18416 &Placement::Schedulable {
18417 pool: "explorers".into(),
18418 },
18419 Some("mini"),
18420 )
18421 .unwrap();
18422 assert!(s.contains(r#""kind":"remote""#), "{s}");
18423 assert!(s.contains(r#""host":"mini""#), "{s}");
18424
18425 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18427 assert_eq!(p.kind, "remote");
18428 assert_eq!(p.host, "mini");
18429 }
18430
18431 #[tokio::test]
18434 async fn execute_external_child_rejects_direct_remote_worker() {
18435 let mut placements = HashMap::new();
18436 placements.insert(
18437 "explorer".to_string(),
18438 ResolvedRemotePlacement {
18439 endpoint: "ws://127.0.0.1:1".into(),
18440 token: Some("remote-test-token".into()),
18441 ca_cert_file: None,
18442 host_label: Some("mini-e2e".into()),
18443 broker_peer: None,
18444 requirements: None,
18445 },
18446 );
18447 let runner = bogus_runner(placements);
18448 let mut session = session_of_role("explorer", "hello remote");
18449 let job = job_for("child-1");
18450 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18451 let error = runner
18452 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18453 .await
18454 .err()
18455 .expect("direct remote transport must fail closed");
18456 assert!(error
18457 .to_string()
18458 .contains("remote_broker_activation_unavailable"));
18459 assert!(session
18460 .messages
18461 .iter()
18462 .all(|message| message.role != Role::Assistant));
18463 }
18464
18465 fn bogus_sched_runner(
18471 remote: HashMap<String, ResolvedRemotePlacement>,
18472 sched: HashMap<String, ResolvedSchedulablePlacement>,
18473 ) -> ActorChildRunner {
18474 ActorChildRunner::new(
18475 "test-actor".into(),
18476 PathBuf::from("/bin/false"),
18477 vec![],
18478 std::env::temp_dir().join("bamboo-test-fab-181"),
18479 ExecutorSpec::Echo,
18480 vec![],
18481 "anthropic".into(),
18482 4,
18483 )
18484 .with_remote_placements(remote)
18485 .with_schedulable_placements(sched)
18486 }
18487
18488 fn sched_placement(
18489 pool: &str,
18490 _registry_url: impl Into<String>,
18491 ) -> ResolvedSchedulablePlacement {
18492 ResolvedSchedulablePlacement {
18493 pool: pool.into(),
18494 host_label: None,
18495 requirements: None,
18496 ..Default::default()
18497 }
18498 }
18499
18500 #[test]
18501 fn build_spec_sets_schedulable_placement_for_matching_role() {
18502 let mut sched = HashMap::new();
18503 sched.insert(
18504 "explorer".to_string(),
18505 sched_placement("gpu-pool", "unused"),
18506 );
18507 let runner = bogus_sched_runner(HashMap::new(), sched);
18508
18509 let s = session_of_role("explorer", "do the thing");
18510 let spec = runner.build_spec(&s, &job_for("child-1"));
18511 match &spec.placement {
18512 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18513 other => panic!("expected Schedulable, got {other:?}"),
18514 }
18515 assert!(spec.secrets.worker_auth_token.is_none());
18517 }
18518
18519 #[test]
18520 fn build_spec_remote_wins_when_role_in_both_maps() {
18521 let mut remote = HashMap::new();
18524 remote.insert(
18525 "explorer".to_string(),
18526 ResolvedRemotePlacement {
18527 endpoint: "wss://fixed-host:8443".into(),
18528 token: Some("T-remote".into()),
18529 ca_cert_file: None,
18530 host_label: None,
18531 broker_peer: None,
18532 requirements: None,
18533 },
18534 );
18535 let mut sched = HashMap::new();
18536 sched.insert(
18537 "explorer".to_string(),
18538 sched_placement("gpu-pool", "https://control-plane:9562"),
18539 );
18540 let runner = bogus_sched_runner(remote, sched);
18541
18542 let s = session_of_role("explorer", "do the thing");
18543 let spec = runner.build_spec(&s, &job_for("child-1"));
18544 match &spec.placement {
18545 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18546 other => panic!("expected Remote (precedence), got {other:?}"),
18547 }
18548 assert!(spec.secrets.worker_auth_token.is_none());
18549 }
18550
18551 #[test]
18552 fn build_spec_local_for_unmatched_schedulable_role() {
18553 let mut sched = HashMap::new();
18554 sched.insert(
18555 "explorer".to_string(),
18556 sched_placement("gpu-pool", "https://control-plane:9562"),
18557 );
18558 let runner = bogus_sched_runner(HashMap::new(), sched);
18559 let s = session_of_role("writer", "do the thing");
18560 let spec = runner.build_spec(&s, &job_for("child-1"));
18561 assert_eq!(spec.placement, Placement::Local);
18562 assert!(spec.secrets.worker_auth_token.is_none());
18563 }
18564
18565 #[test]
18570 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18571 let mut remote = HashMap::new();
18573 remote.insert(
18574 "explorer".to_string(),
18575 ResolvedRemotePlacement {
18576 endpoint: "ws://169.254.230.101:8899".into(),
18577 token: None,
18578 ca_cert_file: None,
18579 host_label: Some("mini".into()),
18580 broker_peer: None,
18581 requirements: None,
18582 },
18583 );
18584 let runner = bogus_runner(remote);
18585 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18586 let stamp = runner
18587 .placement_stamp_for(&spec)
18588 .expect("remote child is stamped");
18589 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18590 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18591
18592 let mut remote_nolabel = HashMap::new();
18594 remote_nolabel.insert(
18595 "explorer".to_string(),
18596 ResolvedRemotePlacement {
18597 endpoint: "ws://169.254.230.101:8899".into(),
18598 token: None,
18599 ca_cert_file: None,
18600 host_label: None,
18601 broker_peer: None,
18602 requirements: None,
18603 },
18604 );
18605 let r2 = bogus_runner(remote_nolabel);
18606 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18607 assert!(r2
18608 .placement_stamp_for(&spec2)
18609 .unwrap()
18610 .contains(r#""host":"169.254.230.101""#));
18611
18612 let mut sched = HashMap::new();
18614 sched.insert(
18615 "mac-mini-monitor".to_string(),
18616 ResolvedSchedulablePlacement {
18617 pool: "mac-mini-monitor".into(),
18618 host_label: Some("mini".into()),
18619 requirements: None,
18620 ..Default::default()
18621 },
18622 );
18623 let sr = bogus_sched_runner(HashMap::new(), sched);
18624 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18625 let stamp3 = sr
18626 .placement_stamp_for(&spec3)
18627 .expect("scheduled child is stamped");
18628 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18629 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18630
18631 let local = bogus_runner(HashMap::new());
18633 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18634 assert_eq!(local.placement_stamp_for(&spec4), None);
18635 }
18636
18637 #[tokio::test]
18638 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18639 let mut sched = HashMap::new();
18640 sched.insert(
18641 "explorer".to_string(),
18642 sched_placement("gpu-pool", "unused"),
18643 );
18644 let runner = bogus_sched_runner(HashMap::new(), sched);
18645 let mut session = session_of_role("explorer", "do the thing");
18646 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18647 let error = runner
18648 .execute_external_child(
18649 &mut session,
18650 &job_for("child-1"),
18651 event_tx,
18652 CancellationToken::new(),
18653 )
18654 .await
18655 .expect_err("legacy bus presence cannot grant placement");
18656 assert!(error
18657 .to_string()
18658 .contains("remote_broker_activation_unavailable"));
18659 }
18660}