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 const MAX_SPAWN_DEPTH: u32 = 4;
63
64const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
66
67const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
71
72const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
75
76const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
80
81const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
88
89fn active_scoped_session_deny_count(
90 config: &bamboo_tools::permission::PermissionConfig,
91 session_id: &str,
92) -> usize {
93 config
94 .temporary_grants()
95 .into_iter()
96 .filter(|grant| {
97 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
98 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
99 && grant.session_id.as_deref() == Some(session_id)
100 })
101 .count()
102}
103
104fn ensure_no_active_scoped_session_denies(
105 config: &bamboo_tools::permission::PermissionConfig,
106 session_id: &str,
107) -> Result<(), AgentError> {
108 let count = active_scoped_session_deny_count(config, session_id);
109 if count == 0 {
110 Ok(())
111 } else {
112 Err(AgentError::LLM(format!(
113 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
114 )))
115 }
116}
117
118pub struct IssuedCodexRunToken {
121 pub token_id: String,
122 pub token: String,
123}
124
125pub trait CodexRunTokenAuthority: Send + Sync + 'static {
128 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
129 fn revoke(&self, token_id: &str);
130}
131
132struct CodexRunTokenGuard {
133 authority: Arc<dyn CodexRunTokenAuthority>,
134 token_id: String,
135}
136
137impl Drop for CodexRunTokenGuard {
138 fn drop(&mut self) {
139 self.authority.revoke(&self.token_id);
140 }
141}
142
143fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
144 matches!(
145 executor,
146 ExecutorSpec::Codex {
147 auth_mode: Some(mode),
148 ..
149 } if mode == "bamboo"
150 ) || matches!(
151 executor,
152 ExecutorSpec::Codex {
153 auth_mode: None,
154 inherit_user_config,
155 ..
156 } if !inherit_user_config.unwrap_or(false)
157 )
158}
159
160fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
161 match executor {
162 ExecutorSpec::ClaudeCode {
163 permission_mode, ..
164 } => permission_mode
165 .as_deref()
166 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
167 ExecutorSpec::Codex {
168 permission_profile,
169 sandbox,
170 ..
171 } => {
172 permission_profile
173 .as_deref()
174 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
175 || sandbox
176 .as_deref()
177 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
178 }
179 _ => false,
180 }
181}
182
183fn expected_permission_executor_mapping(
191 executor: &ExecutorSpec,
192 resolution: bamboo_domain::PermissionModeResolution,
193 has_explicit_deny: bool,
194) -> Result<Option<String>, AgentError> {
195 let mapping = match executor {
196 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
197 ExecutorSpec::BambooRuntime => {
198 format!("bamboo_runtime:{}", resolution.effective.as_str())
199 }
200 ExecutorSpec::ClaudeCode {
201 permission_mode, ..
202 } => {
203 if has_explicit_deny {
204 "claude_code:blocked_explicit_deny".to_string()
205 } else {
206 let mode = match resolution.effective {
207 bamboo_domain::PermissionMode::Plan => "plan",
208 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
209 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
210 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
211 bamboo_domain::PermissionMode::Default
212 | bamboo_domain::PermissionMode::BypassPermissions => {
213 permission_mode.as_deref().unwrap_or("default")
214 }
215 };
216 format!("claude_code:permission_mode={mode}")
217 }
218 }
219 ExecutorSpec::Codex {
220 mode,
221 sandbox,
222 approval_policy,
223 allow_danger_bypass,
224 ..
225 } => match mode.as_deref().unwrap_or("exec") {
226 "exec" => {
227 let approval_policy = expected_codex_exec_approval_policy(
228 sandbox.as_deref(),
229 approval_policy.as_deref(),
230 allow_danger_bypass.unwrap_or(false),
231 resolution,
232 )?;
233 if has_explicit_deny {
234 "codex_exec:blocked_explicit_deny".to_string()
235 } else {
236 format!("codex_exec:approval_policy={approval_policy}")
237 }
238 }
239 "app_server" => {
240 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
241 return Err(AgentError::LLM(
242 "invalid Codex app-server permission posture configuration".to_string(),
243 ));
244 }
245 if has_explicit_deny {
246 "codex_app_server:blocked_explicit_deny".to_string()
247 } else {
248 let approval_policy = if resolution.suppress_approval_prompts()
249 || resolution.effective == bamboo_domain::PermissionMode::Plan
250 {
251 "never"
252 } else {
253 "on-request"
254 };
255 format!("codex_app_server:approvalPolicy={approval_policy}")
256 }
257 }
258 _ => {
259 return Err(AgentError::LLM(
260 "unsupported Codex executor mode for permission posture contract".to_string(),
261 ));
262 }
263 },
264 };
265 Ok(Some(mapping))
266}
267
268fn expected_codex_exec_approval_policy(
269 sandbox: Option<&str>,
270 approval_policy: Option<&str>,
271 allow_danger_bypass: bool,
272 resolution: bamboo_domain::PermissionModeResolution,
273) -> Result<&'static str, AgentError> {
274 let configured = match approval_policy {
275 None | Some("never") => "never",
276 Some("on-failure") => "on-failure",
277 Some(_) => {
278 return Err(AgentError::LLM(
279 "invalid Codex exec permission posture configuration".to_string(),
280 ));
281 }
282 };
283 if resolution.suppress_approval_prompts()
284 || resolution.effective == bamboo_domain::PermissionMode::Plan
285 {
286 return Ok("never");
287 }
288 match sandbox {
289 Some("danger-full-access") => Ok("never"),
290 Some("read-only") | Some("workspace-write") => Ok(configured),
291 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
292 None => Ok(configured),
293 Some(_) => Err(AgentError::LLM(
294 "invalid Codex exec permission posture configuration".to_string(),
295 )),
296 }
297}
298
299fn workspace_is_bamboo_owned(raw: &str) -> bool {
300 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
301 let configured_root = bamboo_config::paths::resolve_workspace_root();
302 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
303 if workspace.starts_with(&configured_root) {
304 return true;
305 }
306
307 workspace.ancestors().any(|candidate| {
312 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
313 return false;
314 };
315 let Some(worktree_root) = candidate.parent() else {
316 return false;
317 };
318 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
319 || worktree_root.parent().and_then(Path::file_name)
320 != Some(std::ffi::OsStr::new(".bamboo"))
321 {
322 return false;
323 }
324 let marker = worktree_root.join(".bamboo-owned").join(name);
325 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
326 })
327}
328
329fn build_codex_run_secrets(
330 executor: &ExecutorSpec,
331 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
332 child_session_id: &str,
333) -> Result<
334 (
335 bamboo_subagent::proto::RunSecrets,
336 Option<CodexRunTokenGuard>,
337 ),
338 AgentError,
339> {
340 if !executor_uses_bamboo_codex(executor) {
341 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
342 }
343
344 let authority = authority.ok_or_else(|| {
345 AgentError::LLM(
346 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
347 )
348 })?;
349 let issued = authority
350 .issue(child_session_id)
351 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
352 let guard = CodexRunTokenGuard {
353 authority,
354 token_id: issued.token_id,
355 };
356 Ok((
357 bamboo_subagent::proto::RunSecrets {
358 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
359 },
360 Some(guard),
361 ))
362}
363
364struct PooledWorker {
370 worker: SpawnedChild,
371 mailbox_id: String,
373 parked_at: Option<Instant>,
376}
377
378type WorkerPool = HashMap<String, Vec<PooledWorker>>;
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381enum PoolReapReason {
382 ProcessExited,
383 IdleTimeout,
384}
385
386impl PoolReapReason {
387 fn as_str(self) -> &'static str {
388 match self {
389 Self::ProcessExited => "process_exited_while_parked",
390 Self::IdleTimeout => "pool_idle_timeout",
391 }
392 }
393}
394
395fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
396 parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
397}
398
399fn idle_pool_limit_reason(
400 pool: &WorkerPool,
401 key: &str,
402 max_per_key: usize,
403 max_total: usize,
404) -> Option<&'static str> {
405 if pool.get(key).map_or(0, Vec::len) >= max_per_key {
406 Some("pool_per_key_limit")
407 } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
408 Some("pool_global_limit")
409 } else {
410 None
411 }
412}
413
414fn take_reapable_workers(
415 pool: &mut WorkerPool,
416 now: Instant,
417 timeout: Duration,
418) -> Vec<(String, PoolReapReason, PooledWorker)> {
419 let mut reaped = Vec::new();
420 for (key, bucket) in pool.iter_mut() {
421 let mut retained = Vec::with_capacity(bucket.len());
422 for mut worker in std::mem::take(bucket) {
423 let reason = if !worker.worker.is_alive() {
424 Some(PoolReapReason::ProcessExited)
425 } else if parked_worker_expired(worker.parked_at, now, timeout) {
426 Some(PoolReapReason::IdleTimeout)
427 } else {
428 None
429 };
430 if let Some(reason) = reason {
431 reaped.push((key.clone(), reason, worker));
432 } else {
433 retained.push(worker);
434 }
435 }
436 *bucket = retained;
437 }
438 pool.retain(|_, bucket| !bucket.is_empty());
439 reaped
440}
441
442async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
443 let now = Instant::now();
444 let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
445 let reaped = {
446 let mut pool = pool.lock().await;
447 take_reapable_workers(&mut pool, now, timeout)
448 };
449 let count = reaped.len();
450 for (pool_key, reason, worker) in reaped {
451 let idle_ms = worker
452 .parked_at
453 .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
454 tracing::info!(
455 pool_key,
456 mailbox_id = %worker.mailbox_id,
457 worker_pid = ?worker.worker.pid(),
458 worker_idle_ms = ?idle_ms,
459 shutdown_reason = reason.as_str(),
460 "reaping parked sub-agent worker"
461 );
462 worker.worker.kill().await;
463 }
464 count
465}
466
467#[derive(Clone, Default)]
472pub struct ResolvedRemotePlacement {
473 pub endpoint: String,
474 pub token: Option<String>,
475 pub ca_cert_file: Option<PathBuf>,
476 pub host_label: Option<String>,
480 pub broker_peer: Option<Result<bamboo_config::RemoteBrokerPeer, ()>>,
482 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
483}
484impl std::fmt::Debug for ResolvedRemotePlacement {
485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 f.debug_struct("ResolvedRemotePlacement")
487 .field("broker_peer", &self.broker_peer.is_some())
488 .finish_non_exhaustive()
489 }
490}
491
492#[derive(Debug, Clone, Default)]
499pub struct ResolvedSchedulablePlacement {
500 pub pool: String,
501 pub host_label: Option<String>,
505 pub endpoint: String,
506 pub token: Option<String>,
507 pub ca_cert_file: Option<PathBuf>,
508 pub broker_parent: Option<bamboo_config::SchedulableBrokerParent>,
509 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
510}
511
512#[derive(Clone)]
513struct ScopedPlacementRoute {
514 endpoint: String,
515 token: String,
516 ca_cert_file: PathBuf,
517 parent: bamboo_subagent::AgentRef,
518 worker_role: String,
519 pinned_mailbox: Option<String>,
520 requirements: bamboo_config::OperatorPlacementRequirements,
521}
522
523impl ScopedPlacementRoute {
524 async fn query(&self) -> Result<bamboo_broker::BrokerClient, AgentError> {
525 let tls = bamboo_broker::client_config_trusting_cert(&self.ca_cert_file)
526 .map_err(|_| remote_broker_unavailable())?;
527 bamboo_broker::BrokerClient::connect_with_tls(
528 &self.endpoint,
529 self.parent.clone(),
530 &self.token,
531 Some(tls),
532 )
533 .await
534 .map_err(|_| remote_broker_unavailable())
535 }
536}
537
538struct ActivePlacementLease {
543 registry: bamboo_storage::v2::FileHostRegistry,
544 actor_store: Arc<bamboo_storage::SessionStoreV2>,
545 route: ScopedPlacementRoute,
546 mailbox: String,
547 host_ref: String,
548 fence: ActorActivationFence,
549 placement_ref: bamboo_domain::ActorPlacementRef,
550 lease: Arc<tokio::sync::Mutex<WorkerSlotLease>>,
551 heartbeat: tokio::task::JoinHandle<()>,
552 run_cancel: CancellationToken,
553 lost: Arc<AtomicBool>,
554 release_on_drop: AtomicBool,
555}
556
557async fn fail_pre_dispatch_activation(
561 actor_store: &bamboo_storage::SessionStoreV2,
562 fence: &ActorActivationFence,
563) -> Result<(), bamboo_domain::ActorDirectoryError> {
564 actor_store
565 .finish_activation(fence, chrono::Utc::now(), ActorActivationFinish::Failed)
566 .await
567 .map(|_| ())
568}
569
570async fn send_actor_run(
574 client: &mut dyn bamboo_subagent::ChildLink,
575 spec: RunSpec,
576 send_invoked: &mut bool,
577) -> Result<(), AgentError> {
578 *send_invoked = true;
579 client
580 .send(ParentFrame::Run(spec))
581 .await
582 .map_err(|error| AgentError::LLM(format!("actor run dispatch failed: {error}")))
583}
584
585fn remote_activation_finish(
586 result: &Result<Option<String>, AgentError>,
587 accepted_terminal: Option<TerminalStatus>,
588) -> ActorActivationFinish {
589 if result.is_ok() {
590 ActorActivationFinish::Succeeded
591 } else if matches!(result, Err(AgentError::Cancelled))
592 && accepted_terminal == Some(TerminalStatus::Cancelled)
593 {
594 ActorActivationFinish::Cancelled
595 } else {
596 ActorActivationFinish::Failed
597 }
598}
599
600async fn append_fenced_remote_reply(
604 actor_store: &bamboo_storage::SessionStoreV2,
605 fence: &ActorActivationFence,
606 session: &mut Session,
607 text: &str,
608) -> Result<(), AgentError> {
609 let message = bamboo_agent_core::Message::assistant(text, None);
610 let committed = actor_store
611 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
612 fence: fence.clone(),
613 expected_created_at: session.created_at,
614 expected_messages: session.messages.clone(),
615 expected_provider_transcript: session.provider_transcript.clone(),
616 messages: vec![message],
617 native_groups: Vec::new(),
618 })
619 .await
620 .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
621 session.messages = committed.messages;
622 session.provider_transcript = committed.provider_transcript;
623 session.updated_at = committed.updated_at;
624 Ok(())
625}
626
627impl ActivePlacementLease {
628 async fn reserve(
629 registry: bamboo_storage::v2::FileHostRegistry,
630 actor_store: Arc<bamboo_storage::SessionStoreV2>,
631 route: ScopedPlacementRoute,
632 mailbox: String,
633 actor_id: String,
634 run_id: String,
635 project_id: Option<bamboo_domain::ProjectId>,
636 required_tools: BTreeSet<String>,
637 parent_cancel: &CancellationToken,
638 ) -> Result<Self, AgentError> {
639 let scheduler = PlacementScheduler::new(registry.clone());
640 let mut query = route.query().await?;
641 let observed = query
642 .observe_host_capacity(&mailbox, &route.worker_role)
643 .await
644 .map_err(|_| remote_broker_unavailable())?
645 .ok_or_else(remote_broker_unavailable)?;
646 let host = scheduler
647 .observe_scoped_host(
648 &mut query,
649 &observed.host_ref,
650 &mailbox,
651 &route.worker_role,
652 chrono::Duration::seconds(110),
653 )
654 .await
655 .map_err(|_| remote_broker_unavailable())?;
656 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
657 || host.connection_generation != observed.connection_generation
658 || route
659 .pinned_mailbox
660 .as_deref()
661 .is_some_and(|pinned| pinned != mailbox)
662 {
663 return Err(remote_broker_unavailable());
664 }
665 let now = chrono::Utc::now();
666 let request = HostPlacementRequest {
667 intent: HostPlacementIntent::PinnedHost {
668 host_ref: host.host_ref.clone(),
669 },
670 actor_id,
671 run_id,
672 project_id: project_id.clone(),
673 trust_zone: route.requirements.trust_zone.clone(),
674 workspace_label: route.requirements.workspace_label.clone(),
675 executor: "bamboo-runtime".into(),
676 required_tools,
677 network_zone: route.requirements.network_zone.clone(),
678 require_network_isolation: route.requirements.require_network_isolation,
679 preferred_host_ref: None,
680 now,
681 lease_expires_at: now + chrono::Duration::seconds(80),
682 };
683 if !request.eligible(&host) {
684 return Err(remote_broker_unavailable());
685 }
686 let lease = scheduler
687 .reserve(request)
688 .await
689 .map_err(|_| remote_broker_unavailable())?;
690 let placement_ref = bamboo_domain::ActorPlacementRef {
691 class: bamboo_domain::ActorPlacementClass::Remote,
692 lease_id: lease.lease_id.clone(),
693 slot_epoch: Some(lease.epoch),
694 };
695 let mut claimed_fence = None;
696 let actor_binding = async {
697 let actor = actor_store
698 .ensure_actor(&lease.actor_id)
699 .await
700 .map_err(|_| remote_broker_unavailable())?;
701 if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
702 return Err(remote_broker_unavailable());
703 }
704 let placement_intent = if route.pinned_mailbox.is_some() {
705 bamboo_domain::ActorPlacementIntent::PinnedHost {
706 host_ref: host.host_ref.clone(),
707 }
708 } else {
709 bamboo_domain::ActorPlacementIntent::Pool {
710 pool_id: route.worker_role.clone(),
711 }
712 };
713 actor_store
714 .bind_control_plane(
715 &lease.actor_id,
716 bamboo_domain::ActorControlPlaneBinding {
717 policy_revision: None,
718 placement_intent: Some(placement_intent),
719 },
720 )
721 .await
722 .map_err(|_| {
723 tracing::warn!(stage = "actor_bind", "remote placement admission failed");
724 remote_broker_unavailable()
725 })?;
726 let activation = actor_store
727 .claim_activation(&ActorActivationClaim {
728 actor_id: lease.actor_id.clone(),
729 run_id: lease.run_id.clone(),
730 lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
731 lease_expires_at: lease.expires_at,
732 inbox_generation: 0,
733 placement_ref: Some(placement_ref.clone()),
734 now: chrono::Utc::now(),
735 })
736 .await
737 .map_err(|_| {
738 tracing::warn!(stage = "actor_claim", "remote placement admission failed");
739 remote_broker_unavailable()
740 })?;
741 let fence = activation.fence();
742 claimed_fence = Some(fence.clone());
743 actor_store
744 .start_activation(&fence, chrono::Utc::now())
745 .await
746 .map_err(|_| {
747 tracing::warn!(stage = "actor_start", "remote placement admission failed");
748 remote_broker_unavailable()
749 })?;
750 let reservation = registry
751 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
752 placement_ref: placement_ref.clone(),
753 actor_id: lease.actor_id.clone(),
754 run_id: lease.run_id.clone(),
755 observed_host_ref: host.host_ref.clone(),
756 observed_connection_generation: host.connection_generation.clone(),
757 now: chrono::Utc::now(),
758 })
759 .await
760 .map_err(|error| {
761 tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
762 remote_broker_unavailable()
763 })?;
764 if reservation.mailbox != mailbox || reservation.lease != lease {
765 tracing::warn!(stage = "slot_binding", "remote placement admission failed");
766 return Err(remote_broker_unavailable());
767 }
768 Ok::<_, AgentError>(fence)
769 }
770 .await;
771 let fence = match actor_binding {
772 Ok(fence) => fence,
773 Err(error) => {
774 if let Some(fence) = claimed_fence.as_ref() {
775 if let Err(rollback_error) =
776 fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
777 {
778 tracing::warn!(
779 stage = "actor_pre_dispatch_rollback",
780 reason = %rollback_error,
781 "remote placement activation rollback failed"
782 );
783 }
784 }
785 let _ = registry.release_slot(&lease).await;
786 return Err(error);
787 }
788 };
789 let lease = Arc::new(tokio::sync::Mutex::new(lease));
790 let host_ref = host.host_ref.clone();
791 let lost = Arc::new(AtomicBool::new(false));
792 let run_cancel = parent_cancel.child_token();
793 let heartbeat = {
794 let registry = registry.clone();
795 let actor_store = actor_store.clone();
796 let route = route.clone();
797 let host_ref = host_ref.clone();
798 let mailbox = mailbox.clone();
799 let fence = fence.clone();
800 let placement_ref = placement_ref.clone();
801 let lease = lease.clone();
802 let lost = lost.clone();
803 let run_cancel = run_cancel.clone();
804 tokio::spawn(async move {
805 let mut interval = tokio::time::interval(Duration::from_secs(30));
806 interval.tick().await;
807 loop {
808 tokio::select! {
809 _ = run_cancel.cancelled() => break,
810 _ = interval.tick() => {}
811 }
812 let refreshed = async {
813 let mut query = route.query().await?;
814 let host = PlacementScheduler::new(registry.clone())
815 .observe_scoped_host(
816 &mut query,
817 &host_ref,
818 &mailbox,
819 &route.worker_role,
820 chrono::Duration::seconds(110),
821 )
822 .await
823 .map_err(|_| remote_broker_unavailable())?;
824 let current = lease.lock().await.clone();
825 if host.connection_generation != current.connection_generation {
826 return Err(remote_broker_unavailable());
827 }
828 let entry = actor_store
829 .inspect_actor(¤t.actor_id)
830 .await
831 .map_err(|_| remote_broker_unavailable())?;
832 if entry.activation.as_ref().is_none_or(|activation| {
833 activation.fence() != fence
834 || activation.placement_ref.as_ref() != Some(&placement_ref)
835 }) {
836 return Err(remote_broker_unavailable());
837 }
838 let now = chrono::Utc::now();
839 registry
840 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
841 placement_ref: placement_ref.clone(),
842 actor_id: current.actor_id.clone(),
843 run_id: current.run_id.clone(),
844 observed_host_ref: host.host_ref.clone(),
845 observed_connection_generation: host.connection_generation.clone(),
846 now,
847 })
848 .await
849 .map_err(|_| remote_broker_unavailable())?;
850 let renewed = registry
851 .renew_slot(¤t, now, now + chrono::Duration::seconds(80))
852 .await
853 .map_err(|_| remote_broker_unavailable())?;
854 *lease.lock().await = renewed;
855 let expiry = lease.lock().await.expires_at;
856 actor_store
857 .renew_activation(&fence, now, expiry)
858 .await
859 .map_err(|_| remote_broker_unavailable())?;
860 Ok::<(), AgentError>(())
861 }
862 .await;
863 if refreshed.is_err() {
864 lost.store(true, Ordering::SeqCst);
865 run_cancel.cancel();
866 break;
867 }
868 }
869 })
870 };
871 Ok(Self {
872 registry,
873 actor_store,
874 route,
875 mailbox,
876 host_ref,
877 fence,
878 placement_ref,
879 lease,
880 heartbeat,
881 run_cancel,
882 lost,
883 release_on_drop: AtomicBool::new(false),
884 })
885 }
886
887 fn lost(&self) -> bool {
888 self.lost.load(Ordering::SeqCst)
889 }
890
891 async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
892 if send_invoked {
893 return Ok(false);
894 }
895 self.abort_before_dispatch().await?;
896 Ok(true)
897 }
898
899 async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
900 self.heartbeat.abort();
901 let _ = (&mut self.heartbeat).await;
902 let current = self.lease.lock().await.clone();
903 let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
904 let released = self.registry.release_slot(¤t).await;
905 if let Err(error) = failed {
906 tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
907 return Err(remote_broker_unavailable());
908 }
909 if let Err(error) = released {
910 if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
911 tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
912 return Err(remote_broker_unavailable());
913 }
914 }
915 Ok(())
916 }
917
918 async fn complete(
919 &mut self,
920 outcome: ActorActivationFinish,
921 terminal_proven: bool,
922 ) -> Result<(), AgentError> {
923 if !terminal_proven {
924 return if outcome == ActorActivationFinish::Succeeded {
927 Err(remote_broker_unavailable())
928 } else {
929 Ok(())
930 };
931 }
932 self.heartbeat.abort();
935 let _ = (&mut self.heartbeat).await;
936 let current = self.lease.lock().await.clone();
937 let mut query = self.route.query().await?;
941 let observed = query
942 .observe_host_capacity(&self.mailbox, &self.route.worker_role)
943 .await
944 .map_err(|_| remote_broker_unavailable())?
945 .ok_or_else(remote_broker_unavailable)?;
946 if observed.host_ref != self.host_ref
947 || observed.connection_generation != current.connection_generation
948 || observed.mailbox != self.mailbox
949 || observed.role.as_deref() != Some(self.route.worker_role.as_str())
950 {
951 return Err(remote_broker_unavailable());
952 }
953 let entry = self
954 .actor_store
955 .inspect_actor(¤t.actor_id)
956 .await
957 .map_err(|_| remote_broker_unavailable())?;
958 if entry.activation.as_ref().is_none_or(|activation| {
959 activation.fence() != self.fence
960 || activation.placement_ref.as_ref() != Some(&self.placement_ref)
961 }) {
962 return Err(remote_broker_unavailable());
963 }
964 self.registry
965 .validate_slot(¤t, chrono::Utc::now())
966 .await
967 .map_err(|_| remote_broker_unavailable())?;
968 self.actor_store
969 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
970 .await
971 .map_err(|_| remote_broker_unavailable())?;
972 self.release_on_drop.store(true, Ordering::SeqCst);
975 match self.registry.release_slot(¤t).await {
976 Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
977 self.release_on_drop.store(false, Ordering::SeqCst);
978 Ok(())
979 }
980 Err(error) => {
981 tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
982 Err(remote_broker_unavailable())
983 }
984 }
985 }
986}
987
988impl Drop for ActivePlacementLease {
989 fn drop(&mut self) {
990 self.run_cancel.cancel();
991 self.heartbeat.abort();
992 if !self.release_on_drop.load(Ordering::SeqCst) {
993 return;
994 }
995 let registry = self.registry.clone();
996 let actor_store = self.actor_store.clone();
997 let fence = self.fence.clone();
998 let placement_ref = self.placement_ref.clone();
999 let lease = self.lease.clone();
1000 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
1001 runtime.spawn(async move {
1002 let current = lease.lock().await.clone();
1003 if actor_store
1004 .inspect_actor(¤t.actor_id)
1005 .await
1006 .ok()
1007 .and_then(|entry| entry.activation)
1008 .is_some_and(|activation| {
1009 activation.fence() == fence
1010 && activation.placement_ref.as_ref() == Some(&placement_ref)
1011 })
1012 {
1013 let _ = registry.release_slot(¤t).await;
1014 }
1015 });
1016 }
1017 }
1018}
1019
1020pub struct BrokerTerminalReceiptReconciler {
1025 store: Arc<bamboo_storage::SessionStoreV2>,
1026 bus: Option<bamboo_subagent::BusEndpoint>,
1027 remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1028 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1029}
1030
1031struct ReceiptRemoteRoute {
1032 endpoint: String,
1033 token: String,
1034 ca_cert_file: PathBuf,
1035 parent: bamboo_subagent::AgentRef,
1036}
1037
1038#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1039pub struct BrokerReceiptRepairReport {
1040 pub candidates: usize,
1041 pub repaired: usize,
1042 pub blocked: usize,
1043}
1044
1045impl BrokerTerminalReceiptReconciler {
1046 pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1047 let subagents = config.subagents();
1048 let mut counts = HashMap::<&str, usize>::new();
1049 for placement in &subagents.remote_placements {
1050 *counts.entry(placement.role.as_str()).or_default() += 1;
1051 }
1052 let mut remote = HashMap::new();
1053 for placement in &subagents.remote_placements {
1054 let route = (counts.get(placement.role.as_str()) == Some(&1))
1055 .then(|| receipt_remote_route(placement))
1056 .flatten();
1057 remote.insert(placement.role.clone(), route);
1058 }
1059 Self {
1060 store,
1061 ticket_service: None,
1062 bus: subagents.broker.clone().and_then(|broker| {
1063 (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1064 bamboo_subagent::BusEndpoint {
1065 endpoint: broker.endpoint,
1066 token: broker.token,
1067 },
1068 )
1069 }),
1070 remote,
1071 }
1072 }
1073
1074 pub fn with_ticket_service(
1075 mut self,
1076 service: Option<Arc<bamboo_tickets::TicketService>>,
1077 ) -> Self {
1078 self.ticket_service = service;
1079 self
1080 }
1081
1082 pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1085 let candidates = match self
1086 .store
1087 .discover_unconfirmed_broker_terminal_children()
1088 .await
1089 {
1090 Ok(candidates) => candidates,
1091 Err(error) => {
1092 tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1093 return BrokerReceiptRepairReport {
1094 blocked: 1,
1095 ..BrokerReceiptRepairReport::default()
1096 };
1097 }
1098 };
1099 let mut report = BrokerReceiptRepairReport {
1100 candidates: candidates.len(),
1101 ..BrokerReceiptRepairReport::default()
1102 };
1103 for child in candidates {
1104 let result =
1105 tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1106 match result {
1107 Ok(Ok(())) => report.repaired += 1,
1108 Ok(Err(reason)) => {
1109 report.blocked += 1;
1110 tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1111 }
1112 Err(_) => {
1113 report.blocked += 1;
1114 tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1115 }
1116 }
1117 }
1118 report
1119 }
1120
1121 async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1122 if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1123 || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1124 || child.metadata.get("external.agent_id").map(String::as_str)
1125 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1126 {
1127 return Err("Child has no trusted built-in actor recovery route");
1128 }
1129 let current = self
1130 .store
1131 .load_session(&child.id)
1132 .await
1133 .map_err(|_| "canonical Child reload failed")?
1134 .ok_or("canonical Child disappeared")?;
1135 if current.kind != bamboo_domain::SessionKind::Child
1136 || current.id != child.id
1137 || current.created_at != child.created_at
1138 || current.parent_session_id != child.parent_session_id
1139 || current.root_session_id != child.root_session_id
1140 || current.project_id_meta() != child.project_id_meta()
1141 || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1142 || current
1143 .metadata
1144 .get("external.protocol")
1145 .map(String::as_str)
1146 != Some("actor")
1147 || current
1148 .metadata
1149 .get("external.agent_id")
1150 .map(String::as_str)
1151 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1152 || bamboo_domain::ActorSession::from_session(¤t).is_err()
1153 {
1154 return Err("canonical Child birth, Root, or Project changed");
1155 }
1156 let root = self
1157 .store
1158 .load_session(&child.root_session_id)
1159 .await
1160 .map_err(|_| "canonical Root reload failed")?
1161 .ok_or("canonical Root disappeared")?;
1162 if root.kind != bamboo_domain::SessionKind::Root
1163 || root.id != child.root_session_id
1164 || root.root_session_id != child.root_session_id
1165 || root.project_id_meta() != child.project_id_meta()
1166 {
1167 return Err("canonical Root identity changed");
1168 }
1169 let parent_id = child
1170 .parent_session_id
1171 .as_deref()
1172 .ok_or("Child parent identity missing")?;
1173 let parent = self
1174 .store
1175 .load_session(parent_id)
1176 .await
1177 .map_err(|_| "canonical parent reload failed")?
1178 .ok_or("canonical parent disappeared")?;
1179 if parent.id != parent_id
1180 || !matches!(
1181 parent.kind,
1182 bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1183 )
1184 || parent.root_session_id != child.root_session_id
1185 || parent.project_id_meta() != child.project_id_meta()
1186 || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1187 {
1188 return Err("canonical parent lineage changed");
1189 }
1190
1191 let receipts = self
1195 .store
1196 .recover_broker_terminal_receipts(¤t)
1197 .await
1198 .map_err(|_| "Host broker terminal checkpoint unverified")?;
1199 if receipts.is_empty() {
1200 return Ok(());
1201 }
1202 let role = current
1203 .metadata
1204 .get("subagent_type")
1205 .map(String::as_str)
1206 .unwrap_or("worker");
1207 for receipt in receipts {
1208 publish_recovered_completion_source(&self.store, &receipt)
1209 .await
1210 .map_err(|_| "committed Child completion source remains pending")?;
1211 if current
1212 .metadata
1213 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1214 {
1215 let service = self
1216 .ticket_service
1217 .as_ref()
1218 .ok_or("Ticket authority unavailable; no result ACK")?;
1219 crate::ticket_runtime::checkpoint_ticket_result(
1220 service,
1221 self.store.as_ref(),
1222 ¤t,
1223 &receipt.activation_run_id,
1224 )
1225 .await
1226 .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1227 }
1228 let local_mailbox = format!("p-{}", child.id);
1229 let mut link = if receipt.parent_mailbox == local_mailbox {
1232 let bus = self
1233 .bus
1234 .as_ref()
1235 .ok_or("local broker receipt bus unavailable")?;
1236 bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1237 &bus.endpoint,
1238 bamboo_subagent::AgentRef {
1239 session_id: local_mailbox,
1240 role: None,
1241 },
1242 &bus.token,
1243 )
1244 .await
1245 .map_err(|_| "local broker receipt connection unavailable")?
1246 } else if let Some(remote) = self.remote.get(role) {
1247 let route = remote
1248 .as_ref()
1249 .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1250 if receipt.parent_mailbox != route.parent.session_id {
1251 return Err("fixed remote parent mailbox changed since Host checkpoint");
1252 }
1253 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1254 .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1255 bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1256 &route.endpoint,
1257 route.parent.clone(),
1258 &route.token,
1259 tls,
1260 )
1261 .await
1262 .map_err(|_| "fixed remote broker receipt connection unavailable")?
1263 } else {
1264 return Err("committed broker receipt route is unavailable");
1265 };
1266 if link.parent_mailbox() != receipt.parent_mailbox {
1267 return Err("broker receipt link mailbox differs from Host checkpoint");
1268 }
1269 bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1270 &mut link,
1271 &receipt.broker_identity,
1272 &receipt.message_ids,
1273 )
1274 .await
1275 .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1276 self.store
1277 .clear_acknowledged_broker_terminal_receipt(
1278 &receipt.session_id,
1279 receipt.created_at,
1280 &receipt.activation_run_id,
1281 &receipt.parent_mailbox,
1282 )
1283 .await
1284 .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1285 }
1286 Ok(())
1287 }
1288}
1289
1290async fn publish_recovered_completion_source(
1294 store: &Arc<bamboo_storage::SessionStoreV2>,
1295 receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1296) -> Result<(), String> {
1297 let expected = store
1298 .load_session(&receipt.session_id)
1299 .await
1300 .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1301 .ok_or("Child completion recovery snapshot missing")?;
1302 if expected.id != receipt.session_id
1303 || expected.created_at != receipt.created_at
1304 || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1305 || expected.root_session_id != receipt.root_session_id
1306 || expected.project_id_meta() != receipt.project_id
1307 {
1308 return Err("Child completion recovery identity changed".into());
1309 }
1310 let mut candidate = expected.clone();
1311 if candidate.last_run_status().as_deref() == Some("running") {
1312 candidate.set_last_run_status(receipt.terminal_status.clone());
1313 if let Some(error) = &receipt.terminal_error {
1314 candidate.set_last_run_error(error.clone());
1315 } else {
1316 candidate.clear_last_run_error();
1317 }
1318 }
1319 if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1320 &mut candidate,
1321 &receipt.activation_run_id,
1322 )? {
1323 let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1324 .ok_or("Child completion recovery source invalid")?;
1325 let published = candidate
1326 .metadata
1327 .get("runtime.child_completion_source_v1")
1328 .ok_or("Child completion recovery source missing")?;
1329 let saved = store
1333 .publish_broker_terminal_source(receipt, &expected, published)
1334 .await
1335 .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1336 if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1337 != Some(&source)
1338 {
1339 return Err("Child completion recovery source changed".into());
1340 }
1341 }
1342 Ok(())
1343}
1344
1345fn receipt_remote_route(
1346 placement: &bamboo_config::RemoteActorPlacement,
1347) -> Option<ReceiptRemoteRoute> {
1348 let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1349 let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1350 if placement.endpoint.len() > 2048
1351 || endpoint.scheme() != "wss"
1352 || endpoint.host_str().is_none()
1353 || !endpoint.username().is_empty()
1354 || endpoint.password().is_some()
1355 || endpoint.query().is_some()
1356 || endpoint.fragment().is_some()
1357 {
1358 return None;
1359 }
1360 let token_env = placement
1361 .token_env
1362 .as_deref()
1363 .filter(|name| !name.is_empty() && name.len() <= 256)?;
1364 let token = std::env::var(token_env).ok()?;
1365 if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1366 return None;
1367 }
1368 let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1369 bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1370 Some(ReceiptRemoteRoute {
1371 endpoint: placement.endpoint.clone(),
1372 token,
1373 ca_cert_file,
1374 parent: bamboo_subagent::AgentRef {
1375 session_id: peer.parent_mailbox.clone(),
1376 role: peer.parent_role.clone(),
1377 },
1378 })
1379}
1380
1381enum PlacementKind {
1386 Local,
1387 Remote,
1388 Schedulable,
1389}
1390
1391fn ensure_ticket_placement_is_enforceable(
1393 session: &Session,
1394 spec: &ProvisionSpec,
1395) -> Result<(), AgentError> {
1396 let ticket = session
1397 .metadata
1398 .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1399 || session
1400 .metadata
1401 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1402 if ticket && !matches!(spec.placement, Placement::Local) {
1403 return Err(AgentError::LLM(
1404 "Ticket execution requires local placement and owned stop proof".into(),
1405 ));
1406 }
1407 Ok(())
1408}
1409
1410fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1419 if !spec.capabilities.read_only_enforced() {
1420 return Ok(());
1421 }
1422
1423 if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1424 return Err(AgentError::LLM(format!(
1425 "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1426 spec.identity.role
1427 )));
1428 }
1429
1430 if !matches!(spec.placement, Placement::Local) {
1431 return Err(AgentError::LLM(format!(
1432 "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1433 spec.identity.role
1434 )));
1435 }
1436
1437 Ok(())
1438}
1439
1440pub struct ActorChildRunner {
1442 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1443 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1444 agent_id: String,
1445 worker_bin: PathBuf,
1446 worker_args: Vec<String>,
1447 builtin_required_context_route: bool,
1448 native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1449 fabric_dir: PathBuf,
1450 executor: ExecutorSpec,
1451 credentials: Vec<ScopedCredential>,
1456 default_provider: String,
1458 live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1462 bus: Option<bamboo_subagent::BusEndpoint>,
1465 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1469 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1475 max_idle_per_key: usize,
1476 max_idle_total: usize,
1477 pool_reaper_started: AtomicBool,
1478 next_execution_epoch: AtomicU64,
1482 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1486 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1490 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1501 remote_placements: HashMap<String, ResolvedRemotePlacement>,
1507 strict_remote_subscription: tokio::sync::Mutex<()>,
1509 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1517 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1523 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1526 actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1527 canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1528 ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1529 actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1530 pending_durable_links: tokio::sync::Mutex<
1533 HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1534 >,
1535}
1536
1537struct PendingDurableChildLink {
1538 link: Box<dyn bamboo_subagent::ChildLink>,
1539 host_nested_wait_handoff: bool,
1542 strict_history: Option<StrictTerminalHistory>,
1544 strict_history_required: bool,
1545 strict_history_committed: bool,
1546}
1547
1548#[derive(Clone)]
1549struct StrictTerminalHistory {
1550 proof: bamboo_storage::v2::HostTerminalCompleteness,
1551 watermark: bamboo_subagent::ActorEventWatermark,
1552}
1553
1554fn effective_broker_terminal_status(
1555 raw: TerminalStatus,
1556 host_nested_wait_handoff: bool,
1557) -> Result<&'static str, String> {
1558 if host_nested_wait_handoff {
1559 if raw != TerminalStatus::Completed {
1560 return Err("nested wait cannot project a failed broker terminal".into());
1561 }
1562 Ok("suspended")
1563 } else {
1564 Ok(broker_terminal_status_label(raw))
1565 }
1566}
1567
1568fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1569 match status {
1570 TerminalStatus::Completed => "completed",
1571 TerminalStatus::Suspended => "suspended",
1572 TerminalStatus::Error => "error",
1573 TerminalStatus::Cancelled => "cancelled",
1574 }
1575}
1576
1577#[async_trait]
1587pub trait ChildApprovalDecider: Send + Sync {
1588 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1591}
1592
1593async fn decide_child_approval(
1596 decider: Option<&Arc<dyn ChildApprovalDecider>>,
1597 child_session_id: &str,
1598 request: &serde_json::Value,
1599) -> bool {
1600 match decider {
1601 Some(decider) => decider.decide(child_session_id, request).await,
1602 None => false,
1603 }
1604}
1605
1606const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1610
1611#[derive(Clone)]
1622pub struct ChildApprovalScope {
1623 stamp: serde_json::Value,
1624 deadline: chrono::DateTime<chrono::Utc>,
1625 parent: String,
1626 child: String,
1627 run: String,
1628 epoch: u64,
1629 current_epoch: Arc<AtomicU64>,
1630 cancel: CancellationToken,
1631 router: Arc<crate::SessionActivationRouter>,
1632 admission: Arc<AtomicUsize>,
1633}
1634
1635pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1636impl Drop for ChildApprovalAdmission {
1637 fn drop(&mut self) {
1638 self.0.store(2, Ordering::Release);
1639 }
1640}
1641
1642impl ChildApprovalScope {
1643 pub fn new(
1645 parent: &str,
1646 child: &str,
1647 transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1648 router: Arc<crate::SessionActivationRouter>,
1649 cancel: CancellationToken,
1650 current_epoch: Arc<AtomicU64>,
1651 ) -> Self {
1652 let (attempt, run, epoch, reply, deadline) = transport;
1653 let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1654 Self {
1655 stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1656 "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1657 "deadline": deadline}),
1658 deadline,
1659 parent: parent.into(),
1660 child: child.into(),
1661 run: run.into(),
1662 epoch,
1663 current_epoch,
1664 cancel,
1665 router,
1666 admission: Arc::new(AtomicUsize::new(0)),
1667 }
1668 }
1669 pub fn stamp(&self) -> &serde_json::Value {
1670 &self.stamp
1671 }
1672 pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1673 self.deadline
1674 }
1675 pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1676 self.admission
1677 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1678 .ok()
1679 .map(|_| ChildApprovalAdmission(self.admission.clone()))
1680 }
1681 pub fn is_admitting(&self) -> bool {
1682 self.admission.load(Ordering::Acquire) == 1
1683 }
1684 pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1685 self.parent == parent
1686 && self.child == child
1687 && !self.cancel.is_cancelled()
1688 && self.current_epoch.load(Ordering::Acquire) == self.epoch
1689 && self.router.owns_run(child, &self.run).await
1690 }
1691}
1692
1693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1694pub enum ChildApprovalReview {
1695 Reply(bool),
1696 NoReply,
1697}
1698
1699#[async_trait]
1700pub trait ChildApprovalReviewer: Send + Sync {
1701 async fn review(
1704 &self,
1705 parent_session_id: &str,
1706 child_session_id: &str,
1707 request: &serde_json::Value,
1708 ) -> bool;
1709
1710 async fn review_scoped(
1711 &self,
1712 parent: &str,
1713 child: &str,
1714 request: &serde_json::Value,
1715 _scope: &ChildApprovalScope,
1716 ) -> ChildApprovalReview {
1717 ChildApprovalReview::Reply(self.review(parent, child, request).await)
1718 }
1719}
1720
1721fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1722 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1723 &SLOT
1724}
1725
1726pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1728 let _ = child_approval_reviewer_slot().set(reviewer);
1729}
1730
1731pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1733 child_approval_reviewer_slot().get().cloned()
1734}
1735
1736impl ActorChildRunner {
1737 #[allow(clippy::too_many_arguments)]
1738 pub fn new(
1739 agent_id: String,
1740 worker_bin: PathBuf,
1741 worker_args: Vec<String>,
1742 fabric_dir: PathBuf,
1743 executor: ExecutorSpec,
1744 credentials: Vec<ScopedCredential>,
1745 default_provider: String,
1746 max_concurrent: usize,
1747 ) -> Self {
1748 Self {
1749 approval_registry: None,
1750 permission_config: None,
1751 agent_id,
1752 worker_bin,
1753 worker_args,
1754 builtin_required_context_route: false,
1755 fabric_dir,
1756 executor,
1757 credentials,
1758 default_provider,
1759 live_provider_config: None,
1760 native_tool_ceiling: None,
1761 bus: None,
1762 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1763 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1764 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1765 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1766 pool_reaper_started: AtomicBool::new(false),
1767 next_execution_epoch: AtomicU64::new(0),
1768 approval_decider: None,
1769 approval_reviewer: None,
1770 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1771 remote_placements: HashMap::new(),
1772 strict_remote_subscription: tokio::sync::Mutex::new(()),
1773 schedulable_placements: HashMap::new(),
1774 codex_run_tokens: None,
1775 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1776 actor_directory_store: std::sync::Mutex::new(None),
1777 canonical_subagent_tool: std::sync::Mutex::new(None),
1778 ticket_service: std::sync::Mutex::new(None),
1779 actor_event_observer: std::sync::Mutex::new(None),
1780 pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1781 }
1782 }
1783
1784 pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1789 self.builtin_required_context_route = supported;
1790 self
1791 }
1792
1793 pub(crate) fn with_native_tool_ceiling_source(
1794 mut self,
1795 source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1796 ) -> Self {
1797 self.native_tool_ceiling = source;
1798 self
1799 }
1800
1801 async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1802 bamboo_subagent::fleet::require_worker_capability(
1803 &self.worker_bin,
1804 &self.worker_args,
1805 bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1806 )
1807 .await
1808 .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1809 }
1810
1811 pub fn with_live_provider_config(
1812 mut self,
1813 config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1814 ) -> Self {
1815 self.live_provider_config = Some(config);
1816 self
1817 }
1818
1819 pub fn with_approval_registry(
1820 mut self,
1821 registry: super::approval_registry::SharedApprovalRegistry,
1822 ) -> Self {
1823 self.approval_registry = Some(registry);
1824 self
1825 }
1826
1827 pub fn with_permission_config(
1828 mut self,
1829 config: Arc<bamboo_tools::permission::PermissionConfig>,
1830 ) -> Self {
1831 self.permission_config = Some(config);
1832 self
1833 }
1834
1835 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1840 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1841 self
1842 }
1843
1844 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1847 self.approval_decider = Some(decider);
1848 self
1849 }
1850
1851 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1852 self.approval_reviewer = Some(reviewer);
1853 self
1854 }
1855
1856 pub fn with_codex_run_tokens(
1857 mut self,
1858 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1859 ) -> Self {
1860 self.codex_run_tokens = authority;
1861 self
1862 }
1863
1864 pub fn with_remote_placements(
1870 mut self,
1871 placements: HashMap<String, ResolvedRemotePlacement>,
1872 ) -> Self {
1873 self.remote_placements = placements;
1874 self
1875 }
1876
1877 pub fn with_schedulable_placements(
1883 mut self,
1884 placements: HashMap<String, ResolvedSchedulablePlacement>,
1885 ) -> Self {
1886 self.schedulable_placements = placements;
1887 self
1888 }
1889
1890 fn fingerprint(spec: &ProvisionSpec) -> String {
1909 let role = spec.identity.role.as_str();
1910 let (provider, model) = spec
1911 .model
1912 .as_ref()
1913 .map(|m| (m.provider.as_str(), m.model.as_str()))
1914 .unwrap_or(("", ""));
1915 let workspace = spec.workspace.as_deref().unwrap_or("");
1916 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1917 tools.sort();
1918 let caps = &spec.capabilities;
1919 let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1924 "none".to_string()
1925 } else {
1926 let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1927 .expect("scoped provider credentials are serializable");
1928 hex::encode(Sha256::digest(serialized))
1929 };
1930 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1933 format!(
1934 "{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}",
1935 tools.join(","),
1936 spec.identity.depth,
1937 caps.nested_spawn,
1938 caps.permission_requested_mode,
1939 caps.permission_effective_mode,
1940 caps.bypass,
1941 caps.auto_approve_permissions,
1942 caps.enforce_permissions,
1943 caps.max_spawn_depth.unwrap_or(0),
1944 caps.no_human_approver,
1951 caps.read_only,
1954 caps.guardian_read_only,
1957 caps.child_creation_identity,
1958 )
1959 }
1960
1961 fn ensure_pool_reaper(&self) {
1964 if self
1965 .pool_reaper_started
1966 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1967 .is_err()
1968 {
1969 return;
1970 }
1971 let pool = Arc::downgrade(&self.pool);
1972 tokio::spawn(async move {
1973 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1974 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1975 interval.tick().await;
1978 loop {
1979 interval.tick().await;
1980 let Some(pool) = pool.upgrade() else {
1981 break;
1982 };
1983 let _ = reap_worker_pool_once(&pool).await;
1984 }
1985 });
1986 }
1987
1988 fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1989 let mut physical = spec.clone();
1990 physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1994 physical
1995 }
1996
1997 async fn acquire_bus_worker(
2003 &self,
2004 key: &str,
2005 spec: &ProvisionSpec,
2006 ) -> crate::runtime::runner::Result<PooledWorker> {
2007 if spec.capabilities.required_child_context {
2008 let physical = Self::fresh_required_worker_spec(spec);
2009 let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2010 .await
2011 .map_err(|error| {
2012 AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2013 })?;
2014 let mailbox_id = worker.record.agent_id.clone();
2015 return Ok(PooledWorker {
2016 worker,
2017 mailbox_id,
2018 parked_at: None,
2019 });
2020 }
2021 self.ensure_pool_reaper();
2022 loop {
2026 let candidate = {
2027 let mut pool = self.pool.lock().await;
2028 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2029 if pool.get(key).is_some_and(Vec::is_empty) {
2030 pool.remove(key);
2031 }
2032 candidate
2033 };
2034 let Some(mut candidate) = candidate else {
2035 break;
2036 };
2037 let now = Instant::now();
2038 let expired = parked_worker_expired(
2039 candidate.parked_at,
2040 now,
2041 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2042 );
2043 if candidate.worker.is_alive() && !expired {
2044 candidate.parked_at = None;
2045 return Ok(candidate);
2046 }
2047 tracing::info!(
2048 pool_key = key,
2049 mailbox_id = %candidate.mailbox_id,
2050 worker_pid = ?candidate.worker.pid(),
2051 shutdown_reason = if expired {
2052 PoolReapReason::IdleTimeout.as_str()
2053 } else {
2054 PoolReapReason::ProcessExited.as_str()
2055 },
2056 "discarding unusable parked sub-agent worker during checkout"
2057 );
2058 candidate.worker.kill().await;
2059 }
2060
2061 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2062 .await
2063 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2064 let mailbox_id = spawned.record.agent_id.clone();
2065 Ok(PooledWorker {
2066 worker: spawned,
2067 mailbox_id,
2068 parked_at: None,
2069 })
2070 }
2071
2072 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2076 self.ensure_pool_reaper();
2077 if !worker.worker.is_alive() {
2078 tracing::info!(
2079 pool_key = key,
2080 mailbox_id = %worker.mailbox_id,
2081 worker_pid = ?worker.worker.pid(),
2082 shutdown_reason = "process_exited_before_park",
2083 "discarding sub-agent worker instead of parking"
2084 );
2085 worker.worker.kill().await;
2086 return;
2087 }
2088 worker.parked_at = Some(Instant::now());
2089 let mut pool = self.pool.lock().await;
2090 let key_count = pool.get(key).map_or(0, Vec::len);
2091 let total_count = pool.values().map(Vec::len).sum::<usize>();
2092 let limit_reason =
2093 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2094 if let Some(shutdown_reason) = limit_reason {
2095 drop(pool);
2096 tracing::info!(
2097 pool_key = key,
2098 mailbox_id = %worker.mailbox_id,
2099 worker_pid = ?worker.worker.pid(),
2100 idle_workers_for_key = key_count,
2101 idle_workers_total = total_count,
2102 max_idle_per_key = self.max_idle_per_key,
2103 max_idle_total = self.max_idle_total,
2104 shutdown_reason,
2105 "discarding sub-agent worker because the warm pool is full"
2106 );
2107 worker.worker.kill().await;
2108 return;
2109 }
2110 pool.entry(key.to_string()).or_default().push(worker);
2111 }
2112
2113 fn build_spec_with_provider_config(
2116 &self,
2117 session: &Session,
2118 job: &SpawnJob,
2119 credentials: &[ScopedCredential],
2120 default_provider: &str,
2121 ) -> ProvisionSpec {
2122 let mut spec = ProvisionSpec::new(
2123 ChildIdentity {
2124 child_id: job.child_session_id.clone(),
2125 parent_id: Some(job.parent_session_id.clone()),
2126 project_key: None,
2127 role: session
2128 .metadata
2129 .get("subagent_type")
2130 .cloned()
2131 .unwrap_or_else(|| "worker".to_string()),
2132 depth: session.spawn_depth,
2137 },
2138 self.executor.clone(),
2139 self.fabric_dir.to_string_lossy().into_owned(),
2140 );
2141 spec.workspace = session.workspace.clone();
2142 if let ExecutorSpec::Codex {
2143 workspace_owned, ..
2144 } = &mut spec.executor
2145 {
2146 *workspace_owned = Some(
2147 spec.workspace
2148 .as_deref()
2149 .is_some_and(workspace_is_bamboo_owned),
2150 );
2151 }
2152 spec.bus = self.bus.clone();
2155 spec.model = session
2158 .model_ref
2159 .as_ref()
2160 .map(|r| ModelRefSpec {
2161 provider: r.provider.clone(),
2162 model: r.model.clone(),
2163 })
2164 .or_else(|| {
2165 let m = job.model.trim();
2166 (!m.is_empty()).then(|| ModelRefSpec {
2167 provider: default_provider.to_string(),
2168 model: m.to_string(),
2169 })
2170 });
2171 spec.disabled_tools = job.disabled_tools.clone();
2172 match &spec.executor {
2173 ExecutorSpec::Codex {
2179 auth_mode,
2180 provider_key_ref,
2181 ..
2182 } => {
2183 if auth_mode.as_deref() == Some("custom") {
2184 if let Some(reference) = provider_key_ref {
2185 if let Some(credential) = credentials.iter().find(|credential| {
2186 credential.credential_ref.as_deref() == Some(reference)
2187 }) {
2188 spec.secrets.provider_credentials.push(credential.clone());
2189 } else {
2190 tracing::warn!(
2191 "actor child {}: custom Codex credential reference '{}' did not resolve",
2192 job.child_session_id,
2193 reference
2194 );
2195 }
2196 } else {
2197 tracing::warn!(
2198 "actor child {}: custom Codex executor has no credential reference",
2199 job.child_session_id
2200 );
2201 }
2202 }
2203 }
2204 _ => {
2207 let provider = spec
2208 .model
2209 .as_ref()
2210 .map(|model| model.provider.as_str())
2211 .filter(|provider| !provider.trim().is_empty())
2212 .unwrap_or(default_provider);
2213 if let Some(credential) = credentials
2214 .iter()
2215 .find(|credential| credential.provider == provider)
2216 {
2217 spec.secrets.provider_credentials.push(credential.clone());
2218 } else {
2219 tracing::warn!(
2220 "actor child {}: no credential found for provider '{}'",
2221 job.child_session_id,
2222 provider
2223 );
2224 }
2225 }
2226 }
2227 spec.capabilities.nested_spawn = session.spawn_depth < MAX_SPAWN_DEPTH;
2234 spec.capabilities.max_spawn_depth = Some(MAX_SPAWN_DEPTH);
2235 spec.capabilities.enforce_permissions = true;
2241 let requested_permission_mode = session
2246 .agent_runtime_state
2247 .as_ref()
2248 .map(|state| state.effective_permission_mode())
2249 .unwrap_or_default();
2250 let configured_permission_mode = self
2251 .permission_config
2252 .as_ref()
2253 .map(|config| config.mode())
2254 .unwrap_or_default();
2255 spec.capabilities.no_human_approver = session
2260 .agent_runtime_state
2261 .as_ref()
2262 .is_some_and(|s| s.no_human_approver);
2263 let typed_read_only = session
2269 .agent_runtime_state
2270 .as_ref()
2271 .is_some_and(|state| state.read_only);
2272 let legacy_guardian_read_only = spec.identity.role == "guardian"
2273 && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2274 ["Edit", "Write", "SubAgent", "WebFetch"]
2275 .iter()
2276 .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2277 });
2278 let read_only = typed_read_only || legacy_guardian_read_only;
2279 spec.capabilities.read_only = read_only;
2280 spec.capabilities.guardian_read_only = read_only;
2284 if spec.capabilities.read_only_enforced() {
2285 spec.capabilities.nested_spawn = false;
2288 spec.disabled_tools
2293 .get_or_insert_with(Default::default)
2294 .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2295 match &mut spec.executor {
2296 ExecutorSpec::ClaudeCode {
2297 permission_mode,
2298 inherit_user_config,
2299 ..
2300 } => {
2301 *permission_mode = Some("plan".to_string());
2302 *inherit_user_config = Some(false);
2305 }
2306 ExecutorSpec::Codex {
2307 mode,
2308 sandbox,
2309 inherit_user_config,
2310 approval_policy,
2311 network_access,
2312 allow_danger_bypass,
2313 permission_profile,
2314 ..
2315 } => {
2316 *sandbox = Some("read-only".to_string());
2317 *inherit_user_config = Some(false);
2318 *approval_policy = Some(
2321 if mode.as_deref() == Some("app_server") {
2322 "on-request"
2323 } else {
2324 "never"
2325 }
2326 .to_string(),
2327 );
2328 *network_access = Some(false);
2329 *allow_danger_bypass = Some(false);
2330 *permission_profile = Some("read-only".to_string());
2331 }
2332 _ => {}
2333 }
2334 }
2335 let read_only_overlay = session
2336 .agent_runtime_state
2337 .as_ref()
2338 .is_some_and(|state| state.plan_mode.is_some())
2339 || spec.capabilities.read_only_enforced()
2340 || executor_has_read_only_permission_profile(&spec.executor);
2341 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2342 requested_permission_mode,
2343 configured_permission_mode,
2344 read_only_overlay,
2345 );
2346 spec.capabilities.bypass = permission_resolution.bypass_permissions();
2347 spec.capabilities.auto_approve_permissions =
2348 permission_resolution.suppress_approval_prompts();
2349 spec.capabilities.permission_requested_mode =
2350 permission_resolution.requested.as_str().to_string();
2351 spec.capabilities.permission_effective_mode =
2352 permission_resolution.effective.as_str().to_string();
2353 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2360 spec.placement = Placement::Remote {
2361 endpoint: placement.endpoint.clone(),
2362 };
2363 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2364 {
2365 spec.placement = Placement::Schedulable {
2373 pool: placement.pool.clone(),
2374 };
2375 }
2376 spec
2377 }
2378
2379 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2382 self.build_spec_with_provider_config(
2383 session,
2384 job,
2385 &self.credentials,
2386 &self.default_provider,
2387 )
2388 }
2389
2390 async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2395 let Some(config) = self.live_provider_config.as_ref() else {
2396 return self.build_spec(session, job);
2397 };
2398
2399 let config = config.read().await;
2400 let credentials = super::runtime::extract_provider_credentials(&config);
2401 let default_provider = config.effective_default_provider().to_string();
2402 self.build_spec_with_provider_config(session, job, &credentials, &default_provider)
2403 }
2404
2405 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2411 let host_label = match &spec.placement {
2412 Placement::Remote { .. } => self
2413 .remote_placements
2414 .get(spec.identity.role.as_str())
2415 .and_then(|p| p.host_label.as_deref()),
2416 Placement::Schedulable { .. } => self
2417 .schedulable_placements
2418 .get(spec.identity.role.as_str())
2419 .and_then(|p| p.host_label.as_deref()),
2420 Placement::Local => None,
2421 };
2422 placement_metadata(&spec.placement, host_label)
2423 }
2424
2425 fn scoped_placement_route(
2426 &self,
2427 spec: &ProvisionSpec,
2428 ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2429 match &spec.placement {
2430 Placement::Local => Ok(None),
2431 Placement::Remote { .. } => {
2432 let placement = self
2433 .remote_placements
2434 .get(&spec.identity.role)
2435 .ok_or_else(remote_broker_unavailable)?;
2436 let peer = placement
2437 .broker_peer
2438 .as_ref()
2439 .and_then(|peer| peer.as_ref().ok())
2440 .ok_or_else(remote_broker_unavailable)?;
2441 let worker_role = peer
2442 .worker_role
2443 .clone()
2444 .ok_or_else(remote_broker_unavailable)?;
2445 Ok(Some(ScopedPlacementRoute {
2446 endpoint: placement.endpoint.clone(),
2447 token: placement
2448 .token
2449 .clone()
2450 .ok_or_else(remote_broker_unavailable)?,
2451 ca_cert_file: placement
2452 .ca_cert_file
2453 .clone()
2454 .ok_or_else(remote_broker_unavailable)?,
2455 parent: bamboo_subagent::AgentRef {
2456 session_id: peer.parent_mailbox.clone(),
2457 role: peer.parent_role.clone(),
2458 },
2459 worker_role,
2460 pinned_mailbox: Some(peer.worker_mailbox.clone()),
2461 requirements: placement
2462 .requirements
2463 .clone()
2464 .ok_or_else(remote_broker_unavailable)?,
2465 }))
2466 }
2467 Placement::Schedulable { .. } => {
2468 let placement = self
2469 .schedulable_placements
2470 .get(&spec.identity.role)
2471 .ok_or_else(remote_broker_unavailable)?;
2472 let parent = placement
2473 .broker_parent
2474 .as_ref()
2475 .ok_or_else(remote_broker_unavailable)?;
2476 Ok(Some(ScopedPlacementRoute {
2477 endpoint: placement.endpoint.clone(),
2478 token: placement
2479 .token
2480 .clone()
2481 .ok_or_else(remote_broker_unavailable)?,
2482 ca_cert_file: placement
2483 .ca_cert_file
2484 .clone()
2485 .ok_or_else(remote_broker_unavailable)?,
2486 parent: bamboo_subagent::AgentRef {
2487 session_id: parent.parent_mailbox.clone(),
2488 role: Some(parent.parent_role.clone()),
2489 },
2490 worker_role: placement.pool.clone(),
2491 pinned_mailbox: None,
2492 requirements: placement
2493 .requirements
2494 .clone()
2495 .ok_or_else(remote_broker_unavailable)?,
2496 }))
2497 }
2498 }
2499 }
2500
2501 async fn select_scoped_worker(
2502 &self,
2503 route: &ScopedPlacementRoute,
2504 registry: &bamboo_storage::v2::FileHostRegistry,
2505 actor_id: &str,
2506 project_id: Option<bamboo_domain::ProjectId>,
2507 required_tools: &BTreeSet<String>,
2508 ) -> Result<String, AgentError> {
2509 if let Some(mailbox) = &route.pinned_mailbox {
2510 return Ok(mailbox.clone());
2511 }
2512 let mut query = route.query().await?;
2513 let candidates = query
2514 .list_connected(&route.worker_role)
2515 .await
2516 .map_err(|_| remote_broker_unavailable())?;
2517 let scheduler = PlacementScheduler::new(registry.clone());
2518 let mut selected: Option<(String, usize, u16)> = None;
2519 for mailbox in candidates {
2520 let Some(observed) = query
2521 .observe_host_capacity(&mailbox, &route.worker_role)
2522 .await
2523 .map_err(|_| remote_broker_unavailable())?
2524 else {
2525 continue;
2526 };
2527 let Ok(host) = scheduler
2528 .observe_scoped_host(
2529 &mut query,
2530 &observed.host_ref,
2531 &mailbox,
2532 &route.worker_role,
2533 chrono::Duration::seconds(110),
2534 )
2535 .await
2536 else {
2537 continue;
2538 };
2539 let now = chrono::Utc::now();
2540 let request = HostPlacementRequest {
2541 intent: HostPlacementIntent::Pool {
2542 pool_id: route.worker_role.clone(),
2543 },
2544 actor_id: actor_id.to_owned(),
2545 run_id: "selection".into(),
2546 project_id: project_id.clone(),
2547 trust_zone: route.requirements.trust_zone.clone(),
2548 workspace_label: route.requirements.workspace_label.clone(),
2549 executor: "bamboo-runtime".into(),
2550 required_tools: required_tools.clone(),
2551 network_zone: route.requirements.network_zone.clone(),
2552 require_network_isolation: route.requirements.require_network_isolation,
2553 preferred_host_ref: None,
2554 now,
2555 lease_expires_at: now + chrono::Duration::seconds(80),
2556 };
2557 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2558 || !request.eligible(&host)
2559 {
2560 continue;
2561 }
2562 let load = host.active_slots(now);
2563 if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2564 load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2565 }) {
2566 selected = Some((mailbox, load, host.max_slots));
2567 }
2568 }
2569 selected
2570 .map(|(mailbox, _, _)| mailbox)
2571 .ok_or_else(remote_broker_unavailable)
2572 }
2573}
2574
2575#[async_trait]
2576impl ExternalChildRunner for ActorChildRunner {
2577 async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2578 let role = session
2579 .metadata
2580 .get("subagent_type")
2581 .map(String::as_str)
2582 .unwrap_or("worker");
2583 let current = std::env::current_exe()
2584 .ok()
2585 .and_then(|path| path.canonicalize().ok());
2586 if self.native_tool_ceiling.is_none()
2587 || !self.builtin_required_context_route
2588 || !self.should_handle(session).await
2589 || current.is_none()
2590 || self.worker_bin.canonicalize().ok() != current
2591 || self.worker_args != ["subagent-worker".to_string()]
2592 || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2593 || self.bus.is_none()
2594 || self.remote_placements.contains_key(role)
2595 || self.schedulable_placements.contains_key(role)
2596 {
2597 return Err(
2598 "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2599 .into(),
2600 );
2601 }
2602 bamboo_subagent::fleet::require_worker_capability(
2603 &self.worker_bin,
2604 &self.worker_args,
2605 bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2606 )
2607 .await
2608 .map_err(|_| {
2609 "required_child_context_unsupported: worker capability unconfirmed".to_string()
2610 })?;
2611 bamboo_subagent::fleet::require_worker_capability(
2612 &self.worker_bin,
2613 &self.worker_args,
2614 bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2615 )
2616 .await
2617 .map_err(|_| {
2618 "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2619 })?;
2620 bamboo_subagent::fleet::require_worker_capability(
2621 &self.worker_bin,
2622 &self.worker_args,
2623 bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2624 )
2625 .await
2626 .map_err(|_| {
2627 "required_child_context_unsupported: worker birth capability unconfirmed".into()
2628 })
2629 }
2630 async fn should_handle(&self, session: &Session) -> bool {
2631 session.metadata.get("runtime.kind") == Some(&"external".to_string())
2632 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2633 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2634 }
2635
2636 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2637 *self.escalation_bridge.lock().recover_poison() = bridge;
2638 }
2639
2640 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2641 *self.session_inbox_runtime.lock().recover_poison() = binding;
2642 }
2643
2644 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2645 *self.actor_directory_store.lock().recover_poison() = store;
2646 }
2647
2648 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2649 *self.canonical_subagent_tool.lock().recover_poison() = tool;
2650 }
2651
2652 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2653 *self.ticket_service.lock().recover_poison() = service;
2654 }
2655
2656 fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2657 *self.actor_event_observer.lock().recover_poison() = observer;
2658 }
2659
2660 async fn prepare_durable_child_delivery(
2661 &self,
2662 session: &Session,
2663 activation_run_id: &str,
2664 ) -> Result<bool, String> {
2665 let key = (
2666 session.id.clone(),
2667 session.created_at,
2668 activation_run_id.to_owned(),
2669 );
2670 let (delivery, host_nested_wait_handoff, strict_history) = {
2671 let pending = self.pending_durable_links.lock().await;
2672 let Some(pending) = pending.get(&key) else {
2673 return Ok(false);
2674 };
2675 if pending.strict_history_required && pending.strict_history.is_none() {
2676 return Err("Host strict terminal completeness proof missing".into());
2677 }
2678 (
2679 pending
2680 .link
2681 .durable_delivery_receipt()
2682 .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2683 pending.host_nested_wait_handoff,
2684 pending.strict_history.clone(),
2685 )
2686 };
2687 let expected_status =
2688 effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2689 if session.last_run_status().as_deref() != Some(expected_status) {
2690 tracing::warn!(
2691 child_session_id = %session.id,
2692 activation_run_id,
2693 expected_status,
2694 actual_status = ?session.last_run_status(),
2695 "Host final status differs from accepted broker terminal"
2696 );
2697 return Err("Host final status differs from accepted broker terminal".into());
2698 }
2699 let store = self
2700 .actor_directory_store
2701 .lock()
2702 .recover_poison()
2703 .clone()
2704 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2705 let binding = self
2706 .session_inbox_runtime
2707 .lock()
2708 .recover_poison()
2709 .clone()
2710 .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2711 if !binding
2712 .router
2713 .owns_run(&session.id, activation_run_id)
2714 .await
2715 {
2716 return Err("stale Child activation cannot prepare broker receipt".into());
2717 }
2718 let ticket_service = self.ticket_service.lock().recover_poison().clone();
2719 let yielded_call = if expected_status == "completed"
2720 && session
2721 .metadata
2722 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2723 {
2724 let service = ticket_service
2725 .as_deref()
2726 .ok_or("Ticket authority unavailable for broker question")?;
2727 crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2728 .map_err(|e| e.to_string())?
2729 } else {
2730 None
2731 };
2732 let route = bamboo_storage::v2::BrokerTerminalRoute {
2733 activation_run_id,
2734 broker_identity: &delivery.broker_identity,
2735 parent_mailbox: &delivery.parent_mailbox,
2736 broker_correlation_id: &delivery.correlation_id,
2737 message_ids: &delivery.message_ids,
2738 };
2739 if let Some(call_id) = yielded_call {
2740 let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2741 session,
2742 activation_run_id,
2743 &call_id,
2744 )
2745 .map_err(|error| error.to_string())?;
2746 if let Some(history) = strict_history.as_ref() {
2747 store
2748 .prepare_broker_tool_yield_receipt_with_completeness(
2749 session,
2750 route,
2751 &proof,
2752 &history.proof,
2753 )
2754 .await
2755 } else {
2756 store
2757 .prepare_broker_tool_yield_receipt(session, route, &proof)
2758 .await
2759 }
2760 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2761 } else {
2762 if let Some(history) = strict_history.as_ref() {
2763 store
2764 .prepare_broker_terminal_receipt_with_completeness(
2765 session,
2766 route,
2767 &history.proof,
2768 )
2769 .await
2770 } else {
2771 store
2772 .prepare_broker_terminal_receipt(
2773 session,
2774 activation_run_id,
2775 &delivery.broker_identity,
2776 &delivery.parent_mailbox,
2777 &delivery.correlation_id,
2778 &delivery.message_ids,
2779 )
2780 .await
2781 }
2782 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2783 }
2784 Ok(true)
2785 }
2786
2787 async fn commit_durable_child_delivery(
2788 &self,
2789 session: &Session,
2790 activation_run_id: &str,
2791 ) -> Result<(), String> {
2792 let key = (
2793 session.id.clone(),
2794 session.created_at,
2795 activation_run_id.to_owned(),
2796 );
2797 let mut pending_links = self.pending_durable_links.lock().await;
2798 let Some(pending) = pending_links.get_mut(&key) else {
2799 return Ok(());
2800 };
2801 let delivery = pending
2802 .link
2803 .durable_delivery_receipt()
2804 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2805 let expected_status = effective_broker_terminal_status(
2806 delivery.terminal_status,
2807 pending.host_nested_wait_handoff,
2808 )?;
2809 if session.last_run_status().as_deref() != Some(expected_status) {
2810 return Err("Host final status differs from accepted broker terminal".into());
2811 }
2812 if pending.strict_history_required && pending.strict_history.is_none() {
2813 return Err("Host strict terminal completeness proof missing".into());
2814 }
2815 let store = self
2816 .actor_directory_store
2817 .lock()
2818 .recover_poison()
2819 .clone()
2820 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2821 let committed = if let Some(history) = pending.strict_history.as_ref() {
2822 store
2823 .commit_broker_terminal_receipt_with_completeness(
2824 session,
2825 activation_run_id,
2826 &history.proof,
2827 )
2828 .await
2829 } else {
2830 store
2831 .commit_broker_terminal_receipt(session, activation_run_id)
2832 .await
2833 }
2834 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2835 if committed.broker_identity != delivery.broker_identity
2836 || committed.parent_mailbox != delivery.parent_mailbox
2837 || committed.broker_correlation_id != delivery.correlation_id
2838 || committed.message_ids != delivery.message_ids
2839 || committed.terminal_status != expected_status
2840 {
2841 return Err("Host broker receipt changed before completion".into());
2842 }
2843 if let Some(history) = pending.strict_history.as_ref() {
2844 let proof = committed
2845 .terminal_completeness
2846 .as_ref()
2847 .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2848 let creation = bamboo_subagent::proto::ChildCreationIdentity {
2849 created_at: session.created_at,
2850 spawn_depth: session.spawn_depth,
2851 };
2852 validate_strict_history_terminal(
2853 Some(&history.watermark),
2854 session,
2855 session
2856 .parent_session_id
2857 .as_deref()
2858 .ok_or("Child parent missing")?,
2859 Some(activation_run_id),
2860 proof.execution_epoch,
2861 Some(&creation),
2862 proof.contiguous_applied_seq,
2863 )
2864 .map_err(|error| error.to_string())?;
2865 }
2866 publish_recovered_completion_source(&store, &committed).await?;
2870 pending.strict_history_committed = true;
2871 Ok(())
2872 }
2873
2874 async fn confirm_durable_child_delivery(
2875 &self,
2876 session: &Session,
2877 activation_run_id: &str,
2878 save_succeeded: bool,
2879 ) -> Result<(), String> {
2880 if save_succeeded
2881 && session
2882 .metadata
2883 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2884 {
2885 let service = self
2886 .ticket_service
2887 .lock()
2888 .recover_poison()
2889 .clone()
2890 .ok_or_else(|| {
2891 "Ticket authority unavailable for terminal checkpoint".to_string()
2892 })?;
2893 let runtime = self
2894 .session_inbox_runtime
2895 .lock()
2896 .recover_poison()
2897 .clone()
2898 .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2899 crate::ticket_runtime::checkpoint_ticket_result(
2900 &service,
2901 runtime.storage.as_ref(),
2902 session,
2903 activation_run_id,
2904 )
2905 .await
2906 .map_err(|e| e.to_string())?;
2907 }
2908 let key = (
2909 session.id.clone(),
2910 session.created_at,
2911 activation_run_id.to_owned(),
2912 );
2913 let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2914 return Ok(());
2915 };
2916 let delivery = pending
2919 .link
2920 .durable_delivery_receipt()
2921 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2922 let expected_status = effective_broker_terminal_status(
2923 delivery.terminal_status,
2924 pending.host_nested_wait_handoff,
2925 )?;
2926 if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2927 return Ok(());
2928 }
2929 let store = self
2930 .actor_directory_store
2931 .lock()
2932 .recover_poison()
2933 .clone()
2934 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2935 let committed = if pending.strict_history_required {
2936 if !pending.strict_history_committed {
2937 return Err("Host strict completeness was not committed before ACK".into());
2938 }
2939 let history = pending
2940 .strict_history
2941 .as_ref()
2942 .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2943 store
2944 .commit_broker_terminal_receipt_with_completeness(
2945 session,
2946 activation_run_id,
2947 &history.proof,
2948 )
2949 .await
2950 } else {
2951 store
2952 .commit_broker_terminal_receipt(session, activation_run_id)
2953 .await
2954 }
2955 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2956 if committed.broker_identity != delivery.broker_identity
2957 || committed.parent_mailbox != delivery.parent_mailbox
2958 || committed.broker_correlation_id != delivery.correlation_id
2959 || committed.message_ids != delivery.message_ids
2960 || committed.terminal_status != expected_status
2961 {
2962 return Err("Host broker receipt changed before ACK".into());
2963 }
2964 pending
2965 .link
2966 .acknowledge_durable_frames()
2967 .await
2968 .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2969 store
2970 .clear_acknowledged_broker_terminal_receipt(
2971 &session.id,
2972 session.created_at,
2973 activation_run_id,
2974 &delivery.parent_mailbox,
2975 )
2976 .await
2977 .map_err(|error| {
2978 format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2979 })
2980 }
2981
2982 #[tracing::instrument(
2983 name = "actor_child_run",
2984 level = "warn",
2985 skip_all,
2986 fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2987 )]
2988 async fn execute_external_child(
2989 &self,
2990 session: &mut Session,
2991 job: &SpawnJob,
2992 event_tx: mpsc::Sender<AgentEvent>,
2993 cancel_token: CancellationToken,
2994 ) -> crate::runtime::runner::Result<()> {
2995 crate::session_app::child_session::named_profile::validate_named_profile(session)
2996 .map_err(|error| AgentError::LLM(error.to_string()))?;
2997 let escalation = self.escalation_bridge.lock().recover_poison().clone();
3006 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3007 let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3008 let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3009 let ticket_service = self.ticket_service.lock().recover_poison().clone();
3010 let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3011 let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3012 .map_err(|error| AgentError::Budget(error.to_string()))?;
3013 if crate::session_app::child_session::named_profile::has_named_profile(session)
3014 && required_context.is_none()
3015 {
3016 return Err(AgentError::LLM(
3017 "named_profile_requires_strict_context_route".into(),
3018 ));
3019 }
3020 let assignment = required_context
3021 .as_ref()
3022 .map(|binding| binding.payload.required_assignment.clone())
3023 .unwrap_or_else(|| extract_assignment(session));
3024 let mut spec = self.build_live_spec(session, job).await;
3025 ensure_ticket_placement_is_enforceable(session, &spec)?;
3028 let scoped_route = self.scoped_placement_route(&spec)?;
3029 let strict_remote = scoped_route.as_ref();
3030 if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3031 return Err(remote_broker_unavailable());
3032 }
3033 let lineage = if strict_remote.is_some() {
3034 Some(
3035 remote_canonical_lineage(
3036 session_inbox_runtime
3037 .as_ref()
3038 .ok_or_else(remote_broker_unavailable)?,
3039 session,
3040 )
3041 .await?,
3042 )
3043 } else {
3044 None
3045 };
3046 let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3047 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3048 {
3049 let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3050 AgentError::LLM(
3051 "child creation identity unavailable: canonical host Storage required".into(),
3052 )
3053 })?;
3054 spec.capabilities.child_creation_identity = true;
3055 Some(canonical_child_creation(binding, session, job).await?)
3056 } else {
3057 None
3058 };
3059 spec.reusable = required_context.is_none();
3062 spec.capabilities.required_child_context = required_context.is_some();
3063 if required_context.is_some() {
3064 self.validate_required_child_context_route(session)
3065 .await
3066 .map_err(AgentError::LLM)?;
3067 }
3068 if required_context.is_some() {
3069 let names = self
3070 .native_tool_ceiling
3071 .as_ref()
3072 .unwrap()
3073 .observe(session)
3074 .await
3075 .map_err(AgentError::LLM)?;
3076 let birth = creation
3077 .as_ref()
3078 .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3079 spec.capabilities.native_tool_ceiling_required = true;
3080 spec.capabilities.native_tool_ceiling =
3081 Some(bamboo_subagent::proto::NativeToolCeiling {
3082 version: 1,
3083 child_session_id: session.id.clone(),
3084 parent_session_id: job.parent_session_id.clone(),
3085 root_session_id: session.root_session_id.clone(),
3086 created_at: birth.created_at,
3087 spawn_depth: birth.spawn_depth,
3088 project_id: project_id_for_actor_run(session)?,
3089 tools: names,
3090 });
3091 spec.capabilities.mcp = None;
3093 spec.capabilities.mcp_proxy = None;
3094 spec.capabilities.skills_dir = None;
3095 spec.capabilities.nested_spawn = false;
3096 spec.validate()
3097 .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3098 }
3099 let first_reply_store = actor_directory_store.clone().filter(|_| {
3102 required_context.is_some()
3103 && crate::session_app::child_session::named_profile::has_named_profile(session)
3104 && matches!(spec.placement, Placement::Local)
3105 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3106 && spec.capabilities.native_tool_ceiling_required
3107 && spec
3108 .capabilities
3109 .native_tool_ceiling
3110 .as_ref()
3111 .is_some_and(|ceiling| {
3112 ceiling.tools.is_empty()
3113 || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3114 })
3115 });
3116 let plain_actor_store = if let Some(store) = first_reply_store {
3117 let root = bamboo_agent_core::storage::Storage::load_session(
3118 store.as_ref(),
3119 &session.root_session_id,
3120 )
3121 .await
3122 .map_err(|_| plain_actor_unsupported())?
3123 .ok_or_else(|| plain_actor_unsupported())?;
3124 if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3125 return Err(plain_actor_unsupported());
3126 }
3127 if root.root_orchestration_only_enabled() {
3128 if !plain_initial_history(session)
3129 && (spec
3130 .capabilities
3131 .native_tool_ceiling
3132 .as_ref()
3133 .is_none_or(|ceiling| !ceiling.tools.is_empty())
3134 || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3135 {
3136 return Err(plain_actor_unsupported());
3137 }
3138 Some(store)
3139 } else if spec
3140 .capabilities
3141 .native_tool_ceiling
3142 .as_ref()
3143 .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3144 {
3145 require_unowned_glob(store.as_ref(), session).await?;
3148 None
3149 } else {
3150 let observed = store
3153 .actor_subtree_snapshot(
3154 ActorSnapshotPrincipal::host_owner(),
3155 &session.root_session_id,
3156 &session.id,
3157 ActorSnapshotLimits::default(),
3158 )
3159 .await
3160 .map_err(|_| plain_actor_unsupported())?;
3161 if observed
3162 .nodes
3163 .iter()
3164 .any(|node| node.actor_id == session.id && node.activation.is_some())
3165 {
3166 return Err(plain_actor_unsupported());
3167 }
3168 None
3169 }
3170 } else {
3171 None
3172 };
3173 let readonly_actor = plain_actor_store.is_some()
3174 && spec
3175 .capabilities
3176 .native_tool_ceiling
3177 .as_ref()
3178 .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3179 let local_history_tools = (plain_actor_store.is_none()
3180 && required_context.is_some()
3181 && (crate::session_app::child_session::named_profile::has_named_profile(session)
3182 || session
3183 .metadata
3184 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3185 && matches!(spec.placement, Placement::Local)
3186 && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3187 .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3188 .flatten()
3189 .filter(|ceiling| {
3190 bamboo_subagent::proto::LocalToolMessages::supports_tools(
3191 &ceiling.tools,
3192 spec.capabilities.read_only_enforced(),
3193 )
3194 })
3195 .map(|ceiling| ceiling.tools.clone());
3196 if plain_actor_store.is_some() && !readonly_actor {
3197 self.require_initial_input_release_worker().await?;
3198 spec.capabilities.initial_input_release_required = true;
3199 spec.validate().map_err(|_| plain_actor_unsupported())?;
3200 }
3201 let mut readonly_messages = Vec::new();
3202 let mut plain_activation = None;
3203 if spec.limits.idle_timeout_secs.is_none() {
3204 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3205 }
3206 ensure_read_only_activation_is_enforceable(&spec)?;
3207 let pool_key = Self::fingerprint(&spec);
3208
3209 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3214 {
3215 return Err(AgentError::LLM(
3216 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3217 .to_string(),
3218 ));
3219 }
3220 let project_id = project_id_for_actor_run(session)?;
3221 let requested_permission_mode = session
3222 .agent_runtime_state
3223 .as_ref()
3224 .map(|state| state.effective_permission_mode())
3225 .unwrap_or_default();
3226 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3231 ensure_no_active_scoped_session_denies(config, &session.id)?;
3232 let read_only_overlay = session
3233 .agent_runtime_state
3234 .as_ref()
3235 .is_some_and(|state| state.plan_mode.is_some())
3236 || spec.capabilities.read_only_enforced()
3237 || executor_has_read_only_permission_profile(&spec.executor);
3238 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3239 requested_permission_mode,
3240 config.mode(),
3241 read_only_overlay,
3242 );
3243 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3244 AgentError::LLM(format!(
3245 "failed to serialize permission policy for external executor: {error}"
3246 ))
3247 })?;
3248 Some(PermissionPolicyContext {
3249 revision: config.policy_revision(),
3250 requested_mode: resolution.requested.as_str().to_string(),
3251 effective_mode: resolution.effective.as_str().to_string(),
3252 bypass_permissions: resolution.bypass_permissions(),
3253 auto_approve_permissions: resolution.suppress_approval_prompts(),
3254 session_id: session.id.clone(),
3255 workspace_path: session.workspace.clone(),
3256 environment_lease: None,
3257 inherit_session_grants: false,
3258 policy,
3259 })
3260 } else {
3261 None
3262 };
3263 let provisioned_permission =
3264 spec.capabilities.permission_resolution().map_err(|error| {
3265 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3266 })?;
3267 let policy_resolution = permission_policy
3268 .as_ref()
3269 .map(|context| {
3270 context.resolved_modes().map(|(requested, effective)| {
3271 bamboo_domain::PermissionModeResolution {
3272 requested,
3273 effective,
3274 }
3275 })
3276 })
3277 .transpose()
3278 .map_err(|error| {
3279 AgentError::LLM(format!("invalid host permission posture: {error}"))
3280 })?;
3281 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3282 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3283 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3284 .is_some()
3285 });
3286 let expected_executor_mapping = expected_permission_executor_mapping(
3287 &spec.executor,
3288 policy_resolution.unwrap_or(provisioned_permission),
3289 has_explicit_deny,
3290 )?;
3291 let mut expected_permission_posture =
3292 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3293 policy_revision: permission_policy
3294 .as_ref()
3295 .map(|context| context.revision)
3296 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3297 .unwrap_or_default(),
3298 resolution: policy_resolution.unwrap_or(provisioned_permission),
3299 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3300 executor_mapping,
3301 });
3302
3303 let _slot = if strict_remote.is_some() {
3308 tokio::select! { biased;
3309 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3310 permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3311 }
3312 } else {
3313 self.concurrency
3314 .acquire()
3315 .await
3316 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3317 };
3318 let _remote_subscription = if strict_remote.is_some() {
3319 Some(tokio::select! { biased;
3320 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3321 guard = self.strict_remote_subscription.lock() => guard,
3322 })
3323 } else {
3324 None
3325 };
3326
3327 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3334 &spec.executor,
3335 self.codex_run_tokens.clone(),
3336 &job.child_session_id,
3337 )?;
3338
3339 let kind = match spec.placement {
3347 Placement::Remote { .. } => PlacementKind::Remote,
3348 Placement::Schedulable { .. } => PlacementKind::Schedulable,
3349 Placement::Local => PlacementKind::Local,
3350 };
3351 let remote = !matches!(kind, PlacementKind::Local);
3352 let scoped_registry = if remote {
3353 let store = actor_directory_store
3354 .as_ref()
3355 .ok_or_else(remote_broker_unavailable)?;
3356 Some(
3357 bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3358 .await
3359 .map_err(|_| remote_broker_unavailable())?,
3360 )
3361 } else {
3362 None
3363 };
3364 let scoped_tools = scoped_route.as_ref().map(|route| {
3365 let mut tools = route.requirements.required_tools.clone();
3366 if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3367 tools.extend(ceiling.tools.iter().cloned());
3368 }
3369 tools
3370 });
3371
3372 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3378 session
3379 .metadata
3380 .insert("placement".to_string(), placement_meta);
3381 }
3382
3383 let mut attempt = 0u8;
3390 let (result, actor) = loop {
3391 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3392 let (actor, mut client) = match kind {
3393 PlacementKind::Remote => {
3394 let route = scoped_route
3395 .as_ref()
3396 .ok_or_else(remote_broker_unavailable)?;
3397 let mailbox_id = route
3398 .pinned_mailbox
3399 .clone()
3400 .ok_or_else(remote_broker_unavailable)?;
3401 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3402 .map_err(|_| remote_broker_unavailable())?;
3403 let connect =
3404 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3405 &route.endpoint,
3406 route.parent.clone(),
3407 &route.token,
3408 bamboo_subagent::AgentRef {
3409 session_id: mailbox_id.clone(),
3410 role: Some(route.worker_role.clone()),
3411 },
3412 tls,
3413 );
3414 let link = tokio::select! { biased;
3415 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3416 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3417 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3418 };
3419 let record = AgentRecord {
3420 agent_id: job.child_session_id.clone(),
3421 role: spec.identity.role.clone(),
3422 labels: Vec::new(),
3423 endpoint: String::new(),
3424 pid: 0,
3425 version: String::new(),
3426 started_at: chrono::Utc::now(),
3427 lease_expires_at: chrono::Utc::now(),
3428 };
3429 (
3430 PooledWorker {
3431 worker: SpawnedChild::remote(record),
3432 mailbox_id,
3433 parked_at: None,
3434 },
3435 Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3436 )
3437 }
3438 PlacementKind::Schedulable => {
3439 let route = scoped_route
3440 .as_ref()
3441 .ok_or_else(remote_broker_unavailable)?;
3442 let registry = scoped_registry
3443 .as_ref()
3444 .ok_or_else(remote_broker_unavailable)?;
3445 let mailbox_id = self
3446 .select_scoped_worker(
3447 route,
3448 registry,
3449 &session.id,
3450 project_id.clone(),
3451 scoped_tools
3452 .as_ref()
3453 .ok_or_else(remote_broker_unavailable)?,
3454 )
3455 .await?;
3456 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3457 .map_err(|_| remote_broker_unavailable())?;
3458 let connect =
3459 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3460 &route.endpoint,
3461 route.parent.clone(),
3462 &route.token,
3463 bamboo_subagent::AgentRef {
3464 session_id: mailbox_id.clone(),
3465 role: Some(route.worker_role.clone()),
3466 },
3467 tls,
3468 );
3469 let link = tokio::select! { biased;
3470 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3471 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3472 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3473 };
3474 let actor = PooledWorker {
3475 worker: SpawnedChild::remote(AgentRecord {
3476 agent_id: mailbox_id.clone(),
3477 role: spec.identity.role.clone(),
3478 labels: Vec::new(),
3479 endpoint: route.endpoint.clone(),
3480 pid: 0,
3481 version: String::new(),
3482 started_at: chrono::Utc::now(),
3483 lease_expires_at: chrono::Utc::now(),
3484 }),
3485 mailbox_id,
3486 parked_at: None,
3487 };
3488 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3489 (actor, client)
3490 }
3491 PlacementKind::Local => {
3492 let bus = self.bus.as_ref().ok_or_else(|| {
3499 AgentError::LLM(
3500 "local sub-agents require a mailbox bus (subagents.broker); none is \
3501 configured and the bus could not be embedded"
3502 .to_string(),
3503 )
3504 })?;
3505 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3506 let parent = bamboo_subagent::AgentRef {
3507 session_id: format!("p-{}", job.child_session_id),
3508 role: None,
3509 };
3510 let link = bamboo_broker::BrokerChildLink::connect(
3511 &bus.endpoint,
3512 parent,
3513 &bus.token,
3514 actor.mailbox_id.clone(),
3515 )
3516 .await
3517 .map_err(|e| {
3518 AgentError::LLM(format!("broker child link connect failed: {e}"))
3519 })?;
3520 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3521 (actor, client)
3522 }
3523 };
3524
3525 if let Some(store) = actor_directory_store.as_ref() {
3531 let recovery = async {
3532 let receipts = store
3533 .recover_broker_terminal_receipts(session)
3534 .await
3535 .map_err(|error| {
3536 format!("Host broker receipt recovery blocked: {error}")
3537 })?;
3538 for receipt in receipts {
3539 if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3543 return Err(
3544 "old Child broker parent mailbox changed before ACK".to_string()
3545 );
3546 }
3547 client
3548 .acknowledge_recovered_durable_frames(
3549 &receipt.broker_identity,
3550 &receipt.message_ids,
3551 )
3552 .await
3553 .map_err(|error| {
3554 format!("old Child broker ACK unconfirmed: {error}")
3555 })?;
3556 store
3557 .clear_acknowledged_broker_terminal_receipt(
3558 &receipt.session_id,
3559 receipt.created_at,
3560 &receipt.activation_run_id,
3561 &receipt.parent_mailbox,
3562 )
3563 .await
3564 .map_err(|error| {
3565 format!("old Child broker ACK cleanup failed: {error}")
3566 })?;
3567 }
3568 Ok::<(), String>(())
3569 }
3570 .await;
3571 if let Err(error) = recovery {
3572 if !remote {
3573 actor.worker.kill().await;
3574 }
3575 return Err(AgentError::LLM(error));
3576 }
3577 }
3578
3579 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3584 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3585 Some(binding) => {
3586 let run_id = binding
3587 .router
3588 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3589 .await;
3590 if run_id.is_none() {
3591 tracing::debug!(
3592 session_id = %job.child_session_id,
3593 "actor driver had no current SessionInbox activation owner to bind"
3594 );
3595 }
3596 run_id
3597 }
3598 None => None,
3599 };
3600 tracing::Span::current().record(
3601 "activation_run_id",
3602 bound_activation_run_id.as_deref().unwrap_or("unbound"),
3603 );
3604 drop(delivery_tx);
3605 let initial_pairs = match (
3606 session_inbox_runtime.as_ref(),
3607 bound_activation_run_id.as_deref(),
3608 ) {
3609 (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3610 match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3611 .await
3612 {
3613 Ok(deliveries) => deliveries,
3614 Err(error) => {
3615 binding
3616 .router
3617 .detach_delivery_sink(&job.child_session_id, run_id)
3618 .await;
3619 if !remote {
3620 actor.worker.kill().await;
3621 }
3622 return Err(error);
3623 }
3624 }
3625 }
3626 _ => Vec::new(),
3627 };
3628 let mut initial_session_messages = initial_pairs
3629 .iter()
3630 .map(|(_, delivery)| delivery.clone())
3631 .collect::<Vec<_>>();
3632 let mut initial_inflight_claims = initial_pairs
3633 .into_iter()
3634 .map(|(claim, _)| claim)
3635 .collect::<VecDeque<_>>();
3636 let mut messages = if let Some(binding) = &required_context {
3639 binding
3640 .validate_messages(&session.id, &session.messages)
3641 .map_err(|error| AgentError::Budget(error.to_string()))?;
3642 session
3643 .messages
3644 .iter()
3645 .map(serde_json::to_value)
3646 .collect::<std::result::Result<Vec<_>, _>>()
3647 .map_err(|_| {
3648 AgentError::Budget(
3649 bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3650 )
3651 })?
3652 } else {
3653 session
3654 .messages
3655 .iter()
3656 .filter_map(|message| serde_json::to_value(message).ok())
3657 .collect()
3658 };
3659
3660 if strict_remote.is_some() {
3661 let current =
3662 remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3663 .await;
3664 if current.as_ref().ok() != lineage.as_ref()
3665 || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3666 || cancel_token.is_cancelled()
3667 {
3668 if let (Some(binding), Some(run_id)) = (
3669 session_inbox_runtime.as_ref(),
3670 bound_activation_run_id.as_deref(),
3671 ) {
3672 binding
3673 .router
3674 .detach_delivery_sink(&job.child_session_id, run_id)
3675 .await;
3676 }
3677 return Err(if cancel_token.is_cancelled() {
3678 AgentError::Cancelled
3679 } else {
3680 remote_broker_unavailable()
3681 });
3682 }
3683 }
3684 if let Some(expected) = creation.as_ref() {
3685 let checked =
3686 canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3687 .await;
3688 if checked.as_ref().ok() != Some(expected) {
3689 if let (Some(binding), Some(run_id)) = (
3690 session_inbox_runtime.as_ref(),
3691 bound_activation_run_id.as_deref(),
3692 ) {
3693 binding
3694 .router
3695 .detach_delivery_sink(&job.child_session_id, run_id)
3696 .await;
3697 }
3698 actor.worker.kill().await;
3699 return Err(AgentError::LLM(
3700 "child creation identity changed before dispatch".into(),
3701 ));
3702 }
3703 }
3704 if let Some(store) = &plain_actor_store {
3705 let started = async {
3706 let policy = permission_policy
3711 .as_ref()
3712 .ok_or_else(|| plain_actor_unsupported())?;
3713 let config = self
3714 .permission_config
3715 .as_ref()
3716 .ok_or_else(|| plain_actor_unsupported())?;
3717 if config.policy_revision() != policy.revision
3718 || !matches!(spec.placement, Placement::Local)
3719 {
3720 return Err(plain_actor_unsupported());
3721 }
3722 store
3723 .bind_control_plane(
3724 &session.id,
3725 bamboo_domain::ActorControlPlaneBinding {
3726 policy_revision: Some(policy.revision),
3727 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3728 },
3729 )
3730 .await
3731 .map_err(|error| AgentError::LLM(error.to_string()))?;
3732 PlainActorActivation::start_and_prepare(
3733 store.clone(),
3734 session,
3735 session_inbox_runtime.as_ref().unwrap(),
3736 bound_activation_run_id.as_deref(),
3737 self.permission_config.clone(),
3738 !readonly_actor,
3739 spec.capabilities
3740 .initial_input_release_required
3741 .then_some(actor.mailbox_id.as_str()),
3742 )
3743 .await
3744 }
3745 .await;
3746 match started {
3747 Ok((activation, prepared)) => {
3748 if let Some((prefix, delivery)) = prepared {
3749 messages = prefix;
3750 initial_session_messages = vec![delivery];
3751 }
3752 plain_activation = Some(activation);
3753 }
3754 Err(error) => {
3755 if let Some(run_id) = bound_activation_run_id.as_deref() {
3756 session_inbox_runtime
3757 .as_ref()
3758 .unwrap()
3759 .router
3760 .detach_delivery_sink(&job.child_session_id, run_id)
3761 .await;
3762 }
3763 actor.worker.kill().await;
3764 return Err(error);
3765 }
3766 }
3767 }
3768 let mut logical_identity = logical_identity_for_actor_run(session, job);
3769 logical_identity.creation = creation.clone();
3770 let run_permission_policy = if strict_remote.is_some() {
3771 let prepared = async {
3772 let mut policy = permission_policy.clone().ok_or_else(|| {
3773 AgentError::LLM("remote_environment_permission_policy_missing".into())
3774 })?;
3775 let workspace = session.workspace.as_deref().ok_or_else(|| {
3776 AgentError::LLM("remote_environment_workspace_missing".into())
3777 })?;
3778 let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3779 AgentError::LLM("remote_environment_activation_missing".into())
3780 })?;
3781 let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3782 workspace,
3783 &session.id,
3784 run_id,
3785 execution_epoch,
3786 )
3787 .await
3788 .map_err(|error| AgentError::LLM(error.into()))?;
3789 policy.workspace_path = None;
3790 policy.environment_lease = Some(lease);
3791 Ok::<_, AgentError>(Some(policy))
3792 }
3793 .await;
3794 match prepared {
3795 Ok(policy) => policy,
3796 Err(error) => {
3797 if let (Some(binding), Some(run_id)) = (
3798 session_inbox_runtime.as_ref(),
3799 bound_activation_run_id.as_deref(),
3800 ) {
3801 binding
3802 .router
3803 .detach_delivery_sink(&job.child_session_id, run_id)
3804 .await;
3805 }
3806 return Err(error);
3807 }
3808 }
3809 } else {
3810 permission_policy.clone()
3811 };
3812 if session
3813 .metadata
3814 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3815 {
3816 let admission = async {
3817 let service = ticket_service
3818 .as_ref()
3819 .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3820 let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3821 AgentError::LLM("Ticket activation runtime unavailable".into())
3822 })?;
3823 let run_id = bound_activation_run_id
3824 .as_deref()
3825 .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3826 crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3827 .await
3828 .map_err(|e| AgentError::LLM(e.to_string()))
3829 }
3830 .await;
3831 if let Err(error) = admission {
3832 if let (Some(runtime), Some(run_id)) = (
3833 session_inbox_runtime.as_ref(),
3834 bound_activation_run_id.as_deref(),
3835 ) {
3836 runtime
3837 .router
3838 .detach_delivery_sink(&job.child_session_id, run_id)
3839 .await;
3840 }
3841 actor.worker.kill().await;
3842 return Err(error);
3843 }
3844 }
3845 let mut run_spec = RunSpec {
3846 assignment: assignment.clone(),
3848 logical_session: Some(logical_identity),
3849 project_id: project_id.clone(),
3850 reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3854 {
3855 session
3856 .reasoning_effort
3857 .map(|effort| effort.as_str().to_owned())
3858 } else {
3859 None
3860 },
3861 permission_policy: run_permission_policy,
3862 messages,
3863 activation_run_id: bound_activation_run_id.clone(),
3864 execution_epoch,
3865 initial_session_messages,
3866 secrets: run_secrets.clone(),
3867 };
3868 let mut active_lease = if remote {
3869 let reserve = ActivePlacementLease::reserve(
3870 scoped_registry
3871 .as_ref()
3872 .ok_or_else(remote_broker_unavailable)?
3873 .clone(),
3874 actor_directory_store
3875 .as_ref()
3876 .ok_or_else(remote_broker_unavailable)?
3877 .clone(),
3878 scoped_route
3879 .as_ref()
3880 .ok_or_else(remote_broker_unavailable)?
3881 .clone(),
3882 actor.mailbox_id.clone(),
3883 session.id.clone(),
3884 bound_activation_run_id
3885 .clone()
3886 .ok_or_else(remote_broker_unavailable)?,
3887 project_id.clone(),
3888 scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3889 &cancel_token,
3890 )
3891 .await;
3892 match reserve {
3893 Ok(lease) => Some(lease),
3894 Err(error) => {
3895 if let (Some(binding), Some(run_id)) = (
3896 session_inbox_runtime.as_ref(),
3897 bound_activation_run_id.as_deref(),
3898 ) {
3899 binding
3900 .router
3901 .detach_delivery_sink(&job.child_session_id, run_id)
3902 .await;
3903 }
3904 return Err(error);
3905 }
3906 }
3907 } else {
3908 None
3909 };
3910 if let Some(lease) = active_lease.as_mut() {
3911 let seeded = async {
3912 let binding = session_inbox_runtime
3913 .as_ref()
3914 .ok_or_else(remote_broker_unavailable)?;
3915 let run_id = bound_activation_run_id
3916 .as_deref()
3917 .ok_or_else(remote_broker_unavailable)?;
3918 let pairs = claim_canonical_deliveries(
3919 binding,
3920 session,
3921 run_id,
3922 usize::MAX,
3923 Some((lease.actor_store.as_ref(), &lease.fence)),
3924 )
3925 .await?;
3926 if let Some(binding) = &required_context {
3927 binding
3928 .validate_messages(&session.id, &session.messages)
3929 .map_err(|error| AgentError::Budget(error.to_string()))?;
3930 }
3931 run_spec.messages = session
3932 .messages
3933 .iter()
3934 .map(serde_json::to_value)
3935 .collect::<std::result::Result<Vec<_>, _>>()
3936 .map_err(|_| remote_broker_unavailable())?;
3937 run_spec.initial_session_messages =
3938 pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3939 initial_inflight_claims = pairs
3940 .into_iter()
3941 .map(|(claim, _)| claim)
3942 .collect::<VecDeque<_>>();
3943 Ok::<(), AgentError>(())
3944 }
3945 .await;
3946 if let Err(error) = seeded {
3947 if let Err(rollback_error) = lease.abort_before_dispatch().await {
3948 tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3949 }
3950 if let (Some(binding), Some(run_id)) = (
3951 session_inbox_runtime.as_ref(),
3952 bound_activation_run_id.as_deref(),
3953 ) {
3954 binding
3955 .router
3956 .detach_delivery_sink(&job.child_session_id, run_id)
3957 .await;
3958 }
3959 return Err(error);
3960 }
3961 }
3962 let mut run_send_invoked = false;
3963 let dispatch = async {
3964 if let Some(activation) = plain_activation
3965 .as_ref()
3966 .filter(|activation| activation.initial_input.is_some())
3967 {
3968 activation.refresh_continuation_posture(
3969 session,
3970 &run_spec,
3971 &mut expected_permission_posture,
3972 )?;
3973 }
3974 send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3975 }
3976 .await;
3977 if let Err(e) = dispatch {
3978 if remote {
3979 tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3980 }
3981 if let (Some(binding), Some(run_id)) = (
3982 session_inbox_runtime.as_ref(),
3983 bound_activation_run_id.as_deref(),
3984 ) {
3985 binding
3986 .router
3987 .detach_delivery_sink(&job.child_session_id, run_id)
3988 .await;
3989 }
3990 if !remote {
3991 actor.worker.kill().await;
3992 }
3993 if let Some(activation) = &plain_activation {
3994 activation.finish(ActorActivationFinish::Failed).await?;
3995 }
3996 if let Some(lease) = active_lease.as_mut() {
3997 match lease.abort_if_run_unsent(run_send_invoked).await {
3998 Ok(false) => {
3999 tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4000 }
4001 Ok(true) => {}
4002 Err(rollback_error) => {
4003 tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4004 }
4005 }
4006 }
4007 return Err(e);
4008 }
4009
4010 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4014 let live_guard = super::live::register(
4015 &job.child_session_id,
4016 live_tx,
4017 attempt as u32,
4018 self.approval_registry.clone(),
4019 );
4020
4021 let mut strict_history = None;
4022 let result = drive(ActorDriveContext {
4023 client: &mut *client,
4024 parent_session_id: &job.parent_session_id,
4025 child_session_id: &job.child_session_id,
4026 child_attempt: attempt as u32,
4027 approval_registry: self.approval_registry.as_ref(),
4028 approval_decider: self.approval_decider.as_ref(),
4029 approval_reviewer: self.approval_reviewer.as_ref(),
4030 escalation_bridge: escalation.clone(),
4031 event_tx: &event_tx,
4032 cancel_token: active_lease
4033 .as_ref()
4034 .map_or(&cancel_token, |lease| &lease.run_cancel),
4035 live_rx: &mut live_rx,
4036 delivery_rx: &mut delivery_rx,
4037 logical_session: session,
4038 expected_permission_posture: expected_permission_posture.clone(),
4039 expected_creation: creation.as_ref(),
4040 session_inbox_runtime: session_inbox_runtime.as_ref(),
4041 actor_directory_store: actor_directory_store.as_deref(),
4042 canonical_subagent_tool: canonical_subagent_tool.clone(),
4043 ticket_service: ticket_service.clone(),
4044 activation_run_id: bound_activation_run_id.as_deref(),
4045 execution_epoch,
4046 expected_source_actor_id: &actor.mailbox_id,
4047 initial_inflight_claims,
4048 plain_actor: plain_activation.is_some(),
4055 remote_environment_lease: strict_remote.is_some(),
4056 readonly_output: if readonly_actor {
4057 Some(&mut readonly_messages)
4058 } else {
4059 None
4060 },
4061 local_history_tools: local_history_tools.as_deref(),
4062 local_history_read_only: spec.capabilities.read_only_enforced(),
4063 strict_history_output: Some(&mut strict_history),
4064 plain_input: plain_activation.as_ref(),
4065 canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4066 canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4067 actor_event_observer: actor_event_observer.as_deref(),
4068 plain_run: self
4071 .bus
4072 .as_ref()
4073 .map(|_| (&run_spec, &self.next_execution_epoch)),
4074 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4075 })
4076 .await;
4077 let mut result = if active_lease
4078 .as_ref()
4079 .is_some_and(ActivePlacementLease::lost)
4080 {
4081 Err(remote_broker_unavailable())
4082 } else {
4083 result
4084 };
4085 if local_history_tools.is_none() {
4086 let reply = result
4087 .as_ref()
4088 .ok()
4089 .and_then(|reply| reply.as_deref())
4090 .filter(|reply| !reply.is_empty())
4091 .map(str::to_owned);
4092 if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4093 if let Err(error) = append_fenced_remote_reply(
4094 lease.actor_store.as_ref(),
4095 &lease.fence,
4096 session,
4097 &reply,
4098 )
4099 .await
4100 {
4101 result = Err(error);
4102 }
4103 }
4104 }
4105 if remote {
4106 if let Err(error) = &result {
4107 let code = match error {
4108 AgentError::LLM(message) if message.starts_with("actor event") => {
4109 "actor_event"
4110 }
4111 AgentError::LLM(message) if message.starts_with("remote_actor") => {
4112 "remote_actor"
4113 }
4114 AgentError::LLM(message) if message.starts_with("remote_environment") => {
4115 if message.len() <= 128
4116 && message.bytes().all(|byte| {
4117 byte.is_ascii_lowercase()
4118 || byte.is_ascii_digit()
4119 || byte == b'_'
4120 })
4121 {
4122 message.as_str()
4123 } else {
4124 "environment"
4125 }
4126 }
4127 AgentError::LLM(message) if message.contains("permission posture") => {
4128 "permission_posture"
4129 }
4130 AgentError::LLM(message) if message.contains("broker") => "broker",
4131 AgentError::LLM(_) => "other_llm",
4132 AgentError::WorkerUnresponsive(_) => "unresponsive",
4133 AgentError::Cancelled => "cancelled",
4134 _ => "other",
4135 };
4136 tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4137 }
4138 }
4139 if let Some(lease) = active_lease.as_mut() {
4140 let accepted_terminal = client
4141 .durable_delivery_receipt()
4142 .map(|receipt| receipt.terminal_status);
4143 if let Err(error) = lease
4144 .complete(
4145 remote_activation_finish(&result, accepted_terminal),
4146 client.has_pending_durable_terminal(),
4147 )
4148 .await
4149 {
4150 tracing::warn!(
4151 stage = "actor_complete",
4152 "remote placement admission failed"
4153 );
4154 if let (Some(binding), Some(run_id)) = (
4155 session_inbox_runtime.as_ref(),
4156 bound_activation_run_id.as_deref(),
4157 ) {
4158 binding
4159 .router
4160 .detach_delivery_sink(&job.child_session_id, run_id)
4161 .await;
4162 }
4163 drop(live_guard);
4164 drop(client); drop(active_lease);
4166 return Err(error);
4167 }
4168 }
4169 drop(active_lease);
4170 if let (Some(binding), Some(run_id)) = (
4171 session_inbox_runtime.as_ref(),
4172 bound_activation_run_id.as_deref(),
4173 ) {
4174 binding
4175 .router
4176 .detach_delivery_sink(&job.child_session_id, run_id)
4177 .await;
4178 }
4179 drop(live_guard);
4185 if client.has_pending_durable_terminal() {
4189 if let Some(run_id) = bound_activation_run_id.as_deref() {
4190 let key = (session.id.clone(), session.created_at, run_id.to_owned());
4191 let host_nested_wait_handoff = session
4192 .metadata
4193 .get("runtime.actor_nested_wait_handoff")
4194 .is_some_and(|value| value == "true");
4195 let mut pending = self.pending_durable_links.lock().await;
4196 if pending.contains_key(&key) {
4197 return Err(AgentError::LLM(
4198 "child broker terminal receipt already awaits Host checkpoint".into(),
4199 ));
4200 }
4201 pending.insert(
4202 key,
4203 PendingDurableChildLink {
4204 link: client,
4205 host_nested_wait_handoff,
4206 strict_history,
4207 strict_history_required: local_history_tools.is_some(),
4208 strict_history_committed: false,
4209 },
4210 );
4211 } else {
4212 drop(client);
4214 }
4215 } else {
4216 drop(client);
4217 }
4218
4219 if required_context.is_none()
4223 && attempt == 0
4224 && matches!(kind, PlacementKind::Local)
4225 && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4226 {
4227 tracing::warn!(
4228 "actor child {} got no first frame; reaping the worker and respawning once",
4229 job.child_session_id
4230 );
4231 actor.worker.kill().await;
4232 attempt += 1;
4233 continue;
4234 }
4235 break (result, actor);
4236 };
4237
4238 let result = if strict_remote.is_some() {
4239 result.map_err(|error| match &error {
4240 AgentError::Cancelled => error,
4241 AgentError::LLM(message) => remote_environment_error_code(message)
4242 .map(|code| AgentError::LLM(code.to_owned()))
4243 .unwrap_or_else(remote_broker_unavailable),
4244 _ => remote_broker_unavailable(),
4245 })
4246 } else {
4247 result
4248 };
4249
4250 if remote {
4254 drop(actor);
4255 } else if required_context.is_some() {
4256 if session
4257 .metadata
4258 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4259 {
4260 let service = self.ticket_service.lock().recover_poison().clone();
4261 let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4262 let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4263 .ok()
4264 .flatten()
4265 .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4266 match actor.worker.kill_confirmed().await {
4267 Ok(proof) => {
4268 if let (Some(service), Some(runtime), Some(run_id)) =
4269 (service, runtime, owned_run_id.as_deref())
4270 {
4271 if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4272 &service,
4273 &runtime,
4274 session,
4275 run_id,
4276 &proof,
4277 result.is_ok(),
4278 )
4279 .await
4280 {
4281 tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4284 }
4285 }
4286 }
4287 Err(error) => {
4288 tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4289 }
4290 }
4291 } else {
4292 actor.worker.kill().await;
4293 }
4294 } else {
4295 match &result {
4296 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4297 Err(_) => actor.worker.kill().await,
4298 }
4299 }
4300
4301 if let Some(activation) = plain_activation {
4302 return match result {
4303 Ok(Some(text)) if !text.is_empty() => {
4304 if readonly_actor {
4305 let messages = std::mem::take(&mut readonly_messages);
4306 let committed = activation
4307 .store
4308 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4309 fence: activation.fence.clone(),
4310 expected_created_at: activation.created_at,
4311 expected_messages: session.messages.clone(),
4312 expected_provider_transcript: activation
4313 .provider_transcript
4314 .clone(),
4315 messages: messages.clone(),
4316 native_groups: Vec::new(),
4317 })
4318 .await;
4319 return match committed {
4320 Ok(committed) => {
4321 *session = committed;
4322 for message in messages {
4323 let _ = event_tx
4324 .send(AgentEvent::message_appended(&session.id, &message))
4325 .await;
4326 }
4327 activation
4328 .finish_with_observer(
4329 ActorActivationFinish::Succeeded,
4330 actor_event_observer.as_deref(),
4331 )
4332 .await
4333 }
4334 Err(error) => {
4335 let _ = activation.finish(ActorActivationFinish::Failed).await;
4336 Err(AgentError::LLM(format!(
4337 "actor reply commit failed: {error}"
4338 )))
4339 }
4340 };
4341 }
4342 match activation.append_reply(session, text, &event_tx).await {
4343 Ok(()) => {
4344 activation
4345 .finish_with_observer(
4346 ActorActivationFinish::Succeeded,
4347 actor_event_observer.as_deref(),
4348 )
4349 .await
4350 }
4351 Err(error) => {
4352 let _ = activation.finish(ActorActivationFinish::Failed).await;
4354 Err(AgentError::LLM(format!(
4355 "actor reply commit failed: {error}"
4356 )))
4357 }
4358 }
4359 }
4360 other => {
4361 let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4362 ActorActivationFinish::Cancelled
4363 } else {
4364 ActorActivationFinish::Failed
4365 };
4366 activation.finish(outcome).await?;
4367 match other {
4368 Err(error) => Err(error),
4369 _ => Err(plain_actor_unsupported()),
4370 }
4371 }
4372 };
4373 }
4374
4375 match result {
4379 Ok(Some(text)) => {
4380 if !remote && local_history_tools.is_none() && !text.is_empty() {
4381 session.add_message(bamboo_agent_core::Message::assistant(text, None));
4382 }
4383 Ok(())
4384 }
4385 Ok(None) => Ok(()),
4386 Err(e) => Err(e),
4387 }
4388 }
4389}
4390
4391async fn require_unowned_glob(
4394 store: &bamboo_storage::SessionStoreV2,
4395 session: &Session,
4396) -> Result<(), AgentError> {
4397 let rel = store
4398 .resolve_rel_path(&session.id)
4399 .await
4400 .ok_or_else(|| plain_actor_unsupported())?;
4401 if !store.bamboo_home_dir().is_absolute()
4402 || rel
4403 != format!(
4404 "sessions/{}/children/{}",
4405 session.root_session_id, session.id
4406 )
4407 {
4408 return Err(plain_actor_unsupported());
4409 }
4410 let directory = store.bamboo_home_dir().join(rel);
4411 let mut checked = PathBuf::new();
4412 for component in directory.components() {
4413 if !matches!(
4414 component,
4415 std::path::Component::RootDir | std::path::Component::Normal(_)
4416 ) {
4417 return Err(plain_actor_unsupported());
4418 }
4419 checked.push(component.as_os_str());
4420 if !tokio::fs::symlink_metadata(&checked)
4421 .await
4422 .map_err(|_| plain_actor_unsupported())?
4423 .is_dir()
4424 {
4425 return Err(plain_actor_unsupported());
4426 }
4427 }
4428 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4429 match tokio::fs::symlink_metadata(directory.join(name)).await {
4430 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4431 _ => return Err(plain_actor_unsupported()),
4432 }
4433 }
4434 Ok(())
4435}
4436
4437struct PlainActorActivation {
4443 store: Arc<bamboo_storage::SessionStoreV2>,
4444 fence: ActorActivationFence,
4445 inbox_generation: u64,
4446 created_at: chrono::DateTime<chrono::Utc>,
4447 provider_transcript: bamboo_domain::ProviderTranscriptState,
4448 input_consumer: SessionInboxConsumerId,
4449 input_deadline: chrono::DateTime<chrono::Utc>,
4450 actor_deadline: chrono::DateTime<chrono::Utc>,
4451 initial_release_required: bool,
4452 input_inbox: bamboo_storage::FileSessionInbox,
4453 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4454 initial_input: Option<SessionInboxOwnedClaim>,
4455 recovering_pre_ack: bool,
4456 completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4457}
4458type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4459#[track_caller]
4460fn plain_actor_unsupported() -> AgentError {
4461 let source = std::panic::Location::caller();
4462 tracing::warn!(
4463 process_id = std::process::id(),
4464 source_file = source.file(),
4465 source_line = source.line(),
4466 source_column = source.column(),
4467 "plain Actor continuation unsupported"
4468 );
4469 AgentError::LLM("This Actor Child supports a fresh plain activation, a completed plain Child with one new input, two bounded corrections while Running, a Failed retry with one new input, an expired initial Local owner with one new input, or verified expired pre-ACK input recovery through run(reset_to_last_user=false). Other continuation is unsupported; durable history is preserved.".into())
4470}
4471fn plain_initial_history(session: &Session) -> bool {
4472 !session.messages.iter().any(|message| {
4473 matches!(message.role, Role::Assistant | Role::Tool)
4474 || message
4475 .metadata
4476 .as_ref()
4477 .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4478 }) && session.provider_transcript.is_empty()
4479 && session.session_inbox_admission().is_none()
4480 && session.pending_injected_messages().is_none()
4481}
4482fn plain_completed_history(session: &Session) -> bool {
4485 session.provider_transcript.is_empty()
4486 && session.pending_injected_messages().is_none()
4487 && session
4488 .messages
4489 .last()
4490 .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4491 && !session
4492 .messages
4493 .iter()
4494 .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4495}
4496fn pre_ack_input(
4499 session: &Session,
4500) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4501 let Some(message) = session.messages.last() else {
4502 return Ok(None);
4503 };
4504 let Some(proof) = message
4505 .metadata
4506 .as_ref()
4507 .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4508 else {
4509 return Ok(None);
4510 };
4511 let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4512 .map_err(|_| plain_actor_unsupported())?;
4513 let generation = proof
4514 .get("generation")
4515 .and_then(serde_json::Value::as_u64)
4516 .filter(|g| *g > 0)
4517 .ok_or_else(|| plain_actor_unsupported())?;
4518 if message.role != Role::User
4519 || session
4520 .messages
4521 .iter()
4522 .filter(|m| m.id == message.id)
4523 .count()
4524 != 1
4525 || !session
4526 .session_inbox_admission()
4527 .is_some_and(|cursor| cursor.contains(&id))
4528 || !session.provider_transcript.is_empty()
4529 || session.pending_injected_messages().is_some()
4530 || session
4531 .messages
4532 .iter()
4533 .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4534 {
4535 return Err(plain_actor_unsupported());
4536 }
4537 Ok(Some((id, generation)))
4538}
4539fn qualified_pre_ack_owner(
4540 entry: &bamboo_domain::ActorDirectoryEntry,
4541 now: chrono::DateTime<chrono::Utc>,
4542) -> bool {
4543 let Some(old) = entry.activation.as_ref() else {
4544 return false;
4545 };
4546 let recoverable = (entry.actor.state == ActorLogicalState::Failed
4547 && old.status == bamboo_domain::ActorActivationStatus::Failed)
4548 || (entry.actor.state == ActorLogicalState::Active
4549 && old.status.is_live()
4550 && old.lease_expires_at <= now);
4551 let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4552 placement.class == bamboo_domain::ActorPlacementClass::Local
4553 && placement
4554 .lease_id
4555 .strip_prefix("owned-initial-release-v1:")
4556 .is_some_and(|worker| {
4557 !worker.is_empty()
4558 && worker.len() <= 256
4559 && !worker.contains("..")
4560 && worker.bytes().all(|b| {
4561 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4562 })
4563 })
4564 });
4565 recoverable && trusted
4566}
4567async fn expired_pre_ack_input(
4568 inbox: &bamboo_storage::FileSessionInbox,
4569 session: &Session,
4570) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4571 let Some((id, generation)) = pre_ack_input(session)? else {
4572 return Ok(None);
4573 };
4574 let backlog = inbox
4575 .inspect(&session.id)
4576 .await
4577 .map_err(|_| plain_actor_unsupported())?;
4578 let leases = inbox
4579 .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4580 .await
4581 .map_err(|_| plain_actor_unsupported())?;
4582 if backlog.pending != 0
4583 || backlog.claimed != 1
4584 || backlog.generation != generation
4585 || backlog.oldest_generation != Some(generation)
4586 || !backlog.activation_pending()
4587 || leases.len() != 1
4588 || !leases[0].expired
4589 || leases[0].generation != generation
4590 || inbox
4591 .was_admitted(&session.id, &id)
4592 .await
4593 .map_err(|_| plain_actor_unsupported())?
4594 {
4595 return Err(plain_actor_unsupported());
4596 }
4597 Ok(Some((id, generation)))
4598}
4599impl PlainActorActivation {
4600 fn start<'a>(
4603 store: Arc<bamboo_storage::SessionStoreV2>,
4604 session: &'a Session,
4605 binding: &'a SessionInboxRuntimeBinding,
4606 run_id: Option<&'a str>,
4607 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4608 allow_failed_retry: bool,
4609 release_worker: Option<&'a str>,
4610 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4611 {
4612 Box::pin(async move {
4613 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4614 let backlog = binding
4615 .inbox
4616 .inspect(&session.id)
4617 .await
4618 .map_err(|_| plain_actor_unsupported())?;
4619 let entry = store
4620 .ensure_actor(&session.id)
4621 .await
4622 .map_err(|error| AgentError::LLM(error.to_string()))?;
4623 let config = permission_config
4624 .as_ref()
4625 .ok_or_else(|| plain_actor_unsupported())?;
4626 if entry.actor.policy_revision != Some(config.policy_revision())
4627 || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4628 {
4629 return Err(plain_actor_unsupported());
4630 }
4631 let fresh = entry.actor.current_attempt == 0
4632 && entry.actor.state == ActorLogicalState::Cold
4633 && entry.activation.is_none()
4634 && backlog.pending == 0
4635 && backlog.claimed == 0
4636 && backlog.generation == 0;
4637 let new_input = backlog.pending == 1
4638 && backlog.claimed == 0
4639 && backlog.oldest_generation == Some(backlog.generation)
4640 && backlog.activation_pending();
4641 let retry = allow_failed_retry
4642 && entry.actor.state == ActorLogicalState::Failed
4643 && entry.actor.current_attempt > 0
4644 && entry.activation.as_ref().is_some_and(|old| {
4645 old.status == bamboo_domain::ActorActivationStatus::Failed
4646 && backlog.generation > old.inbox_generation
4647 })
4648 && new_input;
4649 let continuation = allow_failed_retry
4650 && release_worker.is_some()
4651 && entry.actor.state == ActorLogicalState::Cold
4652 && entry.actor.current_attempt > 0
4653 && entry.activation.as_ref().is_some_and(|old| {
4654 old.status == bamboo_domain::ActorActivationStatus::Succeeded
4655 && backlog.generation > old.inbox_generation
4656 })
4657 && new_input
4658 && plain_completed_history(session);
4659 let input_inbox = bamboo_storage::FileSessionInbox::new(
4660 store.clone(),
4661 bamboo_domain::SessionInboxLimits::default(),
4662 );
4663 let now = chrono::Utc::now();
4664 let replacement = allow_failed_retry
4668 && release_worker.is_some()
4669 && entry.actor.state == ActorLogicalState::Active
4670 && qualified_pre_ack_owner(&entry, now)
4671 && entry.activation.as_ref().is_some_and(|old| {
4672 old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4673 })
4674 && new_input
4675 && plain_initial_history(session);
4676 let recovery = if allow_failed_retry
4677 && release_worker.is_some()
4678 && qualified_pre_ack_owner(&entry, now)
4679 {
4680 expired_pre_ack_input(&input_inbox, session).await?
4681 } else {
4682 None
4683 };
4684 if (recovery.is_some() || continuation || replacement)
4685 && (!entry.actor.matches_session(session)
4686 || entry.actor.project_id
4687 != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4688 {
4689 return Err(plain_actor_unsupported());
4690 }
4691 if recovery.is_none()
4692 && !continuation
4693 && !replacement
4694 && ((!fresh && !retry) || !plain_initial_history(session))
4695 {
4696 return Err(plain_actor_unsupported());
4697 }
4698 let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4699 .max_total_secs
4700 .checked_add(60)
4701 .and_then(chrono::Duration::try_seconds)
4702 .ok_or_else(|| plain_actor_unsupported())?;
4703 let expires = now
4704 .checked_add_signed(duration)
4705 .ok_or_else(|| plain_actor_unsupported())?;
4706 let input_consumer = SessionInboxConsumerId::new();
4707 let input_deadline = expires.min(now + chrono::Duration::hours(1));
4708 let recovered = if let Some((id, generation)) = &recovery {
4709 if !binding.router.owns_run(&session.id, run_id).await {
4710 return Err(plain_actor_unsupported());
4711 }
4712 let lease = SessionInboxLeaseRequest {
4713 consumer: input_consumer.clone(),
4714 now,
4715 duration: input_deadline - now,
4716 };
4717 let mut claims = input_inbox
4718 .claim_owned(&session.id, 1, Some(run_id), &lease)
4719 .await
4720 .map_err(|_| plain_actor_unsupported())?;
4721 let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4722 if claim.claim.envelope.id != *id
4723 || claim.claim.generation != *generation
4724 || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4725 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4726 {
4727 return Err(plain_actor_unsupported());
4728 }
4729 Some(claim)
4730 } else {
4731 None
4732 };
4733 let claimed = store
4734 .claim_activation(&ActorActivationClaim {
4735 actor_id: session.id.clone(),
4736 run_id: run_id.into(),
4737 lease_owner: input_consumer.as_str().into(),
4738 lease_expires_at: expires,
4739 inbox_generation: recovered
4740 .as_ref()
4741 .map_or(backlog.generation, |claim| claim.claim.generation),
4742 placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4743 class: bamboo_domain::ActorPlacementClass::Local,
4744 lease_id: format!("owned-initial-release-v1:{worker}"),
4747 slot_epoch: None,
4748 }),
4749 now,
4750 })
4751 .await
4752 .map_err(|error| AgentError::LLM(error.to_string()))?;
4753 let activation = Self {
4754 store,
4755 fence: claimed.fence(),
4756 inbox_generation: claimed.inbox_generation,
4757 created_at: session.created_at,
4758 provider_transcript: session.provider_transcript.clone(),
4759 input_consumer,
4760 input_deadline,
4761 actor_deadline: claimed.lease_expires_at,
4762 initial_release_required: release_worker.is_some(),
4763 input_inbox,
4764 permission_config,
4765 initial_input: recovered,
4766 recovering_pre_ack: recovery.is_some(),
4767 completion_router: std::sync::Mutex::new(None),
4768 };
4769 if let Err(error) = activation
4770 .store
4771 .start_activation(&activation.fence, chrono::Utc::now())
4772 .await
4773 {
4774 let _ = activation.finish(ActorActivationFinish::Failed).await;
4775 return Err(AgentError::LLM(error.to_string()));
4776 }
4777 Ok(activation)
4778 })
4779 }
4780 fn start_and_prepare<'a>(
4781 store: Arc<bamboo_storage::SessionStoreV2>,
4782 session: &'a mut Session,
4783 binding: &'a SessionInboxRuntimeBinding,
4784 run_id: Option<&'a str>,
4785 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4786 allow_failed_retry: bool,
4787 release_worker: Option<&'a str>,
4788 ) -> std::pin::Pin<
4789 Box<
4790 impl std::future::Future<
4791 Output = Result<(Self, Option<PlainInitialDelivery>), AgentError>,
4792 > + Send
4793 + 'a,
4794 >,
4795 > {
4796 Box::pin(async move {
4797 let mut activation = Self::start(
4798 store,
4799 session,
4800 binding,
4801 run_id,
4802 permission_config,
4803 allow_failed_retry,
4804 release_worker,
4805 )
4806 .await?;
4807 if activation.inbox_generation == 0 {
4808 return Ok((activation, None));
4809 }
4810 let prepared = async {
4811 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4812 let backlog = activation
4813 .input_inbox
4814 .inspect(&session.id)
4815 .await
4816 .map_err(|_| plain_actor_unsupported())?;
4817 let expected = if activation.recovering_pre_ack {
4818 (0, 1)
4819 } else {
4820 (1, 0)
4821 };
4822 if (backlog.pending, backlog.claimed) != expected
4823 || backlog.generation != activation.inbox_generation
4824 {
4825 return Err(plain_actor_unsupported());
4826 }
4827 let claim = match activation.initial_input.clone() {
4828 Some(claim) => claim,
4829 None => activation
4830 .claim_input(binding, session, run_id)
4831 .await?
4832 .ok_or_else(|| plain_actor_unsupported())?,
4833 };
4834 if claim.claim.generation != activation.inbox_generation {
4835 return Err(plain_actor_unsupported());
4836 }
4837 let delivery = activation.checkpoint_input(session, run_id, &claim).await?;
4838 activation.initial_input = Some(claim);
4839 Ok(delivery)
4840 }
4841 .await;
4842 match prepared {
4843 Ok(delivery) => Ok((activation, Some(delivery))),
4844 Err(error) => {
4845 activation.finish(ActorActivationFinish::Failed).await?;
4846 Err(error)
4847 }
4848 }
4849 })
4850 }
4851 fn claim_input<'a>(
4852 &'a self,
4853 binding: &'a SessionInboxRuntimeBinding,
4854 session: &'a Session,
4855 run_id: &'a str,
4856 ) -> std::pin::Pin<
4857 Box<
4858 impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4859 + Send
4860 + 'a,
4861 >,
4862 > {
4863 Box::pin(async move {
4864 if !self
4865 .input_inbox
4866 .inspect(&session.id)
4867 .await
4868 .map_err(|_| plain_actor_unsupported())?
4869 .activation_pending()
4870 {
4871 return Ok(None);
4872 }
4873 if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4874 return Err(plain_actor_unsupported());
4875 }
4876 let now = chrono::Utc::now();
4877 if now >= self.input_deadline {
4878 return Err(plain_actor_unsupported());
4879 }
4880 let lease = SessionInboxLeaseRequest {
4881 consumer: self.input_consumer.clone(),
4882 now,
4883 duration: self.input_deadline - now,
4884 };
4885 let claim = self
4886 .input_inbox
4887 .claim_owned(&session.id, 1, Some(run_id), &lease)
4888 .await
4889 .map_err(|error| {
4890 tracing::warn!(%error, "Actor correction claim unconfirmed");
4891 AgentError::LLM(
4892 "Actor correction claim unconfirmed; durable input is preserved".into(),
4893 )
4894 })?
4895 .into_iter()
4896 .next();
4897 if let Some(claim) = &claim {
4898 if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4899 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4900 {
4901 self.input_inbox
4902 .fail_owned(
4903 &session.id,
4904 claim,
4905 &SessionInboxFailureReport {
4906 now: chrono::Utc::now(),
4907 error_code: "unsupported_actor_input".into(),
4908 },
4909 )
4910 .await
4911 .map_err(|error| {
4912 tracing::warn!(%error, "Actor correction failure report unconfirmed");
4913 AgentError::LLM(
4914 "Actor correction failure report unconfirmed; durable input is preserved".into(),
4915 )
4916 })?;
4917 return Err(plain_actor_unsupported());
4918 }
4919 }
4920 Ok(claim)
4921 })
4922 }
4923 fn checkpoint_input<'a>(
4924 &'a self,
4925 session: &'a mut Session,
4926 run_id: &'a str,
4927 claim: &'a SessionInboxOwnedClaim,
4928 ) -> std::pin::Pin<
4929 Box<
4930 impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4931 >,
4932 > {
4933 Box::pin(async move {
4934 let request = bamboo_storage::ActorInputCheckpoint {
4937 fence: self.fence.clone(),
4938 expected_created_at: self.created_at,
4939 claim: claim.clone(),
4940 expected_messages: session.messages.clone(),
4941 expected_provider_transcript: session.provider_transcript.clone(),
4942 expected_admission: session.session_inbox_admission().cloned(),
4943 };
4944 let mut retries = 0;
4945 let committed = loop {
4946 let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
4947 if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
4948 && retries < 3
4949 {
4950 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
4956 retries += 1;
4957 continue;
4958 }
4959 break result;
4960 }.map_err(|error| {
4961 tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
4962 AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
4963 })?;
4964 if self.recovering_pre_ack
4965 && committed.status
4966 != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4967 {
4968 return Err(plain_actor_unsupported());
4969 }
4970 let messages = session
4971 .messages
4972 .iter()
4973 .map(|message| {
4974 let mut worker = message.clone();
4975 if committed.status
4976 == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
4977 && worker.id == committed.envelope.id.as_str()
4978 {
4979 worker
4980 .metadata
4981 .as_mut()
4982 .and_then(serde_json::Value::as_object_mut)
4983 .ok_or_else(|| plain_actor_unsupported())?
4984 .remove("_bamboo_owned_input_checkpoint");
4985 if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
4986 {
4987 return Err(plain_actor_unsupported());
4988 }
4989 }
4990 serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
4991 })
4992 .collect::<Result<Vec<_>, _>>()?;
4993 *session = committed.session;
4994 Ok((
4995 messages,
4996 SessionMessageDelivery {
4997 target_session_id: session.id.clone(),
4998 envelope: committed.envelope,
4999 canonical_claim_generation: committed.generation,
5000 activation_run_id: run_id.into(),
5001 activation_policy: claim.claim.activation_policy,
5002 },
5003 ))
5004 })
5005 }
5006 fn append_reply<'a>(
5007 &'a self,
5008 session: &'a mut Session,
5009 text: String,
5010 event_tx: &'a mpsc::Sender<AgentEvent>,
5011 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5012 {
5013 Box::pin(async move {
5014 let message = bamboo_agent_core::Message::assistant(text, None);
5015 let request = bamboo_storage::ActorTranscriptAppend {
5016 fence: self.fence.clone(),
5017 expected_created_at: self.created_at,
5018 expected_messages: session.messages.clone(),
5019 expected_provider_transcript: self.provider_transcript.clone(),
5020 messages: vec![message.clone()],
5021 native_groups: Vec::new(),
5022 };
5023 let mut retries = 0;
5024 *session = loop {
5025 let result = self.store.append_actor_transcript(request.clone()).await;
5026 if matches!(
5027 &result,
5028 Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5029 ) && retries < 3
5030 {
5031 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5036 retries += 1;
5037 continue;
5038 }
5039 break result;
5040 }
5041 .map_err(|error| {
5042 tracing::warn!(
5043 %error,
5044 "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5045 );
5046 AgentError::LLM(format!("actor reply commit failed: {error}"))
5047 })?;
5048 let _ = event_tx
5049 .send(AgentEvent::message_appended(&session.id, &message))
5050 .await;
5051 Ok(())
5052 })
5053 }
5054 fn continue_input<'a>(
5058 &'a self,
5059 client: &'a mut dyn bamboo_subagent::ChildLink,
5060 binding: &'a SessionInboxRuntimeBinding,
5061 session: &'a mut Session,
5062 run_id: &'a str,
5063 run: Option<(&'a RunSpec, &'a AtomicU64)>,
5064 expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5065 text: String,
5066 event_tx: &'a mpsc::Sender<AgentEvent>,
5067 ) -> std::pin::Pin<
5068 Box<
5069 impl std::future::Future<
5070 Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5071 > + Send
5072 + 'a,
5073 >,
5074 > {
5075 Box::pin(async move {
5076 let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5077 return Ok(None);
5078 };
5079 let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5080 self.append_reply(session, text, event_tx).await?;
5081 let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5082 self.refresh_continuation_posture(session, run, expected_posture)?;
5084 let epoch = epochs
5085 .fetch_add(1, Ordering::Relaxed)
5086 .checked_add(1)
5087 .ok_or_else(|| plain_actor_unsupported())?;
5088 let mut next = run.clone();
5089 next.messages = messages;
5090 next.initial_session_messages = vec![delivery];
5091 next.execution_epoch = epoch;
5092 client.send(ParentFrame::Run(next)).await.map_err(|_| {
5093 AgentError::LLM(
5094 "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5095 )
5096 })?;
5097 Ok(Some((claim, epoch)))
5098 })
5099 }
5100 fn refresh_continuation_posture(
5101 &self,
5102 session: &Session,
5103 run: &RunSpec,
5104 expected: &mut Option<ExpectedPermissionPosture>,
5105 ) -> Result<(), AgentError> {
5106 let Some(expected) = expected.as_mut() else {
5107 return Err(plain_actor_unsupported());
5108 };
5109 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5110 .ok_or_else(|| plain_actor_unsupported())?;
5111 if audit.policy_revision != expected.policy_revision
5112 || audit.resolution != expected.resolution
5113 || audit.executor_mapping != expected.executor_mapping
5114 {
5115 return Err(plain_actor_unsupported());
5116 }
5117 match (&self.permission_config, &run.permission_policy) {
5118 (Some(config), Some(policy)) => {
5119 ensure_no_active_scoped_session_denies(config, &session.id)?;
5120 if config.policy_revision() != policy.revision
5121 || policy.revision != audit.policy_revision
5122 || policy.session_id != session.id
5123 || policy.workspace_path != session.workspace
5124 || policy.inherit_session_grants
5125 || serde_json::to_value(config.to_serializable())
5126 .map_err(|_| plain_actor_unsupported())?
5127 != policy.policy
5128 {
5129 return Err(plain_actor_unsupported());
5130 }
5131 }
5132 (None, None) => {}
5133 _ => return Err(plain_actor_unsupported()),
5134 }
5135 expected.expected_audit_revision = Some(audit.audit_revision);
5136 Ok(())
5137 }
5138 fn release_initial_input<'a>(
5139 &'a self,
5140 binding: &'a SessionInboxRuntimeBinding,
5141 session: &'a Session,
5142 run: &'a RunSpec,
5143 epoch: u64,
5144 claim: &'a SessionInboxOwnedClaim,
5145 request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5146 expected: &'a mut Option<ExpectedPermissionPosture>,
5147 previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5148 ) -> std::pin::Pin<
5149 Box<
5150 impl std::future::Future<
5151 Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5152 > + Send
5153 + 'a,
5154 >,
5155 > {
5156 Box::pin(async move {
5157 if !self.initial_release_required {
5158 return Err(plain_actor_unsupported());
5159 }
5160 let delivery = SessionMessageDelivery {
5161 target_session_id: session.id.clone(),
5162 envelope: claim.claim.envelope.clone(),
5163 canonical_claim_generation: claim.claim.generation,
5164 activation_run_id: self.fence.run_id.clone(),
5165 activation_policy: claim.claim.activation_policy,
5166 };
5167 let mut current_run = run.clone();
5168 current_run.execution_epoch = epoch;
5169 let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5170 ¤t_run,
5171 &delivery,
5172 request.nonce.clone(),
5173 )
5174 .map_err(|_| plain_actor_unsupported())?;
5175 if &exact != request
5176 || request.child_id != session.id
5177 || request.created_at != session.created_at
5178 || request.spawn_depth != session.spawn_depth
5179 || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5180 || request.root_id != session.root_session_id
5181 || request.project_id != project_id_for_actor_run(session)?
5182 || !binding
5183 .router
5184 .owns_run(&session.id, &self.fence.run_id)
5185 .await
5186 {
5187 return Err(plain_actor_unsupported());
5188 }
5189 let deadline = self
5190 .actor_deadline
5191 .min(self.input_deadline)
5192 .min(claim.lease.expires_at);
5193 if chrono::Utc::now() >= deadline {
5194 return Err(plain_actor_unsupported());
5195 }
5196 self.store
5197 .validate_fence(&self.fence, chrono::Utc::now())
5198 .await
5199 .map_err(|_| plain_actor_unsupported())?;
5200 let actual =
5201 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5202 .await
5203 .map_err(|_| plain_actor_unsupported())?
5204 .ok_or_else(|| plain_actor_unsupported())?;
5205 if actual.created_at != session.created_at
5206 || actual.root_session_id != session.root_session_id
5207 || actual.parent_session_id != session.parent_session_id
5208 || actual.spawn_depth != session.spawn_depth
5209 || project_id_for_actor_run(&actual)? != request.project_id
5210 || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5211 != serde_json::to_value(&session.messages)
5212 .map_err(|_| plain_actor_unsupported())?
5213 || serde_json::to_value(&actual.provider_transcript)
5214 .map_err(|_| plain_actor_unsupported())?
5215 != serde_json::to_value(&session.provider_transcript)
5216 .map_err(|_| plain_actor_unsupported())?
5217 || serde_json::to_value(actual.session_inbox_admission())
5218 .map_err(|_| plain_actor_unsupported())?
5219 != serde_json::to_value(session.session_inbox_admission())
5220 .map_err(|_| plain_actor_unsupported())?
5221 {
5222 return Err(plain_actor_unsupported());
5223 }
5224 self.refresh_continuation_posture(&actual, ¤t_run, expected)?;
5225 if let Some(previous) = previous {
5226 if !previous.permits(request, chrono::Utc::now())
5227 || previous.expires_at != deadline
5228 || !self
5229 .input_inbox
5230 .was_admitted(&session.id, &claim.claim.envelope.id)
5231 .await
5232 .map_err(|_| plain_actor_unsupported())?
5233 {
5234 return Err(plain_actor_unsupported());
5235 }
5236 } else {
5237 let checkpoint = bamboo_storage::ActorInputCheckpoint {
5240 fence: self.fence.clone(),
5241 expected_created_at: self.created_at,
5242 claim: claim.clone(),
5243 expected_messages: actual.messages.clone(),
5244 expected_provider_transcript: actual.provider_transcript.clone(),
5245 expected_admission: actual.session_inbox_admission().cloned(),
5246 };
5247 let mut retries = 0;
5248 loop {
5249 let result = self
5250 .input_inbox
5251 .checkpoint_actor_input(checkpoint.clone())
5252 .await;
5253 if matches!(
5254 &result,
5255 Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5256 ) && retries < 3
5257 {
5258 tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5262 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5263 retries += 1;
5264 continue;
5265 }
5266 result.map_err(|error| {
5267 tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5268 plain_actor_unsupported()
5269 })?;
5270 break;
5271 }
5272 self.confirm_input(
5273 binding,
5274 &actual,
5275 &self.fence.run_id,
5276 claim,
5277 &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5278 target_session_id: request.child_id.clone(),
5279 envelope_id: request.envelope_id.clone(),
5280 canonical_claim_generation: request.generation,
5281 activation_run_id: request.activation_run_id.clone(),
5282 },
5283 )
5284 .await?;
5285 }
5286 if chrono::Utc::now() >= deadline
5289 || !binding
5290 .router
5291 .owns_run(&session.id, &self.fence.run_id)
5292 .await
5293 {
5294 return Err(plain_actor_unsupported());
5295 }
5296 self.store
5297 .validate_fence(&self.fence, chrono::Utc::now())
5298 .await
5299 .map_err(|_| plain_actor_unsupported())?;
5300 let final_session =
5301 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5302 .await
5303 .map_err(|_| plain_actor_unsupported())?
5304 .ok_or_else(|| plain_actor_unsupported())?;
5305 self.store
5306 .validate_fence(&self.fence, chrono::Utc::now())
5307 .await
5308 .map_err(|_| plain_actor_unsupported())?;
5309 self.refresh_continuation_posture(&final_session, ¤t_run, expected)?;
5310 if chrono::Utc::now() >= deadline {
5311 return Err(plain_actor_unsupported());
5312 }
5313 Ok(bamboo_subagent::proto::InitialInputRelease {
5314 request: request.clone(),
5315 expires_at: deadline,
5316 })
5317 })
5318 }
5319 async fn confirm_input(
5320 &self,
5321 binding: &SessionInboxRuntimeBinding,
5322 session: &Session,
5323 run_id: &str,
5324 claim: &SessionInboxOwnedClaim,
5325 confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5326 ) -> Result<(), AgentError> {
5327 if confirmation.target_session_id != session.id
5328 || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5329 || confirmation.canonical_claim_generation != claim.claim.generation
5330 || confirmation.activation_run_id != run_id
5331 || run_id != self.fence.run_id
5332 || !binding.router.owns_run(&session.id, run_id).await
5333 {
5334 return Err(plain_actor_unsupported());
5335 }
5336 self.input_inbox
5337 .ack_owned(&session.id, claim, chrono::Utc::now())
5338 .await
5339 .map_err(|error| {
5340 tracing::warn!(%error, "Actor correction ACK unresolved");
5341 AgentError::LLM(
5342 "Actor correction ACK unresolved; durable input is preserved".into(),
5343 )
5344 })
5345 }
5346 async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5347 self.finish_with_observer(outcome, None).await
5348 }
5349
5350 async fn finish_with_observer(
5351 &self,
5352 outcome: ActorActivationFinish,
5353 observer: Option<&dyn ActorEventObserver>,
5354 ) -> Result<(), AgentError> {
5355 let committed = self
5356 .store
5357 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5358 .await
5359 .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5360 let router = self.completion_router.lock().recover_poison().take();
5363 if let (Some(observer), Some(router)) = (observer, router) {
5364 match self.store.inspect_actor(&self.fence.actor_id).await {
5368 Ok(entry) => match router.into_host_completion(&entry, &committed) {
5369 Ok(event) => observer.publish(event),
5370 Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5371 "Actor completion notification superseded"),
5372 },
5373 Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5374 "Actor completion committed; notification snapshot unavailable"),
5375 }
5376 }
5377 Ok(())
5378 }
5379}
5380fn local_tool_history_unsupported() -> AgentError {
5381 AgentError::LLM("local_tool_history_unsupported".into())
5382}
5383
5384#[derive(Default)]
5385struct LocalToolCollector {
5386 messages: Option<Vec<bamboo_agent_core::Message>>,
5387 starts: HashMap<String, (String, serde_json::Value)>,
5388 outcomes: HashMap<String, (bool, String)>,
5389 ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5392}
5393
5394impl LocalToolCollector {
5395 fn ticket_event(
5396 &self,
5397 value: &serde_json::Value,
5398 committed: &HashMap<String, serde_json::Value>,
5399 ) -> Result<bool, AgentError> {
5400 if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5401 return Ok(true);
5402 }
5403 match serde_json::from_value::<AgentEvent>(value.clone())
5404 .map_err(|_| local_tool_history_unsupported())?
5405 {
5406 AgentEvent::ToolStart { tool_name, .. }
5407 if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5408 {
5409 Err(local_tool_history_unsupported())
5410 }
5411 AgentEvent::ToolComplete {
5412 tool_call_id,
5413 result,
5414 } if result.success => {
5415 let Some((name, args)) = self.starts.get(&tool_call_id) else {
5416 return Err(local_tool_history_unsupported());
5417 };
5418 let valid = if name == "Task" {
5419 committed.get(&tool_call_id) == Some(args)
5420 } else {
5421 committed.get(&tool_call_id).is_some_and(|proof| {
5422 proof["file_tool"] == *name
5423 && proof["arguments"] == *args
5424 && proof["result"] == result.result
5425 })
5426 };
5427 if !valid {
5428 return Err(AgentError::LLM(
5429 "LocalPlan success has no exact Host receipt".into(),
5430 ));
5431 }
5432 Ok(true)
5433 }
5434 AgentEvent::TaskListUpdated { .. } => Ok(false),
5436 _ => Ok(true),
5437 }
5438 }
5439
5440 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5441 use bamboo_subagent::proto::LocalToolMessages;
5442 if self.messages.is_some() {
5443 return Err(local_tool_history_unsupported());
5444 }
5445 if value["type"] == LocalToolMessages::TYPE {
5446 let observation: LocalToolMessages = serde_json::from_value(value.clone())
5447 .map_err(|_| local_tool_history_unsupported())?;
5448 self.messages = Some(
5449 observation
5450 .validate()
5451 .map_err(|_| local_tool_history_unsupported())?,
5452 );
5453 return Ok(false);
5454 }
5455 let event: AgentEvent =
5456 serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5457 match event {
5458 AgentEvent::ToolStart {
5459 tool_call_id,
5460 tool_name,
5461 arguments,
5462 } => {
5463 if !arguments.is_object()
5464 || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5465 || tool_call_id.is_empty()
5466 || tool_call_id.len() > 128
5467 || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5468 || self.outcomes.contains_key(&tool_call_id)
5469 || self
5470 .starts
5471 .insert(tool_call_id, (tool_name, arguments))
5472 .is_some()
5473 {
5474 return Err(local_tool_history_unsupported());
5475 }
5476 }
5477 AgentEvent::ToolComplete {
5478 tool_call_id,
5479 result,
5480 } => {
5481 if !result.images.is_empty() {
5482 return Err(local_tool_history_unsupported());
5483 }
5484 self.outcome(tool_call_id, result.success, result.result)?;
5485 }
5486 AgentEvent::ToolError {
5487 tool_call_id,
5488 error,
5489 } => {
5490 self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5491 }
5492 AgentEvent::ToolToken { tool_call_id, .. } => {
5493 if !self.starts.contains_key(&tool_call_id)
5494 || self.outcomes.contains_key(&tool_call_id)
5495 {
5496 return Err(local_tool_history_unsupported());
5497 }
5498 }
5499 AgentEvent::ToolLifecycle {
5500 tool_call_id,
5501 tool_name,
5502 is_mutating,
5503 auto_approved,
5504 phase,
5505 ..
5506 } => {
5507 if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5508 || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5509 || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5510 || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5511 {
5512 return Err(local_tool_history_unsupported());
5513 }
5514 }
5515 AgentEvent::ReasoningToken { .. } => {
5516 }
5520 AgentEvent::ContextSummarized { .. }
5521 | AgentEvent::ContextArchived { .. }
5522 | AgentEvent::ContextCompressionStatus { .. }
5523 | AgentEvent::NeedClarification { .. }
5524 | AgentEvent::SubAgentStarted { .. }
5525 | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5526 AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5528 return Ok(false)
5529 }
5530 _ => {}
5531 }
5532 Ok(true)
5533 }
5534
5535 fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5536 if id.is_empty()
5537 || id.len() > 128
5538 || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5539 || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5540 || self.outcomes.insert(id, (success, text)).is_some()
5541 {
5542 return Err(local_tool_history_unsupported());
5543 }
5544 Ok(())
5545 }
5546
5547 fn suffix(
5548 self,
5549 host: &Session,
5550 tools: &[String],
5551 read_only: bool,
5552 terminal: Option<&str>,
5553 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5554 use bamboo_subagent::proto::LocalToolMessages;
5555 let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5556 let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5557 let prefix: Vec<_> = host
5558 .messages
5559 .iter()
5560 .filter(|m| m.role != Role::System)
5561 .collect();
5562 if !host.provider_transcript.groups().is_empty()
5563 || candidate.len() <= prefix.len()
5564 || serde_json::to_value(&candidate[..prefix.len()])
5565 .map_err(|_| local_tool_history_unsupported())?
5566 != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5567 {
5568 return Err(local_tool_history_unsupported());
5569 }
5570 let mut ids = std::collections::HashSet::new();
5571 let mut calls = std::collections::HashSet::new();
5572 for message in &host.messages {
5573 if !ids.insert(message.id.clone()) {
5574 return Err(local_tool_history_unsupported());
5575 }
5576 for call in message.tool_calls.iter().flatten() {
5577 calls.insert(call.id.clone());
5578 }
5579 if let Some(id) = &message.tool_call_id {
5580 calls.insert(id.clone());
5581 }
5582 }
5583 let suffix: Vec<_> = candidate[prefix.len()..]
5584 .iter()
5585 .map(|message| (**message).clone())
5586 .collect();
5587 let mut pending = HashMap::new();
5588 let mut used = std::collections::HashSet::new();
5589 for message in &suffix {
5590 if !ids.insert(message.id.clone()) || message.never_compress {
5591 return Err(local_tool_history_unsupported());
5592 }
5593 match message.role {
5594 Role::Assistant => {
5595 if !pending.is_empty()
5596 || message.metadata.is_some()
5597 || message.tool_call_id.is_some()
5598 || message.tool_success.is_some()
5599 {
5600 return Err(local_tool_history_unsupported());
5601 }
5602 if let Some(batch) = &message.tool_calls {
5603 if batch.is_empty() {
5604 return Err(local_tool_history_unsupported());
5605 }
5606 for call in batch {
5607 let arguments =
5608 serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5609 .ok()
5610 .filter(|arguments| arguments.is_object())
5611 .ok_or_else(local_tool_history_unsupported)?;
5612 if call.id.is_empty()
5613 || call.id.len() > 128
5614 || call.tool_type != "function"
5615 || !calls.insert(call.id.clone())
5616 || calls.len() > LocalToolMessages::MAX_MESSAGES
5617 || !matches!(
5618 call.function.name.as_str(),
5619 "Read" | "Glob" | "Write" | "Task"
5620 )
5621 || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5622 name != &call.function.name || actual != &arguments
5623 })
5624 {
5625 return Err(local_tool_history_unsupported());
5626 }
5627 pending.insert(call.id.clone(), call.function.name.clone());
5628 }
5629 } else if message.content.trim().is_empty() {
5630 return Err(local_tool_history_unsupported());
5631 }
5632 }
5633 Role::Tool => {
5634 let id = message
5635 .tool_call_id
5636 .as_ref()
5637 .ok_or_else(local_tool_history_unsupported)?;
5638 let name = pending
5639 .remove(id)
5640 .ok_or_else(local_tool_history_unsupported)?;
5641 let success = message
5642 .tool_success
5643 .ok_or_else(local_tool_history_unsupported)?;
5644 if message.tool_calls.is_some()
5645 || message.phase.is_some()
5646 || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5647 || !used.insert(id.clone())
5648 || used.len() > LocalToolMessages::MAX_PAIRS
5649 {
5650 return Err(local_tool_history_unsupported());
5651 }
5652 let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5653 if denied_write {
5654 if success
5655 || !(message.content.contains("native_tool_ceiling_denied")
5656 || message
5657 .content
5658 .contains("not callable at the current conversation position"))
5659 {
5660 return Err(local_tool_history_unsupported());
5661 }
5662 } else if !tools.contains(&name)
5663 || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5664 {
5665 return Err(local_tool_history_unsupported());
5666 }
5667 if let Some(metadata) = &message.metadata {
5668 let object = metadata
5669 .as_object()
5670 .ok_or_else(local_tool_history_unsupported)?;
5671 if object.len() != 5
5672 || metadata["elapsed_ms"].as_u64().is_none()
5673 || metadata["tool_name"].as_str() != Some(name.as_str())
5674 || metadata["is_mutating"].as_bool()
5675 != Some(matches!(name.as_str(), "Write" | "Task"))
5676 || metadata["auto_approved"].as_bool()
5677 != Some(!matches!(name.as_str(), "Write" | "Task"))
5678 || metadata["success"].as_bool() != Some(success)
5679 {
5680 return Err(local_tool_history_unsupported());
5681 }
5682 }
5683 }
5684 _ => return Err(local_tool_history_unsupported()),
5685 }
5686 }
5687 let last = suffix.last().ok_or_else(local_tool_history_unsupported)?;
5688 let question_terminal = self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5689 last.role == Role::Tool
5690 && last.tool_call_id.as_ref() == Some(id)
5691 && last.tool_success == Some(true)
5692 && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5693 && terminal.is_none_or(str::is_empty)
5694 && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5695 == Some(serde_json::json!({"status":"waiting_for_answer", "request":question}))
5696 });
5697 let report_terminal = last.role == Role::Assistant
5698 && last.tool_calls.is_none()
5699 && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5700 && terminal == Some(last.content.as_str());
5701 if !pending.is_empty()
5702 || used.len() != self.outcomes.len()
5703 || self.starts.keys().any(|id| !used.contains(id))
5704 || !(report_terminal || question_terminal)
5705 {
5706 return Err(local_tool_history_unsupported());
5707 }
5708 Ok(suffix)
5709 }
5710}
5711
5712async fn commit_local_tool_history(
5713 collector: LocalToolCollector,
5714 session: &mut Session,
5715 binding: &SessionInboxRuntimeBinding,
5716 run_id: &str,
5717 selection: (&[String], bool),
5718 terminal: Option<&str>,
5719 cancel: &CancellationToken,
5720) -> Result<(), AgentError> {
5721 if cancel.is_cancelled() || !binding.router.owns_run(&session.id, run_id).await {
5722 return Err(local_tool_history_unsupported());
5723 }
5724 let latest = binding
5725 .storage
5726 .load_session(&session.id)
5727 .await
5728 .map_err(|_| local_tool_history_unsupported())?
5729 .ok_or_else(local_tool_history_unsupported)?;
5730 if latest.id != session.id
5731 || latest.kind != session.kind
5732 || latest.created_at != session.created_at
5733 || latest.root_session_id != session.root_session_id
5734 || latest.parent_session_id != session.parent_session_id
5735 || latest.spawn_depth != session.spawn_depth
5736 || latest.project_id_meta() != session.project_id_meta()
5737 || serde_json::to_value(&latest.provider_transcript)
5738 .map_err(|_| local_tool_history_unsupported())?
5739 != serde_json::to_value(&session.provider_transcript)
5740 .map_err(|_| local_tool_history_unsupported())?
5741 {
5742 return Err(local_tool_history_unsupported());
5743 }
5744 let suffix = collector.suffix(&latest, selection.0, selection.1, terminal)?;
5745 let before = session.clone();
5746 let mut staged = session.clone();
5747 staged.messages = latest.messages.clone();
5748 staged.messages.extend(suffix.iter().cloned());
5749 staged.updated_at = chrono::Utc::now();
5750 if cancel.is_cancelled() || !binding.router.owns_run(&session.id, run_id).await {
5751 return Err(local_tool_history_unsupported());
5752 }
5753 binding
5754 .persistence
5755 .checkpoint_runtime_session(&mut staged)
5756 .await
5757 .map_err(|_| local_tool_history_unsupported())?;
5758 let saved = binding
5759 .storage
5760 .load_session(&session.id)
5761 .await
5762 .map_err(|_| local_tool_history_unsupported())?
5763 .ok_or_else(local_tool_history_unsupported)?;
5764 if serde_json::to_value(&saved.provider_transcript)
5765 .map_err(|_| local_tool_history_unsupported())?
5766 != serde_json::to_value(&latest.provider_transcript)
5767 .map_err(|_| local_tool_history_unsupported())?
5768 {
5769 return Err(local_tool_history_unsupported());
5770 }
5771 let start = saved
5772 .messages
5773 .iter()
5774 .position(|message| message.id == suffix[0].id)
5775 .ok_or_else(local_tool_history_unsupported)?;
5776 if saved.messages.len() < latest.messages.len()
5777 || serde_json::to_value(&saved.messages[..latest.messages.len()])
5778 .map_err(|_| local_tool_history_unsupported())?
5779 != serde_json::to_value(&latest.messages)
5780 .map_err(|_| local_tool_history_unsupported())?
5781 || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5782 .map_err(|_| local_tool_history_unsupported())?
5783 != serde_json::to_value(Some(&suffix[..]))
5784 .map_err(|_| local_tool_history_unsupported())?
5785 {
5786 return Err(local_tool_history_unsupported());
5787 }
5788 let expected = latest.messages.iter().chain(&suffix);
5791 for message in expected {
5792 let matches: Vec<_> = saved
5793 .messages
5794 .iter()
5795 .filter(|current| current.id == message.id)
5796 .collect();
5797 if matches.len() != 1
5798 || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5799 != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5800 {
5801 *session = before;
5802 return Err(local_tool_history_unsupported());
5803 }
5804 }
5805 if cancel.is_cancelled() || !binding.router.owns_run(&session.id, run_id).await {
5806 return Err(local_tool_history_unsupported());
5807 }
5808 staged.messages = saved.messages;
5809 *session = staged;
5810 Ok(())
5811}
5812
5813#[derive(Default)]
5814struct ReadOnlyActorCollector {
5815 start: Option<(String, serde_json::Value)>,
5816 result: Option<String>,
5817 messages: Option<Vec<bamboo_agent_core::Message>>,
5818}
5819impl ReadOnlyActorCollector {
5820 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5821 if self.messages.is_some() {
5822 return Err(plain_actor_unsupported());
5823 }
5824 if value["type"] == "owned_readonly_transcript" {
5825 let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5826 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5827 tail.validate().map_err(|_| plain_actor_unsupported())?;
5828 let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5829 let typed: Vec<bamboo_agent_core::Message> = messages
5830 .iter()
5831 .map(|raw| {
5832 let message: bamboo_agent_core::Message =
5833 serde_json::from_value(raw.clone())
5834 .map_err(|_| plain_actor_unsupported())?;
5835 if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
5836 != *raw
5837 {
5838 return Err(plain_actor_unsupported());
5839 }
5840 Ok(message)
5841 })
5842 .collect::<Result<_, _>>()?;
5843 let (id, args) = self
5844 .start
5845 .as_ref()
5846 .ok_or_else(|| plain_actor_unsupported())?;
5847 let call = typed[0]
5848 .tool_calls
5849 .as_ref()
5850 .and_then(|calls| {
5851 if calls.len() == 1 {
5852 calls.first()
5853 } else {
5854 None
5855 }
5856 })
5857 .ok_or_else(|| plain_actor_unsupported())?;
5858 if call.id != *id
5859 || call.function.name != "Glob"
5860 || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5861 .map_err(|_| plain_actor_unsupported())?
5862 != *args
5863 || typed[1].tool_call_id.as_ref() != Some(id)
5864 || typed[1].tool_success != Some(true)
5865 || self.result.as_ref() != Some(&typed[1].content)
5866 {
5867 return Err(plain_actor_unsupported());
5868 }
5869 self.messages = Some(typed);
5870 return Ok(false);
5871 }
5872 let event: AgentEvent =
5873 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5874 match event {
5875 AgentEvent::ToolStart {
5876 tool_call_id,
5877 tool_name,
5878 arguments,
5879 } => {
5880 if tool_name != "Glob"
5881 || tool_call_id.trim().is_empty()
5882 || self.start.is_some()
5883 || !arguments.is_object()
5884 {
5885 return Err(plain_actor_unsupported());
5886 }
5887 self.start = Some((tool_call_id, arguments));
5888 Ok(true)
5889 }
5890 AgentEvent::ToolComplete {
5891 tool_call_id,
5892 result,
5893 } => {
5894 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
5895 || self.result.is_some()
5896 || !result.success
5897 || !result.images.is_empty()
5898 {
5899 return Err(plain_actor_unsupported());
5900 }
5901 self.result = Some(result.result);
5902 Ok(true)
5903 }
5904 AgentEvent::ToolToken { tool_call_id, .. } => {
5905 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
5906 {
5907 return Err(plain_actor_unsupported());
5908 }
5909 Ok(true)
5910 }
5911 AgentEvent::ToolLifecycle {
5912 tool_call_id,
5913 tool_name,
5914 is_mutating,
5915 phase,
5916 error,
5917 ..
5918 } => {
5919 if tool_name != "Glob"
5920 || is_mutating
5921 || !matches!(phase.as_str(), "begin" | "finished")
5922 || error.is_some()
5923 || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
5924 {
5925 return Err(plain_actor_unsupported());
5926 }
5927 Ok(true)
5928 }
5929 _ => plain_actor_event(value),
5930 }
5931 }
5932 fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5933 let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
5934 if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
5935 return Err(plain_actor_unsupported());
5936 }
5937 Ok(messages)
5938 }
5939}
5940
5941fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
5942 let event: AgentEvent =
5943 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5944 match event {
5945 AgentEvent::ReasoningToken { .. }
5946 | AgentEvent::ToolToken { .. }
5947 | AgentEvent::ToolStart { .. }
5948 | AgentEvent::ToolComplete { .. }
5949 | AgentEvent::ToolError { .. }
5950 | AgentEvent::ToolLifecycle { .. }
5951 | AgentEvent::ContextSummarized { .. }
5952 | AgentEvent::ContextArchived { .. }
5953 | AgentEvent::ContextCompressionStatus { .. }
5954 | AgentEvent::NeedClarification { .. }
5955 | AgentEvent::SubAgentStarted { .. }
5956 | AgentEvent::SubAgentEvent { .. }
5957 | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
5958 AgentEvent::MessageAppended { .. }
5961 | AgentEvent::SessionHistoryCommitted { .. }
5962 | AgentEvent::VisibleMessageStart { .. }
5963 | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
5964 event if event.is_durable_change() => Ok(false),
5965 _ => Ok(true),
5966 }
5967}
5968
5969fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
5975 let value = match placement {
5978 Placement::Local => return None,
5979 Placement::Remote { endpoint } => serde_json::json!({
5980 "kind": "remote",
5981 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
5982 }),
5983 Placement::Schedulable { pool } => serde_json::json!({
5984 "kind": "remote",
5985 "host": host_label.unwrap_or(pool),
5986 }),
5987 };
5988 serde_json::to_string(&value).ok()
5989}
5990
5991fn host_of_endpoint(endpoint: &str) -> String {
5993 endpoint
5994 .trim()
5995 .trim_start_matches("wss://")
5996 .trim_start_matches("ws://")
5997 .split(['/', ':'])
5998 .next()
5999 .unwrap_or(endpoint)
6000 .to_string()
6001}
6002
6003async fn reconcile_already_admitted_claim(
6004 binding: &SessionInboxRuntimeBinding,
6005 session: &mut Session,
6006 claim: &SessionInboxClaim,
6007) -> crate::runtime::runner::Result<()> {
6008 let latest = binding
6009 .storage
6010 .load_session(&session.id)
6011 .await
6012 .map_err(|error| {
6013 AgentError::LLM(format!(
6014 "load canonical SessionInbox checkpoint for {}: {error}",
6015 session.id
6016 ))
6017 })?
6018 .ok_or_else(|| {
6019 AgentError::LLM(format!(
6020 "canonical SessionInbox target disappeared: {}",
6021 session.id
6022 ))
6023 })?;
6024 let Some(message) = latest
6025 .messages
6026 .iter()
6027 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6028 .cloned()
6029 else {
6030 return Err(AgentError::LLM(format!(
6031 "canonical admitted receipt for {} exists without transcript message {}",
6032 session.id, claim.envelope.id
6033 )));
6034 };
6035 if let Some(existing) = session
6036 .messages
6037 .iter_mut()
6038 .find(|existing| existing.id == message.id)
6039 {
6040 *existing = message;
6041 } else {
6042 session.add_message(message);
6043 }
6044 bamboo_domain::merge_session_inbox_admission(session, &latest);
6045 binding
6046 .inbox
6047 .ack(&session.id, claim)
6048 .await
6049 .map_err(|error| {
6050 AgentError::LLM(format!(
6051 "ack recovered canonical SessionInbox claim {}: {error}",
6052 claim.envelope.id
6053 ))
6054 })
6055}
6056
6057async fn checkpoint_and_ack_canonical_claim(
6061 binding: &SessionInboxRuntimeBinding,
6062 session: &mut Session,
6063 claim: &SessionInboxClaim,
6064 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6065) -> crate::runtime::runner::Result<()> {
6066 if claim.envelope.target_session_id != session.id {
6067 return Err(AgentError::LLM(format!(
6068 "canonical SessionInbox claim target {} does not match active logical session {}",
6069 claim.envelope.target_session_id, session.id
6070 )));
6071 }
6072 if binding
6073 .inbox
6074 .was_admitted(&session.id, &claim.envelope.id)
6075 .await
6076 .map_err(|error| {
6077 AgentError::LLM(format!(
6078 "inspect canonical admitted receipt {}: {error}",
6079 claim.envelope.id
6080 ))
6081 })?
6082 {
6083 return reconcile_already_admitted_claim(binding, session, claim).await;
6084 }
6085
6086 if let Some((store, fence)) = fenced {
6087 let committed = store
6088 .confirm_actor_claim_context(
6089 concrete_file_inbox(binding)?,
6090 bamboo_storage::ActorClaimContextSeed {
6091 fence: fence.clone(),
6092 expected_created_at: session.created_at,
6093 expected_messages: session.messages.clone(),
6094 expected_provider_transcript: session.provider_transcript.clone(),
6095 expected_admission: session.session_inbox_admission().cloned(),
6096 claims: vec![claim.clone()],
6097 },
6098 )
6099 .await;
6100 let committed = match committed {
6101 Ok(committed) => committed,
6102 Err(error) => {
6103 adopt_latest_actor_context(store, session).await;
6107 return Err(AgentError::LLM(format!(
6108 "confirm fenced Actor claim context unconfirmed: {error}"
6109 )));
6110 }
6111 };
6112 bamboo_domain::merge_session_inbox_admission(session, &committed);
6113 session.messages = committed.messages;
6114 session.provider_transcript = committed.provider_transcript;
6115 session.updated_at = committed.updated_at;
6116 if !session
6117 .messages
6118 .iter()
6119 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6120 || !session
6121 .session_inbox_admission()
6122 .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6123 {
6124 return Err(AgentError::LLM(
6125 "fenced Actor claim confirmation lost durable proof".into(),
6126 ));
6127 }
6128 return binding
6129 .inbox
6130 .ack(&session.id, claim)
6131 .await
6132 .map_err(|error| {
6133 AgentError::LLM(format!(
6134 "ack fenced Actor claim {} after confirmation: {error}",
6135 claim.envelope.id
6136 ))
6137 });
6138 }
6139
6140 let transcript_has_id = session
6141 .messages
6142 .iter()
6143 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6144 if session
6145 .messages
6146 .iter()
6147 .any(|message| message.id == claim.envelope.id.as_str())
6148 && !transcript_has_id
6149 {
6150 return Err(AgentError::LLM(format!(
6151 "canonical SessionInbox id {} collides with a non-matching transcript message",
6152 claim.envelope.id
6153 )));
6154 }
6155 let cursor_has_id = session
6156 .session_inbox_admission()
6157 .is_some_and(|state| state.contains(&claim.envelope.id));
6158 if cursor_has_id && !transcript_has_id {
6159 return Err(AgentError::LLM(format!(
6160 "canonical SessionInbox cursor exists without transcript message {}",
6161 claim.envelope.id
6162 )));
6163 }
6164 let before = session.clone();
6165 if !transcript_has_id {
6166 let message = claim.envelope.to_provider_message().map_err(|error| {
6167 AgentError::LLM(format!(
6168 "translate canonical SessionInbox envelope {}: {error}",
6169 claim.envelope.id
6170 ))
6171 })?;
6172 session.add_message(message);
6173 }
6174 session
6175 .session_inbox_admission_mut()
6176 .record(claim.envelope.id.clone(), claim.generation);
6177 session.updated_at = chrono::Utc::now();
6178
6179 if let Err(error) = binding
6180 .persistence
6181 .checkpoint_runtime_session(session)
6182 .await
6183 {
6184 *session = before;
6185 return Err(AgentError::LLM(format!(
6186 "checkpoint canonical SessionInbox claim {}: {error}",
6187 claim.envelope.id
6188 )));
6189 }
6190 if !session
6191 .messages
6192 .iter()
6193 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6194 {
6195 *session = before;
6196 return Err(AgentError::LLM(format!(
6197 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6198 claim.envelope.id
6199 )));
6200 }
6201 binding
6202 .inbox
6203 .ack(&session.id, claim)
6204 .await
6205 .map_err(|error| {
6206 AgentError::LLM(format!(
6207 "ack canonical SessionInbox claim {} after checkpoint: {error}",
6208 claim.envelope.id
6209 ))
6210 })
6211}
6212
6213fn concrete_file_inbox(
6214 binding: &SessionInboxRuntimeBinding,
6215) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6216 binding
6217 .inbox
6218 .as_any()
6219 .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6220 .cloned()
6221 .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6222}
6223
6224async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6225 if let Ok(Some(latest)) = store.load_session(&session.id).await {
6226 if latest.created_at == session.created_at {
6227 bamboo_domain::merge_session_inbox_admission(session, &latest);
6228 session.messages = latest.messages;
6229 session.provider_transcript = latest.provider_transcript;
6230 session.updated_at = latest.updated_at;
6231 }
6232 }
6233}
6234
6235async fn checkpoint_claim_context_before_dispatch(
6246 binding: &SessionInboxRuntimeBinding,
6247 session: &mut Session,
6248 claims: &[SessionInboxClaim],
6249 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6250) -> crate::runtime::runner::Result<()> {
6251 if claims.is_empty() {
6252 return Ok(());
6253 }
6254 let before = session.clone();
6255 let mut additions = Vec::new();
6256 for claim in claims {
6257 if claim.envelope.target_session_id != session.id {
6258 return Err(AgentError::LLM(format!(
6259 "canonical SessionInbox claim target {} does not match actor session {}",
6260 claim.envelope.target_session_id, session.id
6261 )));
6262 }
6263 let matching = session
6264 .messages
6265 .iter()
6266 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6267 if session
6268 .messages
6269 .iter()
6270 .any(|message| message.id == claim.envelope.id.as_str())
6271 && !matching
6272 {
6273 return Err(AgentError::LLM(format!(
6274 "canonical SessionInbox id {} collides before actor dispatch",
6275 claim.envelope.id
6276 )));
6277 }
6278 if session
6279 .session_inbox_admission()
6280 .is_some_and(|state| state.contains(&claim.envelope.id))
6281 && !matching
6282 {
6283 return Err(AgentError::LLM(format!(
6284 "canonical SessionInbox cursor exists without transcript proof for {}",
6285 claim.envelope.id
6286 )));
6287 }
6288 if !matching {
6289 let message = claim.envelope.to_provider_message().map_err(|error| {
6290 AgentError::LLM(format!(
6291 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6292 claim.envelope.id
6293 ))
6294 })?;
6295 additions.push(message);
6296 }
6297 }
6298 let fenced_seed = fenced.is_some();
6299 if let Some((store, fence)) = fenced {
6300 let committed = store
6301 .seed_actor_claim_context(
6302 concrete_file_inbox(binding)?,
6303 bamboo_storage::ActorClaimContextSeed {
6304 fence: fence.clone(),
6305 expected_created_at: session.created_at,
6306 expected_messages: session.messages.clone(),
6307 expected_provider_transcript: session.provider_transcript.clone(),
6308 expected_admission: session.session_inbox_admission().cloned(),
6309 claims: claims.to_vec(),
6310 },
6311 )
6312 .await;
6313 let committed = match committed {
6314 Ok(committed) => committed,
6315 Err(error) => {
6316 adopt_latest_actor_context(store, session).await;
6320 return Err(AgentError::LLM(format!(
6321 "seed fenced Actor claim context unconfirmed: {error}"
6322 )));
6323 }
6324 };
6325 bamboo_domain::merge_session_inbox_admission(session, &committed);
6326 session.messages = committed.messages;
6327 session.provider_transcript = committed.provider_transcript;
6328 session.updated_at = committed.updated_at;
6329 } else {
6330 for message in additions {
6331 session.add_message(message);
6332 }
6333 session.updated_at = chrono::Utc::now();
6334 if let Err(error) = binding
6335 .persistence
6336 .checkpoint_runtime_session(session)
6337 .await
6338 {
6339 *session = before;
6340 return Err(AgentError::LLM(format!(
6341 "checkpoint canonical SessionInbox actor context: {error}"
6342 )));
6343 }
6344 }
6345 for claim in claims {
6346 if !session
6347 .messages
6348 .iter()
6349 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6350 {
6351 if !fenced_seed {
6352 *session = before;
6353 }
6354 return Err(AgentError::LLM(format!(
6355 "actor context checkpoint lost typed transcript proof for {}",
6356 claim.envelope.id
6357 )));
6358 }
6359 }
6360 Ok(())
6361}
6362
6363async fn claim_canonical_deliveries(
6364 binding: &SessionInboxRuntimeBinding,
6365 session: &mut Session,
6366 activation_run_id: &str,
6367 limit: usize,
6368 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6369) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6370 let claims = binding
6371 .inbox
6372 .claim_for_turn(&session.id, limit, Some(activation_run_id))
6373 .await
6374 .map_err(|error| {
6375 AgentError::LLM(format!(
6376 "claim canonical SessionInbox for active actor {}: {error}",
6377 session.id
6378 ))
6379 })?;
6380 if claims.is_empty() {
6381 return Ok(Vec::new());
6382 }
6383 let mut unconfirmed = Vec::with_capacity(claims.len());
6384 for claim in claims {
6385 if binding
6386 .inbox
6387 .was_admitted(&session.id, &claim.envelope.id)
6388 .await
6389 .map_err(|error| {
6390 AgentError::LLM(format!(
6391 "inspect canonical SessionInbox claim {}: {error}",
6392 claim.envelope.id
6393 ))
6394 })?
6395 {
6396 reconcile_already_admitted_claim(binding, session, &claim).await?;
6397 continue;
6398 }
6399 unconfirmed.push(claim);
6400 }
6401 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6402
6403 let mut deliveries = Vec::with_capacity(unconfirmed.len());
6404 for claim in unconfirmed {
6405 if session
6410 .session_inbox_admission()
6411 .is_some_and(|state| state.contains(&claim.envelope.id))
6412 {
6413 binding
6414 .inbox
6415 .ack(&session.id, &claim)
6416 .await
6417 .map_err(|error| {
6418 AgentError::LLM(format!(
6419 "finish confirmed canonical SessionInbox ack {}: {error}",
6420 claim.envelope.id
6421 ))
6422 })?;
6423 continue;
6424 }
6425 let delivery = SessionMessageDelivery {
6426 target_session_id: session.id.clone(),
6427 envelope: claim.envelope.clone(),
6428 canonical_claim_generation: claim.generation,
6429 activation_run_id: activation_run_id.to_string(),
6430 activation_policy: claim.activation_policy,
6431 };
6432 deliveries.push((claim, delivery));
6433 }
6434 Ok(deliveries)
6435}
6436
6437async fn forward_next_canonical_claim(
6438 client: &mut dyn bamboo_subagent::ChildLink,
6439 binding: &SessionInboxRuntimeBinding,
6440 session: &mut Session,
6441 activation_run_id: &str,
6442 inflight: &mut VecDeque<SessionInboxClaim>,
6443 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6444) -> crate::runtime::runner::Result<()> {
6445 if !inflight.is_empty() {
6446 return Ok(());
6447 }
6448 let Some((claim, delivery)) =
6449 claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6450 .await?
6451 .pop()
6452 else {
6453 return Ok(());
6454 };
6455 client
6456 .send(ParentFrame::SessionMessage { delivery })
6457 .await
6458 .map_err(|error| {
6459 AgentError::LLM(format!(
6460 "forward canonical SessionInbox claim {} to active actor: {error}",
6461 claim.envelope.id
6462 ))
6463 })?;
6464 inflight.push_back(claim);
6465 Ok(())
6466}
6467
6468struct ActorDriveContext<'a> {
6470 client: &'a mut dyn bamboo_subagent::ChildLink,
6471 parent_session_id: &'a str,
6472 child_session_id: &'a str,
6473 child_attempt: u32,
6474 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6475 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6476 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6477 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6478 event_tx: &'a mpsc::Sender<AgentEvent>,
6479 cancel_token: &'a CancellationToken,
6480 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6481 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6482 logical_session: &'a mut Session,
6483 expected_permission_posture: Option<ExpectedPermissionPosture>,
6484 expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6485 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6486 actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6487 canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6488 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6489 activation_run_id: Option<&'a str>,
6490 execution_epoch: u64,
6491 expected_source_actor_id: &'a str,
6492 initial_inflight_claims: VecDeque<SessionInboxClaim>,
6493 plain_actor: bool,
6494 remote_environment_lease: bool,
6495 readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6496 local_history_tools: Option<&'a [String]>,
6497 local_history_read_only: bool,
6498 strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6499 plain_input: Option<&'a PlainActorActivation>,
6500 canonical_activation: Option<&'a ActorActivationFence>,
6501 canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6502 actor_event_observer: Option<&'a dyn ActorEventObserver>,
6503 plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6504 first_frame_timeout: Option<Duration>,
6505}
6506
6507#[derive(Debug, Clone, PartialEq, Eq)]
6508struct ExpectedPermissionPosture {
6509 policy_revision: u64,
6510 resolution: bamboo_domain::PermissionModeResolution,
6511 expected_audit_revision: Option<u64>,
6512 executor_mapping: String,
6513}
6514
6515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6516enum PermissionPostureHandshake {
6517 NotRequired,
6519 Awaiting,
6522 Confirmed,
6524}
6525
6526impl PermissionPostureHandshake {
6527 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6528 if expected.is_some() {
6529 Self::Awaiting
6530 } else {
6531 Self::NotRequired
6532 }
6533 }
6534
6535 fn is_awaiting(self) -> bool {
6536 self == Self::Awaiting
6537 }
6538
6539 fn posture_was_confirmed(self) -> bool {
6540 self == Self::Confirmed
6541 }
6542}
6543
6544fn permission_posture_seed_from_event(
6545 session: &Session,
6546 event: &AgentEvent,
6547) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6548 let AgentEvent::PermissionPostureActivated {
6549 session_id,
6550 policy_revision,
6551 requested_mode,
6552 effective_mode,
6553 executor_mapping,
6554 } = event
6555 else {
6556 return Ok(None);
6557 };
6558 if session_id != &session.id {
6559 return Err("permission posture event targets a different logical session".to_string());
6560 }
6561 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6562 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6563 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6564 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6565 let resolution = bamboo_domain::PermissionModeResolution {
6566 requested,
6567 effective,
6568 };
6569 if !resolution.is_consistent() {
6570 return Err("permission posture event has an inconsistent mode pair".to_string());
6571 }
6572 let current_requested = session
6573 .agent_runtime_state
6574 .as_ref()
6575 .map(|state| state.effective_permission_mode())
6576 .unwrap_or_default();
6577 if current_requested != requested {
6578 return Err("permission posture event is stale for the host typed mode".to_string());
6579 }
6580 let mapping_chars = executor_mapping.chars().count();
6581 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6582 return Err("permission posture event has an invalid executor mapping".to_string());
6583 }
6584 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6585 *policy_revision,
6586 resolution,
6587 executor_mapping,
6588 )))
6589}
6590
6591fn actor_event_logical_identity(
6592 session: &Session,
6593 parent_session_id: &str,
6594 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6595) -> LogicalSessionIdentity {
6596 LogicalSessionIdentity {
6597 creation: creation.cloned(),
6598 session_id: session.id.clone(),
6599 parent_session_id: session
6600 .parent_session_id
6601 .clone()
6602 .or_else(|| Some(parent_session_id.to_string())),
6603 root_session_id: if session.root_session_id.trim().is_empty() {
6604 parent_session_id.to_string()
6605 } else {
6606 session.root_session_id.clone()
6607 },
6608 }
6609}
6610
6611fn validate_strict_history_terminal(
6612 watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6613 session: &Session,
6614 parent_session_id: &str,
6615 activation_run_id: Option<&str>,
6616 execution_epoch: u64,
6617 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6618 consumed_seq: u64,
6619) -> Result<(), AgentError> {
6620 let identity = actor_event_logical_identity(session, parent_session_id, creation);
6621 if watermark
6622 .zip(activation_run_id)
6623 .is_some_and(|(watermark, run_id)| {
6624 watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6625 })
6626 {
6627 Ok(())
6628 } else {
6629 Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6630 }
6631}
6632
6633fn validate_actor_event_batch(
6634 batch: &ActorEventBatch,
6635 logical_session: &Session,
6636 parent_session_id: &str,
6637 activation_run_id: Option<&str>,
6638 execution_epoch: u64,
6639 expected_source_actor_id: &str,
6640 expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6641) -> Result<(), AgentError> {
6642 batch
6643 .validate()
6644 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6645 let identity = batch.logical_session.as_ref().ok_or_else(|| {
6646 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6647 })?;
6648 let expected_identity =
6653 actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6654 if identity != &expected_identity {
6655 return Err(AgentError::LLM(
6656 "actor event batch targets a different logical session".to_string(),
6657 ));
6658 }
6659 if batch.activation_id.as_deref() != activation_run_id {
6660 return Err(AgentError::LLM(
6661 "actor event batch belongs to a stale activation".to_string(),
6662 ));
6663 }
6664 if batch.execution_epoch != execution_epoch {
6665 return Err(AgentError::LLM(
6666 "actor event batch belongs to a stale execution epoch".to_string(),
6667 ));
6668 }
6669 if batch
6670 .source_actor_id
6671 .as_deref()
6672 .is_some_and(|source| source != expected_source_actor_id)
6673 {
6674 return Err(AgentError::LLM(
6675 "actor event batch declares a different physical actor".to_string(),
6676 ));
6677 }
6678 Ok(())
6679}
6680
6681async fn validate_directory_actor_event(
6685 store: &bamboo_storage::SessionStoreV2,
6686 fence: &ActorActivationFence,
6687 session: &Session,
6688 run_id: Option<&str>,
6689) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6690 store
6691 .validate_fence(fence, chrono::Utc::now())
6692 .await
6693 .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6694 let entry = store
6695 .inspect_actor(&fence.actor_id)
6696 .await
6697 .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6698 if !entry.actor.matches_session(session)
6699 || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6700 || !entry.activation.as_ref().is_some_and(|current| {
6701 current.matches_fence(fence)
6702 && current.status == bamboo_domain::ActorActivationStatus::Running
6703 })
6704 || run_id.is_some_and(|id| id != fence.run_id)
6705 {
6706 return Err(AgentError::LLM("actor event authority changed".into()));
6707 }
6708 Ok(entry)
6709}
6710
6711async fn inspect_active_owned_tree(
6714 store: &bamboo_storage::SessionStoreV2,
6715 binding: &SessionInboxRuntimeBinding,
6716 run_id: &str,
6717 child_id: &str,
6718 parent_id: &str,
6719 logical_session: &Session,
6720 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6721 plain_input: Option<&PlainActorActivation>,
6722 cursor: Option<&str>,
6723) -> Option<serde_json::Value> {
6724 use bamboo_agent_core::storage::Storage;
6725 let current = match store.load_session(child_id).await {
6726 Ok(Some(current)) => current,
6727 _ => {
6728 tracing::warn!("owned tree denied: canonical caller unavailable");
6729 return None;
6730 }
6731 };
6732 let current_project = match project_id_for_actor_run(¤t) {
6733 Ok(project) => project,
6734 Err(_) => {
6735 tracing::warn!("owned tree denied: invalid canonical Project");
6736 return None;
6737 }
6738 };
6739 let logical_project = match project_id_for_actor_run(logical_session) {
6740 Ok(project) => project,
6741 Err(_) => {
6742 tracing::warn!("owned tree denied: invalid logical Project");
6743 return None;
6744 }
6745 };
6746 if current.id != child_id
6747 || current.kind != bamboo_domain::SessionKind::Child
6748 || current.parent_session_id.as_deref() != Some(parent_id)
6749 || current.parent_session_id != logical_session.parent_session_id
6750 || current.root_session_id != logical_session.root_session_id
6751 || current.created_at != creation.created_at
6752 || current.created_at != logical_session.created_at
6753 || current.spawn_depth != creation.spawn_depth
6754 || current.spawn_depth != logical_session.spawn_depth
6755 || current_project != logical_project
6756 {
6757 tracing::warn!("owned tree denied: caller lifetime or Project changed");
6758 return None;
6759 }
6760 if let Some(activation) = plain_input {
6761 validate_directory_actor_event(
6762 activation.store.as_ref(),
6763 &activation.fence,
6764 ¤t,
6765 Some(run_id),
6766 )
6767 .await
6768 .ok()?;
6769 } else if !binding.router.owns_run(child_id, run_id).await {
6770 tracing::warn!("owned tree denied: activation owner changed");
6771 return None;
6772 }
6773 let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6774 store, child_id, cursor,
6775 )
6776 .await
6777 {
6778 Ok(page) => page,
6779 Err(error) => {
6780 tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6781 return None;
6782 }
6783 };
6784 let again = match store.load_session(child_id).await {
6785 Ok(Some(again)) => again,
6786 _ => {
6787 tracing::warn!("owned tree denied: canonical caller disappeared");
6788 return None;
6789 }
6790 };
6791 if again.created_at != current.created_at
6792 || again.parent_session_id != current.parent_session_id
6793 || again.root_session_id != current.root_session_id
6794 || again.spawn_depth != current.spawn_depth
6795 || project_id_for_actor_run(&again).ok()? != current_project
6796 {
6797 tracing::warn!("owned tree denied: caller changed during inspection");
6798 return None;
6799 }
6800 if let Some(activation) = plain_input {
6801 validate_directory_actor_event(
6802 activation.store.as_ref(),
6803 &activation.fence,
6804 &again,
6805 Some(run_id),
6806 )
6807 .await
6808 .ok()?;
6809 } else if !binding.router.owns_run(child_id, run_id).await {
6810 tracing::warn!("owned tree denied: activation owner changed after inspection");
6811 return None;
6812 }
6813 serde_json::to_vec(&page)
6814 .ok()
6815 .filter(|bytes| bytes.len() <= 8192)?;
6816 Some(page)
6817}
6818
6819async fn load_active_subagent_caller(
6823 store: &bamboo_storage::SessionStoreV2,
6824 binding: &SessionInboxRuntimeBinding,
6825 run_id: &str,
6826 child_id: &str,
6827 parent_id: &str,
6828 logical_session: &Session,
6829 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6830 plain_input: Option<&PlainActorActivation>,
6831) -> Option<Session> {
6832 use bamboo_agent_core::storage::Storage;
6833 let current = store.load_session(child_id).await.ok().flatten()?;
6834 let current_project = project_id_for_actor_run(¤t).ok()?;
6835 let logical_project = project_id_for_actor_run(logical_session).ok()?;
6836 if current.id != child_id
6837 || current.kind != bamboo_domain::SessionKind::Child
6838 || current.parent_session_id.as_deref() != Some(parent_id)
6839 || current.parent_session_id != logical_session.parent_session_id
6840 || current.root_session_id != logical_session.root_session_id
6841 || current.created_at != creation.created_at
6842 || current.created_at != logical_session.created_at
6843 || current.spawn_depth != creation.spawn_depth
6844 || current.spawn_depth != logical_session.spawn_depth
6845 || current_project != logical_project
6846 || bamboo_domain::ActorSession::from_session(¤t).is_err()
6847 {
6848 return None;
6849 }
6850 if let Some(activation) = plain_input {
6851 validate_directory_actor_event(
6852 activation.store.as_ref(),
6853 &activation.fence,
6854 ¤t,
6855 Some(run_id),
6856 )
6857 .await
6858 .ok()?;
6859 } else if !binding.router.owns_run(child_id, run_id).await {
6860 return None;
6861 }
6862 Some(current)
6863}
6864
6865async fn invoke_active_subagent(
6866 tool: &dyn bamboo_agent_core::tools::Tool,
6867 caller: &Session,
6868 args: serde_json::Value,
6869 tool_call_id: &str,
6870) -> serde_json::Value {
6871 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
6872 let mut ctx = ToolCtx::none(tool_call_id.to_owned());
6873 ctx.session_id = Some(Arc::from(caller.id.as_str()));
6874 match tool.invoke(args, ctx).await {
6875 Ok(ToolOutcome::Completed(result))
6876 if result.images.is_empty()
6877 && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
6878 {
6879 serde_json::json!({"result":result})
6880 }
6881 _ => serde_json::json!({
6882 "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
6883 }),
6884 }
6885}
6886
6887#[allow(clippy::too_many_arguments)]
6890async fn checkpoint_active_parent_question(
6891 store: &bamboo_storage::SessionStoreV2,
6892 binding: &SessionInboxRuntimeBinding,
6893 run_id: &str,
6894 child_id: &str,
6895 parent_id: &str,
6896 logical_session: &mut Session,
6897 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6898 plain_input: Option<&PlainActorActivation>,
6899 observation: ParentQuestionCheckpointV1,
6900) -> Option<String> {
6901 use bamboo_agent_core::storage::Storage;
6902 if plain_input.is_some() || observation.validate_shape().is_err() {
6903 return None;
6904 }
6905 let _session_guard = binding
6909 .parent_question_lock
6910 .as_ref()?
6911 .acquire_lock(child_id)
6912 .await;
6913 let caller = load_active_subagent_caller(
6914 store,
6915 binding,
6916 run_id,
6917 child_id,
6918 parent_id,
6919 logical_session,
6920 creation,
6921 plain_input,
6922 )
6923 .await?;
6924 let parent = store.load_session(parent_id).await.ok().flatten()?;
6925 if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
6926 return None;
6927 }
6928 let (saved, question) = store
6929 .checkpoint_parent_question(&caller, &parent, &observation)
6930 .await
6931 .ok()
6932 .flatten()?;
6933 logical_session.messages = saved.messages;
6936 logical_session.pending_question = saved.pending_question;
6937 if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
6938 logical_session
6939 .metadata
6940 .insert("runtime.suspend_reason".into(), reason.clone());
6941 }
6942 if let Some(request) = saved
6943 .metadata
6944 .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
6945 {
6946 logical_session.metadata.insert(
6947 bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
6948 request.clone(),
6949 );
6950 }
6951 logical_session.metadata.insert(
6952 "runtime.actor_parent_question_handoff".into(),
6953 "true".into(),
6954 );
6955 Some(question.id.as_str().to_owned())
6956}
6957
6958async fn ensure_active_nested_wait(
6963 tool: &dyn bamboo_agent_core::tools::Tool,
6964 store: &bamboo_storage::SessionStoreV2,
6965 binding: &SessionInboxRuntimeBinding,
6966 run_id: &str,
6967 child_id: &str,
6968 parent_id: &str,
6969 logical_session: &Session,
6970 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6971 plain_input: Option<&PlainActorActivation>,
6972) -> Result<bool, AgentError> {
6973 use bamboo_agent_core::storage::Storage;
6974 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
6975
6976 let caller = load_active_subagent_caller(
6977 store,
6978 binding,
6979 run_id,
6980 child_id,
6981 parent_id,
6982 logical_session,
6983 creation,
6984 plain_input,
6985 )
6986 .await
6987 .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
6988 if caller
6989 .agent_runtime_state
6990 .as_ref()
6991 .and_then(|state| state.waiting_for_children.as_ref())
6992 .is_some()
6993 {
6994 return Ok(true);
6995 }
6996
6997 let active = store
6998 .list_child_run_statuses(child_id)
6999 .await
7000 .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?
7001 .into_iter()
7002 .filter(|(_, status)| {
7003 !status.as_deref().is_some_and(|status| {
7004 matches!(
7005 status,
7006 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7007 )
7008 })
7009 })
7010 .map(|(id, _)| id)
7011 .collect::<Vec<_>>();
7012 if active.is_empty() {
7013 return Ok(false);
7014 }
7015
7016 let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7017 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7018 let outcome = tokio::time::timeout(
7019 Duration::from_secs(30),
7020 tool.invoke(
7021 serde_json::json!({"action":"wait", "child_session_ids":active}),
7022 ctx,
7023 ),
7024 )
7025 .await
7026 .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7027 .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7028 let wait_registered = match outcome {
7029 ToolOutcome::Completed(result) if result.success => {
7030 let payload: serde_json::Value = serde_json::from_str(&result.result)
7031 .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7032 payload["status"] == "waiting"
7033 }
7034 _ => {
7035 return Err(AgentError::LLM(
7036 "nested wait registration was not completed".into(),
7037 ))
7038 }
7039 };
7040
7041 let latest = load_active_subagent_caller(
7044 store,
7045 binding,
7046 run_id,
7047 child_id,
7048 parent_id,
7049 logical_session,
7050 creation,
7051 plain_input,
7052 )
7053 .await
7054 .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7055 if latest
7056 .agent_runtime_state
7057 .as_ref()
7058 .and_then(|state| state.waiting_for_children.as_ref())
7059 .is_some()
7060 {
7061 return Ok(true);
7062 }
7063 let still_active = store
7064 .list_child_run_statuses(child_id)
7065 .await
7066 .map_err(|error| {
7067 AgentError::LLM(format!("nested child wait verification failed: {error}"))
7068 })?
7069 .into_iter()
7070 .any(|(_, status)| {
7071 !status.as_deref().is_some_and(|status| {
7072 matches!(
7073 status,
7074 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7075 )
7076 })
7077 });
7078 if still_active {
7079 return Err(AgentError::LLM(
7080 "nested child remains active without a durable parent wait".into(),
7081 ));
7082 }
7083 Ok(wait_registered)
7088}
7089
7090fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7091 let message = match error {
7092 ActorEventRouteError::SequenceGap { .. } => {
7093 "actor event sequence gap; authoritative session snapshot is required"
7094 }
7095 ActorEventRouteError::LifecycleMissing => {
7096 "actor emitted live content before its activation lifecycle event"
7097 }
7098 ActorEventRouteError::ForgedSession => {
7099 "actor event payload targets a different canonical session"
7100 }
7101 ActorEventRouteError::ConflictingReplay => {
7102 "actor replay reused an event sequence with different content"
7103 }
7104 ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7105 ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7106 };
7107 AgentError::LLM(message.into())
7108}
7109
7110const MAX_DISPLAY_CALLS: usize = 4_096;
7111const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7112const MAX_DISPLAY_ID_BYTES: usize = 256;
7113const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7114
7115fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7116 if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7117 return None;
7118 }
7119 Some(match result {
7120 HookResult::Continue => HookResult::Continue,
7121 HookResult::Mutated => HookResult::Mutated,
7122 HookResult::Allow => HookResult::Allow,
7123 HookResult::Ask => HookResult::Ask,
7124 HookResult::Deny { .. } => HookResult::Deny {
7125 reason: "Hook denied".into(),
7126 },
7127 HookResult::InjectContext { .. } => HookResult::InjectContext {
7128 text: "Hook context hidden".into(),
7129 },
7130 HookResult::WithContext { result, .. } => HookResult::WithContext {
7131 result: Box::new(hook_result_for_display(*result, depth + 1)?),
7132 text: "Hook context hidden".into(),
7133 },
7134 HookResult::Suspend { .. } => HookResult::Suspend {
7135 reason: "Hook suspended".into(),
7136 },
7137 HookResult::Abort { .. } => HookResult::Abort {
7138 reason: "Hook aborted".into(),
7139 },
7140 })
7141}
7142
7143#[derive(Clone, Copy, PartialEq, Eq)]
7144enum DisplayCallKind {
7145 Ordinary,
7146 PrivateBrowser,
7147 Reused,
7148}
7149
7150#[derive(Clone, Copy)]
7151struct DisplayCall {
7152 kind: DisplayCallKind,
7153 started: bool,
7154}
7155
7156#[derive(Default)]
7157struct ActorEventDisplay {
7158 calls: HashMap<String, DisplayCall>,
7159 hook_names: HashMap<String, usize>,
7160 children: HashMap<(String, String), Box<ActorEventDisplay>>,
7161 identities: Arc<AtomicUsize>,
7164 overflowed: bool,
7165 nested_overflowed: bool,
7166}
7167
7168fn is_browser_tool_name(tool_name: &str) -> bool {
7169 tool_name
7170 .trim()
7171 .rsplit("::")
7172 .next()
7173 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7174}
7175
7176fn known_browser_action(args: &serde_json::Value) -> bool {
7177 matches!(
7178 args.get("action").and_then(serde_json::Value::as_str),
7179 Some(
7180 "tabs"
7181 | "new_tab"
7182 | "activate_tab"
7183 | "close_tab"
7184 | "navigate"
7185 | "history"
7186 | "viewport"
7187 | "snapshot"
7188 | "click"
7189 | "click_at"
7190 | "hover"
7191 | "drag"
7192 | "fill"
7193 | "select_option"
7194 | "set_file_input"
7195 | "type"
7196 | "press"
7197 | "key"
7198 | "scroll"
7199 | "download"
7200 | "screenshot"
7201 | "dialog_respond"
7202 )
7203 )
7204}
7205
7206fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7207 is_browser_tool_name(tool_name)
7208 && (!known_browser_action(args)
7209 || args
7210 .get("action")
7211 .and_then(serde_json::Value::as_str)
7212 .is_some_and(|action| {
7213 action.eq_ignore_ascii_case("download") || action == "set_file_input"
7214 })
7215 || args.get("data_base64").is_some())
7216}
7217
7218fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7219 if is_browser_tool_name(tool_name) {
7220 if !known_browser_action(args) {
7223 return serde_json::json!({"details":"[redacted]"});
7224 }
7225 }
7226 tool_start_arguments_for_display(tool_name, args)
7227}
7228
7229impl ActorEventDisplay {
7230 fn fail_closed(&mut self) {
7231 self.identities
7232 .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7233 self.overflowed = true;
7234 self.calls.clear();
7235 self.hook_names.clear();
7236 self.nested_overflowed = true;
7237 self.children.clear();
7238 }
7239
7240 fn reserve_identity(&mut self) -> bool {
7241 if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7242 true
7243 } else {
7244 self.fail_closed();
7245 false
7246 }
7247 }
7248
7249 fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7250 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7251 return DisplayCallKind::Reused;
7252 }
7253 let kind = if private {
7254 DisplayCallKind::PrivateBrowser
7255 } else {
7256 DisplayCallKind::Ordinary
7257 };
7258 if let Some(call) = self.calls.get_mut(id) {
7259 if call.started || call.kind != kind {
7260 call.kind = DisplayCallKind::Reused;
7261 }
7262 call.started = true;
7263 return call.kind;
7264 }
7265 if !self.reserve_identity() {
7266 return DisplayCallKind::Reused;
7267 }
7268 self.calls.insert(
7269 id.to_string(),
7270 DisplayCall {
7271 kind,
7272 started: true,
7273 },
7274 );
7275 kind
7276 }
7277
7278 fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7279 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7280 return DisplayCallKind::Reused;
7281 }
7282 let kind = if private {
7283 DisplayCallKind::PrivateBrowser
7284 } else {
7285 DisplayCallKind::Ordinary
7286 };
7287 if let Some(call) = self.calls.get_mut(id) {
7288 if call.kind != kind {
7289 call.kind = DisplayCallKind::Reused;
7290 }
7291 return call.kind;
7292 }
7293 if !self.reserve_identity() {
7294 return DisplayCallKind::Reused;
7295 }
7296 self.calls.insert(
7297 id.to_string(),
7298 DisplayCall {
7299 kind,
7300 started: false,
7301 },
7302 );
7303 kind
7304 }
7305
7306 fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7307 if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7308 return None;
7309 }
7310 self.calls
7311 .get(id)
7312 .filter(|call| call.started)
7313 .map(|call| call.kind)
7314 }
7315
7316 fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7317 if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7318 self.fail_closed();
7319 return None;
7320 }
7321 if let Some(index) = self.hook_names.get(hook_name) {
7322 return Some(format!("actor-hook-{index}"));
7323 }
7324 if !self.reserve_identity() {
7325 return None;
7326 }
7327 let index = self.hook_names.len() + 1;
7328 self.hook_names.insert(hook_name.to_string(), index);
7329 Some(format!("actor-hook-{index}"))
7330 }
7331
7332 fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7333 let call_id = match &event {
7334 AgentEvent::ToolStart { tool_call_id, .. }
7335 | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7336 | AgentEvent::ToolToken { tool_call_id, .. }
7337 | AgentEvent::ToolComplete { tool_call_id, .. }
7338 | AgentEvent::ToolError { tool_call_id, .. }
7339 | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7340 _ => None,
7341 };
7342 if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7343 self.fail_closed();
7344 return None;
7345 }
7346 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7347 self.fail_closed();
7348 }
7349 Some(match event {
7350 AgentEvent::ToolStart {
7351 tool_call_id,
7352 tool_name,
7353 arguments,
7354 } => {
7355 let private = is_private_browser_call(&tool_name, &arguments);
7356 let kind = self.remember_start(&tool_call_id, private);
7357 let arguments = if kind == DisplayCallKind::Reused {
7358 serde_json::json!({"details":"[redacted]"})
7359 } else {
7360 actor_arguments_for_display(&tool_name, &arguments)
7361 };
7362 AgentEvent::ToolStart {
7363 tool_call_id,
7364 tool_name: match kind {
7365 DisplayCallKind::Reused => "unknown".into(),
7366 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7367 _ => tool_name,
7368 },
7369 arguments,
7370 }
7371 }
7372 AgentEvent::ToolApprovalRequested {
7373 tool_call_id,
7374 tool_name,
7375 parameters,
7376 } => {
7377 let private = is_private_browser_call(&tool_name, ¶meters);
7378 let kind = self.remember_approval(&tool_call_id, private);
7379 let parameters = if kind == DisplayCallKind::Reused {
7380 serde_json::json!({"details":"[redacted]"})
7381 } else {
7382 actor_arguments_for_display(&tool_name, ¶meters)
7383 };
7384 AgentEvent::ToolApprovalRequested {
7385 tool_call_id,
7386 tool_name: match kind {
7387 DisplayCallKind::Reused => "unknown".into(),
7388 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7389 _ => tool_name,
7390 },
7391 parameters,
7392 }
7393 }
7394 AgentEvent::ToolToken {
7395 tool_call_id,
7396 content,
7397 } => {
7398 let content = match self.known_kind(&tool_call_id) {
7399 Some(DisplayCallKind::Ordinary) => content,
7400 Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7401 _ => "Tool output hidden".into(),
7402 };
7403 AgentEvent::ToolToken {
7404 tool_call_id,
7405 content,
7406 }
7407 }
7408 AgentEvent::ToolComplete {
7409 tool_call_id,
7410 result,
7411 } => {
7412 let result = match self.known_kind(&tool_call_id) {
7413 Some(DisplayCallKind::Ordinary) => result,
7414 Some(DisplayCallKind::PrivateBrowser) => {
7415 bamboo_agent_core::tools::ToolResult::text(
7416 result.success,
7417 "Browser tool result hidden",
7418 )
7419 }
7420 _ => bamboo_agent_core::tools::ToolResult::text(
7421 result.success,
7422 "Tool result hidden",
7423 ),
7424 };
7425 AgentEvent::ToolComplete {
7426 tool_call_id,
7427 result,
7428 }
7429 }
7430 AgentEvent::ToolError {
7431 tool_call_id,
7432 error,
7433 } => {
7434 let error = match self.known_kind(&tool_call_id) {
7435 Some(DisplayCallKind::Ordinary) => error,
7436 Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7437 _ => "Tool failed".into(),
7438 };
7439 AgentEvent::ToolError {
7440 tool_call_id,
7441 error,
7442 }
7443 }
7444 AgentEvent::ToolLifecycle {
7445 tool_call_id,
7446 tool_name,
7447 phase,
7448 elapsed_ms,
7449 is_mutating,
7450 auto_approved,
7451 summary,
7452 error,
7453 } => {
7454 let kind = self.known_kind(&tool_call_id);
7455 let (summary, error) = match kind {
7456 Some(DisplayCallKind::Ordinary) => (summary, error),
7457 Some(DisplayCallKind::PrivateBrowser) => (
7458 summary.map(|_| "Browser tool activity hidden".into()),
7459 error.map(|_| "Browser tool failed".into()),
7460 ),
7461 _ => (
7462 summary.map(|_| "Tool activity hidden".into()),
7463 error.map(|_| "Tool failed".into()),
7464 ),
7465 };
7466 AgentEvent::ToolLifecycle {
7467 tool_call_id,
7468 tool_name: match kind {
7469 Some(DisplayCallKind::Ordinary) => tool_name,
7470 Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7471 _ => "unknown".into(),
7472 },
7473 phase: if kind == Some(DisplayCallKind::Ordinary)
7474 || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7475 {
7476 phase
7477 } else {
7478 "event".into()
7479 },
7480 elapsed_ms,
7481 is_mutating,
7482 auto_approved,
7483 summary,
7484 error,
7485 }
7486 }
7487 AgentEvent::HookLifecycle {
7488 hook_name,
7489 point,
7490 phase,
7491 duration_ms,
7492 decision,
7493 } => {
7494 let Some(decision) = hook_result_for_display(decision, 0) else {
7495 self.fail_closed();
7496 return None;
7497 };
7498 let hook_name = self.display_hook_name(&hook_name)?;
7499 let phase = match phase.as_str() {
7500 "completed" | "started" | "error" => phase,
7501 _ => "event".into(),
7502 };
7503 AgentEvent::HookLifecycle {
7504 hook_name,
7505 point,
7506 phase,
7507 duration_ms,
7508 decision,
7509 }
7510 }
7511 AgentEvent::ChildApprovalRequested {
7512 child_session_id,
7513 request_id,
7514 tool_name,
7515 permission,
7516 resource,
7517 } => {
7518 let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7519 (
7520 "browser".into(),
7521 "Browser interaction approval".into(),
7522 "[redacted]".into(),
7523 )
7524 } else {
7525 (tool_name, permission, resource)
7526 };
7527 AgentEvent::ChildApprovalRequested {
7528 child_session_id,
7529 request_id,
7530 tool_name,
7531 permission,
7532 resource,
7533 }
7534 }
7535 AgentEvent::ChildApprovalChanged { tool_name, .. }
7536 if is_browser_tool_name(&tool_name) =>
7537 {
7538 return None;
7542 }
7543 AgentEvent::SubAgentEvent {
7544 parent_session_id,
7545 child_session_id,
7546 event,
7547 } => {
7548 if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7549 return None;
7550 }
7551 if parent_session_id.is_empty()
7552 || child_session_id.is_empty()
7553 || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7554 || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7555 {
7556 self.fail_closed();
7557 return None;
7558 }
7559 let key = (parent_session_id.clone(), child_session_id.clone());
7560 if !self.children.contains_key(&key) && !self.reserve_identity() {
7561 return None;
7562 }
7563 let identities = self.identities.clone();
7564 let child = self.children.entry(key).or_insert_with(|| {
7565 Box::new(ActorEventDisplay {
7566 identities,
7567 ..Default::default()
7568 })
7569 });
7570 let event = child.project(*event, depth + 1)?;
7571 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7572 self.fail_closed();
7573 return None;
7574 }
7575 AgentEvent::SubAgentEvent {
7576 parent_session_id,
7577 child_session_id,
7578 event: Box::new(event),
7579 }
7580 }
7581 other => other,
7582 })
7583 }
7584}
7585
7586#[allow(clippy::too_many_arguments)]
7587async fn process_actor_event(
7588 event: serde_json::Value,
7589 strict_permission_events: bool,
7590 permission_handshake: &mut PermissionPostureHandshake,
7591 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7592 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7593 logical_session: &mut Session,
7594 event_tx: &mpsc::Sender<AgentEvent>,
7595 display: &mut ActorEventDisplay,
7596) -> crate::runtime::runner::Result<()> {
7597 let event = match serde_json::from_value::<AgentEvent>(event) {
7598 Ok(event) => event,
7599 Err(_) if strict_permission_events => {
7600 return Err(AgentError::LLM(
7604 "actor emitted malformed AgentEvent under a typed permission posture contract"
7605 .to_string(),
7606 ));
7607 }
7608 Err(_) => {
7609 display.fail_closed();
7613 return Ok(());
7614 }
7615 };
7616 process_typed_actor_event(
7617 event,
7618 permission_handshake,
7619 expected_permission_posture,
7620 session_inbox_runtime,
7621 logical_session,
7622 event_tx,
7623 display,
7624 )
7625 .await
7626}
7627
7628#[allow(clippy::too_many_arguments)]
7629async fn process_typed_actor_event(
7630 event: AgentEvent,
7631 permission_handshake: &mut PermissionPostureHandshake,
7632 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7633 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7634 logical_session: &mut Session,
7635 event_tx: &mpsc::Sender<AgentEvent>,
7636 display: &mut ActorEventDisplay,
7637) -> crate::runtime::runner::Result<()> {
7638 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7639 if permission_handshake.posture_was_confirmed() {
7640 return Err(AgentError::LLM(
7641 "actor emitted a duplicate permission posture activation".to_string(),
7642 ));
7643 }
7644 let seed = permission_posture_seed_from_event(logical_session, &event)
7645 .map_err(AgentError::LLM)?
7646 .ok_or_else(|| {
7647 AgentError::LLM(
7648 "actor permission posture event did not decode as a posture".to_string(),
7649 )
7650 })?;
7651 if let Some(expected) = expected_permission_posture {
7652 if seed.policy_revision != expected.policy_revision
7653 || seed.resolution != expected.resolution
7654 {
7655 return Err(AgentError::LLM(
7656 "permission posture event does not match the host-dispatched policy"
7657 .to_string(),
7658 ));
7659 }
7660 if seed.executor_mapping() != expected.executor_mapping {
7661 return Err(AgentError::LLM(
7662 "permission posture event does not match the host-dispatched executor mapping"
7663 .to_string(),
7664 ));
7665 }
7666 }
7667 if let Some(binding) = session_inbox_runtime {
7668 let saved = binding
7669 .persistence
7670 .record_permission_posture_activation(
7671 &logical_session.id,
7672 expected_permission_posture
7673 .and_then(|expected| expected.expected_audit_revision),
7674 &seed,
7675 )
7676 .await
7677 .map_err(|error| {
7678 AgentError::LLM(format!(
7679 "persist child permission posture bootstrap: {error}"
7680 ))
7681 })?
7682 .ok_or_else(|| {
7683 AgentError::LLM(
7684 "persist child permission posture bootstrap: session not found".to_string(),
7685 )
7686 })?;
7687 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7688 .ok_or_else(|| {
7689 AgentError::LLM(
7690 "persisted child permission posture audit is incomplete".to_string(),
7691 )
7692 })?;
7693 snapshot.write_to(&mut logical_session.metadata);
7694 } else {
7695 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7698 .map_err(|error| {
7699 AgentError::LLM(format!(
7700 "record in-memory child permission posture: {error}"
7701 ))
7702 })?;
7703 }
7704 *permission_handshake = PermissionPostureHandshake::Confirmed;
7707 } else if permission_handshake.is_awaiting() {
7708 return Err(AgentError::LLM(
7709 "actor emitted an execution event before permission posture confirmation".to_string(),
7710 ));
7711 }
7712 if let Some(event) = display.project(event, 0) {
7713 let _ = event_tx.send(event).await;
7714 }
7715 Ok(())
7716}
7717
7718async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7729 let ActorDriveContext {
7730 client,
7731 parent_session_id,
7732 child_session_id,
7733 child_attempt,
7734 approval_registry,
7735 approval_decider,
7736 approval_reviewer,
7737 escalation_bridge,
7738 event_tx,
7739 cancel_token,
7740 live_rx,
7741 delivery_rx,
7742 logical_session,
7743 mut expected_permission_posture,
7744 expected_creation,
7745 session_inbox_runtime,
7746 actor_directory_store,
7747 canonical_subagent_tool,
7748 ticket_service,
7749 activation_run_id,
7750 execution_epoch,
7751 expected_source_actor_id,
7752 initial_inflight_claims,
7753 first_frame_timeout,
7754 plain_actor,
7755 remote_environment_lease,
7756 mut readonly_output,
7757 local_history_tools,
7758 local_history_read_only,
7759 mut strict_history_output,
7760 plain_input,
7761 canonical_activation,
7762 canonical_placement_ref,
7763 actor_event_observer,
7764 plain_run,
7765 } = context;
7766
7767 logical_session
7771 .metadata
7772 .remove("runtime.actor_nested_wait_handoff");
7773
7774 let mut got_first_frame = false;
7781 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7782 let mut inflight_claims = initial_inflight_claims;
7783 let mut owned_input = plain_input.and_then(|activation| activation.initial_input.clone());
7784 let continuation_limit = if owned_input.is_some() { 1 } else { 2 };
7787 let mut continuations = usize::from(owned_input.is_some());
7788 let mut released_input: Option<(
7789 SessionInboxOwnedClaim,
7790 bamboo_subagent::proto::InitialInputRelease,
7791 )> = None;
7792 let mut current_epoch = execution_epoch;
7793 let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7794 let approval_cancel = cancel_token.child_token();
7795 let _approval_lifetime = approval_cancel.clone().drop_guard();
7796 let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7797 HashMap::new();
7798 let mut owned_tree_requests = 0usize;
7799 let mut canonical_subagent_requests = 0usize;
7800 let mut committed_plan_calls = HashMap::new();
7801 let mut nested_wait_registered_this_run = false;
7805 let mut parent_question_checkpoint_id: Option<String> = None;
7806 let strict_permission_events = expected_permission_posture.is_some();
7807 let mut permission_handshake =
7808 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
7809 let mut next_actor_event_seq = 1u64;
7810 let mut canonical_router: Option<ActorEventRouter> = None;
7811 let canonical_fence = plain_input
7812 .map(|activation| &activation.fence)
7813 .or(canonical_activation);
7814 let canonical_store = plain_input
7815 .map(|activation| activation.store.as_ref())
7816 .or(actor_directory_store);
7817 let remote_claim_fence = actor_directory_store.zip(canonical_activation);
7818 let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
7819 let mut display = ActorEventDisplay::default();
7820 let mut readonly = readonly_output
7821 .as_ref()
7822 .map(|_| ReadOnlyActorCollector::default());
7823 let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
7824 loop {
7825 tokio::select! {
7826 _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
7827 if remote_claim_fence.is_some() {
7828 let _ = client.send(ParentFrame::Cancel).await;
7834 remote_cancel_deadline = Some(
7835 tokio::time::Instant::now() + Duration::from_secs(10)
7836 );
7837 continue;
7838 }
7839 break;
7841 }
7842 _ = async {
7843 if let Some(deadline) = remote_cancel_deadline {
7844 tokio::time::sleep_until(deadline).await;
7845 } else {
7846 std::future::pending::<()>().await;
7847 }
7848 } => {
7849 return Err(AgentError::Cancelled);
7850 }
7851 _ = async {
7852 match first_frame_watch.as_mut() {
7853 Some(s) => s.as_mut().await,
7854 None => std::future::pending::<()>().await,
7855 }
7856 }, if !got_first_frame => {
7857 return Err(AgentError::WorkerUnresponsive(format!(
7858 "child {child_session_id} produced no frame within {:?}",
7859 first_frame_timeout.unwrap_or_default()
7860 )));
7861 }
7862 Some(_generation) = delivery_rx.recv(),
7863 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
7864 {
7865 if plain_input.is_some() {
7866 continue;
7869 }
7870 forward_next_canonical_claim(
7871 client,
7872 session_inbox_runtime.expect("guarded"),
7873 logical_session,
7874 activation_run_id.expect("guarded"),
7875 &mut inflight_claims,
7876 remote_claim_fence,
7877 )
7878 .await?;
7879 }
7880 Some(frame) = live_rx.recv() => {
7881 if plain_actor { return Err(plain_actor_unsupported()); }
7882 if client.send(frame).await.is_err() {
7884 tracing::warn!("live steering frame could not be sent; connection failing");
7885 }
7886 }
7887 frame = client.next_frame() => {
7888 got_first_frame = true;
7891 first_frame_watch = None;
7892 match frame {
7893 Ok(Some(ChildFrame::Event { event })) => {
7894 if canonical_activation.is_some() {
7895 return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
7896 }
7897 if local_history.is_some() {
7898 return Err(local_tool_history_unsupported());
7899 }
7900 if event.get("initial_input_control").is_some() {
7901 let bamboo_subagent::proto::InitialInputControl::Request { request } =
7902 bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
7903 else { return Err(plain_actor_unsupported()); };
7904 if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
7905 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
7906 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
7907 let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
7908 let claim = owned_input.as_ref().or_else(|| released_input.as_ref().map(|(claim, _)| claim))
7909 .ok_or_else(|| plain_actor_unsupported())?;
7910 let release = activation.release_initial_input(binding, logical_session, run, current_epoch, claim,
7911 &request, &mut expected_permission_posture, released_input.as_ref().map(|(_, release)| release)).await?;
7912 if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
7913 client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
7914 .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
7915 released_input = Some((claim.clone(), release));
7916 owned_input = None;
7917 continue;
7918 }
7919 if expected_creation.is_some() {
7920 return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
7921 }
7922 if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
7923 if event["type"] == "owned_readonly_transcript" {
7924 if plain_actor { return Err(plain_actor_unsupported()); }
7925 continue;
7926 }
7927 if plain_actor && !plain_actor_event(&event)? { continue; }
7928 if let Some(activation) = plain_input {
7929 validate_directory_actor_event(
7930 activation.store.as_ref(),
7931 &activation.fence,
7932 logical_session,
7933 None,
7934 ).await?;
7935 }
7936 process_actor_event(
7940 event,
7941 strict_permission_events,
7942 &mut permission_handshake,
7943 expected_permission_posture.as_ref(),
7944 session_inbox_runtime,
7945 logical_session,
7946 event_tx,
7947 &mut display,
7948 )
7949 .await?;
7950 }
7951 Ok(Some(ChildFrame::EventBatch { batch })) => {
7952 validate_actor_event_batch(
7953 &batch,
7954 logical_session,
7955 parent_session_id,
7956 activation_run_id,
7957 current_epoch,
7958 expected_source_actor_id,
7959 expected_creation,
7960 )?;
7961 let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
7962 let entry = validate_directory_actor_event(
7963 store, fence, logical_session, batch.activation_id.as_deref(),
7964 ).await?;
7965 if canonical_placement_ref.is_some_and(|expected| {
7966 entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
7967 }) {
7968 return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
7969 }
7970 Some(entry)
7971 } else { None };
7972 if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
7973 if canonical_router.is_none() {
7974 canonical_router = Some(
7975 ActorEventRouter::new(
7976 entry,
7977 fence,
7978 current_epoch,
7979 strict_permission_events && permission_handshake.is_awaiting(),
7980 )
7981 .map_err(actor_event_route_error)?,
7982 );
7983 }
7984 }
7985 if local_history_tools.is_some()
7986 && (batch.first_seq != next_actor_event_seq
7987 || batch.qos != bamboo_subagent::ActorEventQos::Durable
7988 || batch.last_seq == u64::MAX) {
7989 return Err(local_tool_history_unsupported());
7990 }
7991 if batch.last_seq < next_actor_event_seq {
7992 continue;
7993 }
7994 if batch.first_seq > next_actor_event_seq {
7995 if plain_actor {
7996 return Err(AgentError::LLM(
7997 "actor event sequence gap; authoritative session snapshot is required".into(),
7998 ));
7999 }
8000 tracing::warn!(
8001 child_session_id,
8002 expected_seq = next_actor_event_seq,
8003 received_seq = batch.first_seq,
8004 "actor event sequence gap; authoritative session snapshot is required"
8005 );
8006 display.fail_closed();
8010 }
8011 let skip = next_actor_event_seq
8012 .saturating_sub(batch.first_seq)
8013 .min(batch.events.len() as u64) as usize;
8014 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8015 let seq = batch.first_seq + offset as u64;
8016 let mut publish = true;
8017 if let Some(collector) = local_history.as_mut() {
8018 if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8019 return Err(local_tool_history_unsupported());
8020 }
8021 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8022 publish = collector.ticket_event(&event, &committed_plan_calls)?;
8023 }
8024 publish &= collector.event(&event)?;
8025 } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8026 publish = false;
8028 }
8029 if publish { publish = if let Some(collector) = readonly.as_mut() {
8030 if permission_handshake.is_awaiting()
8031 && event["type"] != "permission_posture_activated" {
8032 return Err(plain_actor_unsupported());
8033 }
8034 collector.event(&event)?
8035 } else if event["type"] == "owned_readonly_transcript" {
8036 if plain_actor { return Err(plain_actor_unsupported()); }
8037 false } else if plain_actor { plain_actor_event(&event)? } else { true };
8039 }
8040 if let (Some(router), Some(entry), Some(fence)) =
8041 (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8042 {
8043 let typed = if publish {
8044 Some(serde_json::from_value::<AgentEvent>(event.clone())
8045 .map_err(|_| plain_actor_unsupported())?)
8046 } else {
8047 None
8048 };
8049 match router.route(entry, fence, current_epoch, seq, typed)
8050 .map_err(actor_event_route_error)?
8051 {
8052 ActorEventRoute::Publish(envelope) => {
8053 let public_event = actor_event_observer
8054 .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8055 .map(|_| PublicActorEvent::from(envelope.as_ref()));
8056 tracing::trace!(
8057 actor_id = %envelope.actor_id,
8058 event_id = %envelope.event_id,
8059 class = ?envelope.class,
8060 "publishing canonical Actor event"
8061 );
8062 process_typed_actor_event(
8063 envelope.payload,
8064 &mut permission_handshake,
8065 expected_permission_posture.as_ref(),
8066 session_inbox_runtime,
8067 logical_session,
8068 event_tx,
8069 &mut display,
8070 ).await?;
8071 if let (Some(observer), Some(public_event)) =
8072 (actor_event_observer, public_event)
8073 {
8074 observer.publish(public_event);
8075 }
8076 }
8077 ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8078 }
8079 next_actor_event_seq = seq.saturating_add(1);
8080 continue;
8081 }
8082 if !publish {
8083 next_actor_event_seq = seq.saturating_add(1);
8084 continue;
8085 }
8086 process_actor_event(
8087 event,
8088 strict_permission_events,
8089 &mut permission_handshake,
8090 expected_permission_posture.as_ref(),
8091 session_inbox_runtime,
8092 logical_session,
8093 event_tx,
8094 &mut display,
8095 )
8096 .await?;
8097 next_actor_event_seq = seq.saturating_add(1);
8098 }
8099 }
8100 Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8101 owned_tree_requests += 1;
8102 if owned_tree_requests > 16 {
8103 return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8104 }
8105 let page = if id.len() <= 128
8106 && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8107 && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8108 && !cancel_token.is_cancelled()
8109 {
8110 match (actor_directory_store, session_inbox_runtime,
8111 activation_run_id, expected_creation) {
8112 (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8113 tokio::time::timeout(
8114 Duration::from_secs(8),
8115 inspect_active_owned_tree(
8116 store, binding, run_id, child_session_id,
8117 parent_session_id, logical_session, creation,
8118 plain_input, cursor.as_deref(),
8119 ),
8120 ).await.ok().flatten()
8121 }
8122 _ => {
8123 tracing::warn!("owned tree denied: canonical Host scope unavailable");
8124 None
8125 },
8126 }
8127 } else {
8128 tracing::warn!("owned tree denied: request bounds or cancellation");
8129 None
8130 };
8131 if cancel_token.is_cancelled() {
8132 return Err(AgentError::Cancelled);
8133 }
8134 client.send(ParentFrame::OwnedTreeReply { id, page }).await
8135 .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8136 }
8137 Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8138 canonical_subagent_requests += 1;
8139 if canonical_subagent_requests > 64 {
8140 return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8141 }
8142 let valid_shape = !id.is_empty() && id.len() <= 128
8143 && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8144 && args.is_object()
8145 && serde_json::to_vec(&args)
8146 .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8147 let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8148 Some("wait"))
8149 || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8150 && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8151 let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8152 let ticket_bootstrap_pending = valid_shape
8153 && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8154 && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8155 && permission_handshake.is_awaiting()
8156 && !cancel_token.is_cancelled();
8157 let result = if ticket_bootstrap_pending {
8158 serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8162 } else if valid_shape
8163 && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8164 || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8165 || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8166 && !cancel_token.is_cancelled()
8167 && !permission_handshake.is_awaiting()
8168 {
8169 if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8170 match (ticket_service.as_deref(), actor_directory_store,
8171 session_inbox_runtime, activation_run_id, expected_creation) {
8172 (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8173 if let Some(caller) = load_active_subagent_caller(
8174 store, binding, run_id, child_session_id,
8175 parent_session_id, logical_session, creation, plain_input,
8176 ).await {
8177 match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8178 Ok(projection) => {
8179 logical_session.task_list = projection.task_list.clone();
8180 logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8181 if let Some(question) = &projection.question {
8182 let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8183 logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8184 if let Some(collector) = local_history.as_mut() {
8185 collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8186 }
8187 }
8188 if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8189 committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8190 if let Some(task_list) = projection.task_list.clone() {
8191 let _ = event_tx.send(AgentEvent::TaskListUpdated {
8192 task_list,
8193 version: Some(projection.plan_revision),
8194 }).await;
8195 }
8196 }
8197 serde_json::json!({"result":projection})
8198 }
8199 Err(error) => serde_json::json!({"error":error.to_string()}),
8200 }
8201 } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8202 }
8203 _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8204 }
8205 } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8206 match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8207 activation_run_id, expected_creation, local_history_tools) {
8208 (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8209 if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8210 match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8211 Ok(reply)=>{
8212 let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8213 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())?}));
8214 serde_json::json!({"result":reply})
8215 },
8216 Err(error)=>serde_json::json!({"error":error.to_string()}),
8217 }
8218 } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8219 },
8220 _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8221 }
8222 } else if let Some(payload) = checkpoint_payload {
8223 let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8224 .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8225 .flatten()
8226 .filter(|observation| observation.tool_call_id == tool_call_id);
8227 match (parsed, actor_directory_store, session_inbox_runtime,
8228 activation_run_id, expected_creation) {
8229 (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8230 let receipt = tokio::time::timeout(Duration::from_secs(25),
8231 checkpoint_active_parent_question(
8232 store, binding, run_id, child_session_id,
8233 parent_session_id, logical_session, creation,
8234 plain_input, observation,
8235 )).await.ok().flatten();
8236 if let Some(request_id) = receipt {
8237 parent_question_checkpoint_id = Some(request_id.clone());
8238 serde_json::json!({"result":{"request_id":request_id}})
8239 } else {
8240 serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8241 }
8242 }
8243 _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8244 }
8245 } else {
8246 match (canonical_subagent_tool.as_deref(), actor_directory_store,
8247 session_inbox_runtime, activation_run_id, expected_creation) {
8248 (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8249 if let Some(caller) = load_active_subagent_caller(
8250 store, binding, run_id, child_session_id,
8251 parent_session_id, logical_session, creation,
8252 plain_input,
8253 ).await {
8254 tokio::time::timeout(
8255 Duration::from_secs(30),
8256 invoke_active_subagent(tool, &caller, args, &tool_call_id),
8257 ).await.unwrap_or_else(|_| serde_json::json!({
8258 "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8259 }))
8260 } else {
8261 serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8262 }
8263 }
8264 _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8265 }
8266 }
8267 } else {
8268 serde_json::json!({"error":"canonical SubAgent request rejected"})
8269 };
8270 if registers_wait
8271 && result["result"]["success"] == true
8272 && result["result"]["result"]
8273 .as_str()
8274 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8275 .is_some_and(|value| value["status"] == "waiting")
8276 {
8277 nested_wait_registered_this_run = true;
8278 }
8279 if cancel_token.is_cancelled() {
8280 return Err(AgentError::Cancelled);
8281 }
8282 client.send(ParentFrame::SubAgentReply { id, result }).await
8283 .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8284 }
8285 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8286 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8287 return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8288 }
8289 if plain_actor { return Err(plain_actor_unsupported()); }
8290 if permission_handshake.is_awaiting() {
8291 return Err(AgentError::LLM(
8292 "actor requested approval before permission posture confirmation"
8293 .to_string(),
8294 ));
8295 }
8296 if let Some(reviewer) = approval_reviewer
8302 .cloned()
8303 .or_else(child_approval_reviewer)
8304 {
8305 let child = child_session_id.to_string();
8313 let parent = parent_session_id.to_string();
8314 let req_id = id.clone();
8315 let body = body.clone();
8316 let registry = approval_registry.cloned();
8317 let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8318 tokio::spawn(async move {
8321 let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8322 reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8323 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8324 child_attempt, &req_id, approved);
8325 });
8326 continue;
8327 };
8328 let generation = body.pointer("/permission_request/request_generation")
8329 .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8330 let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8331 if original != &body || scope.stamp()["reply"] != req_id {
8332 return Err(AgentError::LLM("approval generation changed in live scope".into()));
8333 }
8334 scope.clone()
8335 } else {
8336 if approval_scopes.len() >= 64 {
8337 return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8338 }
8339 let scope = ChildApprovalScope::new(&parent, &child,
8340 (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8341 approval_cancel.clone(), approval_epoch.clone());
8342 approval_scopes.insert(generation, (body.clone(), scope.clone()));
8343 scope
8344 };
8345 tokio::spawn(async move {
8346 let result = tokio::time::timeout(
8347 CHILD_APPROVAL_TIMEOUT,
8348 reviewer.review_scoped(&parent, &child, &body, &scope),
8349 ).await.unwrap_or(ChildApprovalReview::Reply(false));
8350 if let ChildApprovalReview::Reply(approved) = result {
8351 if scope.is_current(&parent, &child).await {
8352 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8353 child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8354 }
8355 }
8356 });
8357 } else if approval_decider.is_some() {
8358 let approved =
8362 decide_child_approval(approval_decider, child_session_id, &body)
8363 .await;
8364 if client
8365 .send(ParentFrame::ApprovalReply { id, approved })
8366 .await
8367 .is_err()
8368 {
8369 tracing::warn!(
8370 "failed to answer approval_request; connection failing"
8371 );
8372 }
8373 } else if let Some(host) = escalation_bridge.clone() {
8374 let child = child_session_id.to_string();
8381 let req_id = id.clone();
8382 let body = body.clone();
8383 let registry = approval_registry.cloned();
8384 tokio::spawn(async move {
8385 let approved = match tokio::time::timeout(
8386 CHILD_APPROVAL_TIMEOUT,
8387 host.approval_call(body),
8388 )
8389 .await
8390 {
8391 Ok(Ok(reply)) => reply
8392 .get("approved")
8393 .and_then(|v| v.as_bool())
8394 .unwrap_or(false),
8395 _ => false,
8397 };
8398 super::live::deliver_approval_scoped(
8399 registry.as_ref(),
8400 &child,
8401 child_attempt,
8402 &req_id,
8403 approved,
8404 );
8405 });
8406 } else {
8407 tracing::warn!(
8411 parent_session_id,
8412 child_session_id,
8413 request_id = %id,
8414 "forced-ask request has no parent-agent reviewer; denying"
8415 );
8416 if client
8417 .send(ParentFrame::ApprovalReply {
8418 id,
8419 approved: false,
8420 })
8421 .await
8422 .is_err()
8423 {
8424 tracing::warn!(
8425 "failed to send fail-closed approval reply; connection failing"
8426 );
8427 }
8428 }
8429 }
8430 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8431 if let Some(activation) = plain_input {
8432 if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8433 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8434 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8435 let claim = owned_input.as_ref().ok_or_else(|| plain_actor_unsupported())?;
8436 activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8437 owned_input = None;
8438 continue;
8439 }
8440 let Some(binding) = session_inbox_runtime else {
8441 tracing::warn!(
8442 child_session_id,
8443 "ignoring SessionInbox confirmation without a runtime binding"
8444 );
8445 continue;
8446 };
8447 let Some(bound_run_id) = activation_run_id else {
8448 tracing::warn!(
8449 child_session_id,
8450 "ignoring SessionInbox confirmation without an activation owner"
8451 );
8452 continue;
8453 };
8454 let Some(claim) = inflight_claims.front() else {
8455 tracing::warn!(
8456 child_session_id,
8457 envelope_id = %confirmation.envelope_id,
8458 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8459 );
8460 continue;
8461 };
8462 let exact = confirmation.target_session_id == logical_session.id
8463 && confirmation.envelope_id == claim.envelope.id.as_str()
8464 && confirmation.canonical_claim_generation == claim.generation
8465 && confirmation.activation_run_id == bound_run_id;
8466 if !exact
8467 || !binding
8468 .router
8469 .owns_run(&logical_session.id, bound_run_id)
8470 .await
8471 {
8472 tracing::warn!(
8473 child_session_id,
8474 expected_target = %logical_session.id,
8475 received_target = %confirmation.target_session_id,
8476 expected_envelope_id = %claim.envelope.id,
8477 received_envelope_id = %confirmation.envelope_id,
8478 expected_generation = claim.generation,
8479 received_generation = confirmation.canonical_claim_generation,
8480 expected_run_id = bound_run_id,
8481 received_run_id = %confirmation.activation_run_id,
8482 "rejecting stale or mismatched SessionInbox admission confirmation"
8483 );
8484 continue;
8485 }
8486 let claim = inflight_claims
8487 .pop_front()
8488 .expect("validated in-flight canonical claim");
8489 checkpoint_and_ack_canonical_claim(
8492 binding,
8493 logical_session,
8494 &claim,
8495 remote_claim_fence,
8496 )
8497 .await?;
8498 if inflight_claims.is_empty() {
8502 forward_next_canonical_claim(
8503 client,
8504 binding,
8505 logical_session,
8506 bound_run_id,
8507 &mut inflight_claims,
8508 remote_claim_fence,
8509 )
8510 .await?;
8511 }
8512 }
8513 Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8514 if local_history_tools.is_some() {
8515 validate_strict_history_terminal(
8518 final_event_watermark.as_ref(), logical_session, parent_session_id,
8519 activation_run_id, current_epoch, expected_creation,
8520 next_actor_event_seq - 1,
8521 )?;
8522 }
8523 if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8524 client.accept_durable_terminal(status);
8530 return Err(AgentError::Cancelled);
8531 }
8532 if parent_question_checkpoint_id.is_some()
8533 && status != TerminalStatus::Suspended
8534 {
8535 return Err(AgentError::LLM(
8536 "Child question checkpoint must end in a suspended Run".into(),
8537 ));
8538 }
8539 if permission_handshake.is_awaiting() {
8540 if remote_environment_lease && status == TerminalStatus::Error {
8541 if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8542 client.accept_durable_terminal(status);
8549 return Err(AgentError::LLM(code.to_owned()));
8550 }
8551 }
8552 return Err(AgentError::LLM(
8553 "actor terminated before permission posture confirmation"
8554 .to_string(),
8555 ));
8556 }
8557 if owned_input.is_some() {
8558 return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8559 }
8560 if let Some(claim) = inflight_claims.front() {
8561 return Err(AgentError::LLM(format!(
8562 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8563 claim.envelope.id
8564 )));
8565 }
8566 if status == TerminalStatus::Completed {
8567 if let Some(collector) = local_history.take() {
8568 commit_local_tool_history(
8569 collector, logical_session,
8570 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8571 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8572 (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8573 result.as_deref(), cancel_token,
8574 ).await?;
8575 }
8576 }
8577 if let Some(collector) = readonly.take() {
8578 if status == TerminalStatus::Completed {
8579 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8580 let backlog = activation.input_inbox.inspect(&logical_session.id).await
8581 .map_err(|_| plain_actor_unsupported())?;
8582 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8583 return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8584 }
8585 *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8586 collector.finish(result.as_deref())?;
8587 }
8588 } else if let Some(activation) = plain_input {
8589 if status == TerminalStatus::Completed {
8590 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8591 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8592 if continuations < continuation_limit {
8593 if let Some((claim, epoch)) = activation.continue_input(
8594 client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8595 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8596 ).await? {
8597 owned_input = Some(claim);
8598 released_input = None;
8599 continuations += 1;
8600 current_epoch = epoch;
8601 approval_epoch.store(epoch, Ordering::Release);
8602 next_actor_event_seq = 1;
8605 canonical_router = None;
8606 permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8607 display = ActorEventDisplay::default();
8608 continue;
8609 }
8610 } else if activation.input_inbox.inspect(&logical_session.id).await
8611 .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8612 if continuation_limit == 1 {
8613 return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8614 }
8615 activation.append_reply(logical_session,
8620 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8621 event_tx).await?;
8622 return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8623 }
8624 }
8625 }
8626 if status == TerminalStatus::Completed {
8627 if let (Some(tool), Some(creation)) =
8632 (canonical_subagent_tool.as_deref(), expected_creation)
8633 {
8634 let (Some(store), Some(binding), Some(run_id)) =
8635 (actor_directory_store, session_inbox_runtime,
8636 activation_run_id)
8637 else {
8638 return Err(AgentError::LLM(
8639 "nested SubAgent Host authority unavailable at Child terminal"
8640 .into(),
8641 ));
8642 };
8643 let must_suspend = ensure_active_nested_wait(
8644 tool, store, binding, run_id, child_session_id,
8645 parent_session_id, logical_session, creation, plain_input,
8646 ).await?;
8647 if must_suspend || nested_wait_registered_this_run {
8648 logical_session.metadata.insert(
8655 "runtime.actor_nested_wait_handoff".into(),
8656 "true".into(),
8657 );
8658 }
8659 }
8660 }
8661 let terminal_result = match status {
8662 TerminalStatus::Completed => Ok(result),
8663 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8664 TerminalStatus::Error => Err(AgentError::LLM(
8665 error.unwrap_or_else(|| "actor child errored".to_string()),
8666 )),
8667 TerminalStatus::Suspended => {
8668 use bamboo_agent_core::storage::Storage;
8669 let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8670 (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8671 session_inbox_runtime, activation_run_id, expected_creation)
8672 else {
8673 return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8674 };
8675 if load_active_subagent_caller(
8676 store, binding, run_id, child_session_id,
8677 parent_session_id, logical_session, creation, plain_input,
8678 ).await.is_none() {
8679 return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8680 }
8681 let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8682 .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8683 let child = store.load_session(child_session_id).await
8684 .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8685 .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8686 let parent = store.load_session(parent_session_id).await
8687 .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8688 .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8689 let pending = ParentQuestion::for_pending(&parent, &child)
8690 .is_some_and(|question| question.id == id);
8691 let answered = bamboo_domain::ParentQuestionResolution::from_child(
8692 &parent, &child, &id,
8693 ).is_some();
8694 if !pending && !answered {
8695 return Err(AgentError::LLM("canonical Child question proof changed".into()));
8696 }
8697 logical_session.metadata.insert(
8698 "runtime.actor_parent_question_handoff".into(), "true".into(),
8699 );
8700 Ok(None)
8701 }
8702 };
8703 if local_history_tools.is_some() {
8704 if status != TerminalStatus::Completed {
8708 return terminal_result;
8709 }
8710 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8711 logical_session,
8712 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8713 current_epoch,
8714 next_actor_event_seq - 1,
8715 ).map_err(|_| local_tool_history_unsupported())?;
8716 let history = StrictTerminalHistory {
8717 proof,
8718 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8719 };
8720 if let Some(output) = strict_history_output.as_deref_mut() {
8721 *output = Some(history);
8722 }
8723 }
8724 client.accept_durable_terminal(status);
8731 if status == TerminalStatus::Completed {
8732 if let Some(activation) = plain_input {
8733 *activation.completion_router.lock().recover_poison() =
8736 canonical_router.take();
8737 }
8738 }
8739 return terminal_result;
8740 }
8741 Ok(None) => {
8742 return Err(AgentError::LLM(
8743 "actor child closed before terminal".to_string(),
8744 ));
8745 }
8746 Err(e) => {
8747 return Err(AgentError::LLM(format!("actor transport error: {e}")));
8748 }
8749 }
8750 }
8751 }
8752 }
8753
8754 let _ = client.send(ParentFrame::Cancel).await;
8756 Err(AgentError::Cancelled)
8757}
8758
8759fn project_id_for_actor_run(
8761 session: &Session,
8762) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
8763 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
8764 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
8765 Ok(Some(project_id))
8766 }
8767 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
8768 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
8769 Err(AgentError::LLM(format!(
8770 "child session carries an invalid Project identity '{raw}': {message}"
8771 )))
8772 }
8773 }
8774}
8775
8776fn remote_broker_unavailable() -> AgentError {
8777 AgentError::LLM("remote_broker_activation_unavailable".into())
8778}
8779
8780fn remote_environment_error_code(value: &str) -> Option<&'static str> {
8784 const CODES: &[&str] = &[
8785 "remote_environment_lease_invalid",
8786 "remote_environment_snapshot_mismatch",
8787 "remote_environment_checkout_not_clean",
8788 "remote_environment_checkout_unsupported",
8789 "remote_environment_workspace_unavailable",
8790 "remote_environment_workspace_outside_git_root",
8791 "remote_environment_path_unsupported",
8792 "remote_environment_git_unavailable",
8793 "remote_environment_git_inspection_failed",
8794 "remote_environment_git_inspection_timed_out",
8795 "remote_environment_git_output_too_large",
8796 "remote_environment_snapshot_too_large",
8797 "remote_environment_snapshot_changed",
8798 "remote_environment_index_invalid",
8799 "remote_environment_index_unsupported",
8800 "remote_environment_host_path_or_grants_rejected",
8801 "remote_environment_worker_workspace_missing",
8802 "remote_environment_runspec_invalid",
8803 "remote_environment_lease_missing_or_host_path_present",
8804 ];
8805 CODES.iter().copied().find(|code| *code == value)
8806}
8807
8808async fn remote_canonical_lineage(
8811 binding: &SessionInboxRuntimeBinding,
8812 session: &Session,
8813) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
8814 let mut current = bamboo_domain::ActorSession::from_session(session)
8815 .map_err(|_| remote_broker_unavailable())?;
8816 let mut chain = Vec::new();
8817 loop {
8818 if chain.len() > MAX_SPAWN_DEPTH as usize {
8819 return Err(remote_broker_unavailable());
8820 }
8821 let saved = binding
8822 .storage
8823 .load_session(¤t.actor_id)
8824 .await
8825 .map_err(|_| remote_broker_unavailable())?
8826 .ok_or_else(remote_broker_unavailable)?;
8827 let observed = bamboo_domain::ActorSession::from_session(&saved)
8828 .map_err(|_| remote_broker_unavailable())?;
8829 if observed.actor_id != current.actor_id
8830 || observed.session_created_at != current.session_created_at
8831 || observed.parent_actor_id != current.parent_actor_id
8832 || observed.root_actor_id != current.root_actor_id
8833 || observed.spawn_depth != current.spawn_depth
8834 || observed.project_id != current.project_id
8835 {
8836 return Err(remote_broker_unavailable());
8837 }
8838 chain.push(observed.clone());
8839 let Some(parent_id) = observed.parent_actor_id.as_ref() else {
8840 if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
8841 return Err(remote_broker_unavailable());
8842 }
8843 return Ok(chain);
8844 };
8845 let parent = binding
8846 .storage
8847 .load_session(parent_id)
8848 .await
8849 .map_err(|_| remote_broker_unavailable())?
8850 .ok_or_else(remote_broker_unavailable)?;
8851 let parent = bamboo_domain::ActorSession::from_session(&parent)
8852 .map_err(|_| remote_broker_unavailable())?;
8853 if parent.root_actor_id != observed.root_actor_id
8854 || parent.project_id != observed.project_id
8855 || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
8856 || parent.session_created_at > observed.session_created_at
8857 {
8858 return Err(remote_broker_unavailable());
8859 }
8860 current = parent;
8861 }
8862}
8863
8864async fn canonical_child_creation(
8865 binding: &SessionInboxRuntimeBinding,
8866 session: &Session,
8867 job: &SpawnJob,
8868) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
8869 let conflict = || {
8870 AgentError::LLM(
8871 "child creation identity unavailable or changed in canonical host Storage".into(),
8872 )
8873 };
8874 let current = binding
8875 .storage
8876 .load_session(&job.child_session_id)
8877 .await
8878 .map_err(|_| conflict())?
8879 .ok_or_else(conflict)?;
8880 if bamboo_domain::ActorSession::from_session(¤t).is_err()
8881 || bamboo_domain::ActorSession::from_session(session).is_err()
8882 || current.kind != bamboo_domain::SessionKind::Child
8883 || session.kind != current.kind
8884 || current.id != session.id
8885 || current.id != job.child_session_id
8886 || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
8887 || current.parent_session_id != session.parent_session_id
8888 || current.root_session_id.trim().is_empty()
8889 || current.root_session_id != session.root_session_id
8890 || current.created_at != session.created_at
8891 || current.spawn_depth != session.spawn_depth
8892 || current.spawn_depth == 0
8893 || project_id_for_actor_run(¤t)? != project_id_for_actor_run(session)?
8894 {
8895 return Err(conflict());
8896 }
8897 Ok(bamboo_subagent::proto::ChildCreationIdentity {
8898 created_at: current.created_at,
8899 spawn_depth: current.spawn_depth,
8900 })
8901}
8902
8903fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
8904 LogicalSessionIdentity {
8905 creation: None,
8906 session_id: session.id.clone(),
8907 parent_session_id: session
8908 .parent_session_id
8909 .clone()
8910 .or_else(|| Some(job.parent_session_id.clone())),
8911 root_session_id: if session.root_session_id.trim().is_empty() {
8912 job.parent_session_id.clone()
8913 } else {
8914 session.root_session_id.clone()
8915 },
8916 }
8917}
8918
8919fn extract_assignment(session: &Session) -> String {
8920 session
8921 .messages
8922 .iter()
8923 .rev()
8924 .find(|m| matches!(m.role, Role::User))
8925 .map(|m| m.content.clone())
8926 .unwrap_or_else(|| {
8927 session
8928 .metadata
8929 .get("title")
8930 .cloned()
8931 .unwrap_or_else(|| "Execute task".to_string())
8932 })
8933}
8934
8935#[cfg(test)]
8936mod tests {
8937 use super::*;
8938 use crate::SessionActivationRouter;
8939 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
8940
8941 include!("expired_plain_owner_tests.rs");
8942
8943 #[test]
8944 fn plain_rejection_logs_branch_and_run_without_changing_error() {
8945 let output = tempfile::NamedTempFile::new().unwrap();
8946 let subscriber = tracing_subscriber::fmt()
8947 .with_ansi(false)
8948 .without_time()
8949 .with_writer(output.reopen().unwrap())
8950 .finish();
8951 let branch_line = std::cell::Cell::new(0);
8952 let error = tracing::subscriber::with_default(subscriber, || {
8953 let span = tracing::warn_span!(
8954 "actor_child_run",
8955 child_session_id = %"diagnostic-child",
8956 activation_run_id = tracing::field::Empty
8957 );
8958 let _entered = span.enter();
8959 span.record("activation_run_id", "diagnostic-run");
8960 None::<()>
8961 .ok_or_else(|| {
8962 let caller_line = line!() + 1;
8963 let error = plain_actor_unsupported();
8964 branch_line.set(caller_line);
8965 error
8966 })
8967 .unwrap_err()
8968 });
8969 assert!(matches!(&error, AgentError::LLM(message)
8970 if message.starts_with("This Actor Child supports a fresh plain activation,")
8971 && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
8972 let log = std::fs::read_to_string(output.path()).unwrap();
8973 assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
8974 assert!(
8975 log.contains("activation_run_id=\"diagnostic-run\""),
8976 "{log}"
8977 );
8978 assert!(
8979 log.contains(&format!("process_id={}", std::process::id())),
8980 "{log}"
8981 );
8982 assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
8983 assert!(
8984 log.contains(&format!("source_line={}", branch_line.get())),
8985 "{log}"
8986 );
8987 assert!(!error.to_string().contains("diagnostic-run"));
8988 }
8989
8990 #[tokio::test]
8991 async fn completed_plain_continuation_requires_one_input_and_current_identity() {
8992 for case in [
8993 "valid",
8994 "two-inputs",
8995 "tool-history",
8996 "stale-lineage",
8997 "no-release",
8998 ] {
8999 let temp = tempfile::tempdir().unwrap();
9000 let store = Arc::new(
9001 bamboo_storage::SessionStoreV2::new(temp.path().into())
9002 .await
9003 .unwrap(),
9004 );
9005 let parent = Session::new("continuation-parent", "model");
9006 store.save_session(&parent).await.unwrap();
9007 let mut child =
9008 Session::new_child_of("continuation-child", &parent, "model", "first task");
9009 child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9010 if case == "tool-history" {
9011 let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9012 tool.role = Role::Tool;
9013 child.add_message(tool);
9014 }
9015 child.set_last_run_status("completed");
9016 store.save_session(&child).await.unwrap();
9017 store.ensure_actor(&child.id).await.unwrap();
9018 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9019 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9020 let now = chrono::Utc::now();
9021 let old = store
9022 .claim_activation(&ActorActivationClaim {
9023 actor_id: child.id.clone(),
9024 run_id: "first-run".into(),
9025 lease_owner: "first-owner".into(),
9026 lease_expires_at: now + chrono::Duration::minutes(1),
9027 inbox_generation: 0,
9028 placement_ref: None,
9029 now,
9030 })
9031 .await
9032 .unwrap();
9033 store.start_activation(&old.fence(), now).await.unwrap();
9034 store
9035 .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9036 .await
9037 .unwrap();
9038 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9039 store.clone(),
9040 Default::default(),
9041 ));
9042 for slot in 0..if case == "two-inputs" { 2 } else { 1 } {
9043 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9044 &child.id,
9045 format!("new task {slot}"),
9046 );
9047 let receipt = inbox.deliver(&envelope).await.unwrap();
9048 inbox
9049 .mark_activation_eligible(
9050 &child.id,
9051 receipt.generation,
9052 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9053 )
9054 .await
9055 .unwrap();
9056 }
9057 let storage: Arc<dyn Storage> = store.clone();
9058 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9059 let binding = actor_binding(store.clone(), inbox, persistence);
9060 let _registration = binding
9061 .router
9062 .register_run(&child.id, "second-run")
9063 .await
9064 .unwrap();
9065 if case == "stale-lineage" {
9066 child.parent_session_id = Some("foreign-parent".into());
9067 }
9068 let result = PlainActorActivation::start(
9069 store.clone(),
9070 &child,
9071 &binding,
9072 Some("second-run"),
9073 Some(policy),
9074 true,
9075 (case != "no-release").then_some("second-worker"),
9076 )
9077 .await;
9078 if case == "valid" {
9079 let activation = result.expect("one new input on a completed plain Child");
9080 assert_eq!(activation.fence.attempt, old.attempt + 1);
9081 assert_eq!(activation.fence.actor_id, child.id);
9082 assert_eq!(activation.inbox_generation, 1);
9083 activation
9084 .finish(ActorActivationFinish::Failed)
9085 .await
9086 .unwrap();
9087 } else {
9088 assert!(
9089 result.is_err(),
9090 "unsupported continuation {case} must fail before claim"
9091 );
9092 assert_eq!(
9093 store
9094 .inspect_actor(&child.id)
9095 .await
9096 .unwrap()
9097 .actor
9098 .current_attempt,
9099 old.attempt
9100 );
9101 }
9102 let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9103 assert_eq!(
9104 canonical.parent_session_id.as_deref(),
9105 Some(parent.id.as_str())
9106 );
9107 assert!(canonical
9108 .messages
9109 .iter()
9110 .any(|message| message.content == "first result"));
9111 }
9112 }
9113
9114 #[tokio::test]
9115 async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9116 let temp = tempfile::tempdir().unwrap();
9117 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9118 .await
9119 .unwrap();
9120 let parent = Session::new("placement-parent", "model");
9121 let child = Session::new_child_of("placement-child", &parent, "model", "task");
9122 store.save_session(&parent).await.unwrap();
9123 store.save_session(&child).await.unwrap();
9124 store.ensure_actor(&child.id).await.unwrap();
9125 store
9126 .bind_control_plane(
9127 &child.id,
9128 bamboo_domain::ActorControlPlaneBinding {
9129 policy_revision: None,
9130 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9131 pool_id: "remote-worker".into(),
9132 }),
9133 },
9134 )
9135 .await
9136 .unwrap();
9137
9138 let claim = |run_id: &str| ActorActivationClaim {
9139 actor_id: child.id.clone(),
9140 run_id: run_id.into(),
9141 lease_owner: format!("host-{run_id}"),
9142 lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9143 inbox_generation: 0,
9144 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9145 class: bamboo_domain::ActorPlacementClass::Remote,
9146 lease_id: format!("slot-{run_id}"),
9147 slot_epoch: Some(1),
9148 }),
9149 now: chrono::Utc::now(),
9150 };
9151 let first = store.claim_activation(&claim("first")).await.unwrap();
9152 store
9153 .start_activation(&first.fence(), chrono::Utc::now())
9154 .await
9155 .unwrap();
9156 assert!(store.claim_activation(&claim("retry")).await.is_err());
9157
9158 fail_pre_dispatch_activation(&store, &first.fence())
9161 .await
9162 .unwrap();
9163 let failed = store.inspect_actor(&child.id).await.unwrap();
9164 assert_eq!(
9165 failed.activation.unwrap().status,
9166 bamboo_domain::ActorActivationStatus::Failed
9167 );
9168 let retry = store.claim_activation(&claim("retry")).await.unwrap();
9169 assert_eq!(retry.attempt, first.attempt + 1);
9170 assert!(fail_pre_dispatch_activation(&store, &first.fence())
9171 .await
9172 .is_err());
9173 assert_eq!(
9174 store.inspect_actor(&child.id).await.unwrap().activation,
9175 Some(retry)
9176 );
9177 }
9178
9179 #[tokio::test]
9180 async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9181 struct AcceptedWithoutReceipt {
9182 published: bool,
9183 }
9184
9185 #[async_trait]
9186 impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9187 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9188 assert!(matches!(frame, ParentFrame::Run(_)));
9189 self.published = true;
9190 Err(bamboo_subagent::TransportError::Protocol(
9191 "broker delivery receipt lost after publication".into(),
9192 ))
9193 }
9194
9195 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9196 std::future::pending().await
9197 }
9198 }
9199
9200 let temp = tempfile::tempdir().unwrap();
9201 let store = Arc::new(
9202 bamboo_storage::SessionStoreV2::new(temp.path().into())
9203 .await
9204 .unwrap(),
9205 );
9206 let parent = Session::new("dispatch-parent", "model");
9207 let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9208 store.save_session(&parent).await.unwrap();
9209 store.save_session(&child).await.unwrap();
9210 store.ensure_actor(&child.id).await.unwrap();
9211 let now = chrono::Utc::now();
9212 let claim = ActorActivationClaim {
9213 actor_id: child.id.clone(),
9214 run_id: "dispatch-run".into(),
9215 lease_owner: "dispatch-host".into(),
9216 lease_expires_at: now + chrono::Duration::seconds(80),
9217 inbox_generation: 0,
9218 placement_ref: None,
9219 now,
9220 };
9221 let activation = store.claim_activation(&claim).await.unwrap();
9222 store
9223 .start_activation(&activation.fence(), chrono::Utc::now())
9224 .await
9225 .unwrap();
9226 let mut link = AcceptedWithoutReceipt { published: false };
9227 let mut send_invoked = false;
9228 let run = RunSpec {
9229 assignment: "task".into(),
9230 logical_session: None,
9231 project_id: None,
9232 reasoning_effort: None,
9233 permission_policy: None,
9234 messages: Vec::new(),
9235 activation_run_id: Some(claim.run_id.clone()),
9236 execution_epoch: 1,
9237 initial_session_messages: Vec::new(),
9238 secrets: Default::default(),
9239 };
9240 assert!(send_actor_run(&mut link, run, &mut send_invoked)
9241 .await
9242 .is_err());
9243 assert!(send_invoked && link.published);
9244 let slot = WorkerSlotLease {
9245 lease_id: "dispatch-slot".into(),
9246 host_ref: "dispatch-worker".into(),
9247 connection_generation: "dispatch-generation".into(),
9248 slot: 0,
9249 epoch: 1,
9250 actor_id: child.id.clone(),
9251 run_id: claim.run_id.clone(),
9252 expires_at: claim.lease_expires_at,
9253 };
9254 let mut lease = ActivePlacementLease {
9255 registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9256 .await
9257 .unwrap(),
9258 actor_store: store.clone(),
9259 route: ScopedPlacementRoute {
9260 endpoint: String::new(),
9261 token: String::new(),
9262 ca_cert_file: PathBuf::new(),
9263 parent: bamboo_subagent::AgentRef {
9264 session_id: "dispatch-parent".into(),
9265 role: Some("host".into()),
9266 },
9267 worker_role: "worker".into(),
9268 pinned_mailbox: None,
9269 requirements: Default::default(),
9270 },
9271 mailbox: "dispatch-worker".into(),
9272 host_ref: "dispatch-worker".into(),
9273 fence: activation.fence(),
9274 placement_ref: bamboo_domain::ActorPlacementRef {
9275 class: bamboo_domain::ActorPlacementClass::Remote,
9276 lease_id: slot.lease_id.clone(),
9277 slot_epoch: Some(slot.epoch),
9278 },
9279 lease: Arc::new(tokio::sync::Mutex::new(slot)),
9280 heartbeat: tokio::spawn(std::future::pending()),
9281 run_cancel: CancellationToken::new(),
9282 lost: Arc::new(AtomicBool::new(false)),
9283 release_on_drop: AtomicBool::new(false),
9284 };
9285 assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9286 assert_eq!(
9287 store
9288 .inspect_actor(&child.id)
9289 .await
9290 .unwrap()
9291 .activation
9292 .unwrap()
9293 .status,
9294 bamboo_domain::ActorActivationStatus::Running
9295 );
9296 let mut retry = claim.clone();
9297 retry.run_id = "dispatch-retry".into();
9298 retry.now = chrono::Utc::now();
9299 retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9300 assert!(matches!(
9301 store.claim_activation(&retry).await,
9302 Err(bamboo_domain::ActorDirectoryError::Busy)
9303 ));
9304 }
9305
9306 #[test]
9307 fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9308 let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9309 assert_eq!(
9310 remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9311 ActorActivationFinish::Cancelled
9312 );
9313 assert_eq!(
9314 remote_activation_finish(&cancelled, None),
9315 ActorActivationFinish::Failed
9316 );
9317 assert_eq!(
9318 remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9319 ActorActivationFinish::Failed
9320 );
9321 }
9322
9323 #[tokio::test]
9324 async fn remote_reply_is_fenced_before_terminal_status_save() {
9325 let temp = tempfile::tempdir().unwrap();
9326 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9327 .await
9328 .unwrap();
9329 let parent = Session::new("remote-reply-parent", "model");
9330 let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9331 child.add_message(bamboo_agent_core::Message::user("task"));
9332 store.save_session(&parent).await.unwrap();
9333 store.save_session(&child).await.unwrap();
9334 store.ensure_actor(&child.id).await.unwrap();
9335 store
9336 .bind_control_plane(
9337 &child.id,
9338 bamboo_domain::ActorControlPlaneBinding {
9339 policy_revision: None,
9340 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9341 pool_id: "remote-worker".into(),
9342 }),
9343 },
9344 )
9345 .await
9346 .unwrap();
9347 let now = chrono::Utc::now();
9348 let activation = store
9349 .claim_activation(&ActorActivationClaim {
9350 actor_id: child.id.clone(),
9351 run_id: "remote-reply-run".into(),
9352 lease_owner: "remote-worker".into(),
9353 lease_expires_at: now + chrono::Duration::seconds(80),
9354 inbox_generation: 0,
9355 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9356 class: bamboo_domain::ActorPlacementClass::Remote,
9357 lease_id: "remote-slot".into(),
9358 slot_epoch: Some(1),
9359 }),
9360 now,
9361 })
9362 .await
9363 .unwrap();
9364 let fence = activation.fence();
9365 store.start_activation(&fence, now).await.unwrap();
9366
9367 append_fenced_remote_reply(&store, &fence, &mut child, "done")
9368 .await
9369 .unwrap();
9370 store
9371 .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9372 .await
9373 .unwrap();
9374 child.set_last_run_status("completed");
9375 store.save_session(&child).await.unwrap();
9376 let saved = store.load_session(&child.id).await.unwrap().unwrap();
9377 assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9378 assert_eq!(saved.messages.len(), 2);
9379 assert_eq!(saved.messages.last().unwrap().content, "done");
9380 assert!(
9381 append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9382 .await
9383 .is_err()
9384 );
9385 assert_eq!(
9386 store
9387 .load_session(&child.id)
9388 .await
9389 .unwrap()
9390 .unwrap()
9391 .messages
9392 .len(),
9393 2
9394 );
9395 }
9396
9397 async fn start_bus() -> (String, tempfile::TempDir) {
9398 let dir = tempfile::tempdir().unwrap();
9399 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9400 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9401 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9402 let addr = listener.local_addr().unwrap();
9403 tokio::spawn(async move {
9404 let _ = server.serve(listener).await;
9405 });
9406 (format!("ws://{addr}"), dir)
9407 }
9408
9409 struct DurableAckProbe {
9410 calls: Arc<AtomicUsize>,
9411 fail: bool,
9412 terminal_status: TerminalStatus,
9413 parent_mailbox: String,
9414 }
9415
9416 #[async_trait]
9417 impl bamboo_subagent::ChildLink for DurableAckProbe {
9418 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9419 Ok(())
9420 }
9421
9422 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9423 Ok(None)
9424 }
9425
9426 fn has_pending_durable_terminal(&self) -> bool {
9427 true
9428 }
9429
9430 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9431 Some(bamboo_subagent::DurableChildDeliveryReceipt {
9432 broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9433 parent_mailbox: self.parent_mailbox.clone(),
9434 correlation_id: "broker-run-1".into(),
9435 message_ids: vec!["broker-outcome-1".into()],
9436 terminal_status: self.terminal_status,
9437 })
9438 }
9439
9440 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9441 self.calls.fetch_add(1, Ordering::SeqCst);
9442 if self.fail {
9443 Err(bamboo_subagent::TransportError::Protocol(
9444 "injected ACK delivery failure".into(),
9445 ))
9446 } else {
9447 Ok(())
9448 }
9449 }
9450 }
9451
9452 async fn insert_ack_probe(
9453 runner: &ActorChildRunner,
9454 session: &Session,
9455 calls: &Arc<AtomicUsize>,
9456 terminal_status: TerminalStatus,
9457 ) {
9458 insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9459 }
9460
9461 async fn insert_ack_probe_with_nested_wait(
9462 runner: &ActorChildRunner,
9463 session: &Session,
9464 calls: &Arc<AtomicUsize>,
9465 terminal_status: TerminalStatus,
9466 host_nested_wait_handoff: bool,
9467 ) {
9468 runner.pending_durable_links.lock().await.insert(
9469 (session.id.clone(), session.created_at, "run-1".into()),
9470 PendingDurableChildLink {
9471 link: Box::new(DurableAckProbe {
9472 calls: calls.clone(),
9473 fail: false,
9474 terminal_status,
9475 parent_mailbox: format!("p-{}", session.id),
9476 }),
9477 host_nested_wait_handoff,
9478 strict_history: None,
9479 strict_history_required: false,
9480 strict_history_committed: false,
9481 },
9482 );
9483 }
9484
9485 #[tokio::test]
9486 async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9487 let (endpoint, broker_dir) = start_bus().await;
9488 let home = tempfile::tempdir().unwrap();
9489 let store = Arc::new(
9490 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9491 .await
9492 .unwrap(),
9493 );
9494 let root = Session::new("receipt-root", "model");
9495 store.save_session(&root).await.unwrap();
9496 let mut sender = bamboo_broker::BrokerClient::connect(
9497 &endpoint,
9498 bamboo_subagent::AgentRef {
9499 session_id: "receipt-worker".into(),
9500 role: None,
9501 },
9502 "t",
9503 )
9504 .await
9505 .unwrap();
9506 let broker_identity = sender.durable_broker_identity().await.unwrap();
9507 let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9508 "00000000-0000-4000-8000-000000000002"
9509 } else {
9510 "00000000-0000-4000-8000-000000000001"
9511 };
9512
9513 for (id, receipt_mailbox, receipt_broker) in [
9514 ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9515 (
9516 "receipt-wrong-mailbox",
9517 "p-another-child",
9518 broker_identity.as_str(),
9519 ),
9520 (
9521 "receipt-wrong-broker",
9522 "p-receipt-wrong-broker",
9523 wrong_broker_identity,
9524 ),
9525 (
9526 "receipt-local-role-changed",
9527 "p-receipt-local-role-changed",
9528 broker_identity.as_str(),
9529 ),
9530 (
9531 "receipt-remote-unproven",
9532 "remote-parent-unproven",
9533 broker_identity.as_str(),
9534 ),
9535 ] {
9536 let mut child = Session::new_child_of(id, &root, "model", "task");
9537 child
9538 .metadata
9539 .insert("runtime.kind".into(), "external".into());
9540 child
9541 .metadata
9542 .insert("external.protocol".into(), "actor".into());
9543 child.metadata.insert(
9544 "external.agent_id".into(),
9545 crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9546 );
9547 if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9548 child
9549 .metadata
9550 .insert("subagent_type".into(), "remote-worker".into());
9551 }
9552 child.add_message(bamboo_agent_core::Message::user("work"));
9553 store.save_session(&child).await.unwrap();
9554 let message_id = bamboo_subagent::MsgId::new();
9555 sender
9556 .deliver(
9557 &format!("p-{id}"),
9558 bamboo_subagent::InboxMessage {
9559 id: message_id.clone(),
9560 from: bamboo_subagent::AgentRef {
9561 session_id: "receipt-worker".into(),
9562 role: None,
9563 },
9564 kind: bamboo_subagent::InboxKind::Outcome,
9565 body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9566 "done",
9567 ))
9568 .unwrap(),
9569 created_at: chrono::Utc::now(),
9570 correlation_id: Some(bamboo_subagent::MsgId::new()),
9571 },
9572 )
9573 .await
9574 .unwrap();
9575 let mailbox =
9578 bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9579 assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9580 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9581 child.set_last_run_status("completed");
9582 if id == "receipt-good" {
9583 let prior_ids = child.messages[..child.messages.len() - 1]
9584 .iter()
9585 .map(|message| message.id.clone())
9586 .collect();
9587 crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9588 assert!(
9589 crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9590 &mut child, "run-1"
9591 )
9592 .unwrap()
9593 );
9594 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9595 &child, "run-1", 7, 1,
9596 )
9597 .unwrap();
9598 store
9599 .prepare_broker_terminal_receipt_with_completeness(
9600 &child,
9601 bamboo_storage::v2::BrokerTerminalRoute {
9602 activation_run_id: "run-1",
9603 broker_identity: receipt_broker,
9604 parent_mailbox: receipt_mailbox,
9605 broker_correlation_id: "correlation-1",
9606 message_ids: &[message_id.as_str().to_owned()],
9607 },
9608 &proof,
9609 )
9610 .await
9611 .unwrap();
9612 let mut staged = child.clone();
9613 staged.set_last_run_status("running");
9614 staged.clear_last_run_error();
9615 store.save_session(&staged).await.unwrap();
9616 store
9617 .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9618 .await
9619 .unwrap();
9620 } else {
9621 store
9622 .prepare_broker_terminal_receipt(
9623 &child,
9624 "run-1",
9625 receipt_broker,
9626 receipt_mailbox,
9627 "correlation-1",
9628 &[message_id.as_str().to_owned()],
9629 )
9630 .await
9631 .unwrap();
9632 store.save_session(&child).await.unwrap();
9633 }
9634 }
9635
9636 let mut config = bamboo_config::Config::default();
9637 config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9638 endpoint,
9639 token: "t".into(),
9640 ..Default::default()
9641 });
9642 config
9643 .subagents_mut()
9644 .remote_placements
9645 .push(bamboo_config::RemoteActorPlacement {
9646 role: "remote-worker".into(),
9647 endpoint: "ws://unscoped.example.invalid".into(),
9648 token_env: None,
9649 ca_cert_file: None,
9650 broker_peer: None,
9651 placement_requirements: None,
9652 });
9653 let cold_store = Arc::new(
9656 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9657 .await
9658 .unwrap(),
9659 );
9660 let before = cold_store
9661 .load_session("receipt-good")
9662 .await
9663 .unwrap()
9664 .unwrap();
9665 assert!(crate::execution::ChildCompletionSource::has_source_record(
9666 &before
9667 ));
9668 assert_eq!(before.last_run_status().as_deref(), Some("running"));
9669 assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9670 let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9671 let report = repair.reconcile_once().await;
9672 assert_eq!(report.candidates, 5);
9673 assert_eq!(report.repaired, 2);
9674 assert_eq!(report.blocked, 3);
9675 let after = cold_store
9676 .load_session("receipt-good")
9677 .await
9678 .unwrap()
9679 .unwrap();
9680 assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9681 assert_eq!(
9682 serde_json::to_value(before.messages).unwrap(),
9683 serde_json::to_value(after.messages).unwrap()
9684 );
9685 let retry = repair.reconcile_once().await;
9686 assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9687
9688 let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9689 let wrong = bamboo_subagent::Mailbox::at(
9690 broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9691 );
9692 let wrong_broker = bamboo_subagent::Mailbox::at(
9693 broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9694 );
9695 let local_role_changed = bamboo_subagent::Mailbox::at(
9696 broker_dir
9697 .path()
9698 .join("mailboxes/p-receipt-local-role-changed"),
9699 );
9700 let remote_unproven = bamboo_subagent::Mailbox::at(
9701 broker_dir
9702 .path()
9703 .join("mailboxes/p-receipt-remote-unproven"),
9704 );
9705 assert_eq!(good.pending_count().await.unwrap(), 0);
9706 assert_eq!(wrong.pending_count().await.unwrap(), 1);
9707 assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9708 assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9709 assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9710 let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9711 let wrong_child = store
9712 .load_session("receipt-wrong-mailbox")
9713 .await
9714 .unwrap()
9715 .unwrap();
9716 let wrong_broker_child = store
9717 .load_session("receipt-wrong-broker")
9718 .await
9719 .unwrap()
9720 .unwrap();
9721 let local_role_changed_child = store
9722 .load_session("receipt-local-role-changed")
9723 .await
9724 .unwrap()
9725 .unwrap();
9726 let remote_unproven_child = store
9727 .load_session("receipt-remote-unproven")
9728 .await
9729 .unwrap()
9730 .unwrap();
9731 assert!(store
9732 .recover_broker_terminal_receipts(&good_child)
9733 .await
9734 .unwrap()
9735 .is_empty());
9736 assert_eq!(
9737 store
9738 .recover_broker_terminal_receipts(&wrong_child)
9739 .await
9740 .unwrap()
9741 .len(),
9742 1
9743 );
9744 assert_eq!(
9745 store
9746 .recover_broker_terminal_receipts(&wrong_broker_child)
9747 .await
9748 .unwrap()
9749 .len(),
9750 1
9751 );
9752 assert!(store
9753 .recover_broker_terminal_receipts(&local_role_changed_child)
9754 .await
9755 .unwrap()
9756 .is_empty());
9757 assert_eq!(
9758 store
9759 .recover_broker_terminal_receipts(&remote_unproven_child)
9760 .await
9761 .unwrap()
9762 .len(),
9763 1
9764 );
9765 }
9766
9767 #[tokio::test]
9768 async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
9769 let runner = ActorChildRunner::new(
9770 "test".into(),
9771 PathBuf::new(),
9772 vec![],
9773 PathBuf::new(),
9774 ExecutorSpec::BambooRuntime,
9775 vec![],
9776 "test".into(),
9777 1,
9778 );
9779 let home = tempfile::tempdir().unwrap();
9780 let store = Arc::new(
9781 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9782 .await
9783 .unwrap(),
9784 );
9785 let root = Session::new("ack-parent", "model");
9786 store.save_session(&root).await.unwrap();
9787 let mut child = Session::new_child_of("ack-child", &root, "model", "task");
9788 child.add_message(bamboo_agent_core::Message::user("work"));
9789 store.save_session(&child).await.unwrap();
9790 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9791 child.set_last_run_status("completed");
9792 store
9793 .prepare_broker_terminal_receipt(
9794 &child,
9795 "run-1",
9796 "00000000-0000-4000-8000-000000000001",
9797 "p-ack-child",
9798 "broker-run-1",
9799 &["broker-outcome-1".into()],
9800 )
9801 .await
9802 .unwrap();
9803 store.save_session(&child).await.unwrap();
9804 runner.set_actor_directory_store(Some(store));
9805 let calls = Arc::new(AtomicUsize::new(0));
9806 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9807 runner
9808 .confirm_durable_child_delivery(&child, "wrong-run", true)
9809 .await
9810 .unwrap();
9811 let mut wrong_birth = child.clone();
9812 wrong_birth.created_at += chrono::Duration::seconds(1);
9813 runner
9814 .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
9815 .await
9816 .unwrap();
9817 assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
9818 child.set_last_run_status("completed");
9819 runner
9820 .confirm_durable_child_delivery(&child, "run-1", false)
9821 .await
9822 .unwrap();
9823 assert_eq!(calls.load(Ordering::SeqCst), 0);
9824
9825 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9826 child.set_last_run_status("error");
9827 runner
9828 .confirm_durable_child_delivery(&child, "run-1", true)
9829 .await
9830 .unwrap();
9831 assert_eq!(calls.load(Ordering::SeqCst), 0);
9832
9833 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
9834 child.set_last_run_status("completed");
9835 runner
9836 .confirm_durable_child_delivery(&child, "run-1", true)
9837 .await
9838 .unwrap();
9839 assert_eq!(calls.load(Ordering::SeqCst), 1);
9840 }
9841
9842 #[tokio::test]
9843 async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
9844 for commit in [false, true] {
9845 let runner = ActorChildRunner::new(
9846 "test".into(),
9847 PathBuf::new(),
9848 vec![],
9849 PathBuf::new(),
9850 ExecutorSpec::BambooRuntime,
9851 vec![],
9852 "test".into(),
9853 1,
9854 );
9855 let home = tempfile::tempdir().unwrap();
9856 let store = Arc::new(
9857 bamboo_storage::SessionStoreV2::new(home.path().into())
9858 .await
9859 .unwrap(),
9860 );
9861 let root = Session::new("strict-ack-parent", "model");
9862 store.save_session(&root).await.unwrap();
9863 let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
9864 child.add_message(bamboo_agent_core::Message::user("work"));
9865 store.save_session(&child).await.unwrap();
9866 child.add_message(bamboo_agent_core::Message::assistant("finished", None));
9867 child.set_last_run_status("completed");
9868 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9869 &child, "run-1", 7, 2,
9870 )
9871 .unwrap();
9872 let creation = bamboo_subagent::proto::ChildCreationIdentity {
9873 created_at: child.created_at,
9874 spawn_depth: child.spawn_depth,
9875 };
9876 let history = StrictTerminalHistory {
9877 proof: proof.clone(),
9878 watermark: bamboo_subagent::ActorEventWatermark {
9879 version: 1,
9880 logical_session: Some(actor_event_logical_identity(
9881 &child,
9882 &root.id,
9883 Some(&creation),
9884 )),
9885 activation_id: Some("run-1".into()),
9886 execution_epoch: 7,
9887 final_seq: 2,
9888 },
9889 };
9890 let parent_mailbox = "p-strict-ack-child";
9891 store
9892 .prepare_broker_terminal_receipt_with_completeness(
9893 &child,
9894 bamboo_storage::v2::BrokerTerminalRoute {
9895 activation_run_id: "run-1",
9896 broker_identity: "00000000-0000-4000-8000-000000000001",
9897 parent_mailbox,
9898 broker_correlation_id: "broker-run-1",
9899 message_ids: &["broker-outcome-1".into()],
9900 },
9901 &proof,
9902 )
9903 .await
9904 .unwrap();
9905 store.save_session(&child).await.unwrap();
9906 runner.set_actor_directory_store(Some(store.clone()));
9907 let calls = Arc::new(AtomicUsize::new(0));
9908 runner.pending_durable_links.lock().await.insert(
9909 (child.id.clone(), child.created_at, "run-1".into()),
9910 PendingDurableChildLink {
9911 link: Box::new(DurableAckProbe {
9912 calls: calls.clone(),
9913 fail: true,
9914 terminal_status: TerminalStatus::Completed,
9915 parent_mailbox: parent_mailbox.into(),
9916 }),
9917 host_nested_wait_handoff: false,
9918 strict_history: Some(history),
9919 strict_history_required: true,
9920 strict_history_committed: false,
9921 },
9922 );
9923 if commit {
9924 runner
9925 .commit_durable_child_delivery(&child, "run-1")
9926 .await
9927 .unwrap();
9928 assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
9929 }
9930 assert!(runner
9931 .confirm_durable_child_delivery(&child, "run-1", true)
9932 .await
9933 .is_err());
9934 assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
9935 let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
9936 .await
9937 .unwrap();
9938 let recovered = cold.recover_broker_terminal_receipts(&child).await;
9939 if commit {
9940 let receipts = recovered.unwrap();
9941 assert_eq!(receipts.len(), 1);
9942 assert_eq!(
9943 receipts[0]
9944 .terminal_completeness
9945 .as_ref()
9946 .unwrap()
9947 .contiguous_applied_seq,
9948 2
9949 );
9950 assert_eq!(
9951 cold.load_session(&child.id)
9952 .await
9953 .unwrap()
9954 .unwrap()
9955 .last_run_status()
9956 .as_deref(),
9957 Some("completed")
9958 );
9959 } else {
9960 assert!(
9961 recovered.is_err(),
9962 "matching canonical history is not proof commitment"
9963 );
9964 }
9965 assert_eq!(
9966 serde_json::to_value(
9967 cold.load_session(&child.id)
9968 .await
9969 .unwrap()
9970 .unwrap()
9971 .messages
9972 )
9973 .unwrap(),
9974 serde_json::to_value(&child.messages).unwrap(),
9975 );
9976 }
9977 }
9978
9979 #[tokio::test]
9980 async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
9981 for (terminal_status, status) in [
9982 (TerminalStatus::Error, "error"),
9983 (TerminalStatus::Cancelled, "cancelled"),
9984 ] {
9985 let runner = ActorChildRunner::new(
9986 "test".into(),
9987 PathBuf::new(),
9988 vec![],
9989 PathBuf::new(),
9990 ExecutorSpec::BambooRuntime,
9991 vec![],
9992 "test".into(),
9993 1,
9994 );
9995 let home = tempfile::tempdir().unwrap();
9996 let store = Arc::new(
9997 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9998 .await
9999 .unwrap(),
10000 );
10001 let root = Session::new("failed-ack-parent", "model");
10002 store.save_session(&root).await.unwrap();
10003 let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10004 child.add_message(bamboo_agent_core::Message::user("work"));
10005 store.save_session(&child).await.unwrap();
10006 child.set_last_run_status(status);
10007 child.set_last_run_error(format!("worker {status}"));
10008 store
10009 .prepare_broker_terminal_receipt(
10010 &child,
10011 "run-1",
10012 "00000000-0000-4000-8000-000000000001",
10013 "p-failed-ack-child",
10014 "broker-run-1",
10015 &["broker-outcome-1".into()],
10016 )
10017 .await
10018 .unwrap();
10019 store.save_session(&child).await.unwrap();
10020 runner.set_actor_directory_store(Some(store));
10021 let calls = Arc::new(AtomicUsize::new(0));
10022 insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10023 runner
10024 .confirm_durable_child_delivery(&child, "run-1", true)
10025 .await
10026 .unwrap();
10027 assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10028 }
10029 }
10030
10031 #[tokio::test]
10032 async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10033 assert_eq!(
10034 effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10035 "suspended"
10036 );
10037 for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10038 assert!(effective_broker_terminal_status(raw, true).is_err());
10039 }
10040 let runner = ActorChildRunner::new(
10041 "test".into(),
10042 PathBuf::new(),
10043 vec![],
10044 PathBuf::new(),
10045 ExecutorSpec::BambooRuntime,
10046 vec![],
10047 "test".into(),
10048 1,
10049 );
10050 let home = tempfile::tempdir().unwrap();
10051 let store = Arc::new(
10052 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10053 .await
10054 .unwrap(),
10055 );
10056 let root = Session::new("nested-ack-parent", "model");
10057 store.save_session(&root).await.unwrap();
10058 let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10059 child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10060 store.save_session(&child).await.unwrap();
10061 child.set_last_run_status("suspended");
10062 store
10063 .prepare_broker_terminal_receipt(
10064 &child,
10065 "run-1",
10066 "00000000-0000-4000-8000-000000000001",
10067 "p-nested-ack-child",
10068 "broker-run-1",
10069 &["broker-outcome-1".into()],
10070 )
10071 .await
10072 .unwrap();
10073 store.save_session(&child).await.unwrap();
10074 runner.set_actor_directory_store(Some(store.clone()));
10075 let calls = Arc::new(AtomicUsize::new(0));
10076
10077 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10078 runner
10079 .confirm_durable_child_delivery(&child, "run-1", true)
10080 .await
10081 .unwrap();
10082 assert_eq!(calls.load(Ordering::SeqCst), 0);
10083
10084 insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10085 .await;
10086 runner
10087 .confirm_durable_child_delivery(&child, "run-1", true)
10088 .await
10089 .unwrap();
10090 assert_eq!(calls.load(Ordering::SeqCst), 1);
10091 assert_eq!(
10092 store
10093 .load_session(&child.id)
10094 .await
10095 .unwrap()
10096 .unwrap()
10097 .last_run_status()
10098 .as_deref(),
10099 Some("suspended")
10100 );
10101 assert!(store
10102 .recover_broker_terminal_receipts(&child)
10103 .await
10104 .unwrap()
10105 .is_empty());
10106 }
10107
10108 #[tokio::test]
10109 async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10110 struct CommittedObserver {
10111 authority: PathBuf,
10112 events: std::sync::Mutex<Vec<PublicActorEvent>>,
10113 }
10114 impl ActorEventObserver for CommittedObserver {
10115 fn publish(&self, event: PublicActorEvent) {
10116 let entry: bamboo_domain::ActorDirectoryEntry =
10118 serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10119 let committed = entry.activation.unwrap();
10120 assert_eq!(
10121 committed.status,
10122 bamboo_domain::ActorActivationStatus::Succeeded
10123 );
10124 assert_eq!(committed.activation_id, event.activation_id);
10125 assert!(committed.finished_at.is_some());
10126 self.events.lock().unwrap().push(event);
10127 }
10128 }
10129 struct CompletionLink(VecDeque<ChildFrame>);
10130 #[async_trait]
10131 impl bamboo_subagent::ChildLink for CompletionLink {
10132 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10133 Ok(())
10134 }
10135 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10136 Ok(self.0.pop_front())
10137 }
10138 }
10139 for cut in [
10140 "success",
10141 "no_terminal",
10142 "invalid_terminal",
10143 "stale",
10144 "storage_failure",
10145 "append_failure",
10146 ] {
10147 let temp = tempfile::tempdir().unwrap();
10148 let store = Arc::new(
10149 bamboo_storage::SessionStoreV2::new(temp.path().into())
10150 .await
10151 .unwrap(),
10152 );
10153 let root = Session::new("notify-root", "model");
10154 store.save_session(&root).await.unwrap();
10155 let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10156 store.save_session(&child).await.unwrap();
10157 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10158 store.clone(),
10159 bamboo_domain::SessionInboxLimits::default(),
10160 ));
10161 let binding = actor_binding(
10162 store.clone(),
10163 inbox,
10164 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10165 );
10166 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10167 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10168 let activation = PlainActorActivation::start(
10169 store.clone(),
10170 &child,
10171 &binding,
10172 Some("notify-run"),
10173 Some(policy),
10174 false,
10175 None,
10176 )
10177 .await
10178 .unwrap();
10179 let _owner = binding
10180 .router
10181 .register_run(&child.id, "notify-run")
10182 .await
10183 .unwrap();
10184 let batch = ActorEventBatch {
10185 logical_session: Some(LogicalSessionIdentity {
10186 session_id: child.id.clone(),
10187 parent_session_id: child.parent_session_id.clone(),
10188 root_session_id: child.root_session_id.clone(),
10189 creation: None,
10190 }),
10191 activation_id: Some("notify-run".into()),
10192 execution_epoch: 9,
10193 source_node_id: None,
10194 source_actor_id: Some("notify-worker".into()),
10195 first_seq: 1,
10196 last_seq: 2,
10197 qos: bamboo_subagent::ActorEventQos::Durable,
10198 events: vec![
10199 serde_json::to_value(AgentEvent::Token {
10200 content: "PRIVATE_PROVIDER_TOKEN".into(),
10201 })
10202 .unwrap(),
10203 serde_json::to_value(AgentEvent::message_appended(
10204 &child.id,
10205 &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10206 ))
10207 .unwrap(),
10208 ],
10209 };
10210 let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10211 if cut != "no_terminal" {
10212 let mut terminal = completed_actor_frame();
10213 if cut == "invalid_terminal" {
10214 if let ChildFrame::Terminal { status, .. } = &mut terminal {
10215 *status = TerminalStatus::Suspended;
10216 }
10217 }
10218 frames.push_back(terminal);
10219 }
10220 let mut link = CompletionLink(frames);
10221 let (tx, mut rx) = mpsc::channel(8);
10222 let (_live, mut live_rx) = mpsc::unbounded_channel();
10223 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10224 let cancel = CancellationToken::new();
10225 let outcome = drive(ActorDriveContext {
10226 client: &mut link,
10227 parent_session_id: "notify-root",
10228 child_session_id: "notify-child",
10229 child_attempt: 0,
10230 approval_registry: None,
10231 approval_decider: None,
10232 approval_reviewer: None,
10233 escalation_bridge: None,
10234 event_tx: &tx,
10235 cancel_token: &cancel,
10236 live_rx: &mut live_rx,
10237 delivery_rx: &mut delivery_rx,
10238 logical_session: &mut child,
10239 expected_permission_posture: None,
10240 expected_creation: None,
10241 session_inbox_runtime: Some(&binding),
10242 actor_directory_store: Some(store.as_ref()),
10243 canonical_subagent_tool: None,
10244 ticket_service: None,
10245 activation_run_id: Some("notify-run"),
10246 execution_epoch: 9,
10247 expected_source_actor_id: "notify-worker",
10248 initial_inflight_claims: VecDeque::new(),
10249 plain_actor: true,
10250 remote_environment_lease: false,
10251 readonly_output: None,
10252 local_history_tools: None,
10253 local_history_read_only: false,
10254 strict_history_output: None,
10255 plain_input: Some(&activation),
10256 canonical_activation: None,
10257 canonical_placement_ref: None,
10258 actor_event_observer: None,
10259 plain_run: None,
10260 first_frame_timeout: Some(Duration::from_secs(1)),
10261 })
10262 .await;
10263 let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10264 assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10265 assert_eq!(
10266 activation.completion_router.lock().unwrap().is_some(),
10267 admitted,
10268 "{cut}"
10269 );
10270 while rx.try_recv().is_ok() {}
10271 let authority = temp
10272 .path()
10273 .join(store.resolve_rel_path(&child.id).await.unwrap())
10274 .join("actor-authority.json");
10275 let observer = CommittedObserver {
10276 authority: authority.clone(),
10277 events: Default::default(),
10278 };
10279 if cut == "append_failure" {
10280 child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10281 assert!(activation
10282 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10283 .await
10284 .is_err());
10285 activation
10286 .finish(ActorActivationFinish::Failed)
10287 .await
10288 .unwrap();
10289 assert!(observer.events.lock().unwrap().is_empty());
10290 continue;
10291 }
10292 activation
10293 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10294 .await
10295 .unwrap();
10296 assert!(
10297 rx.try_recv().is_ok(),
10298 "legacy content notification retained"
10299 );
10300 assert!(
10301 observer.events.lock().unwrap().is_empty(),
10302 "reply alone must not publish"
10303 );
10304 if cut == "stale" {
10305 store
10306 .retire_actor(&child.id, chrono::Utc::now())
10307 .await
10308 .unwrap();
10309 } else if cut == "storage_failure" {
10310 std::fs::remove_file(&authority).unwrap();
10313 std::fs::create_dir(&authority).unwrap();
10314 }
10315 let result = activation
10316 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10317 .await;
10318 assert_eq!(
10319 result.is_ok(),
10320 matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10321 "{cut}"
10322 );
10323 let events = observer.events.lock().unwrap().clone();
10324 assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10325 if let Some(event) = events.first() {
10326 assert_eq!(event.actor_id, child.id);
10327 assert_eq!(event.root_actor_id, root.id);
10328 assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10329 assert_eq!(event.source_order.sequence, 3);
10330 let json = serde_json::to_string(event).unwrap();
10331 assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10332 let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10333 assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10334 }
10335 assert!(activation
10336 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10337 .await
10338 .is_err());
10339 assert_eq!(
10340 observer.events.lock().unwrap().len(),
10341 events.len(),
10342 "repeat finish cannot publish"
10343 );
10344 }
10345 }
10346
10347 async fn bind_local_control_plane(
10348 store: &bamboo_storage::SessionStoreV2,
10349 actor_id: &str,
10350 policy: &bamboo_tools::permission::PermissionConfig,
10351 ) {
10352 store
10353 .bind_control_plane(
10354 actor_id,
10355 bamboo_domain::ActorControlPlaneBinding {
10356 policy_revision: Some(policy.policy_revision()),
10357 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10358 },
10359 )
10360 .await
10361 .unwrap();
10362 }
10363
10364 fn local_tool_sample(
10365 host: &Session,
10366 denied_write: bool,
10367 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10368 local_tool_sample_with_reasoning(host, denied_write, None)
10369 }
10370
10371 fn local_tool_sample_with_reasoning(
10372 host: &Session,
10373 denied_write: bool,
10374 reasoning: Option<&str>,
10375 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10376 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10377 let calls = ["Read", "Write"]
10378 .map(|name| ToolCall {
10379 id: format!("local-{name}"),
10380 tool_type: "function".into(),
10381 function: FunctionCall {
10382 name: name.into(),
10383 arguments: "{\"file_path\":\"answer.txt\"}".into(),
10384 },
10385 })
10386 .to_vec();
10387 let mut rows = vec![Message::assistant("checking", Some(calls))];
10388 rows[0].reasoning = reasoning.map(str::to_owned);
10389 let mut collector = LocalToolCollector::default();
10390 if let Some(reasoning) = reasoning {
10391 assert!(collector
10392 .event(
10393 &serde_json::to_value(AgentEvent::ReasoningToken {
10394 content: reasoning.into(),
10395 })
10396 .unwrap()
10397 )
10398 .unwrap());
10399 }
10400 for name in ["Read", "Write"] {
10401 let denied = name == "Write" && denied_write;
10402 let id = format!("local-{name}");
10403 if !denied {
10404 collector
10405 .event(
10406 &serde_json::to_value(AgentEvent::ToolStart {
10407 tool_call_id: id.clone(),
10408 tool_name: name.into(),
10409 arguments: serde_json::json!({"file_path":"answer.txt"}),
10410 })
10411 .unwrap(),
10412 )
10413 .unwrap();
10414 }
10415 let text = if denied {
10416 "Error: native_tool_ceiling_denied: Write"
10417 } else {
10418 "actual file result"
10419 };
10420 let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10421 message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10422 "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10423 rows.push(message);
10424 let event = if denied {
10425 AgentEvent::ToolError {
10426 tool_call_id: id,
10427 error: "native_tool_ceiling_denied: Write".into(),
10428 }
10429 } else {
10430 AgentEvent::ToolComplete {
10431 tool_call_id: id,
10432 result: bamboo_agent_core::tools::ToolResult::text(true, text),
10433 }
10434 };
10435 collector
10436 .event(&serde_json::to_value(event).unwrap())
10437 .unwrap();
10438 }
10439 rows.push(Message::assistant("complete exact report", None));
10440 rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10441 if let Some(reasoning) = reasoning {
10442 assert!(collector
10443 .event(
10444 &serde_json::to_value(AgentEvent::ReasoningToken {
10445 content: reasoning.into(),
10446 })
10447 .unwrap()
10448 )
10449 .unwrap());
10450 }
10451 let mut messages = host.messages.clone();
10452 if let Some(system) = messages
10453 .iter_mut()
10454 .find(|message| message.role == Role::System)
10455 {
10456 system.content = "worker-private system never imported".into();
10457 }
10458 messages.extend(rows.iter().cloned());
10459 let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10460 version: 1,
10461 messages: messages
10462 .iter()
10463 .map(|message| serde_json::to_value(message).unwrap())
10464 .collect(),
10465 };
10466 assert!(!collector
10467 .event(&serde_json::to_value(data).unwrap())
10468 .unwrap());
10469 (collector, rows)
10470 }
10471
10472 #[test]
10473 fn ticket_task_event_requires_exact_host_callback_receipt() {
10474 let mut collector = LocalToolCollector::default();
10475 let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10476 let start = serde_json::to_value(AgentEvent::ToolStart {
10477 tool_call_id: "task-call".into(),
10478 tool_name: "Task".into(),
10479 arguments: args.clone(),
10480 })
10481 .unwrap();
10482 let mut receipts = HashMap::new();
10483 assert!(collector.ticket_event(&start, &receipts).unwrap());
10484 collector.event(&start).unwrap();
10485 let complete = serde_json::to_value(AgentEvent::ToolComplete {
10486 tool_call_id: "task-call".into(),
10487 result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10488 })
10489 .unwrap();
10490 assert!(collector.ticket_event(&complete, &receipts).is_err());
10491 receipts.insert("other-call".into(), args.clone());
10492 assert!(collector.ticket_event(&complete, &receipts).is_err());
10493 receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10494 assert!(collector.ticket_event(&complete, &receipts).is_err());
10495 receipts.insert("task-call".into(), args);
10496 assert!(collector.ticket_event(&complete, &receipts).unwrap());
10497 let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10498 task_list: bamboo_tools::TaskTool::task_list_from_args(
10499 &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10500 "root",
10501 )
10502 .unwrap(),
10503 version: Some(999),
10504 })
10505 .unwrap();
10506 assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10507 }
10508
10509 #[test]
10510 fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10511 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10512 let mut host = Session::new("question-child", "model");
10513 host.messages.push(Message::user("own assignment"));
10514 let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10515 "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10516 "generation":1, "contract_revision":1, "prompt_revision":1,
10517 "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10518 "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10519 })).unwrap();
10520 let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10521 for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10522 let mut returned =
10523 serde_json::json!({"status":"waiting_for_answer", "request":question});
10524 if case == "forged_request" {
10525 returned["request"]["id"] = serde_json::json!("sibling-question");
10526 }
10527 let text = returned.to_string();
10528 let rows = [
10529 Message::assistant(
10530 "",
10531 Some(vec![ToolCall {
10532 id: "own-call".into(),
10533 tool_type: "function".into(),
10534 function: FunctionCall {
10535 name: "Task".into(),
10536 arguments: args.to_string(),
10537 },
10538 }]),
10539 ),
10540 Message::tool_result_with_status("own-call", &text, true),
10541 ];
10542 let collector = LocalToolCollector {
10543 messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10544 starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10545 outcomes: HashMap::from([("own-call".into(), (true, text))]),
10546 ticket_yield: (case != "no_host_receipt")
10547 .then(|| ("own-call".into(), question.clone())),
10548 };
10549 let terminal = if case == "fake_report" {
10550 Some("fake delivery")
10551 } else {
10552 Some("")
10553 };
10554 assert_eq!(
10555 collector
10556 .suffix(&host, &["Task".into()], false, terminal)
10557 .is_ok(),
10558 case == "exact",
10559 "{case}"
10560 );
10561 }
10562 }
10563
10564 #[test]
10565 fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10566 let mut host = Session::new("tool-history-prefix", "model");
10567 host.messages
10568 .push(bamboo_agent_core::Message::user("assignment"));
10569 let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10570 confirmed.metadata = Some(
10571 serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10572 );
10573 host.messages.push(confirmed);
10574 let tools = vec!["Read".into(), "Write".into()];
10575 let (good, rows) = local_tool_sample(&host, false);
10576 assert_eq!(
10577 serde_json::to_value(
10578 good.suffix(&host, &tools, false, Some("complete exact report"))
10579 .unwrap()
10580 )
10581 .unwrap(),
10582 serde_json::to_value(&rows).unwrap()
10583 );
10584 for case in [
10585 "missing",
10586 "changed",
10587 "metadata",
10588 "system",
10589 "user",
10590 "collision",
10591 "dangling",
10592 "result",
10593 "terminal",
10594 "tool_metadata",
10595 "grant",
10596 "start_arguments",
10597 "ledger",
10598 ] {
10599 let (mut collector, _) = local_tool_sample(&host, false);
10600 let mut current = host.clone();
10601 let messages = collector.messages.as_mut().unwrap();
10602 match case {
10603 "missing" => {
10604 messages.remove(host.messages.len() - 1);
10605 }
10606 "changed" => messages[0].content.push('!'),
10607 "metadata" => messages[1].metadata = None,
10608 "system" => messages.last_mut().unwrap().role = Role::System,
10609 "user" => messages.last_mut().unwrap().role = Role::User,
10610 "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10611 "dangling" => {
10612 messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10613 }
10614 "result" => messages[host.messages.len() + 1].content.push('!'),
10615 "tool_metadata" => {
10616 messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10617 true.into()
10618 }
10619 "grant" => {
10620 messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10621 .function
10622 .name = "Bash".into()
10623 }
10624 "start_arguments" => {
10625 collector.starts.get_mut("local-Read").unwrap().1 =
10626 serde_json::json!({"file_path":"different.txt"})
10627 }
10628 "ledger" => {
10629 current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10630 bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10631 bamboo_domain::ProviderFamily::OpenAi,
10632 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10633 bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10634 bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10635 serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10636 ).unwrap(),
10637 ]).unwrap();
10638 }
10639 "terminal" => {}
10640 _ => unreachable!(),
10641 }
10642 let terminal = if case == "terminal" {
10643 "different"
10644 } else {
10645 "complete exact report"
10646 };
10647 assert!(
10648 collector
10649 .suffix(¤t, &tools, false, Some(terminal))
10650 .is_err(),
10651 "{case}"
10652 );
10653 }
10654 let (denied, _) = local_tool_sample(&host, true);
10655 assert!(denied
10656 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10657 .is_ok());
10658 let (mut contradicted, _) = local_tool_sample(&host, true);
10659 contradicted.starts.insert(
10660 "local-Write".into(),
10661 ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10662 );
10663 assert!(contradicted
10664 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10665 .is_err());
10666 let (successful, _) = local_tool_sample(&host, false);
10667 assert!(successful
10668 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10669 .is_err());
10670 let mut empty = LocalToolCollector::default();
10671 assert!(empty
10672 .event(
10673 &serde_json::to_value(AgentEvent::ReasoningToken {
10674 content: "private".into()
10675 })
10676 .unwrap()
10677 )
10678 .unwrap());
10679 assert!(
10680 empty
10681 .suffix(&host, &tools, false, Some("complete exact report"))
10682 .is_err(),
10683 "stream observations alone cannot commit history"
10684 );
10685 let (mut completed, _) = local_tool_sample(&host, false);
10686 assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10687 }
10688
10689 struct LocalToolConcurrentCheckpoint {
10690 real: SessionInboxRuntimeBinding,
10691 claim: SessionInboxClaim,
10692 }
10693 #[async_trait]
10694 impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10695 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10696 self.real.persistence.save_runtime_session(session).await
10697 }
10698 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10699 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10702 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10703 .await
10704 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10705 self.real
10706 .persistence
10707 .checkpoint_runtime_session(session)
10708 .await
10709 }
10710 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10711 self.real.storage.load_session(id).await
10712 }
10713 }
10714
10715 #[tokio::test]
10716 async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
10717 for case in [
10718 "success",
10719 "reasoning",
10720 "concurrent_input",
10721 "checkpoint_error",
10722 "stale_prefix",
10723 "cancelled",
10724 "lost_owner",
10725 ] {
10726 let temp = tempfile::tempdir().unwrap();
10727 let store = Arc::new(
10728 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10729 .await
10730 .unwrap(),
10731 );
10732 let root = Session::new("local-tool-root", "model");
10733 store.save_session(&root).await.unwrap();
10734 let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
10735 let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
10736 system.role = Role::System;
10737 child.messages.push(system);
10738 child
10739 .messages
10740 .push(bamboo_agent_core::Message::user("assignment"));
10741 store.save_session(&child).await.unwrap();
10742 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10743 store.clone(),
10744 bamboo_domain::SessionInboxLimits::default(),
10745 ));
10746 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
10747 let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
10748 let mut owner = Some(
10749 binding
10750 .router
10751 .register_run(&child.id, "current-run")
10752 .await
10753 .unwrap(),
10754 );
10755 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
10756 &child.id,
10757 "current confirmed User",
10758 );
10759 let receipt = inbox.deliver(&envelope).await.unwrap();
10760 inbox
10761 .mark_activation_eligible(
10762 &child.id,
10763 receipt.generation,
10764 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10765 )
10766 .await
10767 .unwrap();
10768 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10769 checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
10770 .await
10771 .unwrap();
10772 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
10773 let before = serde_json::to_value(&child).unwrap();
10774 let reasoning = (case == "reasoning")
10775 .then_some("Complete plain reasoning before Read and the final reply 🪷.");
10776 let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
10777 let mut binding = binding;
10778 let late = bamboo_domain::SessionMessageEnvelope::user_input(
10779 &child.id,
10780 "concurrent confirmed User",
10781 );
10782 if case == "concurrent_input" {
10783 let receipt = inbox.deliver(&late).await.unwrap();
10784 inbox
10785 .mark_activation_eligible(
10786 &child.id,
10787 receipt.generation,
10788 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
10789 )
10790 .await
10791 .unwrap();
10792 let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
10793 binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
10794 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
10795 claim: late_claim,
10796 });
10797 }
10798 if case == "checkpoint_error" {
10799 binding.persistence = Arc::new(ActorFaultingPersistence {
10800 inner: locked.clone(),
10801 fail_checkpoint_once: AtomicBool::new(true),
10802 });
10803 }
10804 if case == "stale_prefix" {
10805 let mut latest = child.clone();
10806 latest
10807 .messages
10808 .push(bamboo_agent_core::Message::user("new confirmed prefix"));
10809 store.save_session(&latest).await.unwrap();
10810 }
10811 let cancel = CancellationToken::new();
10812 if case == "cancelled" {
10813 cancel.cancel();
10814 }
10815 if case == "lost_owner" {
10816 owner.take().unwrap().abandon().await;
10817 }
10818 let durable_before =
10819 serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
10820 let result = commit_local_tool_history(
10821 collector,
10822 &mut child,
10823 &binding,
10824 "current-run",
10825 (&["Read".into(), "Write".into()], false),
10826 Some("complete exact report"),
10827 &cancel,
10828 )
10829 .await;
10830 let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
10831 .await
10832 .unwrap()
10833 .load_session(&child.id)
10834 .await
10835 .unwrap()
10836 .unwrap();
10837 if !matches!(case, "success" | "reasoning" | "concurrent_input") {
10838 assert!(result.is_err(), "{case}");
10839 assert_eq!(
10840 serde_json::to_value(&child).unwrap(),
10841 before,
10842 "{case}: no partial mutable success"
10843 );
10844 assert_eq!(
10845 serde_json::to_value(Some(&cold)).unwrap(),
10846 durable_before,
10847 "{case}: no write"
10848 );
10849 continue;
10850 }
10851 result.unwrap();
10852 assert_eq!(
10853 serde_json::to_value(&cold.messages[..3]).unwrap(),
10854 before["messages"]
10855 );
10856 let suffix_start = if case == "concurrent_input" {
10857 assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
10858 assert_eq!(
10859 serde_json::to_value(&cold.messages[3]).unwrap(),
10860 serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
10861 );
10862 assert!(child
10863 .messages
10864 .iter()
10865 .any(|message| message.id == late.id.as_str()));
10866 4
10867 } else {
10868 3
10869 };
10870 assert_eq!(
10871 serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
10872 serde_json::to_value(rows).unwrap()
10873 );
10874 assert_eq!(
10875 cold.messages
10876 .iter()
10877 .filter(|message| message.id == envelope.id.as_str())
10878 .count(),
10879 1
10880 );
10881 assert_eq!(
10882 cold.messages
10883 .iter()
10884 .filter(|message| message.content == "complete exact report")
10885 .count(),
10886 1
10887 );
10888 assert_eq!(cold.metadata, child.metadata);
10889 assert!(cold.provider_transcript.is_empty());
10890 }
10891 }
10892
10893 #[tokio::test]
10894 async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
10895 for case in [
10896 "legacy",
10897 "missing",
10898 "gap",
10899 "duplicate",
10900 "epoch",
10901 "source",
10902 "birth",
10903 "qos",
10904 "ticket_forged_success",
10905 ] {
10906 let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
10907 session
10908 .messages
10909 .push(bamboo_agent_core::Message::user("original"));
10910 if case == "ticket_forged_success" {
10911 session.metadata.insert(
10912 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
10913 "assignment".into(),
10914 );
10915 }
10916 let before = serde_json::to_value(&session).unwrap();
10917 let creation = bamboo_subagent::proto::ChildCreationIdentity {
10918 created_at: session.created_at,
10919 spawn_depth: session.spawn_depth,
10920 };
10921 let mut messages = session.messages.clone();
10922 messages.push(bamboo_agent_core::Message::assistant("complete", None));
10923 let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
10924 version: 1,
10925 messages: messages
10926 .iter()
10927 .map(|message| serde_json::to_value(message).unwrap())
10928 .collect(),
10929 })
10930 .unwrap();
10931 let mut batch = ActorEventBatch {
10932 logical_session: Some(LogicalSessionIdentity {
10933 session_id: session.id.clone(),
10934 parent_session_id: session.parent_session_id.clone(),
10935 root_session_id: session.root_session_id.clone(),
10936 creation: Some(creation.clone()),
10937 }),
10938 activation_id: Some("current".into()),
10939 execution_epoch: 7,
10940 source_node_id: None,
10941 source_actor_id: Some("selected".into()),
10942 first_seq: 1,
10943 last_seq: 1,
10944 qos: bamboo_subagent::proto::ActorEventQos::Durable,
10945 events: vec![event.clone()],
10946 };
10947 match case {
10948 "gap" => {
10949 batch.first_seq = 2;
10950 batch.last_seq = 2;
10951 }
10952 "epoch" => batch.execution_epoch = 6,
10953 "source" => batch.source_actor_id = Some("replaced".into()),
10954 "birth" => {
10955 batch
10956 .logical_session
10957 .as_mut()
10958 .unwrap()
10959 .creation
10960 .as_mut()
10961 .unwrap()
10962 .created_at += chrono::Duration::nanoseconds(1)
10963 }
10964 "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
10965 "ticket_forged_success" => {
10966 batch.last_seq = 2;
10967 batch.events = vec![
10968 serde_json::to_value(AgentEvent::ToolStart {
10969 tool_call_id: "forged".into(), tool_name: "Task".into(),
10970 arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
10971 }).unwrap(),
10972 serde_json::to_value(AgentEvent::ToolComplete {
10973 tool_call_id: "forged".into(),
10974 result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
10975 }).unwrap(),
10976 ];
10977 }
10978 _ => {}
10979 }
10980 let terminal = ChildFrame::Terminal {
10981 status: TerminalStatus::Completed,
10982 result: Some("complete".into()),
10983 error: None,
10984 final_event_watermark: None,
10985 transcript: vec![],
10986 };
10987 let frames = match case {
10988 "legacy" => vec![ChildFrame::Event { event }, terminal],
10989 "missing" => vec![terminal],
10990 "duplicate" => vec![
10991 ChildFrame::EventBatch {
10992 batch: batch.clone(),
10993 },
10994 ChildFrame::EventBatch { batch },
10995 terminal,
10996 ],
10997 _ => vec![ChildFrame::EventBatch { batch }, terminal],
10998 };
10999 let mut link = ConfirmationSequenceLink {
11000 frames: frames.into(),
11001 sent: vec![],
11002 };
11003 let (tx, mut rx) = mpsc::channel(32);
11004 let (_live, mut live_rx) = mpsc::unbounded_channel();
11005 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11006 let cancel = CancellationToken::new();
11007 let tools = vec![if case == "ticket_forged_success" {
11008 "Task"
11009 } else {
11010 "Read"
11011 }
11012 .into()];
11013 let outcome = drive(ActorDriveContext {
11014 client: &mut link,
11015 parent_session_id: "frame-root",
11016 child_session_id: "frame-child",
11017 child_attempt: 0,
11018 approval_registry: None,
11019 approval_decider: None,
11020 approval_reviewer: None,
11021 escalation_bridge: None,
11022 event_tx: &tx,
11023 cancel_token: &cancel,
11024 live_rx: &mut live_rx,
11025 delivery_rx: &mut delivery_rx,
11026 logical_session: &mut session,
11027 expected_permission_posture: None,
11028 expected_creation: Some(&creation),
11029 session_inbox_runtime: None,
11030 actor_directory_store: None,
11031 canonical_subagent_tool: None,
11032 ticket_service: None,
11033 activation_run_id: Some("current"),
11034 execution_epoch: 7,
11035 expected_source_actor_id: "selected",
11036 initial_inflight_claims: vec![].into(),
11037 plain_actor: false,
11038 remote_environment_lease: false,
11039 readonly_output: None,
11040 local_history_tools: Some(&tools),
11041 local_history_read_only: true,
11042 strict_history_output: None,
11043 plain_input: None,
11044 canonical_activation: None,
11045 canonical_placement_ref: None,
11046 actor_event_observer: None,
11047 plain_run: None,
11048 first_frame_timeout: Some(Duration::from_secs(1)),
11049 })
11050 .await;
11051 assert!(outcome.is_err(), "{case}: no last-text success fallback");
11052 if case == "ticket_forged_success" {
11053 assert!(outcome
11054 .unwrap_err()
11055 .to_string()
11056 .contains("no exact Host receipt"));
11057 assert!(matches!(
11058 rx.try_recv().unwrap(),
11059 AgentEvent::ToolStart { .. }
11060 ));
11061 }
11062 assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11063 assert!(link.sent.is_empty());
11064 assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11065 }
11066 }
11067
11068 struct HistoryTerminalProbe {
11069 frames: VecDeque<ChildFrame>,
11070 accepted: Option<TerminalStatus>,
11071 acks: usize,
11072 }
11073
11074 #[async_trait]
11075 impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11076 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11077 Ok(())
11078 }
11079 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11080 Ok(self.frames.pop_front())
11081 }
11082 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11083 self.accepted = Some(status);
11084 }
11085 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11086 Some(bamboo_subagent::DurableChildDeliveryReceipt {
11087 broker_identity: "fixture".into(),
11088 parent_mailbox: "parent".into(),
11089 correlation_id: "current".into(),
11090 message_ids: vec!["tail".into(), "terminal".into()],
11091 terminal_status: self.accepted?,
11092 })
11093 }
11094 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11095 self.acks += 1;
11096 Ok(())
11097 }
11098 }
11099
11100 #[tokio::test]
11101 async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11102 use bamboo_subagent::ChildLink;
11103 for case in [
11104 "success",
11105 "tail",
11106 "short",
11107 "missing",
11108 "version",
11109 "activation",
11110 "epoch",
11111 "birth",
11112 "duplicate",
11113 "overlap",
11114 "reorder",
11115 "midgap",
11116 "qos",
11117 "zero_missing",
11118 "zero_valid",
11119 "error_tail",
11120 "cancelled_tail",
11121 "cancelled_valid",
11122 "cancelled_zero_valid",
11123 "error_valid",
11124 "error_zero_valid",
11125 "ordinary_lossy",
11126 ] {
11127 let temp = tempfile::tempdir().unwrap();
11128 let store = Arc::new(
11129 bamboo_storage::SessionStoreV2::new(temp.path().into())
11130 .await
11131 .unwrap(),
11132 );
11133 let root = Session::new("watermark-root", "model");
11134 store.save_session(&root).await.unwrap();
11135 let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11136 child
11137 .messages
11138 .push(bamboo_agent_core::Message::user("assignment"));
11139 store.save_session(&child).await.unwrap();
11140 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11141 store.clone(),
11142 bamboo_domain::SessionInboxLimits::default(),
11143 ));
11144 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11145 let binding = actor_binding(store.clone(), inbox, locked);
11146 let _owner = binding
11147 .router
11148 .register_run(&child.id, "current")
11149 .await
11150 .unwrap();
11151 let before = serde_json::to_value(&child).unwrap();
11152 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11153 created_at: child.created_at,
11154 spawn_depth: child.spawn_depth,
11155 };
11156 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11157 let mut rows = child.messages.clone();
11158 rows.push(bamboo_agent_core::Message::assistant("complete", None));
11159 let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11160 version: 1,
11161 messages: rows
11162 .iter()
11163 .map(|row| serde_json::to_value(row).unwrap())
11164 .collect(),
11165 })
11166 .unwrap();
11167 let batch = |seq, event, qos| ActorEventBatch {
11168 logical_session: Some(identity.clone()),
11169 activation_id: Some("current".into()),
11170 execution_epoch: 7,
11171 source_node_id: None,
11172 source_actor_id: Some("selected".into()),
11173 first_seq: seq,
11174 last_seq: seq,
11175 qos,
11176 events: vec![event],
11177 };
11178 let durable = bamboo_subagent::ActorEventQos::Durable;
11179 let token = serde_json::json!({"type":"token", "content":"observation"});
11180 let mut frames = vec![ChildFrame::EventBatch {
11181 batch: batch(1, data.clone(), durable),
11182 }];
11183 let mut marker = bamboo_subagent::ActorEventWatermark {
11184 version: 1,
11185 logical_session: Some(identity.clone()),
11186 activation_id: Some("current".into()),
11187 execution_epoch: 7,
11188 final_seq: 1,
11189 };
11190 match case {
11191 "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11192 "short" => marker.final_seq = 0,
11193 "version" => marker.version = 99,
11194 "activation" => marker.activation_id = Some("old".into()),
11195 "epoch" => marker.execution_epoch = 6,
11196 "birth" => {
11197 marker
11198 .logical_session
11199 .as_mut()
11200 .unwrap()
11201 .creation
11202 .as_mut()
11203 .unwrap()
11204 .created_at += chrono::Duration::nanoseconds(1)
11205 }
11206 "duplicate" => frames.push(frames[0].clone()),
11207 "overlap" | "midgap" | "qos" => {
11208 frames = vec![
11209 ChildFrame::EventBatch {
11210 batch: batch(
11211 1,
11212 token.clone(),
11213 if case == "qos" {
11214 bamboo_subagent::ActorEventQos::Ephemeral
11215 } else {
11216 durable
11217 },
11218 ),
11219 },
11220 ChildFrame::EventBatch {
11221 batch: batch(
11222 if case == "overlap" {
11223 1
11224 } else if case == "midgap" {
11225 3
11226 } else {
11227 2
11228 },
11229 data,
11230 durable,
11231 ),
11232 },
11233 ];
11234 marker.final_seq = 2;
11235 }
11236 "reorder" => {
11237 frames = vec![
11238 ChildFrame::EventBatch {
11239 batch: batch(2, data, durable),
11240 },
11241 ChildFrame::EventBatch {
11242 batch: batch(1, token, durable),
11243 },
11244 ]
11245 }
11246 "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11247 frames.clear();
11248 marker.final_seq = 0;
11249 }
11250 "ordinary_lossy" => {
11251 frames = vec![ChildFrame::EventBatch {
11252 batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11253 }]
11254 }
11255 _ => {}
11256 }
11257 let status = match case {
11258 "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11259 "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11260 TerminalStatus::Cancelled
11261 }
11262 _ => TerminalStatus::Completed,
11263 };
11264 frames.push(ChildFrame::Terminal {
11265 status,
11266 result: Some("complete".into()),
11267 error: Some("worker failed".into()),
11268 transcript: vec![],
11269 final_event_watermark: (!matches!(
11270 case,
11271 "missing" | "zero_missing" | "ordinary_lossy"
11272 ))
11273 .then_some(marker),
11274 });
11275 let mut link = HistoryTerminalProbe {
11276 frames: frames.into(),
11277 accepted: None,
11278 acks: 0,
11279 };
11280 let (tx, mut rx) = mpsc::channel(32);
11281 let (_live, mut live_rx) = mpsc::unbounded_channel();
11282 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11283 let cancel = CancellationToken::new();
11284 let tools = vec!["Read".into()];
11285 let mut strict_history = None;
11286 let outcome = drive(ActorDriveContext {
11287 client: &mut link,
11288 parent_session_id: &root.id,
11289 child_session_id: "watermark-child",
11290 child_attempt: 0,
11291 approval_registry: None,
11292 approval_decider: None,
11293 approval_reviewer: None,
11294 escalation_bridge: None,
11295 event_tx: &tx,
11296 cancel_token: &cancel,
11297 live_rx: &mut live_rx,
11298 delivery_rx: &mut delivery_rx,
11299 logical_session: &mut child,
11300 expected_permission_posture: None,
11301 expected_creation: Some(&creation),
11302 session_inbox_runtime: Some(&binding),
11303 actor_directory_store: None,
11304 canonical_subagent_tool: None,
11305 ticket_service: None,
11306 activation_run_id: Some("current"),
11307 execution_epoch: 7,
11308 expected_source_actor_id: "selected",
11309 initial_inflight_claims: vec![].into(),
11310 plain_actor: false,
11311 remote_environment_lease: false,
11312 readonly_output: None,
11313 local_history_tools: (case != "ordinary_lossy").then_some(&tools),
11314 local_history_read_only: true,
11315 strict_history_output: Some(&mut strict_history),
11316 plain_input: None,
11317 canonical_activation: None,
11318 canonical_placement_ref: None,
11319 actor_event_observer: None,
11320 plain_run: None,
11321 first_frame_timeout: Some(Duration::from_secs(1)),
11322 })
11323 .await;
11324 assert_eq!(strict_history.is_some(), case == "success", "{case}");
11325 if let Some(history) = strict_history {
11326 assert_eq!(history.proof.execution_epoch(), 7);
11327 assert_eq!(history.proof.contiguous_applied_seq(), 1);
11328 assert_eq!(history.watermark.final_seq, 1);
11329 }
11330 let cold = store.load_session(&child.id).await.unwrap().unwrap();
11331 if matches!(case, "success" | "ordinary_lossy") {
11332 assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
11333 } else {
11334 let error = outcome.unwrap_err();
11335 if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
11336 assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
11337 }
11338 let error = error.to_string();
11339 if !matches!(
11340 case,
11341 "duplicate"
11342 | "overlap"
11343 | "reorder"
11344 | "midgap"
11345 | "qos"
11346 | "zero_valid"
11347 | "error_valid"
11348 | "error_zero_valid"
11349 | "cancelled_valid"
11350 | "cancelled_zero_valid"
11351 ) {
11352 assert!(
11353 error.contains("actor_history_tail_incomplete"),
11354 "{case}: {error}"
11355 );
11356 }
11357 }
11358 if case == "success" {
11359 assert_eq!(
11360 serde_json::to_value(&cold.messages).unwrap(),
11361 serde_json::to_value(rows).unwrap()
11362 );
11363 } else {
11364 assert_eq!(
11365 serde_json::to_value(&cold).unwrap(),
11366 before,
11367 "{case}: no cold history commit"
11368 );
11369 assert_eq!(
11370 serde_json::to_value(&child).unwrap(),
11371 before,
11372 "{case}: no mutable history commit"
11373 );
11374 }
11375 let accepted = matches!(case, "success" | "ordinary_lossy");
11376 assert_eq!(link.accepted.is_some(), accepted, "{case}");
11377 assert_eq!(
11378 link.durable_delivery_receipt().is_some(),
11379 accepted,
11380 "{case}"
11381 );
11382 assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
11383 while let Ok(event) = rx.try_recv() {
11384 assert!(
11385 matches!(event, AgentEvent::Token { .. }),
11386 "typed DATA cannot leak"
11387 );
11388 }
11389 }
11390 }
11391
11392 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
11393 PooledWorker {
11394 worker: SpawnedChild::remote(AgentRecord {
11395 agent_id: mailbox_id.to_string(),
11396 role: "test".to_string(),
11397 labels: Vec::new(),
11398 endpoint: "ws://127.0.0.1:1".to_string(),
11399 pid: 0,
11400 version: String::new(),
11401 started_at: chrono::Utc::now(),
11402 lease_expires_at: chrono::Utc::now(),
11403 }),
11404 mailbox_id: mailbox_id.to_string(),
11405 parked_at: None,
11406 }
11407 }
11408
11409 #[test]
11410 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
11411 let now = Instant::now();
11412 let timeout = Duration::from_secs(300);
11413 assert!(!parked_worker_expired(None, now, timeout));
11414 assert!(!parked_worker_expired(
11415 Some(now - timeout + Duration::from_millis(1)),
11416 now,
11417 timeout
11418 ));
11419 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
11420 }
11421
11422 #[test]
11423 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
11424 let mut pool = WorkerPool::new();
11425 pool.insert(
11426 "fingerprint-a".to_string(),
11427 vec![processless_pool_worker("a")],
11428 );
11429 pool.insert(
11430 "fingerprint-b".to_string(),
11431 vec![processless_pool_worker("b")],
11432 );
11433
11434 assert_eq!(
11435 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
11436 Some("pool_global_limit")
11437 );
11438 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
11439 assert_eq!(
11440 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
11441 Some("pool_per_key_limit")
11442 );
11443 }
11444
11445 #[test]
11446 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
11447 let mut pool = WorkerPool::new();
11448 pool.insert(
11449 "fingerprint-a".to_string(),
11450 vec![processless_pool_worker("dead")],
11451 );
11452
11453 let reaped = take_reapable_workers(
11454 &mut pool,
11455 Instant::now(),
11456 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
11457 );
11458
11459 assert!(pool.is_empty());
11460 assert_eq!(reaped.len(), 1);
11461 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
11462 }
11463
11464 #[test]
11465 fn actor_preflight_counts_only_current_session_scoped_denies() {
11466 let config = bamboo_tools::permission::PermissionConfig::new();
11467 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
11468 config.deny_scoped_session_permission(
11469 "target-session",
11470 bamboo_tools::permission::PermissionType::ExecuteCommand,
11471 secret_matcher,
11472 );
11473 config.deny_scoped_session_permission(
11474 "other-session",
11475 bamboo_tools::permission::PermissionType::WriteFile,
11476 "/other/**",
11477 );
11478
11479 assert_eq!(
11480 active_scoped_session_deny_count(&config, "target-session"),
11481 1
11482 );
11483 assert_eq!(
11484 active_scoped_session_deny_count(&config, "clean-session"),
11485 0
11486 );
11487 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
11488 .unwrap_err()
11489 .to_string();
11490 assert!(!error.contains(secret_matcher));
11491 ensure_no_active_scoped_session_denies(&config, "clean-session")
11492 .expect("another session's deny must not block this activation");
11493 }
11494
11495 #[test]
11496 fn permission_posture_event_rejects_oversized_executor_mapping() {
11497 let session = Session::new("mapping-bound", "model");
11498 let event = AgentEvent::PermissionPostureActivated {
11499 session_id: session.id.clone(),
11500 policy_revision: 1,
11501 requested_mode: "default".to_string(),
11502 effective_mode: "default".to_string(),
11503 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
11504 };
11505
11506 assert!(permission_posture_seed_from_event(&session, &event)
11507 .unwrap_err()
11508 .contains("executor mapping"));
11509 }
11510
11511 #[test]
11512 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
11513 let mut session = Session::new("host-resigns-audit", "model");
11514 let hostile_timestamp = "9".repeat(1024);
11515 let event: AgentEvent = serde_json::from_value(serde_json::json!({
11516 "type": "permission_posture_activated",
11517 "session_id": session.id.clone(),
11518 "policy_revision": 31,
11519 "requested_mode": "default",
11520 "effective_mode": "default",
11521 "executor_mapping": "codex_exec:approval_policy=never",
11522 "audit_revision": u64::MAX,
11523 "transitioned_at": hostile_timestamp,
11524 }))
11525 .expect("unknown remote audit fields are ignored by the typed event");
11526 let seed = permission_posture_seed_from_event(&session, &event)
11527 .unwrap()
11528 .expect("permission event");
11529
11530 let host_revision =
11531 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
11532 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
11533 .expect("host-generated complete audit");
11534 assert_eq!(host_audit.audit_revision, host_revision);
11535 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
11536 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
11537 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
11538 }
11539
11540 struct ActorFaultingPersistence {
11541 inner: Arc<bamboo_storage::LockedSessionStore>,
11542 fail_checkpoint_once: std::sync::atomic::AtomicBool,
11543 }
11544
11545 #[async_trait]
11546 impl RuntimeSessionPersistence for ActorFaultingPersistence {
11547 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11548 self.inner.merge_save_runtime(session).await
11549 }
11550
11551 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11552 if self
11553 .fail_checkpoint_once
11554 .swap(false, std::sync::atomic::Ordering::SeqCst)
11555 {
11556 return Err(std::io::Error::other("injected actor checkpoint failure"));
11557 }
11558 self.inner.checkpoint_runtime_session(session).await
11559 }
11560
11561 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
11562 self.inner.storage().load_session(session_id).await
11563 }
11564 }
11565
11566 struct ActorFailBeforeAckInbox {
11567 inner: Arc<dyn SessionInboxPort>,
11568 fail_once: std::sync::atomic::AtomicBool,
11569 }
11570
11571 #[async_trait]
11572 impl SessionInboxPort for ActorFailBeforeAckInbox {
11573 async fn deliver(
11574 &self,
11575 envelope: &bamboo_domain::SessionMessageEnvelope,
11576 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
11577 self.inner.deliver(envelope).await
11578 }
11579
11580 async fn mark_activation_eligible(
11581 &self,
11582 target_session_id: &str,
11583 generation: u64,
11584 policy: bamboo_domain::SessionActivationPolicy,
11585 ) -> Result<(), bamboo_domain::SessionInboxError> {
11586 self.inner
11587 .mark_activation_eligible(target_session_id, generation, policy)
11588 .await
11589 }
11590
11591 async fn claim(
11592 &self,
11593 target_session_id: &str,
11594 limit: usize,
11595 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
11596 self.inner.claim(target_session_id, limit).await
11597 }
11598
11599 async fn was_admitted(
11600 &self,
11601 target_session_id: &str,
11602 id: &bamboo_domain::SessionMessageId,
11603 ) -> Result<bool, bamboo_domain::SessionInboxError> {
11604 self.inner.was_admitted(target_session_id, id).await
11605 }
11606
11607 async fn ack(
11608 &self,
11609 target_session_id: &str,
11610 claim: &SessionInboxClaim,
11611 ) -> Result<(), bamboo_domain::SessionInboxError> {
11612 if self
11613 .fail_once
11614 .swap(false, std::sync::atomic::Ordering::SeqCst)
11615 {
11616 return Err(bamboo_domain::SessionInboxError::Storage(
11617 "injected actor pre-ack failure".to_string(),
11618 ));
11619 }
11620 self.inner.ack(target_session_id, claim).await
11621 }
11622
11623 async fn inspect(
11624 &self,
11625 target_session_id: &str,
11626 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
11627 self.inner.inspect(target_session_id).await
11628 }
11629 }
11630
11631 async fn actor_inbox_fixture(
11632 session_id: &str,
11633 ) -> (
11634 tempfile::TempDir,
11635 Arc<bamboo_storage::SessionStoreV2>,
11636 Arc<bamboo_storage::LockedSessionStore>,
11637 Arc<dyn SessionInboxPort>,
11638 Session,
11639 SessionInboxClaim,
11640 ) {
11641 let temp = tempfile::tempdir().unwrap();
11642 let store = Arc::new(
11643 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11644 .await
11645 .unwrap(),
11646 );
11647 let storage: Arc<dyn Storage> = store.clone();
11648 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
11649 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11650 store.clone(),
11651 bamboo_domain::SessionInboxLimits::default(),
11652 ));
11653 let session = Session::new(session_id, "model");
11654 store.save_session(&session).await.unwrap();
11655 let mut envelope =
11656 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
11657 envelope.id =
11658 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
11659 let receipt = inbox.deliver(&envelope).await.unwrap();
11660 inbox
11661 .mark_activation_eligible(
11662 session_id,
11663 receipt.generation,
11664 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
11665 )
11666 .await
11667 .unwrap();
11668 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
11669 (temp, store, locked, inbox, session, claim)
11670 }
11671
11672 fn actor_binding(
11673 store: Arc<bamboo_storage::SessionStoreV2>,
11674 inbox: Arc<dyn SessionInboxPort>,
11675 persistence: Arc<dyn RuntimeSessionPersistence>,
11676 ) -> SessionInboxRuntimeBinding {
11677 let storage: Arc<dyn Storage> = store;
11678 SessionInboxRuntimeBinding {
11679 router: SessionActivationRouter::new(),
11680 inbox,
11681 storage,
11682 persistence,
11683 parent_question_lock: None,
11684 }
11685 }
11686
11687 #[tokio::test]
11688 async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
11689 let (_temp, store, locked, inbox, mut session, claim) =
11690 actor_inbox_fixture("unsupported-remote-inbox").await;
11691 let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
11692 inner: inbox.clone(),
11693 fail_once: std::sync::atomic::AtomicBool::new(false),
11694 });
11695 let binding = actor_binding(store.clone(), wrapped, locked);
11696 let fence = ActorActivationFence {
11697 schema_version: 1,
11698 actor_id: session.id.clone(),
11699 activation_id: "unsupported-inbox-activation".into(),
11700 attempt: 1,
11701 run_id: "unsupported-inbox-run".into(),
11702 lease_owner: "unsupported-inbox-host".into(),
11703 lease_epoch: 1,
11704 };
11705 let before = session.clone();
11706
11707 let error = checkpoint_claim_context_before_dispatch(
11708 &binding,
11709 &mut session,
11710 &[claim],
11711 Some((store.as_ref(), &fence)),
11712 )
11713 .await
11714 .unwrap_err();
11715 assert!(error
11716 .to_string()
11717 .contains("requires canonical FileSessionInbox"));
11718 assert_eq!(
11719 serde_json::to_value(&session.messages).unwrap(),
11720 serde_json::to_value(&before.messages).unwrap()
11721 );
11722 assert_eq!(
11723 serde_json::to_value(
11724 &store
11725 .load_session(&session.id)
11726 .await
11727 .unwrap()
11728 .unwrap()
11729 .messages
11730 )
11731 .unwrap(),
11732 serde_json::to_value(&before.messages).unwrap()
11733 );
11734 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
11735 }
11736
11737 #[tokio::test]
11738 async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
11739 let (_temp, store, locked, inbox, parent, _claim) =
11740 actor_inbox_fixture("remote-parent").await;
11741 let child = Session::new_child_of("remote-child", &parent, "model", "child");
11742 store.save_session(&child).await.unwrap();
11743 let binding = actor_binding(store.clone(), inbox, locked);
11744 let first = remote_canonical_lineage(&binding, &child).await.unwrap();
11745 assert_eq!(first.len(), 2);
11746 let mut changed = child.clone();
11747 changed.set_project_id_meta("different-project");
11748 assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
11749 store.delete_session(&parent.id).await.unwrap();
11750 assert!(remote_canonical_lineage(&binding, &child).await.is_err());
11751 }
11752
11753 #[tokio::test]
11754 async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
11755 let (_temp, store, locked, inbox, parent, _claim) =
11756 actor_inbox_fixture("creation-parent").await;
11757 let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
11758 child.set_project_id_meta("creation-project");
11759 store.save_session(&child).await.unwrap();
11760 let binding = actor_binding(store.clone(), inbox, locked);
11761 let job = SpawnJob {
11762 parent_session_id: parent.id.clone(),
11763 child_session_id: child.id.clone(),
11764 model: "model".into(),
11765 disabled_tools: None,
11766 };
11767 let creation = canonical_child_creation(&binding, &child, &job)
11768 .await
11769 .unwrap();
11770 assert_eq!(creation.created_at, child.created_at);
11771 assert_eq!(creation.spawn_depth, child.spawn_depth);
11772 for damage in 0..7 {
11773 let mut held = child.clone();
11774 match damage {
11775 0 => held.created_at += chrono::Duration::nanoseconds(1),
11776 1 => held.parent_session_id = Some("foreign-parent".into()),
11777 2 => held.root_session_id = "foreign-root".into(),
11778 3 => held.spawn_depth += 1,
11779 4 => held.set_project_id_meta("foreign-project"),
11780 5 => held.kind = bamboo_domain::SessionKind::Root,
11781 _ => held.id = " creation-child ".into(),
11782 }
11783 assert!(
11784 canonical_child_creation(&binding, &held, &job)
11785 .await
11786 .is_err(),
11787 "damage {damage}"
11788 );
11789 }
11790 store.delete_session(&child.id).await.unwrap();
11791 assert!(canonical_child_creation(&binding, &child, &job)
11792 .await
11793 .is_err());
11794 let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
11795 replacement.set_project_id_meta("creation-project");
11796 assert_ne!(replacement.created_at, child.created_at);
11797 store.save_session(&replacement).await.unwrap();
11798 assert!(
11799 canonical_child_creation(&binding, &child, &job)
11800 .await
11801 .is_err(),
11802 "old held spec cannot adopt replacement birth"
11803 );
11804 canonical_child_creation(&binding, &replacement, &job)
11805 .await
11806 .unwrap();
11807 }
11808
11809 #[tokio::test]
11810 async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
11811 use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
11812 for policies in [
11813 [
11814 SessionActivationPolicy::RespectSpecificWait,
11815 SessionActivationPolicy::InterruptSpecificWait,
11816 ],
11817 [
11818 SessionActivationPolicy::InterruptSpecificWait,
11819 SessionActivationPolicy::RespectSpecificWait,
11820 ],
11821 ] {
11822 let temp = tempfile::tempdir().unwrap();
11823 let store = Arc::new(
11824 bamboo_storage::SessionStoreV2::new(temp.path().into())
11825 .await
11826 .unwrap(),
11827 );
11828 let session_id = "actor-mixed-intent-policy";
11829 store
11830 .save_session(&Session::new(session_id, "model"))
11831 .await
11832 .unwrap();
11833 let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
11834 let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
11835 let staged_receipt = inbox.deliver(&staged).await.unwrap();
11836 let mut sent = Vec::new();
11837 for policy in policies {
11838 let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
11839 let receipt = inbox
11840 .deliver_with_activation_intent(&envelope, policy, None)
11841 .await
11842 .unwrap();
11843 sent.push((envelope, policy, receipt));
11844 }
11845 let restarted = Arc::new(
11846 bamboo_storage::SessionStoreV2::new(temp.path().into())
11847 .await
11848 .unwrap(),
11849 );
11850 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11851 restarted.clone(),
11852 SessionInboxLimits::default(),
11853 ));
11854 let binding = actor_binding(
11855 restarted.clone(),
11856 inbox.clone(),
11857 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
11858 );
11859 let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
11860 let pairs =
11861 claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
11862 .await
11863 .unwrap();
11864 assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
11865 for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
11866 assert_eq!(claim.envelope.id, envelope.id);
11867 assert_eq!(claim.generation, receipt.generation);
11868 assert_eq!(claim.activation_policy, *policy);
11869 assert_eq!(delivery.activation_policy, *policy);
11870 checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
11871 .await
11872 .unwrap();
11873 }
11874 let restarted = Arc::new(
11875 bamboo_storage::SessionStoreV2::new(temp.path().into())
11876 .await
11877 .unwrap(),
11878 );
11879 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11880 restarted.clone(),
11881 SessionInboxLimits::default(),
11882 ));
11883 let binding = actor_binding(
11884 restarted.clone(),
11885 inbox.clone(),
11886 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
11887 );
11888 session = restarted.load_session(session_id).await.unwrap().unwrap();
11889 for (envelope, policy, receipt) in &sent {
11890 assert_eq!(
11891 inbox
11892 .deliver_with_activation_intent(envelope, *policy, None)
11893 .await
11894 .unwrap(),
11895 *receipt
11896 );
11897 }
11898 assert!(
11899 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
11900 .await
11901 .unwrap()
11902 .is_empty()
11903 );
11904 inbox
11905 .mark_activation_eligible(
11906 session_id,
11907 staged_receipt.generation,
11908 SessionActivationPolicy::RespectSpecificWait,
11909 )
11910 .await
11911 .unwrap();
11912 let pairs =
11913 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
11914 .await
11915 .unwrap();
11916 assert_eq!(pairs.len(), 1);
11917 assert_eq!(pairs[0].0.envelope.id, staged.id);
11918 assert_eq!(
11919 pairs[0].1.activation_policy,
11920 SessionActivationPolicy::RespectSpecificWait
11921 );
11922 }
11923 }
11924
11925 #[tokio::test]
11926 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
11927 let (_temp, store, locked, inbox, mut session, claim) =
11928 actor_inbox_fixture("actor-live-id-collision").await;
11929 let mut forged = claim.envelope.to_provider_message().unwrap();
11930 forged.metadata = Some(serde_json::json!({
11931 "session_message": {
11932 "id": claim.envelope.id,
11933 "target_session_id": "different-session"
11934 }
11935 }));
11936 session.add_message(forged);
11937 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
11938 let binding = actor_binding(store, inbox.clone(), persistence);
11939
11940 assert!(
11941 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
11942 .await
11943 .is_err()
11944 );
11945 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
11946 assert!(!inbox
11947 .was_admitted(&session.id, &claim.envelope.id)
11948 .await
11949 .unwrap());
11950 }
11951
11952 #[tokio::test]
11953 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
11954 let (_temp, store, locked, inbox, mut session, claim) =
11955 actor_inbox_fixture("actor-durable-id-collision").await;
11956 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
11957 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
11958 forged.id = claim.envelope.id.to_string();
11959 concurrent.add_message(forged);
11960 store.save_session(&concurrent).await.unwrap();
11961 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
11962 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
11963
11964 assert!(
11965 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
11966 .await
11967 .is_err()
11968 );
11969 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
11970 assert!(!inbox
11971 .was_admitted(&session.id, &claim.envelope.id)
11972 .await
11973 .unwrap());
11974 let durable = store.load_session(&session.id).await.unwrap().unwrap();
11975 assert!(!durable.messages.iter().any(|message| {
11976 bamboo_domain::is_matching_session_message(message, &claim.envelope)
11977 }));
11978 }
11979
11980 #[tokio::test]
11981 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
11982 let (_temp, store, locked, inbox, mut session, claim) =
11983 actor_inbox_fixture("actor-durable-typed-body-collision").await;
11984 let mut different = claim.envelope.clone();
11985 different.body = bamboo_domain::SessionMessageBody::Content(
11986 bamboo_domain::SessionMessageContent::text("forged actor body"),
11987 );
11988 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
11989 concurrent.add_message(different.to_provider_message().unwrap());
11990 store.save_session(&concurrent).await.unwrap();
11991 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
11992 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
11993
11994 assert!(
11995 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
11996 .await
11997 .is_err()
11998 );
11999 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12000 assert!(!inbox
12001 .was_admitted(&session.id, &claim.envelope.id)
12002 .await
12003 .unwrap());
12004 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12005 assert!(!durable
12006 .messages
12007 .iter()
12008 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12009 }
12010
12011 #[tokio::test]
12012 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12013 let (_temp, store, locked, inbox, mut session, claim) =
12014 actor_inbox_fixture("actor-checkpoint-failure").await;
12015 let envelope_id = claim.envelope.id.clone();
12016 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12017 inner: locked.clone(),
12018 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12019 });
12020 let binding = actor_binding(store.clone(), inbox.clone(), fault);
12021
12022 assert!(
12023 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12024 .await
12025 .is_err()
12026 );
12027 assert!(!session
12028 .messages
12029 .iter()
12030 .any(|message| message.id == envelope_id.as_str()));
12031 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12032 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12033
12034 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12035 store.clone(),
12036 bamboo_domain::SessionInboxLimits::default(),
12037 ));
12038 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12039 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12040 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12041 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12042 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12043 .await
12044 .unwrap();
12045 assert_eq!(
12046 restarted
12047 .messages
12048 .iter()
12049 .filter(|message| message.id == envelope_id.as_str())
12050 .count(),
12051 1
12052 );
12053 assert!(reopened
12054 .was_admitted(&session.id, &envelope_id)
12055 .await
12056 .unwrap());
12057 let backlog = reopened.inspect(&session.id).await.unwrap();
12058 assert_eq!(backlog.pending + backlog.claimed, 0);
12059 }
12060
12061 #[tokio::test]
12062 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12063 let (_temp, store, locked, real_inbox, mut session, claim) =
12064 actor_inbox_fixture("actor-pre-ack-failure").await;
12065 let envelope_id = claim.envelope.id.clone();
12066 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12067 inner: real_inbox.clone(),
12068 fail_once: std::sync::atomic::AtomicBool::new(true),
12069 });
12070 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12071 let binding = actor_binding(store.clone(), faulted, persistence);
12072
12073 assert!(
12074 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12075 .await
12076 .is_err()
12077 );
12078 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12079 assert_eq!(
12080 durable
12081 .messages
12082 .iter()
12083 .filter(|message| message.id == envelope_id.as_str())
12084 .count(),
12085 1
12086 );
12087 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12088 assert!(!real_inbox
12089 .was_admitted(&session.id, &envelope_id)
12090 .await
12091 .unwrap());
12092
12093 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12094 store.clone(),
12095 bamboo_domain::SessionInboxLimits::default(),
12096 ));
12097 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12098 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12099 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12100 let mut restarted = durable;
12101 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12102 .await
12103 .unwrap();
12104 assert_eq!(
12105 restarted
12106 .messages
12107 .iter()
12108 .filter(|message| message.id == envelope_id.as_str())
12109 .count(),
12110 1
12111 );
12112 assert!(reopened
12113 .was_admitted(&session.id, &envelope_id)
12114 .await
12115 .unwrap());
12116 let backlog = reopened.inspect(&session.id).await.unwrap();
12117 assert_eq!(backlog.pending + backlog.claimed, 0);
12118 }
12119
12120 struct ConfirmationSequenceLink {
12121 frames: VecDeque<ChildFrame>,
12122 sent: Vec<ParentFrame>,
12123 }
12124
12125 #[async_trait]
12126 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12127 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12128 self.sent.push(frame);
12129 Ok(())
12130 }
12131
12132 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12133 match self.frames.pop_front() {
12134 Some(frame) => Ok(Some(frame)),
12135 None => std::future::pending().await,
12136 }
12137 }
12138 }
12139
12140 #[tokio::test]
12141 async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12142 for case in [
12143 "accepted",
12144 "readonly-refusal",
12145 "prefix-refusal",
12146 "old-claim",
12147 "old-checkpoint",
12148 "succeeded",
12149 "changed-birth",
12150 "oversize",
12151 ] {
12152 let temp = tempfile::tempdir().unwrap();
12153 let store = Arc::new(
12154 bamboo_storage::SessionStoreV2::new(temp.path().into())
12155 .await
12156 .unwrap(),
12157 );
12158 let parent = Session::new("retry-parent", "model");
12159 store.save_session(&parent).await.unwrap();
12160 let mut child =
12161 Session::new_child_of("retry-child", &parent, "model", "original assignment");
12162 child.add_message(bamboo_agent_core::Message::user("original assignment"));
12163 store.save_session(&child).await.unwrap();
12164 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12165 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12166 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12167 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12168 store.clone(),
12169 bamboo_domain::SessionInboxLimits::default(),
12170 ));
12171 let binding = actor_binding(store.clone(), inbox, locked);
12172 let registration = binding
12173 .router
12174 .register_run(&child.id, "first-run")
12175 .await
12176 .unwrap();
12177 let first = PlainActorActivation::start(
12178 store.clone(),
12179 &child,
12180 &binding,
12181 Some("first-run"),
12182 Some(policy.clone()),
12183 true,
12184 None,
12185 )
12186 .await
12187 .unwrap();
12188 if matches!(case, "old-claim" | "old-checkpoint") {
12189 let old = bamboo_domain::SessionMessageEnvelope::user_input(
12190 &child.id,
12191 "unconfirmed old input",
12192 );
12193 let receipt = first.input_inbox.deliver(&old).await.unwrap();
12194 first
12195 .input_inbox
12196 .mark_activation_eligible(
12197 &child.id,
12198 receipt.generation,
12199 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12200 )
12201 .await
12202 .unwrap();
12203 let claim = first
12204 .claim_input(&binding, &child, "first-run")
12205 .await
12206 .unwrap()
12207 .unwrap();
12208 if case == "old-checkpoint" {
12209 first
12210 .checkpoint_input(&mut child, "first-run", &claim)
12211 .await
12212 .unwrap();
12213 }
12214 }
12215 first
12216 .finish(if case == "succeeded" {
12217 ActorActivationFinish::Succeeded
12218 } else {
12219 ActorActivationFinish::Failed
12220 })
12221 .await
12222 .unwrap();
12223 drop(registration);
12224 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12225 &child.id,
12226 if case == "oversize" {
12227 "x".repeat(8193)
12228 } else {
12229 "NEW_CORRECTION".into()
12230 },
12231 );
12232 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12233 first
12234 .input_inbox
12235 .mark_activation_eligible(
12236 &child.id,
12237 receipt.generation,
12238 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12239 )
12240 .await
12241 .unwrap();
12242 let before = store.load_session(&child.id).await.unwrap().unwrap();
12243 let expected_messages = serde_json::to_value(&before.messages).unwrap();
12244 child = before.clone();
12245 if case == "prefix-refusal" {
12246 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12247 }
12248 if case == "changed-birth" {
12249 child.created_at += chrono::Duration::milliseconds(1);
12250 }
12251 let _retry_registration = binding
12252 .router
12253 .register_run(&child.id, "retry-run")
12254 .await
12255 .unwrap();
12256 let result = PlainActorActivation::start_and_prepare(
12257 store.clone(),
12258 &mut child,
12259 &binding,
12260 Some("retry-run"),
12261 Some(policy.clone()),
12262 case != "readonly-refusal",
12263 None,
12264 )
12265 .await;
12266 if case != "accepted" {
12267 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12268 let actual = store.load_session(&before.id).await.unwrap().unwrap();
12269 assert_eq!(
12270 serde_json::to_value(&actual.messages).unwrap(),
12271 expected_messages,
12272 "{case}: failed preparation preserves Main history"
12273 );
12274 assert!(
12275 !first
12276 .input_inbox
12277 .was_admitted(&before.id, &envelope.id)
12278 .await
12279 .unwrap(),
12280 "{case}: no ACK"
12281 );
12282 if case == "readonly-refusal" {
12283 let entry = store.inspect_actor(&before.id).await.unwrap();
12284 assert_eq!(entry.actor.current_attempt, 1);
12285 assert_eq!(
12286 entry.activation.unwrap().activation_id,
12287 first.fence.activation_id
12288 );
12289 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12290 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12291 }
12292 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
12293 assert_eq!(
12294 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
12295 1
12296 );
12297 assert_eq!(
12298 store
12299 .inspect_actor(&before.id)
12300 .await
12301 .unwrap()
12302 .activation
12303 .unwrap()
12304 .status,
12305 bamboo_domain::ActorActivationStatus::Failed
12306 );
12307 }
12308 if case == "oversize" {
12309 let reopened = Arc::new(
12310 bamboo_storage::SessionStoreV2::new(temp.path().into())
12311 .await
12312 .unwrap(),
12313 );
12314 let cold_inbox = bamboo_storage::FileSessionInbox::new(
12315 reopened,
12316 bamboo_domain::SessionInboxLimits::default(),
12317 );
12318 let now = chrono::Utc::now();
12319 let leases = cold_inbox
12320 .inspect_owned_leases(&before.id, 2, now)
12321 .await
12322 .unwrap();
12323 assert_eq!(leases.len(), 1);
12324 assert_eq!(leases[0].failure_count, 1);
12325 assert_eq!(
12326 leases[0].last_error_code.as_deref(),
12327 Some("unsupported_actor_input")
12328 );
12329 let retry_after = leases[0].retry_after.unwrap();
12330 assert!(retry_after > now);
12331 let readiness = cold_inbox
12332 .inspect_wake_readiness(&before.id, now)
12333 .await
12334 .unwrap();
12335 assert!(
12336 readiness.ready.is_none(),
12337 "failed correction must not hot-loop"
12338 );
12339 assert_eq!(readiness.next_due_at, Some(retry_after));
12340 }
12341 continue;
12342 }
12343 let (retry, prepared) = result.unwrap();
12344 let (prefix, delivery) = prepared.unwrap();
12345 assert_eq!(
12346 prefix,
12347 serde_json::to_value(&before.messages)
12348 .unwrap()
12349 .as_array()
12350 .unwrap()
12351 .clone()
12352 );
12353 assert_eq!(delivery.envelope, envelope);
12354 assert_eq!(delivery.activation_run_id, "retry-run");
12355 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
12356 assert!(store
12357 .validate_fence(&first.fence, chrono::Utc::now())
12358 .await
12359 .is_err());
12360 let running = store.inspect_actor(&child.id).await.unwrap();
12361 assert_eq!(running.actor.current_attempt, 2);
12362 assert_eq!(
12363 running.activation.as_ref().unwrap().status,
12364 bamboo_domain::ActorActivationStatus::Running
12365 );
12366 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
12367 let claim = retry.initial_input.as_ref().unwrap();
12368 assert!(child
12369 .session_inbox_admission()
12370 .unwrap()
12371 .contains(&envelope.id));
12372 assert!(
12373 !retry
12374 .input_inbox
12375 .was_admitted(&child.id, &envelope.id)
12376 .await
12377 .unwrap(),
12378 "checkpoint is not startup confirmation"
12379 );
12380 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
12381 confirmation.activation_run_id = "first-run".into();
12382 assert!(retry
12383 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
12384 .await
12385 .is_err());
12386 assert!(!retry
12387 .input_inbox
12388 .was_admitted(&child.id, &envelope.id)
12389 .await
12390 .unwrap());
12391 confirmation.activation_run_id = "retry-run".into();
12392 retry
12393 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
12394 .await
12395 .unwrap();
12396 let (tx, mut rx) = mpsc::channel(1);
12397 retry
12398 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
12399 .await
12400 .unwrap();
12401 rx.recv().await.unwrap();
12402 retry
12403 .finish(ActorActivationFinish::Succeeded)
12404 .await
12405 .unwrap();
12406 let reopened = Arc::new(
12407 bamboo_storage::SessionStoreV2::new(temp.path().into())
12408 .await
12409 .unwrap(),
12410 );
12411 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
12412 assert_eq!(cold.created_at, before.created_at);
12413 assert_eq!(cold.messages.len(), before.messages.len() + 2);
12414 assert_eq!(
12415 cold.messages[before.messages.len()].id,
12416 envelope.id.as_str()
12417 );
12418 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
12419 assert_eq!(
12420 cold.messages
12421 .iter()
12422 .filter(|m| m.id == envelope.id.as_str())
12423 .count(),
12424 1
12425 );
12426 let cold_inbox = bamboo_storage::FileSessionInbox::new(
12427 reopened,
12428 bamboo_domain::SessionInboxLimits::default(),
12429 );
12430 assert!(cold_inbox
12431 .was_admitted(&cold.id, &envelope.id)
12432 .await
12433 .unwrap());
12434 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
12435 }
12436 }
12437
12438 #[tokio::test]
12439 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
12440 for case in [
12441 "accepted",
12442 "actor-live",
12443 "claim-live",
12444 "legacy",
12445 "ack",
12446 "readonly",
12447 "prefix",
12448 "duplicate",
12449 "missing-marker",
12450 "mixed",
12451 "project",
12452 "birth",
12453 ] {
12454 let temp = tempfile::tempdir().unwrap();
12455 let store = Arc::new(
12456 bamboo_storage::SessionStoreV2::new(temp.path().into())
12457 .await
12458 .unwrap(),
12459 );
12460 let parent = Session::new("recovery-parent", "model");
12461 store.save_session(&parent).await.unwrap();
12462 let mut child =
12463 Session::new_child_of("recovery-child", &parent, "model", "original task");
12464 child.add_message(bamboo_agent_core::Message::user("original task"));
12465 store.save_session(&child).await.unwrap();
12466 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12467 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12468 let inbox = bamboo_storage::FileSessionInbox::new(
12469 store.clone(),
12470 bamboo_domain::SessionInboxLimits::default(),
12471 );
12472 let envelope =
12474 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
12475 let receipt = inbox.deliver(&envelope).await.unwrap();
12476 inbox
12477 .mark_activation_eligible(
12478 &child.id,
12479 receipt.generation,
12480 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12481 )
12482 .await
12483 .unwrap();
12484 let now = chrono::Utc::now();
12485 let cutoff = now + chrono::Duration::seconds(10);
12486 let consumer = SessionInboxConsumerId::new();
12487 let old = store
12488 .claim_activation(&ActorActivationClaim {
12489 actor_id: child.id.clone(),
12490 run_id: "old-run".into(),
12491 lease_owner: consumer.as_str().into(),
12492 lease_expires_at: if case == "actor-live" {
12493 now + chrono::Duration::minutes(5)
12494 } else {
12495 cutoff
12496 },
12497 inbox_generation: 0,
12498 placement_ref: if case == "legacy" {
12499 None
12500 } else {
12501 Some(bamboo_domain::ActorPlacementRef {
12502 class: bamboo_domain::ActorPlacementClass::Local,
12503 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
12504 slot_epoch: None,
12505 })
12506 },
12507 now,
12508 })
12509 .await
12510 .unwrap();
12511 store
12512 .start_activation(&old.fence(), chrono::Utc::now())
12513 .await
12514 .unwrap();
12515 let claim_now = chrono::Utc::now();
12516 let claim = inbox
12517 .claim_owned(
12518 &child.id,
12519 1,
12520 Some("old-run"),
12521 &SessionInboxLeaseRequest {
12522 consumer,
12523 now: claim_now,
12524 duration: if case == "claim-live" {
12525 chrono::Duration::minutes(5)
12526 } else {
12527 cutoff - claim_now
12528 },
12529 },
12530 )
12531 .await
12532 .unwrap()
12533 .pop()
12534 .unwrap();
12535 child = inbox
12536 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
12537 fence: old.fence(),
12538 expected_created_at: child.created_at,
12539 claim: claim.clone(),
12540 expected_messages: child.messages.clone(),
12541 expected_provider_transcript: child.provider_transcript.clone(),
12542 expected_admission: None,
12543 })
12544 .await
12545 .unwrap()
12546 .session;
12547 store
12549 .validate_fence(&old.fence(), chrono::Utc::now())
12550 .await
12551 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
12552 let setup_leases = inbox
12553 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
12554 .await
12555 .unwrap();
12556 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
12557 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
12558 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
12559 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
12560 let setup_at = chrono::Utc::now();
12561 assert!(
12562 setup_at < old.lease_expires_at,
12563 "{case}: setup Actor expired"
12564 );
12565 assert!(
12566 setup_at < claim.lease.expires_at,
12567 "{case}: setup claim expired"
12568 );
12569 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
12570 if case == "ack" {
12571 inbox
12572 .ack_owned(&child.id, &claim, chrono::Utc::now())
12573 .await
12574 .unwrap();
12575 }
12576 if case == "mixed" {
12577 inbox
12578 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
12579 &child.id,
12580 "different input",
12581 ))
12582 .await
12583 .unwrap();
12584 }
12585 let directory = store
12586 .bamboo_home_dir()
12587 .join(store.resolve_rel_path(&child.id).await.unwrap());
12588 let main = std::fs::read(directory.join("session.json")).unwrap();
12589 let canonical = child.clone();
12590 match case {
12591 "prefix" => child.messages[0].content.push_str(" stale"),
12592 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
12593 "missing-marker" => {
12594 child
12595 .messages
12596 .last_mut()
12597 .unwrap()
12598 .metadata
12599 .as_mut()
12600 .unwrap()
12601 .as_object_mut()
12602 .unwrap()
12603 .remove("_bamboo_owned_input_checkpoint");
12604 }
12605 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
12606 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
12607 _ => {}
12608 }
12609 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
12612 tokio::time::sleep(delay + Duration::from_millis(10)).await;
12613 let recovery_at = chrono::Utc::now();
12614 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
12615 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
12616 let original = before_recovery.activation.unwrap();
12617 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
12618 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
12619 assert_eq!(
12620 store
12621 .validate_fence(&old.fence(), recovery_at)
12622 .await
12623 .is_ok(),
12624 case == "actor-live",
12625 "{case}: Actor expiry control"
12626 );
12627 let recovery_leases = inbox
12628 .inspect_owned_leases(&child.id, 2, recovery_at)
12629 .await
12630 .unwrap();
12631 if case == "ack" {
12632 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
12633 } else {
12634 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
12635 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
12636 assert_eq!(
12637 recovery_leases[0].expires_at, claim.lease.expires_at,
12638 "{case}"
12639 );
12640 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
12641 }
12642 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
12643 let second = Arc::new(
12644 bamboo_storage::SessionStoreV2::new(temp.path().into())
12645 .await
12646 .unwrap(),
12647 );
12648 let binding = actor_binding(
12649 second.clone(),
12650 Arc::new(bamboo_storage::FileSessionInbox::new(
12651 second.clone(),
12652 bamboo_domain::SessionInboxLimits::default(),
12653 )),
12654 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
12655 );
12656 let _owner = binding
12657 .router
12658 .register_run(&child.id, "replacement-run")
12659 .await
12660 .unwrap();
12661 let prepared = PlainActorActivation::start_and_prepare(
12662 second.clone(),
12663 &mut child,
12664 &binding,
12665 Some("replacement-run"),
12666 Some(policy.clone()),
12667 case != "readonly",
12668 Some("probed-new-worker"),
12669 )
12670 .await;
12671 assert_eq!(
12672 std::fs::read(directory.join("session.json")).unwrap(),
12673 main,
12674 "{case}: Already or refusal never rewrites Main"
12675 );
12676 if case != "accepted" {
12677 assert!(prepared.is_err(), "{case}");
12678 assert_eq!(
12679 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
12680 case == "ack"
12681 );
12682 continue;
12683 }
12684 let (replacement, delivery) = prepared.unwrap();
12685 assert!(replacement.recovering_pre_ack);
12686 let (messages, delivery) = delivery.unwrap();
12687 let new_claim = replacement.initial_input.as_ref().unwrap();
12688 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
12689 assert_eq!(new_claim.claim.generation, claim.claim.generation);
12690 assert_eq!(
12691 new_claim.claim.activation_policy,
12692 claim.claim.activation_policy
12693 );
12694 assert!(new_claim.lease.epoch > claim.lease.epoch);
12695 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
12696 assert_eq!(
12697 replacement.inbox_generation, 1,
12698 "old Actor generation was zero"
12699 );
12700 assert_eq!(
12701 second
12702 .inspect_actor(&child.id)
12703 .await
12704 .unwrap()
12705 .actor
12706 .current_attempt,
12707 2
12708 );
12709 assert!(second
12710 .validate_fence(&old.fence(), chrono::Utc::now())
12711 .await
12712 .is_err());
12713 assert!(inbox
12714 .ack_owned(&child.id, &claim, chrono::Utc::now())
12715 .await
12716 .is_err());
12717 let authority = second.inspect_actor(&child.id).await.unwrap();
12721 let leases = inbox
12722 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
12723 .await
12724 .unwrap();
12725 let delayed = inbox
12726 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
12727 fence: old.fence(),
12728 expected_created_at: child.created_at,
12729 claim: claim.clone(),
12730 expected_messages: child.messages.clone(),
12731 expected_provider_transcript: child.provider_transcript.clone(),
12732 expected_admission: child.session_inbox_admission().cloned(),
12733 })
12734 .await;
12735 assert!(matches!(
12736 delayed,
12737 Err(bamboo_storage::ActorInputCheckpointError::Actor(
12738 bamboo_domain::ActorDirectoryError::StaleFence
12739 ))
12740 ));
12741 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
12742 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
12743 assert_eq!(
12744 inbox
12745 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
12746 .await
12747 .unwrap(),
12748 leases
12749 );
12750 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
12751 assert_eq!(
12752 serde_json::to_value(&child.messages).unwrap(),
12753 serde_json::to_value(&canonical.messages).unwrap()
12754 );
12755 let mut worker: Vec<bamboo_agent_core::Message> = messages
12756 .iter()
12757 .cloned()
12758 .map(|v| serde_json::from_value(v).unwrap())
12759 .collect();
12760 let target = worker.last_mut().unwrap();
12761 assert!(target
12762 .metadata
12763 .as_ref()
12764 .unwrap()
12765 .get("_bamboo_owned_input_checkpoint")
12766 .is_none());
12767 assert!(bamboo_domain::is_matching_session_message(
12768 target, &envelope
12769 ));
12770 let mut wanted = canonical.messages.last().unwrap().clone();
12771 wanted
12772 .metadata
12773 .as_mut()
12774 .unwrap()
12775 .as_object_mut()
12776 .unwrap()
12777 .remove("_bamboo_owned_input_checkpoint");
12778 assert_eq!(
12779 serde_json::to_value(target).unwrap(),
12780 serde_json::to_value(wanted).unwrap()
12781 );
12782 let run = RunSpec {
12783 assignment: "original task".into(),
12784 logical_session: Some(LogicalSessionIdentity {
12785 session_id: child.id.clone(),
12786 parent_session_id: child.parent_session_id.clone(),
12787 root_session_id: child.root_session_id.clone(),
12788 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
12789 created_at: child.created_at,
12790 spawn_depth: child.spawn_depth,
12791 }),
12792 }),
12793 project_id: None,
12794 reasoning_effort: None,
12795 permission_policy: Some(PermissionPolicyContext {
12796 revision: policy.policy_revision(),
12797 requested_mode: "default".into(),
12798 effective_mode: "default".into(),
12799 bypass_permissions: false,
12800 auto_approve_permissions: false,
12801 session_id: child.id.clone(),
12802 workspace_path: child.workspace.clone(),
12803 environment_lease: None,
12804 inherit_session_grants: false,
12805 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
12806 }),
12807 messages,
12808 activation_run_id: Some("replacement-run".into()),
12809 execution_epoch: 2,
12810 initial_session_messages: vec![delivery.clone()],
12811 secrets: Default::default(),
12812 };
12813 let policy_revision = policy.policy_revision();
12814 let mut expected = Some(expected_default_permission_posture(policy_revision));
12815 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
12816 else {
12817 unreachable!()
12818 };
12819 let (events, mut rx) = mpsc::channel(1);
12820 process_actor_event(
12821 event,
12822 true,
12823 &mut PermissionPostureHandshake::Awaiting,
12824 expected.as_ref(),
12825 Some(&binding),
12826 &mut child,
12827 &events,
12828 &mut ActorEventDisplay::default(),
12829 )
12830 .await
12831 .unwrap();
12832 rx.recv().await.unwrap();
12833 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
12834 &run,
12835 &delivery,
12836 uuid::Uuid::new_v4().to_string(),
12837 )
12838 .unwrap();
12839 replacement
12840 .release_initial_input(
12841 &binding,
12842 &child,
12843 &run,
12844 2,
12845 new_claim,
12846 &request,
12847 &mut expected,
12848 None,
12849 )
12850 .await
12851 .unwrap();
12852 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
12853 replacement
12854 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
12855 .await
12856 .unwrap();
12857 rx.recv().await.unwrap();
12858 replacement
12859 .finish(ActorActivationFinish::Succeeded)
12860 .await
12861 .unwrap();
12862 let cold = second.load_session(&child.id).await.unwrap().unwrap();
12863 assert_eq!(cold.created_at, canonical.created_at);
12864 assert_eq!(
12865 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
12866 serde_json::to_value(&canonical.messages).unwrap()
12867 );
12868 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
12869 assert_eq!(
12870 cold.messages
12871 .iter()
12872 .filter(|m| m.id == envelope.id.as_str())
12873 .count(),
12874 1
12875 );
12876 }
12877 }
12878
12879 #[cfg(unix)]
12880 #[tokio::test]
12881 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
12882 use bamboo_subagent::provision::*;
12883 use std::os::unix::fs::PermissionsExt;
12884 let temp = tempfile::tempdir().unwrap();
12885 let worker = temp.path().join("mixed-worker");
12886 let spawned = temp.path().join("spawned");
12887 let run = temp.path().join("run");
12888 let probes = temp.path().join("probes");
12889 let fabric = temp.path().join("fabric");
12890 let mut report = WorkerCapabilityReport::current();
12891 report
12892 .capabilities
12893 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
12894 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
12895 let report = serde_json::to_string(&report).unwrap();
12896 std::fs::write(&worker, format!(
12897 "#!/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"
12898 )).unwrap();
12899 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
12900 let args = vec![
12901 spawned.to_str().unwrap().into(),
12902 run.to_str().unwrap().into(),
12903 probes.to_str().unwrap().into(),
12904 ];
12905 for capability in [
12907 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
12908 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
12909 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
12910 TYPED_READ_ONLY_WORKER_CAPABILITY,
12911 ] {
12912 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
12913 .await
12914 .unwrap();
12915 }
12916 let runner = ActorChildRunner::new(
12917 "mixed".into(),
12918 worker,
12919 args,
12920 fabric.clone(),
12921 ExecutorSpec::BambooRuntime,
12922 vec![],
12923 "openai".into(),
12924 1,
12925 );
12926 let error = runner
12928 .require_initial_input_release_worker()
12929 .await
12930 .unwrap_err();
12931 assert!(error
12932 .to_string()
12933 .contains("owned initial release worker capability unconfirmed"));
12934 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
12935 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
12936 assert!(runner.pool.lock().await.is_empty());
12937 }
12938
12939 #[tokio::test]
12940 async fn ordinary_glob_only_observes_own_initialization_signals() {
12941 let temp = tempfile::tempdir().unwrap();
12942 let home = temp.path().canonicalize().unwrap();
12943 let store = bamboo_storage::SessionStoreV2::new(home.clone())
12944 .await
12945 .unwrap();
12946 let parent = Session::new("glob-standard-parent", "model");
12947 store.save_session(&parent).await.unwrap();
12948 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
12949 store.save_session(&child).await.unwrap();
12950 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
12951 let unrelated = directory.parent().unwrap().join("unrelated");
12954 std::fs::create_dir(&unrelated).unwrap();
12955 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
12956 require_unowned_glob(&store, &child).await.unwrap();
12957 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
12958 let path = directory.join(name);
12959 std::fs::write(&path, b"invalid prior signal").unwrap();
12960 assert!(require_unowned_glob(&store, &child).await.is_err());
12961 std::fs::remove_file(&path).unwrap();
12962 }
12963 #[cfg(unix)]
12964 {
12965 let marker = directory.join("actor-authority.initialized.json");
12966 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
12967 assert!(require_unowned_glob(&store, &child).await.is_err());
12968 }
12969 }
12970
12971 #[test]
12972 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
12973 let call = bamboo_domain::ToolCall {
12974 id: "glob".into(),
12975 tool_type: "function".into(),
12976 function: bamboo_domain::FunctionCall {
12977 name: "Glob".into(),
12978 arguments: r#"{"pattern":"marker.txt"}"#.into(),
12979 },
12980 };
12981 let messages = vec![
12982 bamboo_agent_core::Message::assistant("", Some(vec![call])),
12983 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
12984 bamboo_agent_core::Message::assistant("done", None),
12985 ];
12986 let start = serde_json::to_value(AgentEvent::ToolStart {
12987 tool_call_id: "glob".into(),
12988 tool_name: "Glob".into(),
12989 arguments: serde_json::json!({"pattern":"marker.txt"}),
12990 })
12991 .unwrap();
12992 let complete = serde_json::to_value(AgentEvent::ToolComplete {
12993 tool_call_id: "glob".into(),
12994 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
12995 })
12996 .unwrap();
12997 let tail =
12998 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
12999 messages: messages
13000 .iter()
13001 .map(serde_json::to_value)
13002 .collect::<Result<_, _>>()
13003 .unwrap(),
13004 })
13005 .unwrap();
13006 let mut ordered = ReadOnlyActorCollector::default();
13007 assert!(ordered.event(&start).unwrap());
13008 assert!(ordered.event(&complete).unwrap());
13009 assert!(!ordered.event(&tail).unwrap());
13010 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13011 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13012 assert!(ReadOnlyActorCollector::default()
13013 .finish(Some("done"))
13014 .is_err());
13015 let mut unfinished = ReadOnlyActorCollector::default();
13016 unfinished.event(&start).unwrap();
13017 unfinished.event(&complete).unwrap();
13018 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13019 let mut closed = tail.clone();
13020 closed["grant"] = true.into();
13021 assert!(
13022 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13023 .is_err()
13024 );
13025 let mut oversized = tail.clone();
13026 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13027 assert!(
13028 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13029 .unwrap()
13030 .validate()
13031 .is_err()
13032 );
13033 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13034 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13035 let mut bad = start.clone();
13036 bad["tool_name"] = "Bash".into();
13037 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13038 let mut duplicate = ReadOnlyActorCollector::default();
13039 duplicate.event(&start).unwrap();
13040 assert!(duplicate.event(&start).is_err());
13041 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13042 content: "hidden".into(),
13043 })
13044 .unwrap();
13045 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13046 let mut mismatched = ReadOnlyActorCollector::default();
13047 mismatched.event(&start).unwrap();
13048 let mut bad = complete.clone();
13049 bad["tool_call_id"] = "foreign".into();
13050 assert!(mismatched.event(&bad).is_err());
13051 mismatched.event(&complete).unwrap();
13052 let mut changed = tail.clone();
13053 changed["messages"][1]["content"] = "fabricated".into();
13054 assert!(mismatched.event(&changed).is_err());
13055 }
13056
13057 #[cfg(unix)]
13058 #[tokio::test]
13059 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13060 let temp = tempfile::tempdir().unwrap();
13061 let store = Arc::new(
13062 bamboo_storage::SessionStoreV2::new(temp.path().into())
13063 .await
13064 .unwrap(),
13065 );
13066 let parent = Session::new("release-host-parent", "model");
13067 store.save_session(&parent).await.unwrap();
13068 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13069 store.save_session(&child).await.unwrap();
13070 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13071 store.clone(),
13072 bamboo_domain::SessionInboxLimits::default(),
13073 ));
13074 let binding = actor_binding(
13075 store.clone(),
13076 inbox,
13077 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13078 );
13079 let _owner = binding
13080 .router
13081 .register_run(&child.id, "release-host-run")
13082 .await
13083 .unwrap();
13084 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13085 let revision = policy.policy_revision();
13086 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13087 let activation = PlainActorActivation::start(
13088 store.clone(),
13089 &child,
13090 &binding,
13091 Some("release-host-run"),
13092 Some(policy.clone()),
13093 true,
13094 Some("probed-physical-worker"),
13095 )
13096 .await
13097 .unwrap();
13098 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13099 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13100 activation
13101 .input_inbox
13102 .mark_activation_eligible(
13103 &child.id,
13104 receipt.generation,
13105 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13106 )
13107 .await
13108 .unwrap();
13109 let claim = activation
13110 .claim_input(&binding, &child, "release-host-run")
13111 .await
13112 .unwrap()
13113 .unwrap();
13114 let (prefix, delivery) = activation
13115 .checkpoint_input(&mut child, "release-host-run", &claim)
13116 .await
13117 .unwrap();
13118 let mut expected = Some(expected_default_permission_posture(revision));
13119 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13120 unreachable!()
13121 };
13122 let (events, mut events_rx) = mpsc::channel(1);
13123 process_actor_event(
13124 event,
13125 true,
13126 &mut PermissionPostureHandshake::Awaiting,
13127 expected.as_ref(),
13128 Some(&binding),
13129 &mut child,
13130 &events,
13131 &mut ActorEventDisplay::default(),
13132 )
13133 .await
13134 .unwrap();
13135 events_rx.recv().await.unwrap();
13136 let run = RunSpec {
13137 assignment: "assignment".into(),
13138 logical_session: Some(LogicalSessionIdentity {
13139 session_id: child.id.clone(),
13140 parent_session_id: child.parent_session_id.clone(),
13141 root_session_id: child.root_session_id.clone(),
13142 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13143 created_at: child.created_at,
13144 spawn_depth: child.spawn_depth,
13145 }),
13146 }),
13147 project_id: None,
13148 reasoning_effort: None,
13149 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13150 revision,
13151 requested_mode: "default".into(),
13152 effective_mode: "default".into(),
13153 bypass_permissions: false,
13154 auto_approve_permissions: false,
13155 session_id: child.id.clone(),
13156 workspace_path: child.workspace.clone(),
13157 environment_lease: None,
13158 inherit_session_grants: false,
13159 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13160 }),
13161 messages: prefix,
13162 activation_run_id: Some("release-host-run".into()),
13163 execution_epoch: 9,
13164 initial_session_messages: vec![delivery.clone()],
13165 secrets: Default::default(),
13166 };
13167 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13168 &run,
13169 &delivery,
13170 uuid::Uuid::new_v4().to_string(),
13171 )
13172 .unwrap();
13173 let mut stale = child.clone();
13174 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13175 assert!(activation
13176 .release_initial_input(
13177 &binding,
13178 &stale,
13179 &run,
13180 9,
13181 &claim,
13182 &request,
13183 &mut expected,
13184 None
13185 )
13186 .await
13187 .is_err());
13188 assert!(!activation
13189 .input_inbox
13190 .was_admitted(&child.id, &envelope.id)
13191 .await
13192 .unwrap());
13193 use std::os::unix::fs::PermissionsExt;
13196 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13197 impl Drop for RestorePermissions {
13198 fn drop(&mut self) {
13199 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13200 }
13201 }
13202 let admitted = store
13203 .bamboo_home_dir()
13204 .join(store.resolve_rel_path(&child.id).await.unwrap())
13205 .join("inbox/admitted");
13206 std::fs::create_dir_all(&admitted).unwrap();
13207 let restore = RestorePermissions(
13208 admitted.clone(),
13209 std::fs::metadata(&admitted).unwrap().permissions(),
13210 );
13211 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13212 let verified = activation
13213 .input_inbox
13214 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13215 fence: activation.fence.clone(),
13216 expected_created_at: child.created_at,
13217 claim: claim.clone(),
13218 expected_messages: child.messages.clone(),
13219 expected_provider_transcript: child.provider_transcript.clone(),
13220 expected_admission: child.session_inbox_admission().cloned(),
13221 })
13222 .await
13223 .unwrap();
13224 assert_eq!(
13225 verified.status,
13226 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13227 );
13228 let error = activation
13229 .release_initial_input(
13230 &binding,
13231 &child,
13232 &run,
13233 9,
13234 &claim,
13235 &request,
13236 &mut expected,
13237 None,
13238 )
13239 .await
13240 .unwrap_err();
13241 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13242 drop(restore);
13243 assert!(
13244 error.to_string().contains("ACK unresolved"),
13245 "actual release error (<=512B): {}",
13246 error.to_string().chars().take(128).collect::<String>()
13247 );
13248 let release = activation
13249 .release_initial_input(
13250 &binding,
13251 &child,
13252 &run,
13253 9,
13254 &claim,
13255 &request,
13256 &mut expected,
13257 None,
13258 )
13259 .await
13260 .unwrap();
13261 assert!(activation
13262 .input_inbox
13263 .was_admitted(&child.id, &envelope.id)
13264 .await
13265 .unwrap());
13266 assert_eq!(
13267 release.expires_at,
13268 activation
13269 .actor_deadline
13270 .min(claim.lease.expires_at)
13271 .min(activation.input_deadline)
13272 );
13273 assert_eq!(
13274 activation
13275 .release_initial_input(
13276 &binding,
13277 &child,
13278 &run,
13279 9,
13280 &claim,
13281 &request,
13282 &mut expected,
13283 Some(&release)
13284 )
13285 .await
13286 .unwrap(),
13287 release
13288 );
13289 let mut wrong = request.clone();
13290 wrong.nonce = uuid::Uuid::new_v4().to_string();
13291 assert!(activation
13292 .release_initial_input(
13293 &binding,
13294 &child,
13295 &run,
13296 9,
13297 &claim,
13298 &wrong,
13299 &mut expected,
13300 Some(&release)
13301 )
13302 .await
13303 .is_err());
13304 assert!(activation
13305 .release_initial_input(
13306 &binding,
13307 &child,
13308 &run,
13309 10,
13310 &claim,
13311 &request,
13312 &mut expected,
13313 Some(&release)
13314 )
13315 .await
13316 .is_err());
13317 let deadlines = (
13319 activation.fence.clone(),
13320 activation.actor_deadline,
13321 activation.input_deadline,
13322 );
13323 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
13324 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
13325 activation
13326 .input_inbox
13327 .mark_activation_eligible(
13328 &child.id,
13329 receipt.generation,
13330 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13331 )
13332 .await
13333 .unwrap();
13334 let mut link = ConfirmationSequenceLink {
13335 frames: VecDeque::new(),
13336 sent: Vec::new(),
13337 };
13338 let epochs = AtomicU64::new(9);
13339 let (claim2, epoch2) = activation
13340 .continue_input(
13341 &mut link,
13342 &binding,
13343 &mut child,
13344 "release-host-run",
13345 Some((&run, &epochs)),
13346 &mut expected,
13347 "SECOND_REPLY".into(),
13348 &events,
13349 )
13350 .await
13351 .unwrap()
13352 .unwrap();
13353 events_rx.recv().await.unwrap();
13354 assert_eq!(epoch2, 10);
13355 let ParentFrame::Run(next) = &link.sent[0] else {
13356 panic!("second correlated Run")
13357 };
13358 assert_eq!(next.logical_session, run.logical_session);
13359 assert_eq!(next.activation_run_id, run.activation_run_id);
13360 assert_eq!(next.initial_session_messages.len(), 1);
13361 assert!(next
13362 .messages
13363 .iter()
13364 .any(|m| m["id"] == envelope.id.as_str()));
13365 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
13366 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
13367 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13368 next,
13369 &next.initial_session_messages[0],
13370 uuid::Uuid::new_v4().to_string(),
13371 )
13372 .unwrap();
13373 assert_ne!(request2.nonce, request.nonce);
13374 macro_rules! release_second {
13375 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
13376 activation
13377 .release_initial_input(
13378 &binding,
13379 $candidate,
13380 next,
13381 $epoch,
13382 &claim2,
13383 $request,
13384 &mut expected,
13385 $previous,
13386 )
13387 .await
13388 };
13389 }
13390 assert!(release_second!(&child, &request, epoch2, None).is_err());
13391 assert!(release_second!(&child, &request2, 9, None).is_err());
13392 let mut stale2 = child.clone();
13393 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
13394 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
13395 assert!(!activation
13396 .input_inbox
13397 .was_admitted(&child.id, &second.id)
13398 .await
13399 .unwrap());
13400 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
13401 assert_eq!(
13402 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
13403 release2
13404 );
13405 let mut wrong2 = request2.clone();
13406 wrong2.nonce = request.nonce.clone();
13407 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
13408 assert!(activation
13409 .input_inbox
13410 .was_admitted(&child.id, &second.id)
13411 .await
13412 .unwrap());
13413 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
13414 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
13415 activation
13416 .input_inbox
13417 .mark_activation_eligible(
13418 &child.id,
13419 receipt.generation,
13420 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13421 )
13422 .await
13423 .unwrap();
13424 let now = chrono::Utc::now();
13425 let expired_claim = activation
13426 .input_inbox
13427 .claim_owned(
13428 &child.id,
13429 1,
13430 Some("release-host-run"),
13431 &SessionInboxLeaseRequest {
13432 consumer: activation.input_consumer.clone(),
13433 now,
13434 duration: chrono::Duration::seconds(1),
13435 },
13436 )
13437 .await
13438 .unwrap()
13439 .pop()
13440 .unwrap();
13441 let (prefix, delivery3) = activation
13442 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
13443 .await
13444 .unwrap();
13445 let mut expired_run = next.clone();
13446 expired_run.execution_epoch = 11;
13447 expired_run.messages = prefix;
13448 expired_run.initial_session_messages = vec![delivery3.clone()];
13449 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13450 &expired_run,
13451 &delivery3,
13452 uuid::Uuid::new_v4().to_string(),
13453 )
13454 .unwrap();
13455 tokio::time::sleep(
13456 (expired_claim.lease.expires_at - chrono::Utc::now())
13457 .to_std()
13458 .unwrap_or_default(),
13459 )
13460 .await;
13461 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
13462 assert!(activation
13463 .release_initial_input(
13464 &binding,
13465 &child,
13466 &expired_run,
13467 11,
13468 &expired_claim,
13469 &expired_request,
13470 &mut expected,
13471 None
13472 )
13473 .await
13474 .is_err());
13475 assert!(!activation
13476 .input_inbox
13477 .was_admitted(&child.id, &expired.id)
13478 .await
13479 .unwrap());
13480 assert_eq!(
13481 (
13482 activation.fence.clone(),
13483 activation.actor_deadline,
13484 activation.input_deadline
13485 ),
13486 deadlines
13487 );
13488 assert_eq!(
13489 link.sent.len(),
13490 1,
13491 "no dispatch from rejected release checks"
13492 );
13493 let cold = store.load_session(&child.id).await.unwrap().unwrap();
13494 for id in [&envelope.id, &second.id] {
13495 assert_eq!(
13496 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
13497 1
13498 );
13499 assert!(cold.session_inbox_admission().unwrap().contains(id));
13500 }
13501 policy.deny_scoped_session_permission(
13503 &child.id,
13504 bamboo_tools::permission::PermissionType::ExecuteCommand,
13505 "blocked-action",
13506 );
13507 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
13508 assert!(activation
13509 .input_inbox
13510 .was_admitted(&child.id, &second.id)
13511 .await
13512 .unwrap());
13513 activation
13514 .finish(ActorActivationFinish::Failed)
13515 .await
13516 .unwrap();
13517 activation
13519 .input_inbox
13520 .ack_owned(&child.id, &claim, chrono::Utc::now())
13521 .await
13522 .unwrap();
13523 assert!(activation
13524 .release_initial_input(
13525 &binding,
13526 &child,
13527 &run,
13528 9,
13529 &claim,
13530 &request,
13531 &mut expected,
13532 Some(&release)
13533 )
13534 .await
13535 .is_err());
13536 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
13537 .await
13538 .unwrap()
13539 .load_session(&child.id)
13540 .await
13541 .unwrap()
13542 .unwrap();
13543 assert_eq!(
13544 cold.messages
13545 .iter()
13546 .filter(|m| m.id == envelope.id.as_str())
13547 .count(),
13548 1
13549 );
13550 }
13551
13552 #[tokio::test]
13553 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
13554 for reject_checkpoint in [false, true] {
13555 let temp = tempfile::tempdir().unwrap();
13556 let store = Arc::new(
13557 bamboo_storage::SessionStoreV2::new(temp.path().into())
13558 .await
13559 .unwrap(),
13560 );
13561 let parent = Session::new("owned-input-parent", "model");
13562 store.save_session(&parent).await.unwrap();
13563 let mut child =
13564 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
13565 store.save_session(&child).await.unwrap();
13566 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13567 let raw_storage: Arc<dyn Storage> = store.clone();
13568 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
13569 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13570 store.clone(),
13571 bamboo_domain::SessionInboxLimits::default(),
13572 ));
13573 let binding = actor_binding(store.clone(), inbox, locked);
13574 let run_id = "owned-input-run";
13575 let _registration = binding
13576 .router
13577 .register_run(&child.id, run_id)
13578 .await
13579 .unwrap();
13580 assert!(PlainActorActivation::start(
13581 store.clone(),
13582 &child,
13583 &binding,
13584 Some(run_id),
13585 None,
13586 true,
13587 None,
13588 )
13589 .await
13590 .is_err());
13591 assert!(PlainActorActivation::start(
13592 store.clone(),
13593 &child,
13594 &binding,
13595 Some(run_id),
13596 Some(policy.clone()),
13597 true,
13598 None,
13599 )
13600 .await
13601 .is_err());
13602 assert_eq!(
13603 store
13604 .inspect_actor(&child.id)
13605 .await
13606 .unwrap()
13607 .actor
13608 .current_attempt,
13609 0
13610 );
13611 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13612 let activation = PlainActorActivation::start(
13613 store.clone(),
13614 &child,
13615 &binding,
13616 Some(run_id),
13617 Some(policy.clone()),
13618 true,
13619 None,
13620 )
13621 .await
13622 .unwrap();
13623 let envelope =
13624 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
13625 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13626 activation
13627 .input_inbox
13628 .mark_activation_eligible(
13629 &child.id,
13630 receipt.generation,
13631 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13632 )
13633 .await
13634 .unwrap();
13635 let mut link = ConfirmationSequenceLink {
13636 frames: VecDeque::new(),
13637 sent: Vec::new(),
13638 };
13639 let policy_revision = policy.policy_revision();
13640 let run = RunSpec {
13641 assignment: "one plain task".into(),
13642 logical_session: None,
13643 project_id: None,
13644 reasoning_effort: None,
13645 permission_policy: Some(PermissionPolicyContext {
13646 revision: policy_revision,
13647 requested_mode: "default".into(),
13648 effective_mode: "default".into(),
13649 bypass_permissions: false,
13650 auto_approve_permissions: false,
13651 session_id: child.id.clone(),
13652 workspace_path: child.workspace.clone(),
13653 environment_lease: None,
13654 inherit_session_grants: false,
13655 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13656 }),
13657 messages: vec![],
13658 activation_run_id: Some(run_id.into()),
13659 execution_epoch: 1,
13660 initial_session_messages: vec![],
13661 secrets: Default::default(),
13662 };
13663 let epochs = AtomicU64::new(1);
13664 let (tx, mut rx) = mpsc::channel(1);
13665 let mut expected = Some(expected_default_permission_posture(policy_revision));
13666 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13667 else {
13668 unreachable!()
13669 };
13670 process_actor_event(
13671 event,
13672 true,
13673 &mut PermissionPostureHandshake::Awaiting,
13674 expected.as_ref(),
13675 Some(&binding),
13676 &mut child,
13677 &tx,
13678 &mut ActorEventDisplay::default(),
13679 )
13680 .await
13681 .unwrap();
13682 rx.recv().await.unwrap();
13683 let first_audit =
13684 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
13685 .unwrap()
13686 .audit_revision;
13687 if reject_checkpoint {
13688 tx.send(AgentEvent::Complete {
13691 usage: Default::default(),
13692 })
13693 .await
13694 .unwrap();
13695 }
13696 let mut operation = activation.continue_input(
13697 &mut link,
13698 &binding,
13699 &mut child,
13700 run_id,
13701 Some((&run, &epochs)),
13702 &mut expected,
13703 "FIRST_PLAIN_REPLY".into(),
13704 &tx,
13705 );
13706 if reject_checkpoint {
13707 tokio::select! {
13708 result = &mut operation => panic!("publication barrier released early: {result:?}"),
13709 _ = async {
13710 tokio::time::timeout(Duration::from_secs(5), async {
13711 loop {
13712 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
13713 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
13714 tokio::task::yield_now().await;
13715 }
13716 }).await.unwrap();
13717 activation.finish(ActorActivationFinish::Failed).await.unwrap();
13718 rx.recv().await.unwrap();
13719 } => {}
13720 }
13721 }
13722 let result = operation.await;
13723 if reject_checkpoint {
13724 assert!(result
13725 .unwrap_err()
13726 .to_string()
13727 .contains("checkpoint unconfirmed"));
13728 assert!(
13729 link.sent.is_empty(),
13730 "zero worker/provider correction dispatch"
13731 );
13732 let durable = store.load_session(&child.id).await.unwrap().unwrap();
13733 assert!(durable
13734 .messages
13735 .iter()
13736 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
13737 assert!(!durable
13738 .messages
13739 .iter()
13740 .any(|m| m.id == envelope.id.as_str()));
13741 assert!(!activation
13742 .input_inbox
13743 .was_admitted(&child.id, &envelope.id)
13744 .await
13745 .unwrap());
13746 assert_eq!(
13747 activation
13748 .input_inbox
13749 .inspect(&child.id)
13750 .await
13751 .unwrap()
13752 .claimed,
13753 1
13754 );
13755 continue;
13756 }
13757 let (claim, epoch) = result.unwrap().unwrap();
13758 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
13760 assert_eq!(link.sent.len(), 1);
13761 let ParentFrame::Run(next) = &link.sent[0] else {
13762 panic!("correlated continuation Run")
13763 };
13764 assert_eq!(next.execution_epoch, 2);
13765 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
13766 assert!(next
13767 .messages
13768 .iter()
13769 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
13770 assert!(!next
13771 .messages
13772 .iter()
13773 .any(|m| m["id"] == envelope.id.as_str()));
13774 let delivery = &next.initial_session_messages[0];
13775 assert_eq!(delivery.envelope, envelope);
13776 let durable = store.load_session(&child.id).await.unwrap().unwrap();
13777 assert!(durable
13778 .session_inbox_admission()
13779 .unwrap()
13780 .contains(&envelope.id));
13781 assert_eq!(
13782 durable
13783 .messages
13784 .iter()
13785 .filter(|m| m.id == envelope.id.as_str())
13786 .count(),
13787 1
13788 );
13789 assert!(
13790 !activation
13791 .input_inbox
13792 .was_admitted(&child.id, &envelope.id)
13793 .await
13794 .unwrap(),
13795 "checkpoint is not a worker admission receipt"
13796 );
13797 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
13798 confirmation.canonical_claim_generation += 1;
13799 assert!(activation
13800 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
13801 .await
13802 .is_err());
13803 assert!(!activation
13804 .input_inbox
13805 .was_admitted(&child.id, &envelope.id)
13806 .await
13807 .unwrap());
13808 confirmation.canonical_claim_generation = claim.claim.generation;
13809 activation
13810 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
13811 .await
13812 .unwrap();
13813 assert!(activation
13814 .input_inbox
13815 .was_admitted(&child.id, &envelope.id)
13816 .await
13817 .unwrap());
13818 assert_eq!(
13819 expected.as_ref().unwrap().expected_audit_revision,
13820 Some(first_audit)
13821 );
13822 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13823 else {
13824 unreachable!()
13825 };
13826 process_actor_event(
13827 event,
13828 true,
13829 &mut PermissionPostureHandshake::Awaiting,
13830 expected.as_ref(),
13831 Some(&binding),
13832 &mut child,
13833 &tx,
13834 &mut ActorEventDisplay::default(),
13835 )
13836 .await
13837 .unwrap();
13838 rx.recv().await.unwrap();
13839 assert!(
13840 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
13841 .unwrap()
13842 .audit_revision
13843 > first_audit
13844 );
13845 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
13846 .await
13847 .unwrap();
13848 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13849 assert_eq!(
13850 cold.messages
13851 .iter()
13852 .filter(|m| m.id == envelope.id.as_str())
13853 .count(),
13854 1
13855 );
13856 assert!(cold
13857 .session_inbox_admission()
13858 .unwrap()
13859 .contains(&envelope.id));
13860 }
13861 }
13862
13863 fn admission_confirmation(
13864 session_id: &str,
13865 claim: &SessionInboxClaim,
13866 run_id: &str,
13867 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
13868 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
13869 target_session_id: session_id.to_string(),
13870 envelope_id: claim.envelope.id.to_string(),
13871 canonical_claim_generation: claim.generation,
13872 activation_run_id: run_id.to_string(),
13873 }
13874 }
13875
13876 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
13877 ExpectedPermissionPosture {
13878 policy_revision,
13879 resolution: bamboo_domain::PermissionModeResolution {
13880 requested: bamboo_domain::SessionPermissionMode::Default,
13881 effective: bamboo_domain::PermissionMode::Default,
13882 },
13883 expected_audit_revision: None,
13884 executor_mapping: "test_actor:permission_mode=default".to_string(),
13885 }
13886 }
13887
13888 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
13889 ChildFrame::Event {
13890 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
13891 session_id: session_id.to_string(),
13892 policy_revision,
13893 requested_mode: "default".to_string(),
13894 effective_mode: "default".to_string(),
13895 executor_mapping: "test_actor:permission_mode=default".to_string(),
13896 })
13897 .expect("serialize permission posture event"),
13898 }
13899 }
13900
13901 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
13902 ChildFrame::Event {
13903 event: serde_json::to_value(event).expect("serialize actor event"),
13904 }
13905 }
13906
13907 #[test]
13908 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
13909 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
13910 let mut batch = ActorEventBatch {
13911 logical_session: Some(LogicalSessionIdentity {
13912 creation: None,
13913 session_id: session.id.clone(),
13914 parent_session_id: session.parent_session_id.clone(),
13915 root_session_id: session.root_session_id.clone(),
13916 }),
13917 activation_id: Some("activation-7".into()),
13918 execution_epoch: 11,
13919 source_node_id: Some("node-a".into()),
13920 source_actor_id: Some("worker-a".into()),
13921 first_seq: 1,
13922 last_seq: 1,
13923 qos: bamboo_subagent::ActorEventQos::Ephemeral,
13924 events: vec![serde_json::json!({"type":"token","content":"ok"})],
13925 };
13926 validate_actor_event_batch(
13927 &batch,
13928 &session,
13929 "logical-parent",
13930 Some("activation-7"),
13931 11,
13932 "worker-a",
13933 None,
13934 )
13935 .unwrap();
13936
13937 let creation = bamboo_subagent::proto::ChildCreationIdentity {
13938 created_at: session.created_at,
13939 spawn_depth: session.spawn_depth,
13940 };
13941 assert!(validate_actor_event_batch(
13942 &batch,
13943 &session,
13944 "logical-parent",
13945 Some("activation-7"),
13946 11,
13947 "worker-a",
13948 Some(&creation)
13949 )
13950 .is_err());
13951 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
13952 validate_actor_event_batch(
13953 &batch,
13954 &session,
13955 "logical-parent",
13956 Some("activation-7"),
13957 11,
13958 "worker-a",
13959 Some(&creation),
13960 )
13961 .unwrap();
13962 batch
13963 .logical_session
13964 .as_mut()
13965 .unwrap()
13966 .creation
13967 .as_mut()
13968 .unwrap()
13969 .created_at += chrono::Duration::nanoseconds(1);
13970 assert!(validate_actor_event_batch(
13971 &batch,
13972 &session,
13973 "logical-parent",
13974 Some("activation-7"),
13975 11,
13976 "worker-a",
13977 Some(&creation)
13978 )
13979 .is_err());
13980 batch.logical_session.as_mut().unwrap().creation = None;
13981
13982 batch.execution_epoch = 10;
13983 assert!(validate_actor_event_batch(
13984 &batch,
13985 &session,
13986 "logical-parent",
13987 Some("activation-7"),
13988 11,
13989 "worker-a",
13990 None,
13991 )
13992 .unwrap_err()
13993 .to_string()
13994 .contains("execution epoch"));
13995 batch.execution_epoch = 11;
13996 batch.activation_id = Some("replaced-activation".into());
13997 assert!(validate_actor_event_batch(
13998 &batch,
13999 &session,
14000 "logical-parent",
14001 Some("activation-7"),
14002 11,
14003 "worker-a",
14004 None,
14005 )
14006 .unwrap_err()
14007 .to_string()
14008 .contains("stale activation"));
14009 batch.activation_id = Some("activation-7".into());
14010 batch
14011 .logical_session
14012 .as_mut()
14013 .expect("logical identity")
14014 .session_id = "foreign-child".into();
14015 assert!(validate_actor_event_batch(
14016 &batch,
14017 &session,
14018 "logical-parent",
14019 Some("activation-7"),
14020 11,
14021 "worker-a",
14022 None,
14023 )
14024 .unwrap_err()
14025 .to_string()
14026 .contains("different logical session"));
14027 batch
14028 .logical_session
14029 .as_mut()
14030 .expect("logical identity")
14031 .session_id = session.id.clone();
14032 batch.source_actor_id = Some("worker-b".into());
14033 assert!(validate_actor_event_batch(
14034 &batch,
14035 &session,
14036 "logical-parent",
14037 Some("activation-7"),
14038 11,
14039 "worker-a",
14040 None,
14041 )
14042 .unwrap_err()
14043 .to_string()
14044 .contains("different physical actor"));
14045 }
14046
14047 #[tokio::test]
14048 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14049 use bamboo_domain::Storage;
14050
14051 let temp = tempfile::tempdir().unwrap();
14052 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14053 .await
14054 .unwrap();
14055 let parent = Session::new("event-parent", "model");
14056 let child = Session::new_child_of("event-child", &parent, "model", "task");
14057 store.save_session(&parent).await.unwrap();
14058 store.save_session(&child).await.unwrap();
14059 store.ensure_actor(&child.id).await.unwrap();
14060 let now = chrono::Utc::now();
14061 let claim = |run_id: &str| ActorActivationClaim {
14062 actor_id: child.id.clone(),
14063 run_id: run_id.into(),
14064 lease_owner: "event-owner".into(),
14065 lease_expires_at: now + chrono::Duration::minutes(2),
14066 inbox_generation: 0,
14067 placement_ref: None,
14068 now,
14069 };
14070 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14071 store.start_activation(&first.fence(), now).await.unwrap();
14072 let first_fence = first.fence();
14073 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14074 .await
14075 .unwrap();
14076 validate_directory_actor_event(&store, &first_fence, &child, None)
14077 .await
14078 .unwrap(); assert!(
14080 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14081 .await
14082 .is_err()
14083 );
14084 let mut forged = child.clone();
14085 forged.root_session_id = "foreign-root".into();
14086 assert!(
14087 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14088 .await
14089 .is_err()
14090 );
14091
14092 store
14093 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14094 .await
14095 .unwrap();
14096 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14097 store.start_activation(&second.fence(), now).await.unwrap();
14098 assert!(
14099 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14100 .await
14101 .is_err()
14102 );
14103 assert!(
14104 validate_directory_actor_event(&store, &first_fence, &child, None)
14105 .await
14106 .is_err()
14107 );
14108 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14109 .await
14110 .unwrap();
14111 }
14112
14113 fn completed_actor_frame() -> ChildFrame {
14114 ChildFrame::Terminal {
14115 status: TerminalStatus::Completed,
14116 result: Some("done".to_string()),
14117 error: None,
14118 final_event_watermark: None,
14119 transcript: Vec::new(),
14120 }
14121 }
14122
14123 async fn drive_permission_handshake_frames(
14124 session_id: &str,
14125 frames: impl IntoIterator<Item = ChildFrame>,
14126 expected: ExpectedPermissionPosture,
14127 ) -> (
14128 crate::runtime::runner::Result<Option<String>>,
14129 Session,
14130 Vec<AgentEvent>,
14131 Vec<ParentFrame>,
14132 ) {
14133 let mut link = ConfirmationSequenceLink {
14134 frames: frames.into_iter().collect(),
14135 sent: Vec::new(),
14136 };
14137 let (event_tx, mut event_rx) = mpsc::channel(128);
14138 let cancel = CancellationToken::new();
14139 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14140 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14141 let mut session = Session::new(session_id, "model");
14142 let result = drive(ActorDriveContext {
14143 expected_creation: None,
14144 client: &mut link,
14145 parent_session_id: "permission-parent",
14146 child_session_id: session_id,
14147 child_attempt: 0,
14148 approval_registry: None,
14149 approval_decider: None,
14150 approval_reviewer: None,
14151 escalation_bridge: None,
14152 event_tx: &event_tx,
14153 cancel_token: &cancel,
14154 live_rx: &mut live_rx,
14155 delivery_rx: &mut delivery_rx,
14156 logical_session: &mut session,
14157 expected_permission_posture: Some(expected),
14158 session_inbox_runtime: None,
14159 actor_directory_store: None,
14160 canonical_subagent_tool: None,
14161 ticket_service: None,
14162 activation_run_id: None,
14163 execution_epoch: 0,
14164 expected_source_actor_id: session_id,
14165 initial_inflight_claims: VecDeque::new(),
14166 plain_actor: false,
14167 remote_environment_lease: false,
14168 readonly_output: None,
14169 local_history_tools: None,
14170 local_history_read_only: false,
14171 strict_history_output: None,
14172 plain_input: None,
14173 canonical_activation: None,
14174 canonical_placement_ref: None,
14175 actor_event_observer: None,
14176 plain_run: None,
14177 first_frame_timeout: Some(Duration::from_secs(1)),
14178 })
14179 .await;
14180 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14181 (result, session, events, link.sent)
14182 }
14183
14184 #[tokio::test]
14185 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14186 let session_id = "permission-missing";
14187 let (result, session, events, _) = drive_permission_handshake_frames(
14188 session_id,
14189 [completed_actor_frame()],
14190 expected_default_permission_posture(7),
14191 )
14192 .await;
14193
14194 assert!(result
14195 .unwrap_err()
14196 .to_string()
14197 .contains("terminated before permission posture confirmation"));
14198 assert!(events.is_empty());
14199 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14200 }
14201
14202 #[tokio::test]
14203 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14204 struct TerminalProofProbe {
14205 frame: Option<ChildFrame>,
14206 accepted: bool,
14207 }
14208
14209 #[async_trait]
14210 impl bamboo_subagent::ChildLink for TerminalProofProbe {
14211 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14212 Ok(())
14213 }
14214
14215 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14216 Ok(self.frame.take())
14217 }
14218
14219 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14220 self.accepted = status == TerminalStatus::Error;
14221 }
14222
14223 fn has_pending_durable_terminal(&self) -> bool {
14224 self.accepted
14225 }
14226 }
14227
14228 for (status, error, expected_proof) in [
14229 (
14230 TerminalStatus::Error,
14231 Some("remote_environment_checkout_not_clean"),
14232 true,
14233 ),
14234 (TerminalStatus::Error, Some("untrusted worker error"), false),
14235 (
14236 TerminalStatus::Cancelled,
14237 Some("remote_environment_checkout_not_clean"),
14238 false,
14239 ),
14240 ] {
14241 let mut link = TerminalProofProbe {
14242 frame: Some(ChildFrame::Terminal {
14243 status,
14244 result: None,
14245 error: error.map(str::to_owned),
14246 final_event_watermark: None,
14247 transcript: Vec::new(),
14248 }),
14249 accepted: false,
14250 };
14251 let (event_tx, _event_rx) = mpsc::channel(8);
14252 let cancel = CancellationToken::new();
14253 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14254 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14255 let mut session = Session::new("remote-terminal-proof", "model");
14256 let result = drive(ActorDriveContext {
14257 client: &mut link,
14258 parent_session_id: "proof-parent",
14259 child_session_id: "remote-terminal-proof",
14260 child_attempt: 0,
14261 approval_registry: None,
14262 approval_decider: None,
14263 approval_reviewer: None,
14264 escalation_bridge: None,
14265 event_tx: &event_tx,
14266 cancel_token: &cancel,
14267 live_rx: &mut live_rx,
14268 delivery_rx: &mut delivery_rx,
14269 logical_session: &mut session,
14270 expected_permission_posture: Some(expected_default_permission_posture(7)),
14271 expected_creation: None,
14272 session_inbox_runtime: None,
14273 actor_directory_store: None,
14274 canonical_subagent_tool: None,
14275 ticket_service: None,
14276 activation_run_id: None,
14277 execution_epoch: 0,
14278 expected_source_actor_id: "remote-terminal-proof",
14279 initial_inflight_claims: VecDeque::new(),
14280 plain_actor: false,
14281 remote_environment_lease: true,
14282 readonly_output: None,
14283 local_history_tools: None,
14284 local_history_read_only: false,
14285 strict_history_output: None,
14286 plain_input: None,
14287 canonical_activation: None,
14288 canonical_placement_ref: None,
14289 actor_event_observer: None,
14290 plain_run: None,
14291 first_frame_timeout: Some(Duration::from_secs(1)),
14292 })
14293 .await;
14294 assert!(result.is_err());
14295 assert_eq!(
14296 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14297 expected_proof
14298 );
14299 }
14300 }
14301
14302 #[tokio::test(start_paused = true)]
14303 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
14304 struct CancelOutcomeLink {
14305 cancel_sent: bool,
14306 settle: bool,
14307 accepted: bool,
14308 }
14309
14310 #[async_trait]
14311 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
14312 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14313 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
14314 Ok(())
14315 }
14316
14317 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14318 if !self.cancel_sent || !self.settle {
14319 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
14320 .await
14321 } else {
14322 self.cancel_sent = false;
14323 Ok(Some(ChildFrame::Terminal {
14324 status: TerminalStatus::Cancelled,
14325 result: None,
14326 error: None,
14327 final_event_watermark: None,
14328 transcript: Vec::new(),
14329 }))
14330 }
14331 }
14332
14333 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14334 self.accepted = status == TerminalStatus::Cancelled;
14335 }
14336
14337 fn has_pending_durable_terminal(&self) -> bool {
14338 self.accepted
14339 }
14340 }
14341
14342 let temp = tempfile::tempdir().unwrap();
14343 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14344 .await
14345 .unwrap();
14346 let fence = ActorActivationFence {
14347 schema_version: 2,
14348 actor_id: "cancelled-remote-child".into(),
14349 activation_id: "cancelled-remote-activation".into(),
14350 attempt: 1,
14351 run_id: "cancelled-remote-run".into(),
14352 lease_owner: "cancelled-remote-host".into(),
14353 lease_epoch: 1,
14354 };
14355 for settle in [true, false] {
14356 let mut link = CancelOutcomeLink {
14357 cancel_sent: false,
14358 settle,
14359 accepted: false,
14360 };
14361 let (event_tx, _event_rx) = mpsc::channel(8);
14362 let cancel = CancellationToken::new();
14363 cancel.cancel();
14364 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14365 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14366 let mut session = Session::new("cancelled-remote-child", "model");
14367 let result = drive(ActorDriveContext {
14368 expected_creation: None,
14369 client: &mut link,
14370 parent_session_id: "cancelled-remote-parent",
14371 child_session_id: "cancelled-remote-child",
14372 child_attempt: 1,
14373 approval_registry: None,
14374 approval_decider: None,
14375 approval_reviewer: None,
14376 escalation_bridge: None,
14377 event_tx: &event_tx,
14378 cancel_token: &cancel,
14379 live_rx: &mut live_rx,
14380 delivery_rx: &mut delivery_rx,
14381 logical_session: &mut session,
14382 expected_permission_posture: Some(expected_default_permission_posture(7)),
14383 session_inbox_runtime: None,
14384 actor_directory_store: Some(&store),
14385 canonical_subagent_tool: None,
14386 ticket_service: None,
14387 activation_run_id: Some("cancelled-remote-run"),
14388 execution_epoch: 0,
14389 expected_source_actor_id: "cancelled-remote-worker",
14390 initial_inflight_claims: VecDeque::new(),
14391 plain_actor: false,
14392 remote_environment_lease: true,
14393 readonly_output: None,
14394 local_history_tools: None,
14395 local_history_read_only: false,
14396 strict_history_output: None,
14397 plain_input: None,
14398 canonical_activation: Some(&fence),
14399 canonical_placement_ref: None,
14400 actor_event_observer: None,
14401 plain_run: None,
14402 first_frame_timeout: None,
14403 })
14404 .await;
14405 assert!(matches!(result, Err(AgentError::Cancelled)));
14406 assert_eq!(
14407 link.accepted, settle,
14408 "only a consumed exact terminal can release the remote fence"
14409 );
14410 assert_eq!(
14411 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
14412 settle
14413 );
14414 }
14415 }
14416
14417 #[tokio::test]
14418 async fn actor_permission_handshake_rejects_malformed_agent_event() {
14419 let session_id = "permission-malformed";
14420 let (result, session, events, _) = drive_permission_handshake_frames(
14421 session_id,
14422 [ChildFrame::Event {
14423 event: serde_json::json!({"type": "token", "content": 42}),
14424 }],
14425 expected_default_permission_posture(7),
14426 )
14427 .await;
14428
14429 assert!(result
14430 .unwrap_err()
14431 .to_string()
14432 .contains("malformed AgentEvent"));
14433 assert!(events.is_empty());
14434 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14435
14436 let (result, _, events, _) = drive_permission_handshake_frames(
14437 session_id,
14438 [
14439 permission_posture_frame(session_id, 7),
14440 ChildFrame::Event {
14441 event: serde_json::json!({"type":"private-download-secret"}),
14442 },
14443 ],
14444 expected_default_permission_posture(7),
14445 )
14446 .await;
14447 assert!(!result
14448 .unwrap_err()
14449 .to_string()
14450 .contains("private-download-secret"));
14451 assert_eq!(
14452 events.len(),
14453 1,
14454 "only the confirmed posture may be forwarded"
14455 );
14456 }
14457
14458 #[tokio::test]
14459 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
14460 let session_id = "permission-early-event";
14461 let early_events = [
14462 (
14463 "progress",
14464 AgentEvent::RunnerProgress {
14465 session_id: session_id.to_string(),
14466 round_count: 1,
14467 },
14468 ),
14469 (
14470 "token",
14471 AgentEvent::Token {
14472 content: "must-not-forward".to_string(),
14473 },
14474 ),
14475 (
14476 "tool",
14477 AgentEvent::ToolStart {
14478 tool_call_id: "early-tool".to_string(),
14479 tool_name: "Read".to_string(),
14480 arguments: serde_json::json!({"file_path": "README.md"}),
14481 },
14482 ),
14483 ];
14484 for (kind, event) in early_events {
14485 let (result, session, events, _) = drive_permission_handshake_frames(
14486 session_id,
14487 [
14488 actor_event_frame(event),
14489 permission_posture_frame(session_id, 7),
14490 completed_actor_frame(),
14491 ],
14492 expected_default_permission_posture(7),
14493 )
14494 .await;
14495
14496 assert!(
14497 result
14498 .unwrap_err()
14499 .to_string()
14500 .contains("execution event before permission posture confirmation"),
14501 "{kind} must fail closed before posture"
14502 );
14503 assert!(events.is_empty(), "{kind} must not be forwarded");
14504 assert!(
14505 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
14506 "{kind} must not advance the permission audit"
14507 );
14508 }
14509 }
14510
14511 #[tokio::test]
14512 async fn actor_permission_handshake_rejects_approval_before_posture() {
14513 let session_id = "permission-early-approval";
14514 let (result, session, events, sent) = drive_permission_handshake_frames(
14515 session_id,
14516 [ChildFrame::ApprovalRequest {
14517 id: "approval-before-posture".to_string(),
14518 body: serde_json::json!({
14519 "tool_name": "Bash",
14520 "permission": "execute",
14521 "resource": "echo must-not-run"
14522 }),
14523 }],
14524 expected_default_permission_posture(7),
14525 )
14526 .await;
14527
14528 assert!(result
14529 .unwrap_err()
14530 .to_string()
14531 .contains("requested approval before permission posture confirmation"));
14532 assert!(events.is_empty());
14533 assert!(
14534 sent.is_empty(),
14535 "an unconfirmed actor must receive no approval reply"
14536 );
14537 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14538 }
14539
14540 #[tokio::test]
14541 async fn actor_permission_handshake_rejects_mismatched_posture() {
14542 let session_id = "permission-mismatch";
14543 let (result, session, events, _) = drive_permission_handshake_frames(
14544 session_id,
14545 [permission_posture_frame(session_id, 8)],
14546 expected_default_permission_posture(7),
14547 )
14548 .await;
14549
14550 assert!(result
14551 .unwrap_err()
14552 .to_string()
14553 .contains("does not match the host-dispatched policy"));
14554 assert!(events.is_empty());
14555 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14556 }
14557
14558 #[tokio::test]
14559 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
14560 let session_id = "permission-hostile-mapping";
14561 for hostile_mapping in [
14562 "wrong_executor:permission_mode=default",
14563 "test_actor:permission_mode=default;credential=must-not-persist",
14564 ] {
14565 let frame = ChildFrame::Event {
14566 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14567 session_id: session_id.to_string(),
14568 policy_revision: 7,
14569 requested_mode: "default".to_string(),
14570 effective_mode: "default".to_string(),
14571 executor_mapping: hostile_mapping.to_string(),
14572 })
14573 .expect("serialize hostile posture fixture"),
14574 };
14575 let (result, session, events, _) = drive_permission_handshake_frames(
14576 session_id,
14577 [frame],
14578 expected_default_permission_posture(7),
14579 )
14580 .await;
14581
14582 let error = result.unwrap_err().to_string();
14583 assert!(error.contains("host-dispatched executor mapping"));
14584 assert!(
14585 !error.contains(hostile_mapping),
14586 "untrusted mapping must not be reflected in host errors"
14587 );
14588 assert!(events.is_empty());
14589 assert!(
14590 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
14591 "untrusted mapping must not reach durable or in-memory audit state"
14592 );
14593 }
14594 }
14595
14596 #[tokio::test]
14597 async fn actor_permission_handshake_rejects_duplicate_posture() {
14598 let session_id = "permission-duplicate";
14599 let (result, session, events, _) = drive_permission_handshake_frames(
14600 session_id,
14601 [
14602 permission_posture_frame(session_id, 7),
14603 permission_posture_frame(session_id, 7),
14604 completed_actor_frame(),
14605 ],
14606 expected_default_permission_posture(7),
14607 )
14608 .await;
14609
14610 assert!(result
14611 .unwrap_err()
14612 .to_string()
14613 .contains("duplicate permission posture activation"));
14614 assert_eq!(
14615 events.len(),
14616 1,
14617 "only the confirmed posture may be forwarded"
14618 );
14619 assert!(matches!(
14620 events[0],
14621 AgentEvent::PermissionPostureActivated { .. }
14622 ));
14623 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
14624 .expect("the first matching posture must be recorded");
14625 assert_eq!(audit.policy_revision, 7);
14626 }
14627
14628 #[tokio::test]
14629 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
14630 let session_id = "permission-happy";
14631 let (result, session, events, _) = drive_permission_handshake_frames(
14632 session_id,
14633 [
14634 permission_posture_frame(session_id, 7),
14635 actor_event_frame(AgentEvent::RunnerProgress {
14636 session_id: session_id.to_string(),
14637 round_count: 1,
14638 }),
14639 actor_event_frame(AgentEvent::Token {
14640 content: "working".to_string(),
14641 }),
14642 actor_event_frame(AgentEvent::ToolStart {
14643 tool_call_id: "tool-1".to_string(),
14644 tool_name: "Read".to_string(),
14645 arguments: serde_json::json!({"file_path": "README.md"}),
14646 }),
14647 completed_actor_frame(),
14648 ],
14649 expected_default_permission_posture(7),
14650 )
14651 .await;
14652
14653 assert_eq!(result.unwrap().as_deref(), Some("done"));
14654 assert!(matches!(
14655 events.as_slice(),
14656 [
14657 AgentEvent::PermissionPostureActivated { .. },
14658 AgentEvent::RunnerProgress { .. },
14659 AgentEvent::Token { .. },
14660 AgentEvent::ToolStart { .. }
14661 ]
14662 ));
14663 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
14664 .expect("matching posture must be recorded before execution events are accepted");
14665 assert_eq!(audit.policy_revision, 7);
14666 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
14667 }
14668
14669 #[tokio::test]
14670 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
14671 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
14672 .into_iter()
14673 .enumerate()
14674 {
14675 let session_id = format!("actor-download-{index}");
14676 let args = serde_json::json!({
14677 "action":"download",
14678 "selector":"a[data-secret='private-selector']",
14679 "expected_epoch":17,
14680 "data_base64":"private-extra-bytes",
14681 "extra":{"url":"https://private.test/file"},
14682 });
14683 let original = args.clone();
14684 let read_args = serde_json::json!({"file_path":"README.md"});
14685 let (result, _session, events, _) = drive_permission_handshake_frames(
14686 &session_id,
14687 [
14688 permission_posture_frame(&session_id, 7),
14689 actor_event_frame(AgentEvent::ToolStart {
14690 tool_call_id: "download-call".to_string(),
14691 tool_name: tool_name.to_string(),
14692 arguments: args.clone(),
14693 }),
14694 actor_event_frame(AgentEvent::ToolStart {
14695 tool_call_id: "read-call".to_string(),
14696 tool_name: "Read".to_string(),
14697 arguments: read_args.clone(),
14698 }),
14699 completed_actor_frame(),
14700 ],
14701 expected_default_permission_posture(7),
14702 )
14703 .await;
14704 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
14705 assert_eq!(args, original);
14706 let starts: Vec<_> = events
14707 .iter()
14708 .filter_map(|event| match event {
14709 AgentEvent::ToolStart {
14710 tool_name,
14711 arguments,
14712 ..
14713 } => Some((tool_name.as_str(), arguments)),
14714 _ => None,
14715 })
14716 .collect();
14717 assert_eq!(starts.len(), 2);
14718 assert_eq!(starts[0].0, "browser");
14719 assert_eq!(
14720 starts[0].1,
14721 &serde_json::json!({"action":"download","expected_epoch":17})
14722 );
14723 assert_eq!(starts[1], ("Read", &read_args));
14724 assert!(!format!("{events:?}").contains("private"));
14725 }
14726 }
14727
14728 #[tokio::test]
14729 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
14730 let session_id = "actor-hook-display";
14731 let private = "private-selector-url-filename-response";
14732 let hook_name = format!("audit-{private}");
14733 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
14734 hook_name: hook_name.clone(),
14735 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
14736 phase: phase.into(),
14737 duration_ms,
14738 decision,
14739 };
14740 let decisions = [
14741 HookResult::Deny {
14742 reason: private.into(),
14743 },
14744 HookResult::InjectContext {
14745 text: private.into(),
14746 },
14747 HookResult::WithContext {
14748 result: Box::new(HookResult::WithContext {
14749 result: Box::new(HookResult::Deny {
14750 reason: private.into(),
14751 }),
14752 text: private.into(),
14753 }),
14754 text: private.into(),
14755 },
14756 HookResult::Suspend {
14757 reason: private.into(),
14758 },
14759 HookResult::Abort {
14760 reason: private.into(),
14761 },
14762 ];
14763 let originals = decisions.to_vec();
14764 let batch_events = [
14765 hook(2, "completed", decisions[1].clone()),
14766 hook(3, "completed", decisions[2].clone()),
14767 hook(4, "completed", decisions[3].clone()),
14768 ]
14769 .into_iter()
14770 .map(|event| serde_json::to_value(event).unwrap())
14771 .collect::<Vec<_>>();
14772 let batch = ActorEventBatch {
14773 logical_session: Some(LogicalSessionIdentity {
14774 creation: None,
14775 session_id: session_id.into(),
14776 parent_session_id: Some("permission-parent".into()),
14777 root_session_id: session_id.into(),
14778 }),
14779 activation_id: None,
14780 execution_epoch: 0,
14781 source_node_id: None,
14782 source_actor_id: Some(session_id.into()),
14783 first_seq: 1,
14784 last_seq: batch_events.len() as u64,
14785 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
14786 events: batch_events,
14787 };
14788 let nested = AgentEvent::SubAgentEvent {
14789 parent_session_id: "parent".into(),
14790 child_session_id: "child".into(),
14791 event: Box::new(hook(5, "completed", decisions[4].clone())),
14792 };
14793 let raw = serde_json::to_string(&originals).unwrap();
14794 assert!(raw.contains(private));
14795
14796 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
14797 session_id,
14798 [
14799 permission_posture_frame(session_id, 7),
14800 actor_event_frame(hook(1, private, decisions[0].clone())),
14801 ChildFrame::EventBatch { batch },
14802 actor_event_frame(nested),
14803 completed_actor_frame(),
14804 ],
14805 expected_default_permission_posture(7),
14806 )
14807 .await;
14808 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
14809 let wire = serde_json::to_string(&forwarded).unwrap();
14810 assert!(
14811 !wire.contains(private),
14812 "private hook text reached SSE: {wire}"
14813 );
14814
14815 let hooks = forwarded
14816 .iter()
14817 .filter_map(|event| match event {
14818 AgentEvent::HookLifecycle {
14819 hook_name,
14820 point,
14821 phase,
14822 duration_ms,
14823 decision,
14824 } => Some((hook_name, point, phase, duration_ms, decision)),
14825 _ => None,
14826 })
14827 .collect::<Vec<_>>();
14828 assert_eq!(hooks.len(), 4);
14829 assert!(hooks.iter().all(|(name, point, _, _, _)| {
14830 name == &&"actor-hook-1".to_string()
14831 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
14832 }));
14833 assert_eq!(hooks[0].2, "event");
14834 assert_eq!(hooks[1].2, "completed");
14835 assert_eq!(*hooks[0].3, 1);
14836 assert_eq!(*hooks[3].3, 4);
14837 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
14838 assert!(
14839 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
14840 );
14841 assert!(
14842 matches!(hooks[2].4, HookResult::WithContext { result, text }
14843 if text == "Hook context hidden" && matches!(result.as_ref(),
14844 HookResult::WithContext { result, text }
14845 if text == "Hook context hidden" && matches!(result.as_ref(),
14846 HookResult::Deny { reason } if reason == "Hook denied")))
14847 );
14848 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
14849 assert!(forwarded.iter().any(|event| matches!(event,
14850 AgentEvent::SubAgentEvent { event, .. }
14851 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
14852 hook_name, duration_ms: 5,
14853 decision: HookResult::Abort { reason }, ..
14854 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
14855 )));
14856 }
14857
14858 #[test]
14859 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
14860 let mut display = ActorEventDisplay::default();
14861 let mut deep = HookResult::Allow;
14862 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
14863 deep = HookResult::WithContext {
14864 result: Box::new(deep),
14865 text: "private-download-bytes".into(),
14866 };
14867 }
14868 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
14869 hook_name,
14870 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
14871 phase: "completed".into(),
14872 duration_ms: 7,
14873 decision,
14874 };
14875 assert!(display.project(event("deep".into(), deep), 0).is_none());
14876 assert!(display.overflowed);
14877 assert!(display
14878 .project(
14879 event(
14880 "private-download-bytes".into(),
14881 HookResult::Deny {
14882 reason: "private-download-bytes".into(),
14883 },
14884 ),
14885 0,
14886 )
14887 .is_none());
14888
14889 let mut oversized = ActorEventDisplay::default();
14890 assert!(oversized
14891 .project(
14892 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
14893 0,
14894 )
14895 .is_none());
14896 assert!(oversized.overflowed);
14897
14898 let mut reconnected = ActorEventDisplay::default();
14902 let private = "private-download-bytes";
14903 for _ in 0..2 {
14904 let projected = reconnected
14905 .project(event(private.into(), HookResult::Allow), 0)
14906 .unwrap();
14907 assert!(
14908 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
14909 if hook_name == "actor-hook-1")
14910 );
14911 }
14912 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
14913 for index in 1..MAX_DISPLAY_CALLS {
14914 assert!(reconnected
14915 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
14916 .is_some());
14917 }
14918 assert!(reconnected
14919 .project(event("overflow-private".into(), HookResult::Allow), 0)
14920 .is_none());
14921 assert!(reconnected.overflowed);
14922 assert!(reconnected.hook_names.is_empty());
14923 }
14924
14925 #[test]
14926 fn actor_hook_display_preserves_nontext_decision_kinds() {
14927 for result in [
14928 HookResult::Continue,
14929 HookResult::Mutated,
14930 HookResult::Allow,
14931 HookResult::Ask,
14932 ] {
14933 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
14934 }
14935 }
14936
14937 #[tokio::test]
14938 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
14939 let session_id = "actor-malformed-hook";
14940 let private = "private-download-selector";
14941 let original = AgentEvent::HookLifecycle {
14942 hook_name: "audit".into(),
14943 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
14944 phase: "completed".into(),
14945 duration_ms: 9,
14946 decision: HookResult::Deny {
14947 reason: private.into(),
14948 },
14949 };
14950 let mut invalid = serde_json::to_value(&original).unwrap();
14951 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
14952 let nested = AgentEvent::SubAgentEvent {
14953 parent_session_id: "parent".into(),
14954 child_session_id: "child".into(),
14955 event: Box::new(original),
14956 };
14957 let mut invalid_nested = serde_json::to_value(nested).unwrap();
14958 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
14959 let batch = ActorEventBatch {
14960 logical_session: Some(LogicalSessionIdentity {
14961 creation: None,
14962 session_id: session_id.into(),
14963 parent_session_id: Some("permission-parent".into()),
14964 root_session_id: session_id.into(),
14965 }),
14966 activation_id: None,
14967 execution_epoch: 0,
14968 source_node_id: None,
14969 source_actor_id: Some(session_id.into()),
14970 first_seq: 1,
14971 last_seq: 1,
14972 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
14973 events: vec![invalid.clone()],
14974 };
14975 for frame in [
14976 ChildFrame::Event {
14977 event: invalid.clone(),
14978 },
14979 ChildFrame::EventBatch { batch },
14980 ChildFrame::Event {
14981 event: invalid_nested,
14982 },
14983 ] {
14984 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
14985 session_id,
14986 [permission_posture_frame(session_id, 7), frame],
14987 expected_default_permission_posture(7),
14988 )
14989 .await;
14990 let error = outcome.unwrap_err().to_string();
14991 assert!(error.contains("malformed AgentEvent"), "{error}");
14992 assert!(!error.contains(private));
14993 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
14994 }
14995
14996 let (event_tx, mut event_rx) = mpsc::channel(8);
14997 let mut session = Session::new("legacy-hook", "model");
14998 let mut handshake = PermissionPostureHandshake::NotRequired;
14999 let mut display = ActorEventDisplay::default();
15000 process_actor_event(
15001 invalid,
15002 false,
15003 &mut handshake,
15004 None,
15005 None,
15006 &mut session,
15007 &event_tx,
15008 &mut display,
15009 )
15010 .await
15011 .unwrap();
15012 assert!(display.overflowed);
15013 assert!(event_rx.try_recv().is_err());
15014 }
15015
15016 #[tokio::test]
15017 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15018 let session_id = "actor-download-batch";
15019 let private = "private-download-bytes-and-url";
15020 let events = vec![
15021 AgentEvent::ToolStart {
15022 tool_call_id: "download-call".into(),
15023 tool_name: "default::browser".into(),
15024 arguments: serde_json::json!({
15025 "action":"download", "selector": private, "expected_epoch": 17,
15026 }),
15027 },
15028 AgentEvent::ToolApprovalRequested {
15029 tool_call_id: "download-call".into(),
15030 tool_name: format!("{private}::browser"),
15031 parameters: serde_json::json!({
15032 "action":"download", "selector": private, "expected_epoch": 17,
15033 }),
15034 },
15035 AgentEvent::ToolStart {
15036 tool_call_id: "read-call".into(),
15037 tool_name: "Read".into(),
15038 arguments: serde_json::json!({"file_path":"README.md"}),
15039 },
15040 ];
15041 let batch = ActorEventBatch {
15042 logical_session: Some(LogicalSessionIdentity {
15043 creation: None,
15044 session_id: session_id.into(),
15045 parent_session_id: Some("permission-parent".into()),
15046 root_session_id: session_id.into(),
15047 }),
15048 activation_id: None,
15049 execution_epoch: 0,
15050 source_node_id: None,
15051 source_actor_id: Some(session_id.into()),
15052 first_seq: 1,
15053 last_seq: events.len() as u64,
15054 qos: bamboo_subagent::ActorEventQos::Durable,
15055 events: events
15056 .into_iter()
15057 .map(|event| serde_json::to_value(event).unwrap())
15058 .collect(),
15059 };
15060 let result = bamboo_agent_core::tools::ToolResult {
15061 success: true,
15062 result: format!("{{\"data_base64\":\"{private}\"}}"),
15063 display_preference: Some(private.into()),
15064 images: vec![bamboo_agent_core::tools::ToolResultImage {
15065 mime_type: "image/png".into(),
15066 data: private.into(),
15067 }],
15068 };
15069 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15070 session_id,
15071 [
15072 permission_posture_frame(session_id, 7),
15073 ChildFrame::EventBatch { batch },
15074 actor_event_frame(AgentEvent::ToolToken {
15075 tool_call_id: "download-call".into(),
15076 content: private.into(),
15077 }),
15078 actor_event_frame(AgentEvent::ToolToken {
15079 tool_call_id: "read-call".into(),
15080 content: "public live output".into(),
15081 }),
15082 actor_event_frame(AgentEvent::ToolComplete {
15083 tool_call_id: "download-call".into(),
15084 result,
15085 }),
15086 actor_event_frame(AgentEvent::ToolLifecycle {
15087 tool_call_id: "download-call".into(),
15088 tool_name: "browser".into(),
15089 phase: "finished".into(),
15090 elapsed_ms: Some(1),
15091 is_mutating: false,
15092 auto_approved: false,
15093 summary: Some(private.into()),
15094 error: Some(private.into()),
15095 }),
15096 actor_event_frame(AgentEvent::ToolError {
15097 tool_call_id: "download-call".into(),
15098 error: private.into(),
15099 }),
15100 actor_event_frame(AgentEvent::ToolComplete {
15101 tool_call_id: "read-call".into(),
15102 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15103 }),
15104 completed_actor_frame(),
15105 ],
15106 expected_default_permission_posture(7),
15107 )
15108 .await;
15109 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15110 let wire = serde_json::to_string(&forwarded).unwrap();
15111 assert!(
15112 !wire.contains(private),
15113 "private content reached actor SSE: {wire}"
15114 );
15115 assert!(wire.contains("public README text"));
15116 assert!(wire.contains("public live output"));
15117 assert!(forwarded.iter().any(|event| matches!(event,
15118 AgentEvent::ToolComplete { tool_call_id, result }
15119 if tool_call_id == "download-call"
15120 && result.result == "Browser tool result hidden"
15121 && result.images.is_empty() && result.display_preference.is_none()
15122 )));
15123 }
15124
15125 #[tokio::test]
15126 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15127 let private = "private-download-content";
15128 let nested = |event| AgentEvent::SubAgentEvent {
15129 parent_session_id: "parent".into(),
15130 child_session_id: "nested-child".into(),
15131 event: Box::new(event),
15132 };
15133 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15134 "actor-reorder",
15135 [
15136 permission_posture_frame("actor-reorder", 7),
15137 actor_event_frame(AgentEvent::ToolComplete {
15138 tool_call_id: "missing".into(),
15139 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15140 }),
15141 actor_event_frame(AgentEvent::ToolLifecycle {
15142 tool_call_id: "missing".into(),
15143 tool_name: "browser".into(),
15144 phase: private.into(),
15145 elapsed_ms: None,
15146 is_mutating: false,
15147 auto_approved: false,
15148 summary: Some(private.into()),
15149 error: Some(private.into()),
15150 }),
15151 actor_event_frame(AgentEvent::ToolApprovalRequested {
15152 tool_call_id: "approval-only".into(),
15153 tool_name: "browser".into(),
15154 parameters: serde_json::json!({"action":"download","selector":private}),
15155 }),
15156 actor_event_frame(AgentEvent::ToolToken {
15157 tool_call_id: "approval-only".into(),
15158 content: private.into(),
15159 }),
15160 actor_event_frame(AgentEvent::ToolStart {
15161 tool_call_id: "malformed-browser".into(),
15162 tool_name: format!("{private}::browser"),
15163 arguments: serde_json::json!({"selector":private}),
15164 }),
15165 actor_event_frame(AgentEvent::ToolComplete {
15166 tool_call_id: "malformed-browser".into(),
15167 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15168 }),
15169 actor_event_frame(AgentEvent::ToolStart {
15170 tool_call_id: "reused".into(),
15171 tool_name: "Read".into(),
15172 arguments: serde_json::json!({"file_path":"README.md"}),
15173 }),
15174 actor_event_frame(AgentEvent::ToolStart {
15175 tool_call_id: "reused".into(),
15176 tool_name: "browser".into(),
15177 arguments: serde_json::json!({"action":"download","selector":private}),
15178 }),
15179 actor_event_frame(AgentEvent::ToolToken {
15180 tool_call_id: "reused".into(),
15181 content: private.into(),
15182 }),
15183 actor_event_frame(nested(AgentEvent::ToolStart {
15184 tool_call_id: "nested".into(),
15185 tool_name: "browser".into(),
15186 arguments: serde_json::json!({"action":"download","selector":private}),
15187 })),
15188 actor_event_frame(nested(AgentEvent::ToolComplete {
15189 tool_call_id: "nested".into(),
15190 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15191 })),
15192 actor_event_frame(AgentEvent::ChildApprovalRequested {
15193 child_session_id: "nested-child".into(),
15194 request_id: "approval".into(),
15195 tool_name: format!("{private}::browser"),
15196 permission: private.into(),
15197 resource: private.into(),
15198 }),
15199 actor_event_frame(AgentEvent::ChildApprovalChanged {
15200 parent_session_id: "parent".into(),
15201 child_session_id: "nested-child".into(),
15202 child_attempt: 1,
15203 request_id: "approval".into(),
15204 version: 1,
15205 status: "pending".into(),
15206 reason: Some(private.into()),
15207 tool_name: "browser".into(),
15208 permission: private.into(),
15209 resource: private.into(),
15210 created_at: "2026-01-01T00:00:00Z".into(),
15211 resolved_at: None,
15212 }),
15213 completed_actor_frame(),
15214 ],
15215 expected_default_permission_posture(7),
15216 )
15217 .await;
15218 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15219 let wire = serde_json::to_string(&forwarded).unwrap();
15220 assert!(
15221 !wire.contains(private),
15222 "private content reached actor SSE: {wire}"
15223 );
15224 assert!(wire.contains("Tool result hidden"));
15225 assert!(wire.contains("Browser tool result hidden"));
15226 assert!(wire.contains("Browser interaction approval"));
15227 assert!(!forwarded
15228 .iter()
15229 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15230 }
15231
15232 #[test]
15233 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15234 let mut display = ActorEventDisplay::default();
15235 display
15236 .project(
15237 AgentEvent::ToolStart {
15238 tool_call_id: "private-call".into(),
15239 tool_name: "browser".into(),
15240 arguments: serde_json::json!({"action":"download","selector":"private"}),
15241 },
15242 0,
15243 )
15244 .unwrap();
15245 for index in 0..MAX_DISPLAY_CALLS {
15246 display
15247 .project(
15248 AgentEvent::ToolStart {
15249 tool_call_id: format!("ordinary-{index}"),
15250 tool_name: "Read".into(),
15251 arguments: serde_json::json!({"file_path":"README.md"}),
15252 },
15253 0,
15254 )
15255 .unwrap();
15256 }
15257 assert!(display.overflowed);
15258 let forwarded = display
15259 .project(
15260 AgentEvent::ToolComplete {
15261 tool_call_id: "private-call".into(),
15262 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15263 },
15264 0,
15265 )
15266 .unwrap();
15267 assert!(!serde_json::to_string(&forwarded)
15268 .unwrap()
15269 .contains("private-bytes"));
15270 }
15271
15272 #[test]
15273 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15274 let mut display = ActorEventDisplay::default();
15275 let nested = |index: usize| AgentEvent::SubAgentEvent {
15276 parent_session_id: "parent".into(),
15277 child_session_id: format!("child-{index}"),
15278 event: Box::new(AgentEvent::ToolStart {
15279 tool_call_id: format!("read-{index}"),
15280 tool_name: "Read".into(),
15281 arguments: serde_json::json!({"file_path":"README.md"}),
15282 }),
15283 };
15284 for index in 0..(MAX_DISPLAY_CALLS / 2) {
15285 assert!(display.project(nested(index), 0).is_some());
15286 }
15287 assert_eq!(
15288 display.identities.load(Ordering::Relaxed),
15289 MAX_DISPLAY_CALLS
15290 );
15291 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
15292 assert!(display.overflowed);
15293 assert!(display.children.is_empty());
15294
15295 let mut oversized = ActorEventDisplay::default();
15296 assert!(oversized
15297 .project(
15298 AgentEvent::SubAgentEvent {
15299 parent_session_id: "parent".into(),
15300 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15301 event: Box::new(AgentEvent::ToolComplete {
15302 tool_call_id: "call".into(),
15303 result: bamboo_agent_core::tools::ToolResult::text(
15304 true,
15305 "private-download-bytes",
15306 ),
15307 }),
15308 },
15309 0,
15310 )
15311 .is_none());
15312 assert!(oversized.overflowed);
15313 assert!(oversized
15314 .project(
15315 AgentEvent::ToolStart {
15316 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
15317 tool_name: "Read".into(),
15318 arguments: serde_json::json!({"file_path":"README.md"}),
15319 },
15320 0,
15321 )
15322 .is_none());
15323 }
15324
15325 #[tokio::test]
15326 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
15327 let session_id = "actor-gap";
15328 let batch = |seq, event: AgentEvent| {
15329 let event = serde_json::to_value(event).unwrap();
15330 ChildFrame::EventBatch {
15331 batch: ActorEventBatch {
15332 logical_session: Some(LogicalSessionIdentity {
15333 creation: None,
15334 session_id: session_id.into(),
15335 parent_session_id: Some("permission-parent".into()),
15336 root_session_id: session_id.into(),
15337 }),
15338 activation_id: None,
15339 execution_epoch: 0,
15340 source_node_id: None,
15341 source_actor_id: Some(session_id.into()),
15342 first_seq: seq,
15343 last_seq: seq,
15344 qos: bamboo_subagent::ActorEventQos::classify(&event),
15345 events: vec![event],
15346 },
15347 }
15348 };
15349 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15350 session_id,
15351 [
15352 permission_posture_frame(session_id, 7),
15353 batch(
15354 1,
15355 AgentEvent::ToolStart {
15356 tool_call_id: "reused-call".into(),
15357 tool_name: "Read".into(),
15358 arguments: serde_json::json!({"file_path":"README.md"}),
15359 },
15360 ),
15361 batch(
15364 3,
15365 AgentEvent::ToolComplete {
15366 tool_call_id: "reused-call".into(),
15367 result: bamboo_agent_core::tools::ToolResult::text(
15368 true,
15369 "private-download-bytes",
15370 ),
15371 },
15372 ),
15373 completed_actor_frame(),
15374 ],
15375 expected_default_permission_posture(7),
15376 )
15377 .await;
15378 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15379 let wire = serde_json::to_string(&forwarded).unwrap();
15380 assert!(!wire.contains("private-download-bytes"), "{wire}");
15381 assert!(wire.contains("Tool result hidden"));
15382 }
15383
15384 #[tokio::test]
15385 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
15386 let (event_tx, mut event_rx) = mpsc::channel(8);
15387 let mut session = Session::new("legacy-malformed-private", "model");
15388 let mut handshake = PermissionPostureHandshake::NotRequired;
15389 let mut display = ActorEventDisplay::default();
15390 let read_start = serde_json::to_value(AgentEvent::ToolStart {
15391 tool_call_id: "reused-call".into(),
15392 tool_name: "Read".into(),
15393 arguments: serde_json::json!({"file_path":"README.md"}),
15394 })
15395 .unwrap();
15396 process_actor_event(
15397 read_start,
15398 false,
15399 &mut handshake,
15400 None,
15401 None,
15402 &mut session,
15403 &event_tx,
15404 &mut display,
15405 )
15406 .await
15407 .unwrap();
15408
15409 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
15412 tool_call_id: "reused-call".into(),
15413 tool_name: "browser".into(),
15414 arguments: serde_json::json!({
15415 "action":"download", "selector":"private-download-selector"
15416 }),
15417 })
15418 .unwrap();
15419 malformed["tool_name"] = serde_json::json!(7);
15420 process_actor_event(
15421 malformed,
15422 false,
15423 &mut handshake,
15424 None,
15425 None,
15426 &mut session,
15427 &event_tx,
15428 &mut display,
15429 )
15430 .await
15431 .unwrap();
15432 process_actor_event(
15433 serde_json::to_value(AgentEvent::ToolComplete {
15434 tool_call_id: "reused-call".into(),
15435 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
15436 })
15437 .unwrap(),
15438 false,
15439 &mut handshake,
15440 None,
15441 None,
15442 &mut session,
15443 &event_tx,
15444 &mut display,
15445 )
15446 .await
15447 .unwrap();
15448 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
15449 assert_eq!(forwarded.len(), 2);
15450 let wire = serde_json::to_string(&forwarded).unwrap();
15451 assert!(!wire.contains("private-download"), "{wire}");
15452 assert!(wire.contains("Tool result hidden"));
15453 }
15454
15455 #[tokio::test]
15456 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
15457 let temp = tempfile::tempdir().unwrap();
15458 let store = Arc::new(
15459 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
15460 .await
15461 .unwrap(),
15462 );
15463 let storage: Arc<dyn Storage> = store.clone();
15464 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
15465 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
15466 store.clone(),
15467 bamboo_domain::SessionInboxLimits::default(),
15468 ));
15469 let session_id = "actor-confirmation-order";
15470 let run_id = "actor-run-current";
15471 let mut session = Session::new(session_id, "model");
15472 store.save_session(&session).await.unwrap();
15473 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
15474 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
15475 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
15476 inbox.deliver(&envelope).await.unwrap();
15477 }
15478 inbox
15479 .mark_activation_eligible(
15480 session_id,
15481 2,
15482 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
15483 )
15484 .await
15485 .unwrap();
15486 let router = SessionActivationRouter::new();
15487 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
15488 let binding = SessionInboxRuntimeBinding {
15489 router,
15490 inbox: inbox.clone(),
15491 storage,
15492 persistence: locked,
15493 parent_question_lock: None,
15494 };
15495 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
15496 .await
15497 .unwrap();
15498 assert_eq!(
15499 pairs
15500 .iter()
15501 .map(|(claim, _)| claim.envelope.id.as_str())
15502 .collect::<Vec<_>>(),
15503 vec!["actor-first", "actor-second"]
15504 );
15505 let seeded = store.load_session(session_id).await.unwrap().unwrap();
15506 assert_eq!(
15507 seeded
15508 .messages
15509 .iter()
15510 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
15511 .map(|message| message.id.as_str())
15512 .collect::<Vec<_>>(),
15513 vec!["actor-first", "actor-second"],
15514 "host context must be durable before actor dispatch"
15515 );
15516 for (claim, _) in &pairs {
15517 assert_eq!(
15518 seeded
15519 .messages
15520 .iter()
15521 .filter(|message| bamboo_domain::is_matching_session_message(
15522 message,
15523 &claim.envelope
15524 ))
15525 .count(),
15526 1,
15527 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
15528 claim.envelope.id
15529 );
15530 }
15531 assert!(
15532 seeded.session_inbox_admission().is_none_or(|cursor| {
15533 !cursor.contains(&pairs[0].0.envelope.id)
15534 && !cursor.contains(&pairs[1].0.envelope.id)
15535 }),
15536 "pre-dispatch transcript seeding must not forge worker confirmation"
15537 );
15538 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
15539 let claims = pairs
15540 .into_iter()
15541 .map(|(claim, _)| claim)
15542 .collect::<VecDeque<_>>();
15543 let first = claims[0].clone();
15544 let second = claims[1].clone();
15545 let mut stale = admission_confirmation(session_id, &first, "stale-run");
15546 stale.canonical_claim_generation = second.generation;
15547 let mut link = ConfirmationSequenceLink {
15548 frames: VecDeque::from([
15549 ChildFrame::SessionMessageAdmitted {
15550 confirmation: stale,
15551 },
15552 ChildFrame::SessionMessageAdmitted {
15553 confirmation: admission_confirmation(session_id, &first, run_id),
15554 },
15555 ChildFrame::SessionMessageAdmitted {
15556 confirmation: admission_confirmation(session_id, &second, run_id),
15557 },
15558 ChildFrame::Terminal {
15559 status: TerminalStatus::Completed,
15560 result: Some("done".to_string()),
15561 error: None,
15562 final_event_watermark: None,
15563 transcript: Vec::new(),
15564 },
15565 ]),
15566 sent: Vec::new(),
15567 };
15568 let (event_tx, _event_rx) = mpsc::channel(8);
15569 let cancel = CancellationToken::new();
15570 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15571 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15572 let result = drive(ActorDriveContext {
15573 expected_creation: None,
15574 client: &mut link,
15575 parent_session_id: "parent",
15576 child_session_id: session_id,
15577 child_attempt: 0,
15578 approval_registry: None,
15579 approval_decider: None,
15580 approval_reviewer: None,
15581 escalation_bridge: None,
15582 event_tx: &event_tx,
15583 cancel_token: &cancel,
15584 live_rx: &mut live_rx,
15585 delivery_rx: &mut delivery_rx,
15586 logical_session: &mut session,
15587 expected_permission_posture: None,
15588 session_inbox_runtime: Some(&binding),
15589 actor_directory_store: None,
15590 canonical_subagent_tool: None,
15591 ticket_service: None,
15592 activation_run_id: Some(run_id),
15593 execution_epoch: 0,
15594 expected_source_actor_id: session_id,
15595 initial_inflight_claims: claims,
15596 plain_actor: false,
15597 remote_environment_lease: false,
15598 readonly_output: None,
15599 local_history_tools: None,
15600 local_history_read_only: false,
15601 strict_history_output: None,
15602 plain_input: None,
15603 canonical_activation: None,
15604 canonical_placement_ref: None,
15605 actor_event_observer: None,
15606 plain_run: None,
15607 first_frame_timeout: Some(Duration::from_secs(1)),
15608 })
15609 .await
15610 .unwrap();
15611 assert_eq!(result.as_deref(), Some("done"));
15612 assert_eq!(
15613 session
15614 .messages
15615 .iter()
15616 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
15617 .map(|message| message.id.as_str())
15618 .collect::<Vec<_>>(),
15619 vec!["actor-first", "actor-second"]
15620 );
15621 let backlog = inbox.inspect(session_id).await.unwrap();
15622 assert_eq!(backlog.pending + backlog.claimed, 0);
15623 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
15624 let cursor = confirmed
15625 .session_inbox_admission()
15626 .expect("exact worker confirmation must checkpoint the admission cursor");
15627 assert!(cursor.contains(&first.envelope.id));
15628 assert!(cursor.contains(&second.envelope.id));
15629 for claim in [&first, &second] {
15630 assert_eq!(
15631 confirmed
15632 .messages
15633 .iter()
15634 .filter(|message| bamboo_domain::is_matching_session_message(
15635 message,
15636 &claim.envelope
15637 ))
15638 .count(),
15639 1,
15640 "confirmation must retain one exact canonical marker for {}",
15641 claim.envelope.id
15642 );
15643 }
15644 assert!(inbox
15645 .was_admitted(session_id, &first.envelope.id)
15646 .await
15647 .unwrap());
15648 assert!(inbox
15649 .was_admitted(session_id, &second.envelope.id)
15650 .await
15651 .unwrap());
15652 owner_registration.begin_finalization().await;
15653 owner_registration.finish(2).await.unwrap();
15654 }
15655
15656 #[derive(Default)]
15657 struct RecordingCodexTokenAuthority {
15658 issued_for: std::sync::Mutex<Vec<String>>,
15659 revoked: std::sync::Mutex<Vec<String>>,
15660 }
15661
15662 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
15663 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
15664 self.issued_for
15665 .lock()
15666 .expect("issued fixture lock")
15667 .push(session_id.to_string());
15668 Ok(IssuedCodexRunToken {
15669 token_id: format!("id-{session_id}"),
15670 token: format!("bcx1_secret-{session_id}"),
15671 })
15672 }
15673
15674 fn revoke(&self, token_id: &str) {
15675 self.revoked
15676 .lock()
15677 .expect("revoked fixture lock")
15678 .push(token_id.to_string());
15679 }
15680 }
15681
15682 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
15683 let bamboo_mode = auth_mode == Some("bamboo")
15684 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
15685 ExecutorSpec::Codex {
15686 binary: None,
15687 model: None,
15688 mode: None,
15689 sandbox: None,
15690 inherit_user_config,
15691 auth_mode: auth_mode.map(str::to_string),
15692 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
15693 wire_api: Some("responses".to_string()),
15694 provider_key_ref: None,
15695 forward_env: None,
15696 approval_policy: None,
15697 network_access: None,
15698 allow_danger_bypass: None,
15699 permission_profile: None,
15700 workspace_owned: None,
15701 }
15702 }
15703
15704 fn permission_resolution(
15705 requested: bamboo_domain::SessionPermissionMode,
15706 effective: bamboo_domain::PermissionMode,
15707 ) -> bamboo_domain::PermissionModeResolution {
15708 bamboo_domain::PermissionModeResolution {
15709 requested,
15710 effective,
15711 }
15712 }
15713
15714 #[test]
15715 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
15716 use bamboo_domain::{PermissionMode, SessionPermissionMode};
15717
15718 let default =
15719 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
15720 assert_eq!(
15721 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
15722 .unwrap()
15723 .as_deref(),
15724 Some("bamboo_runtime:default")
15725 );
15726 assert_eq!(
15727 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
15728 None,
15729 "transport-only Echo must not claim the typed permission contract"
15730 );
15731 assert_eq!(
15732 expected_permission_executor_mapping(
15733 &ExecutorSpec::CliAdapter {
15734 command: "must-not-appear-in-contract".to_string(),
15735 args: vec!["credential-like-argument".to_string()],
15736 },
15737 default,
15738 false,
15739 )
15740 .unwrap(),
15741 None,
15742 "unimplemented CliAdapter must not leak command data into a contract"
15743 );
15744
15745 let claude = ExecutorSpec::ClaudeCode {
15746 binary: None,
15747 model: None,
15748 permission_mode: Some("default".to_string()),
15749 inherit_user_config: None,
15750 forward_env: None,
15751 };
15752 for (resolution, mapping) in [
15753 (
15754 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
15755 "claude_code:permission_mode=plan",
15756 ),
15757 (
15758 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
15759 "claude_code:permission_mode=bypassPermissions",
15760 ),
15761 (
15762 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
15763 "claude_code:permission_mode=acceptEdits",
15764 ),
15765 (
15766 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
15767 "claude_code:permission_mode=dontAsk",
15768 ),
15769 (
15770 permission_resolution(
15771 SessionPermissionMode::Bypass,
15772 PermissionMode::BypassPermissions,
15773 ),
15774 "claude_code:permission_mode=default",
15775 ),
15776 ] {
15777 assert_eq!(
15778 expected_permission_executor_mapping(&claude, resolution, false)
15779 .unwrap()
15780 .as_deref(),
15781 Some(mapping)
15782 );
15783 }
15784 assert_eq!(
15785 expected_permission_executor_mapping(&claude, default, true)
15786 .unwrap()
15787 .as_deref(),
15788 Some("claude_code:blocked_explicit_deny")
15789 );
15790
15791 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
15792 if let ExecutorSpec::Codex {
15793 approval_policy, ..
15794 } = &mut codex_exec
15795 {
15796 *approval_policy = Some("on-failure".to_string());
15797 }
15798 assert_eq!(
15799 expected_permission_executor_mapping(&codex_exec, default, false)
15800 .unwrap()
15801 .as_deref(),
15802 Some("codex_exec:approval_policy=on-failure")
15803 );
15804 assert_eq!(
15805 expected_permission_executor_mapping(
15806 &codex_exec,
15807 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
15808 false,
15809 )
15810 .unwrap()
15811 .as_deref(),
15812 Some("codex_exec:approval_policy=never")
15813 );
15814 assert_eq!(
15815 expected_permission_executor_mapping(&codex_exec, default, true)
15816 .unwrap()
15817 .as_deref(),
15818 Some("codex_exec:blocked_explicit_deny")
15819 );
15820
15821 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
15822 if let ExecutorSpec::Codex {
15823 mode,
15824 approval_policy,
15825 ..
15826 } = &mut codex_app_server
15827 {
15828 *mode = Some("app_server".to_string());
15829 *approval_policy = Some("on-request".to_string());
15830 }
15831 assert_eq!(
15832 expected_permission_executor_mapping(&codex_app_server, default, false)
15833 .unwrap()
15834 .as_deref(),
15835 Some("codex_app_server:approvalPolicy=on-request")
15836 );
15837 assert_eq!(
15838 expected_permission_executor_mapping(
15839 &codex_app_server,
15840 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
15841 false,
15842 )
15843 .unwrap()
15844 .as_deref(),
15845 Some("codex_app_server:approvalPolicy=never")
15846 );
15847 assert_eq!(
15848 expected_permission_executor_mapping(&codex_app_server, default, true)
15849 .unwrap()
15850 .as_deref(),
15851 Some("codex_app_server:blocked_explicit_deny")
15852 );
15853 }
15854
15855 #[test]
15856 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
15857 let project = tempfile::tempdir().unwrap();
15858 let managed = project.path().join(".bamboo/worktree/child-571");
15859 std::fs::create_dir_all(&managed).unwrap();
15860 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
15861 let marker = project
15862 .path()
15863 .join(".bamboo/worktree/.bamboo-owned/child-571");
15864 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
15865 std::fs::write(&marker, "bamboo/child-571").unwrap();
15866 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
15867 let nested = managed.join("nested/path");
15868 std::fs::create_dir_all(&nested).unwrap();
15869 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
15870
15871 let arbitrary = tempfile::tempdir().unwrap();
15872 assert!(!workspace_is_bamboo_owned(
15873 arbitrary.path().to_str().unwrap()
15874 ));
15875 }
15876
15877 #[test]
15878 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
15879 let authority = Arc::new(RecordingCodexTokenAuthority::default());
15880 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
15881
15882 let (secrets, guard) = build_codex_run_secrets(
15883 &codex_executor(Some("bamboo"), None),
15884 Some(authority_dyn),
15885 "child-570",
15886 )
15887 .unwrap();
15888
15889 let token = secrets
15890 .codex_provider_token
15891 .as_ref()
15892 .expect("bamboo mode mints a token");
15893 assert_eq!(token.expose(), "bcx1_secret-child-570");
15894 assert!(!format!("{token:?}").contains("secret-child-570"));
15895 assert_eq!(
15896 authority.issued_for.lock().unwrap().as_slice(),
15897 ["child-570"]
15898 );
15899 assert!(authority.revoked.lock().unwrap().is_empty());
15900
15901 drop(guard);
15902 assert_eq!(
15903 authority.revoked.lock().unwrap().as_slice(),
15904 ["id-child-570"]
15905 );
15906 }
15907
15908 #[test]
15909 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
15910 let authority = Arc::new(RecordingCodexTokenAuthority::default());
15911 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
15912 let (secrets, guard) = build_codex_run_secrets(
15913 &codex_executor(Some("custom"), None),
15914 Some(authority_dyn),
15915 "child-custom",
15916 )
15917 .unwrap();
15918 assert!(secrets.codex_provider_token.is_none());
15919 assert!(guard.is_none());
15920 assert!(authority.issued_for.lock().unwrap().is_empty());
15921
15922 let error = build_codex_run_secrets(
15923 &codex_executor(Some("bamboo"), None),
15924 None,
15925 "child-no-authority",
15926 )
15927 .err()
15928 .expect("bamboo mode without an authority must fail closed");
15929 assert!(error.to_string().contains("per-run token authority"));
15930 }
15931
15932 #[test]
15933 fn codex_provisioning_never_leaks_the_session_provider_credential() {
15934 let credentials = vec![ScopedCredential {
15935 provider: "openai".to_string(),
15936 api_key: "upstream-secret-must-not-cross".to_string(),
15937 base_url: None,
15938 provider_type: Some("openai".to_string()),
15939 credential_ref: Some("provider.openai.api_key".to_string()),
15940 }];
15941
15942 for (mode, label) in [
15943 (Some("inherit"), "inherit"),
15944 (Some("api_key"), "api_key"),
15945 (Some("bamboo"), "bamboo"),
15946 (None, "default-bamboo"),
15947 ] {
15948 let runner = ActorChildRunner::new(
15949 format!("codex-{label}-test"),
15950 PathBuf::from("/bin/false"),
15951 Vec::new(),
15952 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
15953 codex_executor(mode, None),
15954 credentials.clone(),
15955 "openai".to_string(),
15956 1,
15957 );
15958 let mut session = Session::new(format!("child-{label}"), "model");
15959 session.add_message(bamboo_agent_core::Message::user("test"));
15960 let spec = runner.build_spec(
15961 &session,
15962 &crate::runtime::execution::SpawnJob {
15963 parent_session_id: "parent".to_string(),
15964 child_session_id: format!("child-{label}"),
15965 model: "gpt-5.4".to_string(),
15966 disabled_tools: None,
15967 },
15968 );
15969 assert!(
15970 spec.secrets.provider_credentials.is_empty(),
15971 "{label} Codex must not receive the session provider key"
15972 );
15973 }
15974 }
15975
15976 #[test]
15977 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
15978 let credentials = vec![
15979 ScopedCredential {
15980 provider: "openai".to_string(),
15981 api_key: "selected-openai-secret".to_string(),
15982 base_url: None,
15983 provider_type: Some("openai".to_string()),
15984 credential_ref: Some("provider.openai.api_key".to_string()),
15985 },
15986 ScopedCredential {
15987 provider: "other".to_string(),
15988 api_key: "unrelated-secret".to_string(),
15989 base_url: None,
15990 provider_type: Some("openai".to_string()),
15991 credential_ref: Some("provider.other.api_key".to_string()),
15992 },
15993 ];
15994 let runner = ActorChildRunner::new(
15995 "echo-test".to_string(),
15996 PathBuf::from("/bin/false"),
15997 Vec::new(),
15998 std::env::temp_dir().join("bamboo-echo-provider-570"),
15999 ExecutorSpec::Echo,
16000 credentials,
16001 "openai".to_string(),
16002 1,
16003 );
16004 let spec = runner.build_spec(
16005 &Session::new("child-echo", "model"),
16006 &crate::runtime::execution::SpawnJob {
16007 parent_session_id: "parent".to_string(),
16008 child_session_id: "child-echo".to_string(),
16009 model: "gpt-5.4".to_string(),
16010 disabled_tools: None,
16011 },
16012 );
16013
16014 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16015 assert_eq!(
16016 spec.secrets.provider_credentials[0].api_key,
16017 "selected-openai-secret"
16018 );
16019 }
16020
16021 #[tokio::test]
16022 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16023 fn provider_instance(
16024 provider_type: &str,
16025 api_key: &str,
16026 ) -> bamboo_config::ProviderInstanceConfig {
16027 bamboo_config::ProviderInstanceConfig {
16028 provider_type: provider_type.to_string(),
16029 label: None,
16030 api_key: api_key.to_string(),
16031 api_key_encrypted: None,
16032 credential_ref: None,
16033 base_url: None,
16034 model: None,
16035 fast_model: None,
16036 vision_model: None,
16037 reasoning_effort: None,
16038 responses_only_models: Vec::new(),
16039 request_overrides: None,
16040 enabled: true,
16041 extra: Default::default(),
16042 }
16043 }
16044
16045 let mut initial = bamboo_llm::Config::default();
16046 initial.provider_instances.insert(
16047 "old-provider".to_string(),
16048 provider_instance("openai", "old-key"),
16049 );
16050 initial.default_provider_instance = Some("old-provider".to_string());
16051 let live = Arc::new(tokio::sync::RwLock::new(initial));
16052
16053 let runner = ActorChildRunner::new(
16054 "echo-live-config-test".to_string(),
16055 PathBuf::from("/bin/false"),
16056 Vec::new(),
16057 std::env::temp_dir().join("bamboo-echo-live-config"),
16058 ExecutorSpec::Echo,
16059 Vec::new(),
16060 "stale-provider".to_string(),
16061 1,
16062 )
16063 .with_live_provider_config(live.clone());
16064 let job = crate::runtime::execution::SpawnJob {
16065 parent_session_id: "parent".to_string(),
16066 child_session_id: "child".to_string(),
16067 model: "model".to_string(),
16068 disabled_tools: None,
16069 };
16070
16071 let first = runner
16072 .build_live_spec(&Session::new("child", "model"), &job)
16073 .await;
16074 assert_eq!(
16075 first.model.as_ref().map(|model| model.provider.as_str()),
16076 Some("old-provider")
16077 );
16078 assert_eq!(first.secrets.provider_credentials.len(), 1);
16079 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16080
16081 let mut reloaded = bamboo_llm::Config::default();
16082 reloaded.provider_instances.insert(
16083 "new-provider".to_string(),
16084 provider_instance("openai", "new-key"),
16085 );
16086 reloaded.default_provider_instance = Some("new-provider".to_string());
16087 *live.write().await = reloaded;
16088
16089 let second = runner
16090 .build_live_spec(&Session::new("child", "model"), &job)
16091 .await;
16092 assert_eq!(
16093 second.model.as_ref().map(|model| model.provider.as_str()),
16094 Some("new-provider")
16095 );
16096 assert_eq!(second.secrets.provider_credentials.len(), 1);
16097 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16098 }
16099
16100 #[test]
16101 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16102 let mut executor = codex_executor(Some("custom"), None);
16103 if let ExecutorSpec::Codex {
16104 base_url,
16105 provider_key_ref,
16106 ..
16107 } = &mut executor
16108 {
16109 *base_url = Some("https://provider.example/v1".to_string());
16110 *provider_key_ref = Some("provider.custom.api_key".to_string());
16111 }
16112 let credentials = vec![
16113 ScopedCredential {
16114 provider: "openai".to_string(),
16115 api_key: "session-provider-secret".to_string(),
16116 base_url: None,
16117 provider_type: Some("openai".to_string()),
16118 credential_ref: Some("provider.openai.api_key".to_string()),
16119 },
16120 ScopedCredential {
16121 provider: "custom".to_string(),
16122 api_key: "selected-secret".to_string(),
16123 base_url: None,
16124 provider_type: Some("openai".to_string()),
16125 credential_ref: Some("provider.custom.api_key".to_string()),
16126 },
16127 ScopedCredential {
16128 provider: "other".to_string(),
16129 api_key: "unrelated-secret".to_string(),
16130 base_url: None,
16131 provider_type: Some("openai".to_string()),
16132 credential_ref: Some("provider.other.api_key".to_string()),
16133 },
16134 ];
16135 let runner = ActorChildRunner::new(
16136 "codex-test".to_string(),
16137 PathBuf::from("/bin/false"),
16138 Vec::new(),
16139 std::env::temp_dir().join("bamboo-codex-570"),
16140 executor,
16141 credentials,
16142 "openai".to_string(),
16143 1,
16144 );
16145 let mut session = Session::new("child-custom", "model");
16146 session.add_message(bamboo_agent_core::Message::user("test"));
16147 let spec = runner.build_spec(
16148 &session,
16149 &crate::runtime::execution::SpawnJob {
16150 parent_session_id: "parent".to_string(),
16151 child_session_id: "child-custom".to_string(),
16152 model: "gpt-5.4".to_string(),
16153 disabled_tools: None,
16154 },
16155 );
16156
16157 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16158 assert_eq!(
16159 spec.secrets.provider_credentials[0]
16160 .credential_ref
16161 .as_deref(),
16162 Some("provider.custom.api_key")
16163 );
16164 assert_eq!(
16165 spec.secrets.provider_credentials[0].api_key,
16166 "selected-secret"
16167 );
16168 }
16169
16170 fn spec_with(
16171 role: &str,
16172 provider: &str,
16173 model: &str,
16174 workspace: Option<&str>,
16175 disabled: Option<Vec<&str>>,
16176 ) -> ProvisionSpec {
16177 let mut spec = ProvisionSpec::new(
16178 ChildIdentity {
16179 child_id: "c".into(),
16180 parent_id: None,
16181 project_key: None,
16182 role: role.into(),
16183 depth: 0,
16184 },
16185 ExecutorSpec::Echo,
16186 "/tmp/fab".into(),
16187 );
16188 spec.workspace = workspace.map(|w| w.to_string());
16189 spec.model = Some(ModelRefSpec {
16190 provider: provider.into(),
16191 model: model.into(),
16192 });
16193 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16194 spec
16195 }
16196
16197 #[test]
16198 fn fingerprint_matches_interchangeable_children() {
16199 let a = spec_with(
16202 "explorer",
16203 "p",
16204 "m",
16205 Some("/ws"),
16206 Some(vec!["Bash", "Edit"]),
16207 );
16208 let mut b = spec_with(
16209 "explorer",
16210 "p",
16211 "m",
16212 Some("/ws"),
16213 Some(vec!["Edit", "Bash"]),
16214 );
16215 b.identity.child_id = "other".into();
16216 assert_eq!(
16217 ActorChildRunner::fingerprint(&a),
16218 ActorChildRunner::fingerprint(&b)
16219 );
16220 }
16221
16222 #[test]
16223 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16224 let mut session =
16225 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16226 session.root_session_id = "logical-root-681".to_string();
16227 let job = SpawnJob {
16228 parent_session_id: "logical-parent-681".to_string(),
16229 child_session_id: "logical-child-681".to_string(),
16230 model: "model".to_string(),
16231 disabled_tools: None,
16232 };
16233 let expected = LogicalSessionIdentity {
16234 creation: None,
16235 session_id: "logical-child-681".to_string(),
16236 parent_session_id: Some("logical-parent-681".to_string()),
16237 root_session_id: "logical-root-681".to_string(),
16238 };
16239
16240 let placements_and_transport_ids = [
16241 (Placement::Local, "local-mailbox-first"),
16242 (
16243 Placement::Remote {
16244 endpoint: "wss://remote.example/actor".to_string(),
16245 },
16246 "remote-process-44",
16247 ),
16248 (
16249 Placement::Schedulable {
16250 pool: "gpu-pool".to_string(),
16251 },
16252 "scheduled-mailbox-9",
16253 ),
16254 (Placement::Local, "warm-mailbox-reused-77"),
16256 ];
16257 for (placement, transport_id) in placements_and_transport_ids {
16258 let mut provision = spec_with("worker", "provider", "model", None, None);
16259 provision.placement = placement;
16260 provision.identity.child_id = transport_id.to_string();
16261 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16262 assert_ne!(
16263 provision.identity.child_id, expected.session_id,
16264 "test fixture must prove transport identity is independent"
16265 );
16266 }
16267 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16268 original.executor = ExecutorSpec::BambooRuntime;
16269 original.identity.depth = session.spawn_depth;
16270 original.capabilities.required_child_context = true;
16271 original.capabilities.child_creation_identity = true;
16272 original.capabilities.enforce_permissions = true;
16273 original.capabilities.native_tool_ceiling_required = true;
16274 original.capabilities.native_tool_ceiling =
16275 Some(bamboo_subagent::proto::NativeToolCeiling {
16276 version: 1,
16277 child_session_id: session.id.clone(),
16278 parent_session_id: job.parent_session_id.clone(),
16279 root_session_id: session.root_session_id.clone(),
16280 created_at: session.created_at,
16281 spawn_depth: session.spawn_depth,
16282 project_id: None,
16283 tools: vec![],
16284 });
16285 original.validate().unwrap();
16286 let first = ActorChildRunner::fresh_required_worker_spec(&original);
16287 let second = ActorChildRunner::fresh_required_worker_spec(&original);
16288 assert_ne!(first.identity.child_id, second.identity.child_id);
16289 assert_ne!(first.identity.child_id, original.identity.child_id);
16290 for physical in [first, second] {
16291 physical.validate().unwrap();
16292 let mut actual = serde_json::to_value(&physical).unwrap();
16293 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
16294 assert_eq!(actual, serde_json::to_value(&original).unwrap());
16295 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16296 }
16297 }
16298
16299 #[test]
16300 fn fingerprint_separates_distinct_runtimes() {
16301 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
16302 let base_fp = ActorChildRunner::fingerprint(&base);
16303 assert_ne!(
16305 base_fp,
16306 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
16307 );
16308 assert_ne!(
16309 base_fp,
16310 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
16311 );
16312 assert_ne!(
16313 base_fp,
16314 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
16315 );
16316 assert_ne!(
16317 base_fp,
16318 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
16319 );
16320 assert_ne!(
16321 base_fp,
16322 ActorChildRunner::fingerprint(&spec_with(
16323 "explorer",
16324 "p",
16325 "m",
16326 Some("/ws"),
16327 Some(vec!["Bash"])
16328 ))
16329 );
16330 }
16331
16332 #[test]
16333 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
16334 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
16335 before.secrets.provider_credentials.push(ScopedCredential {
16336 provider: "provider".to_string(),
16337 api_key: "old-secret-key".to_string(),
16338 base_url: Some("https://provider.example/v1".to_string()),
16339 provider_type: Some("openai".to_string()),
16340 credential_ref: None,
16341 });
16342 let mut after = before.clone();
16343 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
16344
16345 let before_fingerprint = ActorChildRunner::fingerprint(&before);
16346 let after_fingerprint = ActorChildRunner::fingerprint(&after);
16347 assert_ne!(before_fingerprint, after_fingerprint);
16348 assert!(!before_fingerprint.contains("old-secret-key"));
16349 assert!(!after_fingerprint.contains("new-secret-key"));
16350 }
16351
16352 #[test]
16353 fn fingerprint_splits_on_baked_capabilities() {
16354 let base_fp =
16359 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
16360
16361 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
16362 creation.capabilities.child_creation_identity = true;
16363 let typed = ActorChildRunner::fingerprint(&creation);
16364 assert_ne!(
16365 base_fp, typed,
16366 "legacy worker pool cannot satisfy birth-required Run"
16367 );
16368 creation.identity.child_id = "different-logical-child".into();
16369 assert_eq!(
16370 typed,
16371 ActorChildRunner::fingerprint(&creation),
16372 "birth-required mode retains cross-Child pool reuse"
16373 );
16374
16375 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
16376 depth.identity.depth = 2;
16377 assert_ne!(
16378 base_fp,
16379 ActorChildRunner::fingerprint(&depth),
16380 "depth must split"
16381 );
16382
16383 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
16384 nested.capabilities.nested_spawn = true;
16385 assert_ne!(
16386 base_fp,
16387 ActorChildRunner::fingerprint(&nested),
16388 "nested_spawn must split"
16389 );
16390
16391 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
16392 bypass.capabilities.bypass = true;
16393 assert_ne!(
16394 base_fp,
16395 ActorChildRunner::fingerprint(&bypass),
16396 "bypass must split"
16397 );
16398
16399 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
16400 auto.capabilities.auto_approve_permissions = true;
16401 assert_ne!(
16402 base_fp,
16403 ActorChildRunner::fingerprint(&auto),
16404 "auto_approve_permissions must split"
16405 );
16406
16407 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
16408 global_auto.capabilities.permission_requested_mode = "default".to_string();
16409 global_auto.capabilities.permission_effective_mode = "auto".to_string();
16410 global_auto.capabilities.auto_approve_permissions = true;
16411 let mut explicit_auto = global_auto.clone();
16412 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
16413 assert_ne!(
16414 ActorChildRunner::fingerprint(&global_auto),
16415 ActorChildRunner::fingerprint(&explicit_auto),
16416 "permission_requested_mode must split global and explicit Auto"
16417 );
16418
16419 let mut plan_overlay = explicit_auto.clone();
16420 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
16421 assert_ne!(
16422 ActorChildRunner::fingerprint(&explicit_auto),
16423 ActorChildRunner::fingerprint(&plan_overlay),
16424 "permission_effective_mode must split Plan overlay from Auto"
16425 );
16426
16427 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
16428 enforce.capabilities.enforce_permissions = true;
16429 assert_ne!(
16430 base_fp,
16431 ActorChildRunner::fingerprint(&enforce),
16432 "enforce_permissions must split"
16433 );
16434
16435 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
16436 cap.capabilities.max_spawn_depth = Some(8);
16437 assert_ne!(
16438 base_fp,
16439 ActorChildRunner::fingerprint(&cap),
16440 "max_spawn_depth must split"
16441 );
16442
16443 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
16447 nha.capabilities.no_human_approver = true;
16448 assert_ne!(
16449 base_fp,
16450 ActorChildRunner::fingerprint(&nha),
16451 "no_human_approver must split"
16452 );
16453
16454 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
16457 read_only.capabilities.read_only = true;
16458 assert_ne!(
16459 base_fp,
16460 ActorChildRunner::fingerprint(&read_only),
16461 "read_only must split"
16462 );
16463
16464 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
16466 gro.capabilities.guardian_read_only = true;
16467 assert_ne!(
16468 base_fp,
16469 ActorChildRunner::fingerprint(&gro),
16470 "guardian_read_only must split"
16471 );
16472 }
16473
16474 #[test]
16475 fn fingerprint_splits_codex_exec_and_app_server_workers() {
16476 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
16477 exec.executor = codex_executor(Some("inherit"), None);
16478 let mut app_server = exec.clone();
16479 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
16480 *mode = Some("app_server".to_string());
16481 }
16482 assert_ne!(
16483 ActorChildRunner::fingerprint(&exec),
16484 ActorChildRunner::fingerprint(&app_server)
16485 );
16486 }
16487
16488 struct StaticDecider(bool);
16489
16490 #[async_trait]
16491 impl ChildApprovalDecider for StaticDecider {
16492 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
16493 self.0
16494 }
16495 }
16496
16497 struct RecordingReviewer {
16498 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
16499 }
16500
16501 #[async_trait]
16502 impl ChildApprovalReviewer for RecordingReviewer {
16503 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
16504 let _ = self
16505 .reviewed
16506 .send((parent.to_string(), child.to_string(), request.clone()));
16507 true
16508 }
16509 }
16510
16511 struct SilentLink;
16516 #[async_trait]
16517 impl bamboo_subagent::ChildLink for SilentLink {
16518 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
16519 Ok(())
16520 }
16521 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
16522 std::future::pending().await
16523 }
16524 }
16525
16526 struct InstantTerminalLink {
16528 done: bool,
16529 }
16530
16531 struct ApprovalRoundTripLink {
16532 step: u8,
16533 approval_reply: Option<(String, bool)>,
16534 }
16535
16536 #[async_trait]
16537 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
16538 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
16539 if let ParentFrame::ApprovalReply { id, approved } = frame {
16540 self.approval_reply = Some((id, approved));
16541 self.step = 2;
16542 }
16543 Ok(())
16544 }
16545
16546 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
16547 match self.step {
16548 0 => {
16549 self.step = 1;
16550 Ok(Some(ChildFrame::ApprovalRequest {
16551 id: "approval-1".into(),
16552 body: serde_json::json!({
16553 "tool_name": "Bash",
16554 "permission": "execute",
16555 "resource": "rm -rf target",
16556 "permission_request": {"reason_code": "hard_dangerous"}
16557 }),
16558 }))
16559 }
16560 1 => std::future::pending().await,
16561 2 => {
16562 self.step = 3;
16563 Ok(Some(ChildFrame::Terminal {
16564 status: TerminalStatus::Completed,
16565 result: Some("done".into()),
16566 error: None,
16567 final_event_watermark: None,
16568 transcript: vec![],
16569 }))
16570 }
16571 _ => std::future::pending().await,
16572 }
16573 }
16574 }
16575
16576 #[tokio::test]
16577 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
16578 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
16579 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
16580 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
16581 reviewed: review_tx,
16582 });
16583 let cancel = CancellationToken::new();
16584 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16585 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16586 let mut logical_session = Session::new("child-reviewer", "model");
16587 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
16588 let mut link = ApprovalRoundTripLink {
16589 step: 0,
16590 approval_reply: None,
16591 };
16592
16593 let result = tokio::time::timeout(
16594 Duration::from_secs(1),
16595 drive(ActorDriveContext {
16596 expected_creation: None,
16597 client: &mut link,
16598 parent_session_id: "parent-reviewer",
16599 child_session_id: "child-reviewer",
16600 child_attempt: 0,
16601 approval_registry: None,
16602 approval_decider: None,
16603 approval_reviewer: Some(&reviewer),
16604 escalation_bridge: None,
16605 event_tx: &event_tx,
16606 cancel_token: &cancel,
16607 live_rx: &mut live_rx,
16608 delivery_rx: &mut delivery_rx,
16609 logical_session: &mut logical_session,
16610 expected_permission_posture: None,
16611 session_inbox_runtime: None,
16612 actor_directory_store: None,
16613 canonical_subagent_tool: None,
16614 ticket_service: None,
16615 activation_run_id: None,
16616 execution_epoch: 0,
16617 expected_source_actor_id: "child-reviewer",
16618 initial_inflight_claims: VecDeque::new(),
16619 plain_actor: false,
16620 remote_environment_lease: false,
16621 readonly_output: None,
16622 local_history_tools: None,
16623 local_history_read_only: false,
16624 strict_history_output: None,
16625 plain_input: None,
16626 canonical_activation: None,
16627 canonical_placement_ref: None,
16628 actor_event_observer: None,
16629 plain_run: None,
16630 first_frame_timeout: None,
16631 }),
16632 )
16633 .await
16634 .expect("worker must receive the reviewer verdict before terminating");
16635
16636 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
16637 assert_eq!(
16638 link.approval_reply,
16639 Some(("approval-1".to_string(), true)),
16640 "reviewer verdict must traverse the live route back to the worker"
16641 );
16642 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
16643 .await
16644 .expect("reviewer should be invoked off-loop")
16645 .expect("review channel should remain open");
16646 assert_eq!(parent, "parent-reviewer");
16647 assert_eq!(child, "child-reviewer");
16648 assert_eq!(
16649 body.pointer("/permission_request/reason_code")
16650 .and_then(serde_json::Value::as_str),
16651 Some("hard_dangerous")
16652 );
16653 assert!(
16654 event_rx.try_recv().is_err(),
16655 "must not emit a human-review event"
16656 );
16657 drop(live_guard);
16658 }
16659
16660 #[tokio::test]
16661 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
16662 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
16663 let cancel = CancellationToken::new();
16664 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16665 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16666 let mut logical_session = Session::new("child-no-reviewer", "model");
16667 let mut link = ApprovalRoundTripLink {
16668 step: 0,
16669 approval_reply: None,
16670 };
16671
16672 let result = tokio::time::timeout(
16673 Duration::from_secs(1),
16674 drive(ActorDriveContext {
16675 expected_creation: None,
16676 client: &mut link,
16677 parent_session_id: "parent-no-reviewer",
16678 child_session_id: "child-no-reviewer",
16679 child_attempt: 0,
16680 approval_registry: None,
16681 approval_decider: None,
16682 approval_reviewer: None,
16683 escalation_bridge: None,
16684 event_tx: &event_tx,
16685 cancel_token: &cancel,
16686 live_rx: &mut live_rx,
16687 delivery_rx: &mut delivery_rx,
16688 logical_session: &mut logical_session,
16689 expected_permission_posture: None,
16690 session_inbox_runtime: None,
16691 actor_directory_store: None,
16692 canonical_subagent_tool: None,
16693 ticket_service: None,
16694 activation_run_id: None,
16695 execution_epoch: 0,
16696 expected_source_actor_id: "child-no-reviewer",
16697 initial_inflight_claims: VecDeque::new(),
16698 plain_actor: false,
16699 remote_environment_lease: false,
16700 readonly_output: None,
16701 local_history_tools: None,
16702 local_history_read_only: false,
16703 strict_history_output: None,
16704 plain_input: None,
16705 canonical_activation: None,
16706 canonical_placement_ref: None,
16707 actor_event_observer: None,
16708 plain_run: None,
16709 first_frame_timeout: None,
16710 }),
16711 )
16712 .await
16713 .expect("fail-closed reply must unblock the child immediately");
16714
16715 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
16716 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
16717 assert!(
16718 event_rx.try_recv().is_err(),
16719 "missing parent review must not surface a manual approval event"
16720 );
16721 }
16722 #[async_trait]
16723 impl bamboo_subagent::ChildLink for InstantTerminalLink {
16724 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
16725 Ok(())
16726 }
16727 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
16728 if self.done {
16729 std::future::pending().await
16730 } else {
16731 self.done = true;
16732 Ok(Some(ChildFrame::Terminal {
16733 status: TerminalStatus::Completed,
16734 result: Some("done".into()),
16735 error: None,
16736 final_event_watermark: None,
16737 transcript: vec![],
16738 }))
16739 }
16740 }
16741 }
16742
16743 #[tokio::test]
16744 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
16745 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
16746 let cancel = CancellationToken::new();
16747 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16748 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16749 let mut logical_session = Session::new("child-x", "model");
16750 let mut link = SilentLink;
16751 let r = drive(ActorDriveContext {
16752 expected_creation: None,
16753 client: &mut link,
16754 parent_session_id: "parent-x",
16755 child_session_id: "child-x",
16756 child_attempt: 0,
16757 approval_registry: None,
16758 approval_decider: None,
16759 approval_reviewer: None,
16760 escalation_bridge: None,
16761 event_tx: &event_tx,
16762 cancel_token: &cancel,
16763 live_rx: &mut live_rx,
16764 delivery_rx: &mut delivery_rx,
16765 logical_session: &mut logical_session,
16766 expected_permission_posture: None,
16767 session_inbox_runtime: None,
16768 actor_directory_store: None,
16769 canonical_subagent_tool: None,
16770 ticket_service: None,
16771 activation_run_id: None,
16772 execution_epoch: 0,
16773 expected_source_actor_id: "child-x",
16774 initial_inflight_claims: VecDeque::new(),
16775 plain_actor: false,
16776 remote_environment_lease: false,
16777 readonly_output: None,
16778 local_history_tools: None,
16779 local_history_read_only: false,
16780 strict_history_output: None,
16781 plain_input: None,
16782 canonical_activation: None,
16783 canonical_placement_ref: None,
16784 actor_event_observer: None,
16785 plain_run: None,
16786 first_frame_timeout: Some(Duration::from_millis(100)),
16787 })
16788 .await;
16789 assert!(
16790 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
16791 "a silent worker must trip the first-frame watchdog, got {r:?}"
16792 );
16793 }
16794
16795 #[tokio::test]
16796 async fn drive_does_not_trip_when_a_frame_arrives() {
16797 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
16798 let cancel = CancellationToken::new();
16799 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
16800 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16801 let mut logical_session = Session::new("child-y", "model");
16802 let mut link = InstantTerminalLink { done: false };
16803 let r = drive(ActorDriveContext {
16806 expected_creation: None,
16807 client: &mut link,
16808 parent_session_id: "parent-y",
16809 child_session_id: "child-y",
16810 child_attempt: 0,
16811 approval_registry: None,
16812 approval_decider: None,
16813 approval_reviewer: None,
16814 escalation_bridge: None,
16815 event_tx: &event_tx,
16816 cancel_token: &cancel,
16817 live_rx: &mut live_rx,
16818 delivery_rx: &mut delivery_rx,
16819 logical_session: &mut logical_session,
16820 expected_permission_posture: None,
16821 session_inbox_runtime: None,
16822 actor_directory_store: None,
16823 canonical_subagent_tool: None,
16824 ticket_service: None,
16825 activation_run_id: None,
16826 execution_epoch: 0,
16827 expected_source_actor_id: "child-y",
16828 initial_inflight_claims: VecDeque::new(),
16829 plain_actor: false,
16830 remote_environment_lease: false,
16831 readonly_output: None,
16832 local_history_tools: None,
16833 local_history_read_only: false,
16834 strict_history_output: None,
16835 plain_input: None,
16836 canonical_activation: None,
16837 canonical_placement_ref: None,
16838 actor_event_observer: None,
16839 plain_run: None,
16840 first_frame_timeout: Some(Duration::from_millis(50)),
16841 })
16842 .await;
16843 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
16844 }
16845
16846 #[tokio::test]
16847 async fn child_approval_fails_closed_without_decider() {
16848 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
16850 assert!(!decide_child_approval(None, "child-1", &body).await);
16851 }
16852
16853 #[tokio::test]
16854 async fn child_approval_honors_wired_decider() {
16855 let body =
16856 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
16857 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
16858 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
16859 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
16860 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
16861 }
16862
16863 use crate::runtime::execution::SpawnJob;
16866 use bamboo_agent_core::Session;
16867
16868 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
16871 ActorChildRunner::new(
16872 "test-actor".into(),
16873 PathBuf::from("/bin/false"),
16874 vec![],
16875 std::env::temp_dir().join("bamboo-test-fab-193"),
16876 ExecutorSpec::Echo,
16877 vec![],
16878 "anthropic".into(),
16879 4,
16880 )
16881 .with_remote_placements(placements)
16882 }
16883
16884 fn session_of_role(role: &str, assignment: &str) -> Session {
16887 let mut s = Session::new("child-1", "test-model");
16888 s.metadata
16889 .insert("subagent_type".to_string(), role.to_string());
16890 s.add_message(bamboo_agent_core::Message::user(assignment));
16891 s
16892 }
16893
16894 fn job_for(child: &str) -> SpawnJob {
16895 SpawnJob {
16896 parent_session_id: "parent-1".into(),
16897 child_session_id: child.into(),
16898 model: String::new(),
16899 disabled_tools: None,
16900 }
16901 }
16902
16903 #[derive(Default)]
16904 struct RecordingChildSessionPort {
16905 saved: std::sync::Mutex<Option<Session>>,
16906 }
16907
16908 impl RecordingChildSessionPort {
16909 fn saved_child(&self) -> Session {
16910 self.saved
16911 .lock()
16912 .expect("saved-child fixture lock")
16913 .clone()
16914 .expect("create_child_action must save the child")
16915 }
16916 }
16917
16918 #[async_trait]
16919 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
16920 async fn load_root_session(
16921 &self,
16922 _root_id: &str,
16923 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
16924 unreachable!("create_child_action does not load the root")
16925 }
16926
16927 async fn load_child_for_parent(
16928 &self,
16929 _parent_id: &str,
16930 _child_id: &str,
16931 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
16932 unreachable!("create_child_action does not reload the child")
16933 }
16934
16935 async fn save_child_session(
16936 &self,
16937 child: &mut Session,
16938 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16939 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
16940 Ok(())
16941 }
16942
16943 async fn save_child_session_authoritative_flags(
16944 &self,
16945 _child: &mut Session,
16946 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16947 unreachable!("new-child creation uses the ordinary save")
16948 }
16949
16950 async fn is_child_running(&self, _child_id: &str) -> bool {
16951 false
16952 }
16953
16954 async fn list_children(
16955 &self,
16956 _parent_id: &str,
16957 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
16958 Vec::new()
16959 }
16960
16961 async fn enqueue_child_run(
16962 &self,
16963 _parent: &Session,
16964 _child: &Session,
16965 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16966 unreachable!("fixture creates the child with auto_run=false")
16967 }
16968
16969 async fn cancel_child_run_and_wait(
16970 &self,
16971 _child_id: &str,
16972 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
16973 unreachable!("create_child_action does not cancel")
16974 }
16975
16976 async fn delete_child_session(
16977 &self,
16978 _parent_id: &str,
16979 _child_id: &str,
16980 ) -> Result<
16981 crate::session_app::child_session::DeleteChildResult,
16982 crate::session_app::child_session::ChildSessionError,
16983 > {
16984 unreachable!("create_child_action does not delete")
16985 }
16986
16987 async fn get_child_runner_info(
16988 &self,
16989 _child_id: &str,
16990 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
16991 None
16992 }
16993
16994 async fn register_parent_wait_for_child(
16995 &self,
16996 _parent_session_id: &str,
16997 _child_session_id: &str,
16998 _tool_call_id: Option<&str>,
16999 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17000 unreachable!("create_child_action does not register a wait")
17001 }
17002
17003 async fn register_parent_wait_for_children(
17004 &self,
17005 _parent_session_id: &str,
17006 _child_session_ids: &[String],
17007 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17008 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17009 unreachable!("create_child_action does not register a wait")
17010 }
17011
17012 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17013 Vec::new()
17014 }
17015
17016 async fn find_resident_child(
17017 &self,
17018 _root_session_id: &str,
17019 _resident_name: &str,
17020 ) -> Option<String> {
17021 None
17022 }
17023
17024 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17025 }
17026
17027 #[test]
17028 fn build_spec_sets_remote_placement_for_matching_role() {
17029 let mut placements = HashMap::new();
17030 placements.insert(
17031 "explorer".to_string(),
17032 ResolvedRemotePlacement {
17033 endpoint: "wss://gpu-host:8443".into(),
17034 token: Some("T-secret".into()),
17035 ca_cert_file: None,
17036 host_label: None,
17037 broker_peer: None,
17038 requirements: None,
17039 },
17040 );
17041 let runner = bogus_runner(placements);
17042
17043 let s = session_of_role("explorer", "do the thing");
17045 let spec = runner.build_spec(&s, &job_for("child-1"));
17046 match &spec.placement {
17047 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17048 other => panic!("expected Remote, got {other:?}"),
17049 }
17050 assert!(spec.secrets.worker_auth_token.is_none());
17051 }
17052
17053 #[test]
17054 fn build_spec_leaves_local_for_unmatched_role() {
17055 let mut placements = HashMap::new();
17056 placements.insert(
17057 "explorer".to_string(),
17058 ResolvedRemotePlacement {
17059 endpoint: "wss://gpu-host:8443".into(),
17060 token: Some("T".into()),
17061 ca_cert_file: None,
17062 host_label: None,
17063 broker_peer: None,
17064 requirements: None,
17065 },
17066 );
17067 let runner = bogus_runner(placements);
17068
17069 let s = session_of_role("writer", "do the thing");
17071 let spec = runner.build_spec(&s, &job_for("child-1"));
17072 assert_eq!(spec.placement, Placement::Local);
17073 assert!(spec.secrets.worker_auth_token.is_none());
17074 }
17075
17076 #[test]
17077 fn build_spec_local_when_no_placements() {
17078 let runner = bogus_runner(HashMap::new());
17079 let s = session_of_role("explorer", "do the thing");
17080 let spec = runner.build_spec(&s, &job_for("child-1"));
17081 assert_eq!(spec.placement, Placement::Local);
17082 assert!(spec.secrets.worker_auth_token.is_none());
17083 }
17084
17085 #[test]
17086 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17087 for mode in [
17088 bamboo_domain::SessionPermissionMode::Auto,
17089 bamboo_domain::SessionPermissionMode::Bypass,
17090 ] {
17091 let runner = bogus_runner(HashMap::new());
17092 let mut child = session_of_role("planner", "inspect and plan");
17093 let runtime = child
17094 .agent_runtime_state
17095 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17096 runtime.set_permission_mode(mode);
17097 runtime.read_only = true;
17098
17099 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17100
17101 assert!(spec.capabilities.read_only);
17102 assert!(spec.capabilities.read_only_enforced());
17103 assert!(
17104 spec.capabilities.guardian_read_only,
17105 "the compatibility alias must protect planners on older workers"
17106 );
17107 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17108 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17109 assert!(
17110 !spec.capabilities.bypass,
17111 "read-only must win over requested {mode:?}"
17112 );
17113 assert_eq!(
17114 spec.capabilities.auto_approve_permissions,
17115 mode == bamboo_domain::SessionPermissionMode::Auto,
17116 "Auto may suppress prompts but must not widen Plan authorization"
17117 );
17118 let disabled = spec
17119 .disabled_tools
17120 .as_ref()
17121 .expect("read-only state must provision a host-owned denylist");
17122 for tool_name in [
17123 "Edit",
17124 "Write",
17125 "Plan",
17126 "SubAgent",
17127 "session_control",
17128 "request_permissions",
17129 ] {
17130 assert!(
17131 disabled.iter().any(|name| name == tool_name),
17132 "read-only provisioning must disable {tool_name}"
17133 );
17134 }
17135 }
17136
17137 let runner = bogus_runner(HashMap::new());
17138 let cosmetic_planner = runner.build_spec(
17139 &session_of_role("planner", "label only"),
17140 &job_for("cosmetic"),
17141 );
17142 assert!(
17143 !cosmetic_planner.capabilities.read_only_enforced(),
17144 "a cosmetic role label must not change authority"
17145 );
17146 assert!(cosmetic_planner.disabled_tools.is_none());
17147
17148 let cosmetic_guardian = runner.build_spec(
17149 &session_of_role("guardian", "label only"),
17150 &job_for("cosmetic-guardian"),
17151 );
17152 assert!(
17153 !cosmetic_guardian.capabilities.read_only_enforced(),
17154 "a Guardian label without typed state or the legacy denylist is cosmetic"
17155 );
17156
17157 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17158 let mut legacy_job = job_for("legacy-guardian");
17159 legacy_job.disabled_tools = Some(vec![
17160 "Edit".to_string(),
17161 "Write".to_string(),
17162 "SubAgent".to_string(),
17163 "WebFetch".to_string(),
17164 ]);
17165 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17166 assert!(
17167 legacy_spec.capabilities.read_only_enforced(),
17168 "an in-flight pre-upgrade Guardian keeps its read-only authority"
17169 );
17170
17171 let mut differently_named = session_of_role("explorer", "typed authority");
17172 differently_named
17173 .agent_runtime_state
17174 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17175 .read_only = true;
17176 let typed = runner.build_spec(&differently_named, &job_for("typed"));
17177 assert!(
17178 typed.capabilities.read_only_enforced(),
17179 "typed state must enforce read-only independently of role"
17180 );
17181 }
17182
17183 #[test]
17184 fn typed_read_only_forces_native_external_executor_profiles() {
17185 let mut planner = session_of_role("planner", "inspect and plan");
17186 planner
17187 .agent_runtime_state
17188 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17189 .read_only = true;
17190
17191 let claude_runner = ActorChildRunner::new(
17192 "test-actor".into(),
17193 PathBuf::from("/bin/false"),
17194 vec![],
17195 std::env::temp_dir().join("bamboo-test-plan-claude"),
17196 ExecutorSpec::ClaudeCode {
17197 binary: None,
17198 model: None,
17199 permission_mode: Some("bypassPermissions".to_string()),
17200 inherit_user_config: Some(true),
17201 forward_env: None,
17202 },
17203 vec![],
17204 "anthropic".into(),
17205 4,
17206 );
17207 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17208 let disabled = claude_spec
17209 .disabled_tools
17210 .as_ref()
17211 .expect("typed read-only child must receive a host-owned denylist");
17212 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17213 assert!(
17214 disabled.iter().any(|name| name == tool),
17215 "typed read-only child must not advertise {tool}"
17216 );
17217 }
17218 assert!(matches!(
17219 claude_spec.executor,
17220 ExecutorSpec::ClaudeCode {
17221 permission_mode: Some(ref mode),
17222 inherit_user_config: Some(false),
17223 ..
17224 } if mode == "plan"
17225 ));
17226 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17227
17228 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17229 if let ExecutorSpec::Codex {
17230 sandbox,
17231 approval_policy,
17232 network_access,
17233 allow_danger_bypass,
17234 permission_profile,
17235 ..
17236 } = &mut codex_exec
17237 {
17238 *sandbox = Some("danger-full-access".to_string());
17239 *approval_policy = Some("on-failure".to_string());
17240 *network_access = Some(true);
17241 *allow_danger_bypass = Some(true);
17242 *permission_profile = Some("full-access".to_string());
17243 }
17244 let codex_runner = ActorChildRunner::new(
17245 "test-actor".into(),
17246 PathBuf::from("/bin/false"),
17247 vec![],
17248 std::env::temp_dir().join("bamboo-test-plan-codex"),
17249 codex_exec,
17250 vec![],
17251 "openai".into(),
17252 4,
17253 );
17254 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
17255 assert_eq!(
17256 expected_permission_executor_mapping(
17257 &codex_spec.executor,
17258 codex_spec.capabilities.permission_resolution().unwrap(),
17259 false,
17260 )
17261 .unwrap()
17262 .as_deref(),
17263 Some("codex_exec:approval_policy=never")
17264 );
17265 assert!(matches!(
17266 codex_spec.executor,
17267 ExecutorSpec::Codex {
17268 sandbox: Some(ref sandbox),
17269 inherit_user_config: Some(false),
17270 approval_policy: Some(ref approval_policy),
17271 network_access: Some(false),
17272 allow_danger_bypass: Some(false),
17273 permission_profile: Some(ref profile),
17274 ..
17275 } if sandbox == "read-only"
17276 && approval_policy == "never"
17277 && profile == "read-only"
17278 ));
17279
17280 let mut app_server = codex_executor(Some("inherit"), Some(true));
17281 if let ExecutorSpec::Codex {
17282 mode,
17283 sandbox,
17284 approval_policy,
17285 network_access,
17286 allow_danger_bypass,
17287 permission_profile,
17288 ..
17289 } = &mut app_server
17290 {
17291 *mode = Some("app_server".to_string());
17292 *sandbox = Some("danger-full-access".to_string());
17293 *approval_policy = Some("on-request".to_string());
17294 *network_access = Some(true);
17295 *allow_danger_bypass = Some(true);
17296 *permission_profile = Some("full-access".to_string());
17297 }
17298 let app_server_runner = ActorChildRunner::new(
17299 "test-actor".into(),
17300 PathBuf::from("/bin/false"),
17301 vec![],
17302 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
17303 app_server,
17304 vec![],
17305 "openai".into(),
17306 4,
17307 );
17308 let app_server_spec =
17309 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
17310 assert_eq!(
17311 expected_permission_executor_mapping(
17312 &app_server_spec.executor,
17313 app_server_spec
17314 .capabilities
17315 .permission_resolution()
17316 .unwrap(),
17317 false,
17318 )
17319 .unwrap()
17320 .as_deref(),
17321 Some("codex_app_server:approvalPolicy=never")
17322 );
17323 assert!(matches!(
17324 app_server_spec.executor,
17325 ExecutorSpec::Codex {
17326 mode: Some(ref mode),
17327 sandbox: Some(ref sandbox),
17328 inherit_user_config: Some(false),
17329 approval_policy: Some(ref approval_policy),
17330 network_access: Some(false),
17331 allow_danger_bypass: Some(false),
17332 permission_profile: Some(ref profile),
17333 ..
17334 } if mode == "app_server"
17335 && sandbox == "read-only"
17336 && approval_policy == "on-request"
17337 && profile == "read-only"
17338 ));
17339 }
17340
17341 #[test]
17342 fn typed_read_only_rejects_both_codex_executor_modes() {
17343 let mut planner = session_of_role("planner", "inspect and plan");
17344 planner
17345 .agent_runtime_state
17346 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17347 .read_only = true;
17348
17349 for mode in ["exec", "app_server"] {
17350 let mut executor = codex_executor(Some("inherit"), Some(true));
17351 if let ExecutorSpec::Codex {
17352 mode: configured_mode,
17353 ..
17354 } = &mut executor
17355 {
17356 *configured_mode = Some(mode.to_string());
17357 }
17358 let runner = ActorChildRunner::new(
17359 "test-actor".into(),
17360 PathBuf::from("/bin/false"),
17361 vec![],
17362 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
17363 executor,
17364 vec![],
17365 "openai".into(),
17366 4,
17367 );
17368 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
17369 let error = ensure_read_only_activation_is_enforceable(&spec)
17370 .expect_err("Codex command execution must fail closed for read-only activations");
17371 let message = error.to_string();
17372 assert!(message.contains("Codex executor"), "{message}");
17373 assert!(message.contains("command execution"), "{message}");
17374 }
17375 }
17376
17377 #[tokio::test]
17378 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
17379 let mut planner = session_of_role("planner", "inspect and plan");
17380 planner
17381 .agent_runtime_state
17382 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17383 .read_only = true;
17384
17385 let runner = ActorChildRunner::new(
17386 "test-actor".into(),
17387 PathBuf::from("/bin/false"),
17388 vec![],
17389 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
17390 codex_executor(Some("inherit"), Some(true)),
17391 vec![],
17392 "openai".into(),
17393 4,
17394 );
17395 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
17396 let error = runner
17397 .execute_external_child(
17398 &mut planner,
17399 &job_for("planner-codex-no-spawn"),
17400 event_tx,
17401 CancellationToken::new(),
17402 )
17403 .await
17404 .expect_err("typed read-only Codex must fail before invoking /bin/false");
17405 let message = error.to_string();
17406 assert!(message.contains("Codex executor"), "{message}");
17407 assert!(message.contains("command execution"), "{message}");
17408 }
17409
17410 #[test]
17411 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
17412 let runner = bogus_runner(HashMap::new());
17413 let ordinary = session_of_role("worker", "ordinary work");
17414 for placement in [
17415 Placement::Local,
17416 Placement::Remote {
17417 endpoint: "wss://fixture.invalid/actor".into(),
17418 },
17419 Placement::Schedulable {
17420 pool: "fixture-pool".into(),
17421 },
17422 ] {
17423 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
17424 spec.placement = placement;
17425 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
17426 for marker in [
17427 crate::ticket_runtime::TICKET_DISPATCH_KEY,
17428 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
17429 ] {
17430 let mut ticket = ordinary.clone();
17431 ticket.metadata.insert(marker.into(), "fixture".into());
17432 assert_eq!(
17433 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
17434 matches!(spec.placement, Placement::Local)
17435 );
17436 }
17437 }
17438 }
17439
17440 #[tokio::test]
17441 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
17442 for marker in [
17443 crate::ticket_runtime::TICKET_DISPATCH_KEY,
17444 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
17445 ] {
17446 for schedulable in [false, true] {
17447 let mut session = session_of_role("worker", "Ticket work");
17448 session.metadata.insert(marker.into(), "fixture".into());
17449 let before = serde_json::to_value(&session).unwrap();
17450 let remote = if schedulable {
17451 HashMap::new()
17452 } else {
17453 HashMap::from([(
17454 "worker".into(),
17455 ResolvedRemotePlacement {
17456 endpoint: "wss://fixture.invalid/actor".into(),
17457 ..Default::default()
17458 },
17459 )])
17460 };
17461 let sched = if schedulable {
17462 HashMap::from([(
17463 "worker".into(),
17464 sched_placement("fixture-pool", "wss://fixture.invalid"),
17465 )])
17466 } else {
17467 HashMap::new()
17468 };
17469 let runner = bogus_sched_runner(remote, sched);
17470 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
17471 let error = runner
17472 .execute_external_child(
17473 &mut session,
17474 &job_for("ticket-no-remote"),
17475 events,
17476 CancellationToken::new(),
17477 )
17478 .await
17479 .unwrap_err();
17480 assert!(
17481 error
17482 .to_string()
17483 .contains("Ticket execution requires local placement"),
17484 "{error}"
17485 );
17486 assert_eq!(serde_json::to_value(&session).unwrap(), before);
17487 assert!(observations.try_recv().is_err());
17488 }
17489 }
17490 }
17491
17492 #[test]
17493 fn typed_read_only_rejects_resident_actor_placements() {
17494 let runner = bogus_runner(HashMap::new());
17495 let mut planner = session_of_role("planner", "inspect and plan");
17496 planner
17497 .agent_runtime_state
17498 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17499 .read_only = true;
17500
17501 for placement in [
17502 Placement::Remote {
17503 endpoint: "wss://resident.example/actor".to_string(),
17504 },
17505 Placement::Schedulable {
17506 pool: "resident-pool".to_string(),
17507 },
17508 ] {
17509 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
17510 spec.placement = placement;
17511
17512 let error = ensure_read_only_activation_is_enforceable(&spec)
17513 .expect_err("resident placement must fail closed for read-only activation");
17514 let message = error.to_string();
17515 assert!(message.contains("requires local placement"), "{message}");
17516 assert!(message.contains("read-only tool boundary"), "{message}");
17517 }
17518
17519 let local = runner.build_spec(&planner, &job_for("read-only-local"));
17520 ensure_read_only_activation_is_enforceable(&local)
17521 .expect("a locally provisioned read-only worker is enforceable");
17522 }
17523
17524 #[tokio::test]
17525 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
17526 for (label, mode) in [
17530 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
17531 ("auto", bamboo_domain::SessionPermissionMode::Auto),
17532 ] {
17533 let runner = bogus_runner(HashMap::new());
17534 let mut parent = Session::new(format!("parent-{label}"), "test-model");
17535 parent
17536 .agent_runtime_state
17537 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17538 .set_permission_mode(mode);
17539 let workspace = tempfile::tempdir().expect("workspace fixture");
17540 let port = RecordingChildSessionPort::default();
17541 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
17542 crate::session_app::child_session::create_child_action(
17543 &port,
17544 crate::session_app::child_session::CreateChildInput {
17545 parent_session: parent,
17546 child_id: child_id.clone(),
17547 title: format!("{label} child"),
17548 responsibility: "Run ordinary commands".to_string(),
17549 assignment_prompt: "run an ordinary command".to_string(),
17550 subagent_type: "explorer".to_string(),
17551 workspace: workspace.path().to_string_lossy().into_owned(),
17552 workspace_source: crate::project_context::WorkspaceSource::Explicit,
17553 model_override: None,
17554 model_ref_override: None,
17555 runtime_metadata: HashMap::new(),
17556 read_only: false,
17557 auto_run: false,
17558 reasoning_effort: None,
17559 lifecycle: None,
17560 resident_name: None,
17561 resident_context: None,
17562 disabled_tools: None,
17563 context_fork: None,
17564 },
17565 )
17566 .await
17567 .expect("create inherited-permission child");
17568 let child = port.saved_child();
17569
17570 assert_eq!(
17571 child
17572 .agent_runtime_state
17573 .as_ref()
17574 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
17575 Some(mode),
17576 "create_child_action must inherit {label} from the parent"
17577 );
17578
17579 let spec = runner.build_spec(&child, &job_for(&child_id));
17580
17581 assert_eq!(
17582 spec.capabilities.bypass,
17583 mode == bamboo_domain::SessionPermissionMode::Bypass
17584 );
17585 assert_eq!(
17586 spec.capabilities.auto_approve_permissions,
17587 mode == bamboo_domain::SessionPermissionMode::Auto
17588 );
17589 assert!(
17590 spec.capabilities.enforce_permissions,
17591 "policy evaluation must remain active under {label}"
17592 );
17593 }
17594 }
17595
17596 #[tokio::test]
17597 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
17598 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
17599 let workspace = tempfile::tempdir().expect("workspace fixture");
17600
17601 for (role, lifecycle, resident_name) in [
17602 ("explorer", None, None),
17603 ("resident", Some("resident"), Some("stable-reviewer")),
17604 ("guardian", None, None),
17605 ] {
17606 let mut parent = Session::new(format!("parent-{role}"), "test-model");
17607 parent.set_project_id_meta(project_id.to_string());
17608 let port = RecordingChildSessionPort::default();
17609 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
17610 crate::session_app::child_session::create_child_action(
17611 &port,
17612 crate::session_app::child_session::CreateChildInput {
17613 parent_session: parent,
17614 child_id: child_id.clone(),
17615 title: format!("{role} child"),
17616 responsibility: "Review the assigned work".to_string(),
17617 assignment_prompt: "inspect the change".to_string(),
17618 subagent_type: role.to_string(),
17619 workspace: workspace.path().to_string_lossy().into_owned(),
17620 workspace_source: crate::project_context::WorkspaceSource::Explicit,
17621 model_override: None,
17622 model_ref_override: None,
17623 runtime_metadata: HashMap::new(),
17624 read_only: false,
17625 auto_run: false,
17626 reasoning_effort: None,
17627 lifecycle: lifecycle.map(str::to_string),
17628 resident_name: resident_name.map(str::to_string),
17629 resident_context: None,
17630 disabled_tools: None,
17631 context_fork: None,
17632 },
17633 )
17634 .await
17635 .expect("create Project-inheriting child");
17636 let child = port.saved_child();
17637 assert_eq!(
17638 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
17639 Some(project_id.clone()),
17640 "{role} child must inherit its parent's Project"
17641 );
17642
17643 assert_eq!(
17644 project_id_for_actor_run(&child).expect("valid actor Project identity"),
17645 Some(project_id.clone()),
17646 "{role} actor RunSpec must preserve inherited Project identity"
17647 );
17648 }
17649 }
17650
17651 #[test]
17652 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
17653 assert_eq!(placement_metadata(&Placement::Local, None), None);
17655
17656 let r = placement_metadata(
17658 &Placement::Remote {
17659 endpoint: "wss://10.0.0.5:8443/stream".into(),
17660 },
17661 None,
17662 )
17663 .unwrap();
17664 assert!(r.contains(r#""kind":"remote""#), "{r}");
17665 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
17666
17667 let labeled = placement_metadata(
17669 &Placement::Remote {
17670 endpoint: "ws://169.254.230.101:8899".into(),
17671 },
17672 Some("mini"),
17673 )
17674 .unwrap();
17675 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
17676
17677 let s = placement_metadata(
17679 &Placement::Schedulable {
17680 pool: "explorers".into(),
17681 },
17682 Some("mini"),
17683 )
17684 .unwrap();
17685 assert!(s.contains(r#""kind":"remote""#), "{s}");
17686 assert!(s.contains(r#""host":"mini""#), "{s}");
17687
17688 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
17690 assert_eq!(p.kind, "remote");
17691 assert_eq!(p.host, "mini");
17692 }
17693
17694 #[tokio::test]
17697 async fn execute_external_child_rejects_direct_remote_worker() {
17698 let mut placements = HashMap::new();
17699 placements.insert(
17700 "explorer".to_string(),
17701 ResolvedRemotePlacement {
17702 endpoint: "ws://127.0.0.1:1".into(),
17703 token: Some("remote-test-token".into()),
17704 ca_cert_file: None,
17705 host_label: Some("mini-e2e".into()),
17706 broker_peer: None,
17707 requirements: None,
17708 },
17709 );
17710 let runner = bogus_runner(placements);
17711 let mut session = session_of_role("explorer", "hello remote");
17712 let job = job_for("child-1");
17713 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
17714 let error = runner
17715 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
17716 .await
17717 .err()
17718 .expect("direct remote transport must fail closed");
17719 assert!(error
17720 .to_string()
17721 .contains("remote_broker_activation_unavailable"));
17722 assert!(session
17723 .messages
17724 .iter()
17725 .all(|message| message.role != Role::Assistant));
17726 }
17727
17728 fn bogus_sched_runner(
17734 remote: HashMap<String, ResolvedRemotePlacement>,
17735 sched: HashMap<String, ResolvedSchedulablePlacement>,
17736 ) -> ActorChildRunner {
17737 ActorChildRunner::new(
17738 "test-actor".into(),
17739 PathBuf::from("/bin/false"),
17740 vec![],
17741 std::env::temp_dir().join("bamboo-test-fab-181"),
17742 ExecutorSpec::Echo,
17743 vec![],
17744 "anthropic".into(),
17745 4,
17746 )
17747 .with_remote_placements(remote)
17748 .with_schedulable_placements(sched)
17749 }
17750
17751 fn sched_placement(
17752 pool: &str,
17753 _registry_url: impl Into<String>,
17754 ) -> ResolvedSchedulablePlacement {
17755 ResolvedSchedulablePlacement {
17756 pool: pool.into(),
17757 host_label: None,
17758 requirements: None,
17759 ..Default::default()
17760 }
17761 }
17762
17763 #[test]
17764 fn build_spec_sets_schedulable_placement_for_matching_role() {
17765 let mut sched = HashMap::new();
17766 sched.insert(
17767 "explorer".to_string(),
17768 sched_placement("gpu-pool", "unused"),
17769 );
17770 let runner = bogus_sched_runner(HashMap::new(), sched);
17771
17772 let s = session_of_role("explorer", "do the thing");
17773 let spec = runner.build_spec(&s, &job_for("child-1"));
17774 match &spec.placement {
17775 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
17776 other => panic!("expected Schedulable, got {other:?}"),
17777 }
17778 assert!(spec.secrets.worker_auth_token.is_none());
17780 }
17781
17782 #[test]
17783 fn build_spec_remote_wins_when_role_in_both_maps() {
17784 let mut remote = HashMap::new();
17787 remote.insert(
17788 "explorer".to_string(),
17789 ResolvedRemotePlacement {
17790 endpoint: "wss://fixed-host:8443".into(),
17791 token: Some("T-remote".into()),
17792 ca_cert_file: None,
17793 host_label: None,
17794 broker_peer: None,
17795 requirements: None,
17796 },
17797 );
17798 let mut sched = HashMap::new();
17799 sched.insert(
17800 "explorer".to_string(),
17801 sched_placement("gpu-pool", "https://control-plane:9562"),
17802 );
17803 let runner = bogus_sched_runner(remote, sched);
17804
17805 let s = session_of_role("explorer", "do the thing");
17806 let spec = runner.build_spec(&s, &job_for("child-1"));
17807 match &spec.placement {
17808 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
17809 other => panic!("expected Remote (precedence), got {other:?}"),
17810 }
17811 assert!(spec.secrets.worker_auth_token.is_none());
17812 }
17813
17814 #[test]
17815 fn build_spec_local_for_unmatched_schedulable_role() {
17816 let mut sched = HashMap::new();
17817 sched.insert(
17818 "explorer".to_string(),
17819 sched_placement("gpu-pool", "https://control-plane:9562"),
17820 );
17821 let runner = bogus_sched_runner(HashMap::new(), sched);
17822 let s = session_of_role("writer", "do the thing");
17823 let spec = runner.build_spec(&s, &job_for("child-1"));
17824 assert_eq!(spec.placement, Placement::Local);
17825 assert!(spec.secrets.worker_auth_token.is_none());
17826 }
17827
17828 #[test]
17833 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
17834 let mut remote = HashMap::new();
17836 remote.insert(
17837 "explorer".to_string(),
17838 ResolvedRemotePlacement {
17839 endpoint: "ws://169.254.230.101:8899".into(),
17840 token: None,
17841 ca_cert_file: None,
17842 host_label: Some("mini".into()),
17843 broker_peer: None,
17844 requirements: None,
17845 },
17846 );
17847 let runner = bogus_runner(remote);
17848 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
17849 let stamp = runner
17850 .placement_stamp_for(&spec)
17851 .expect("remote child is stamped");
17852 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
17853 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
17854
17855 let mut remote_nolabel = HashMap::new();
17857 remote_nolabel.insert(
17858 "explorer".to_string(),
17859 ResolvedRemotePlacement {
17860 endpoint: "ws://169.254.230.101:8899".into(),
17861 token: None,
17862 ca_cert_file: None,
17863 host_label: None,
17864 broker_peer: None,
17865 requirements: None,
17866 },
17867 );
17868 let r2 = bogus_runner(remote_nolabel);
17869 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
17870 assert!(r2
17871 .placement_stamp_for(&spec2)
17872 .unwrap()
17873 .contains(r#""host":"169.254.230.101""#));
17874
17875 let mut sched = HashMap::new();
17877 sched.insert(
17878 "mac-mini-monitor".to_string(),
17879 ResolvedSchedulablePlacement {
17880 pool: "mac-mini-monitor".into(),
17881 host_label: Some("mini".into()),
17882 requirements: None,
17883 ..Default::default()
17884 },
17885 );
17886 let sr = bogus_sched_runner(HashMap::new(), sched);
17887 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
17888 let stamp3 = sr
17889 .placement_stamp_for(&spec3)
17890 .expect("scheduled child is stamped");
17891 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
17892 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
17893
17894 let local = bogus_runner(HashMap::new());
17896 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
17897 assert_eq!(local.placement_stamp_for(&spec4), None);
17898 }
17899
17900 #[tokio::test]
17901 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
17902 let mut sched = HashMap::new();
17903 sched.insert(
17904 "explorer".to_string(),
17905 sched_placement("gpu-pool", "unused"),
17906 );
17907 let runner = bogus_sched_runner(HashMap::new(), sched);
17908 let mut session = session_of_role("explorer", "do the thing");
17909 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17910 let error = runner
17911 .execute_external_child(
17912 &mut session,
17913 &job_for("child-1"),
17914 event_tx,
17915 CancellationToken::new(),
17916 )
17917 .await
17918 .expect_err("legacy bus presence cannot grant placement");
17919 assert!(error
17920 .to_string()
17921 .contains("remote_broker_activation_unavailable"));
17922 }
17923}