1use std::collections::{BTreeSet, HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use crate::placement_scheduler::PlacementScheduler;
20use async_trait::async_trait;
21use bamboo_agent_core::storage::Storage;
22use bamboo_agent_core::tools::tool_start_arguments_for_display;
23use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
24use bamboo_domain::poison::PoisonRecover;
25use bamboo_domain::{
26 ActorActivationClaim, ActorActivationFence, ActorActivationFinish, ActorDirectoryPort,
27 ActorLogicalState, ActorSnapshotLimits, ActorSnapshotPort, ActorSnapshotPrincipal, HookResult,
28 ParentQuestion, ParentQuestionCheckpointV1, SessionInboxClaim, SessionInboxConsumerId,
29 SessionInboxFailureReport, SessionInboxLeaseRequest, SessionInboxOwnedClaim, SessionInboxPort,
30 PARENT_QUESTION_CHECKPOINT_ACTION,
31};
32use bamboo_domain::{HostPlacementIntent, HostPlacementRequest, WorkerSlotLease};
33use sha2::{Digest, Sha256};
34use tokio::sync::mpsc;
35use tokio_util::sync::CancellationToken;
36
37use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
38use bamboo_subagent::proto::{
39 ActorEventBatch, AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame,
40 PermissionPolicyContext, RunSpec, SessionMessageDelivery, TerminalStatus,
41};
42use bamboo_subagent::provision::{
43 ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
44};
45
46use super::actor_event_router::{
47 ActorEventClass, ActorEventRoute, ActorEventRouteError, ActorEventRouter,
48};
49use super::actor_event_stream::{ActorEventObserver, PublicActorEvent};
50use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
51
52pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 200;
56
57pub use bamboo_config::DEFAULT_MAX_SPAWN_DEPTH as MAX_SPAWN_DEPTH;
59
60const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
62
63const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
67
68const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
71
72const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
76
77const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
84
85fn active_scoped_session_deny_count(
86 config: &bamboo_tools::permission::PermissionConfig,
87 session_id: &str,
88) -> usize {
89 config
90 .temporary_grants()
91 .into_iter()
92 .filter(|grant| {
93 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
94 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
95 && grant.session_id.as_deref() == Some(session_id)
96 })
97 .count()
98}
99
100fn ensure_no_active_scoped_session_denies(
101 config: &bamboo_tools::permission::PermissionConfig,
102 session_id: &str,
103) -> Result<(), AgentError> {
104 let count = active_scoped_session_deny_count(config, session_id);
105 if count == 0 {
106 Ok(())
107 } else {
108 Err(AgentError::LLM(format!(
109 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
110 )))
111 }
112}
113
114pub struct IssuedCodexRunToken {
117 pub token_id: String,
118 pub token: String,
119}
120
121pub trait CodexRunTokenAuthority: Send + Sync + 'static {
124 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
125 fn revoke(&self, token_id: &str);
126}
127
128struct CodexRunTokenGuard {
129 authority: Arc<dyn CodexRunTokenAuthority>,
130 token_id: String,
131}
132
133impl Drop for CodexRunTokenGuard {
134 fn drop(&mut self) {
135 self.authority.revoke(&self.token_id);
136 }
137}
138
139fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
140 matches!(
141 executor,
142 ExecutorSpec::Codex {
143 auth_mode: Some(mode),
144 ..
145 } if mode == "bamboo"
146 ) || matches!(
147 executor,
148 ExecutorSpec::Codex {
149 auth_mode: None,
150 inherit_user_config,
151 ..
152 } if !inherit_user_config.unwrap_or(false)
153 )
154}
155
156fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
157 match executor {
158 ExecutorSpec::ClaudeCode {
159 permission_mode, ..
160 } => permission_mode
161 .as_deref()
162 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
163 ExecutorSpec::Codex {
164 permission_profile,
165 sandbox,
166 ..
167 } => {
168 permission_profile
169 .as_deref()
170 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
171 || sandbox
172 .as_deref()
173 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
174 }
175 _ => false,
176 }
177}
178
179fn expected_permission_executor_mapping(
187 executor: &ExecutorSpec,
188 resolution: bamboo_domain::PermissionModeResolution,
189 has_explicit_deny: bool,
190) -> Result<Option<String>, AgentError> {
191 let mapping = match executor {
192 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
193 ExecutorSpec::BambooRuntime => {
194 format!("bamboo_runtime:{}", resolution.effective.as_str())
195 }
196 ExecutorSpec::ClaudeCode {
197 permission_mode, ..
198 } => {
199 if has_explicit_deny {
200 "claude_code:blocked_explicit_deny".to_string()
201 } else {
202 let mode = match resolution.effective {
203 bamboo_domain::PermissionMode::Plan => "plan",
204 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
205 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
206 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
207 bamboo_domain::PermissionMode::Default
208 | bamboo_domain::PermissionMode::BypassPermissions => {
209 permission_mode.as_deref().unwrap_or("default")
210 }
211 };
212 format!("claude_code:permission_mode={mode}")
213 }
214 }
215 ExecutorSpec::Codex {
216 mode,
217 sandbox,
218 approval_policy,
219 allow_danger_bypass,
220 ..
221 } => match mode.as_deref().unwrap_or("exec") {
222 "exec" => {
223 let approval_policy = expected_codex_exec_approval_policy(
224 sandbox.as_deref(),
225 approval_policy.as_deref(),
226 allow_danger_bypass.unwrap_or(false),
227 resolution,
228 )?;
229 if has_explicit_deny {
230 "codex_exec:blocked_explicit_deny".to_string()
231 } else {
232 format!("codex_exec:approval_policy={approval_policy}")
233 }
234 }
235 "app_server" => {
236 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
237 return Err(AgentError::LLM(
238 "invalid Codex app-server permission posture configuration".to_string(),
239 ));
240 }
241 if has_explicit_deny {
242 "codex_app_server:blocked_explicit_deny".to_string()
243 } else {
244 let approval_policy = if resolution.suppress_approval_prompts()
245 || resolution.effective == bamboo_domain::PermissionMode::Plan
246 {
247 "never"
248 } else {
249 "on-request"
250 };
251 format!("codex_app_server:approvalPolicy={approval_policy}")
252 }
253 }
254 _ => {
255 return Err(AgentError::LLM(
256 "unsupported Codex executor mode for permission posture contract".to_string(),
257 ));
258 }
259 },
260 };
261 Ok(Some(mapping))
262}
263
264fn expected_codex_exec_approval_policy(
265 sandbox: Option<&str>,
266 approval_policy: Option<&str>,
267 allow_danger_bypass: bool,
268 resolution: bamboo_domain::PermissionModeResolution,
269) -> Result<&'static str, AgentError> {
270 let configured = match approval_policy {
271 None | Some("never") => "never",
272 Some("on-failure") => "on-failure",
273 Some(_) => {
274 return Err(AgentError::LLM(
275 "invalid Codex exec permission posture configuration".to_string(),
276 ));
277 }
278 };
279 if resolution.suppress_approval_prompts()
280 || resolution.effective == bamboo_domain::PermissionMode::Plan
281 {
282 return Ok("never");
283 }
284 match sandbox {
285 Some("danger-full-access") => Ok("never"),
286 Some("read-only") | Some("workspace-write") => Ok(configured),
287 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
288 None => Ok(configured),
289 Some(_) => Err(AgentError::LLM(
290 "invalid Codex exec permission posture configuration".to_string(),
291 )),
292 }
293}
294
295fn workspace_is_bamboo_owned(raw: &str) -> bool {
296 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
297 let configured_root = bamboo_config::paths::resolve_workspace_root();
298 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
299 if workspace.starts_with(&configured_root) {
300 return true;
301 }
302
303 workspace.ancestors().any(|candidate| {
308 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
309 return false;
310 };
311 let Some(worktree_root) = candidate.parent() else {
312 return false;
313 };
314 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
315 || worktree_root.parent().and_then(Path::file_name)
316 != Some(std::ffi::OsStr::new(".bamboo"))
317 {
318 return false;
319 }
320 let marker = worktree_root.join(".bamboo-owned").join(name);
321 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
322 })
323}
324
325fn build_codex_run_secrets(
326 executor: &ExecutorSpec,
327 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
328 child_session_id: &str,
329) -> Result<
330 (
331 bamboo_subagent::proto::RunSecrets,
332 Option<CodexRunTokenGuard>,
333 ),
334 AgentError,
335> {
336 if !executor_uses_bamboo_codex(executor) {
337 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
338 }
339
340 let authority = authority.ok_or_else(|| {
341 AgentError::LLM(
342 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
343 )
344 })?;
345 let issued = authority
346 .issue(child_session_id)
347 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
348 let guard = CodexRunTokenGuard {
349 authority,
350 token_id: issued.token_id,
351 };
352 Ok((
353 bamboo_subagent::proto::RunSecrets {
354 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
355 },
356 Some(guard),
357 ))
358}
359
360struct PooledWorker {
366 worker: SpawnedChild,
367 mailbox_id: String,
369 parked_at: Option<Instant>,
372}
373
374type WorkerPool = HashMap<String, Vec<PooledWorker>>;
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377enum PoolReapReason {
378 ProcessExited,
379 IdleTimeout,
380}
381
382impl PoolReapReason {
383 fn as_str(self) -> &'static str {
384 match self {
385 Self::ProcessExited => "process_exited_while_parked",
386 Self::IdleTimeout => "pool_idle_timeout",
387 }
388 }
389}
390
391fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
392 parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
393}
394
395fn idle_pool_limit_reason(
396 pool: &WorkerPool,
397 key: &str,
398 max_per_key: usize,
399 max_total: usize,
400) -> Option<&'static str> {
401 if pool.get(key).map_or(0, Vec::len) >= max_per_key {
402 Some("pool_per_key_limit")
403 } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
404 Some("pool_global_limit")
405 } else {
406 None
407 }
408}
409
410fn take_reapable_workers(
411 pool: &mut WorkerPool,
412 now: Instant,
413 timeout: Duration,
414) -> Vec<(String, PoolReapReason, PooledWorker)> {
415 let mut reaped = Vec::new();
416 for (key, bucket) in pool.iter_mut() {
417 let mut retained = Vec::with_capacity(bucket.len());
418 for mut worker in std::mem::take(bucket) {
419 let reason = if !worker.worker.is_alive() {
420 Some(PoolReapReason::ProcessExited)
421 } else if parked_worker_expired(worker.parked_at, now, timeout) {
422 Some(PoolReapReason::IdleTimeout)
423 } else {
424 None
425 };
426 if let Some(reason) = reason {
427 reaped.push((key.clone(), reason, worker));
428 } else {
429 retained.push(worker);
430 }
431 }
432 *bucket = retained;
433 }
434 pool.retain(|_, bucket| !bucket.is_empty());
435 reaped
436}
437
438async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
439 let now = Instant::now();
440 let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
441 let reaped = {
442 let mut pool = pool.lock().await;
443 take_reapable_workers(&mut pool, now, timeout)
444 };
445 let count = reaped.len();
446 for (pool_key, reason, worker) in reaped {
447 let idle_ms = worker
448 .parked_at
449 .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
450 tracing::info!(
451 pool_key,
452 mailbox_id = %worker.mailbox_id,
453 worker_pid = ?worker.worker.pid(),
454 worker_idle_ms = ?idle_ms,
455 shutdown_reason = reason.as_str(),
456 "reaping parked sub-agent worker"
457 );
458 worker.worker.kill().await;
459 }
460 count
461}
462
463#[derive(Clone, Default)]
468pub struct ResolvedRemotePlacement {
469 pub endpoint: String,
470 pub token: Option<String>,
471 pub ca_cert_file: Option<PathBuf>,
472 pub host_label: Option<String>,
476 pub broker_peer: Option<Result<bamboo_config::RemoteBrokerPeer, ()>>,
478 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
479}
480impl std::fmt::Debug for ResolvedRemotePlacement {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 f.debug_struct("ResolvedRemotePlacement")
483 .field("broker_peer", &self.broker_peer.is_some())
484 .finish_non_exhaustive()
485 }
486}
487
488#[derive(Debug, Clone, Default)]
495pub struct ResolvedSchedulablePlacement {
496 pub pool: String,
497 pub host_label: Option<String>,
501 pub endpoint: String,
502 pub token: Option<String>,
503 pub ca_cert_file: Option<PathBuf>,
504 pub broker_parent: Option<bamboo_config::SchedulableBrokerParent>,
505 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
506}
507
508#[derive(Clone)]
509struct ScopedPlacementRoute {
510 endpoint: String,
511 token: String,
512 ca_cert_file: PathBuf,
513 parent: bamboo_subagent::AgentRef,
514 worker_role: String,
515 pinned_mailbox: Option<String>,
516 requirements: bamboo_config::OperatorPlacementRequirements,
517}
518
519impl ScopedPlacementRoute {
520 async fn query(&self) -> Result<bamboo_broker::BrokerClient, AgentError> {
521 let tls = bamboo_broker::client_config_trusting_cert(&self.ca_cert_file)
522 .map_err(|_| remote_broker_unavailable())?;
523 bamboo_broker::BrokerClient::connect_with_tls(
524 &self.endpoint,
525 self.parent.clone(),
526 &self.token,
527 Some(tls),
528 )
529 .await
530 .map_err(|_| remote_broker_unavailable())
531 }
532}
533
534struct ActivePlacementLease {
539 registry: bamboo_storage::v2::FileHostRegistry,
540 actor_store: Arc<bamboo_storage::SessionStoreV2>,
541 route: ScopedPlacementRoute,
542 mailbox: String,
543 host_ref: String,
544 fence: ActorActivationFence,
545 placement_ref: bamboo_domain::ActorPlacementRef,
546 lease: Arc<tokio::sync::Mutex<WorkerSlotLease>>,
547 heartbeat: tokio::task::JoinHandle<()>,
548 run_cancel: CancellationToken,
549 lost: Arc<AtomicBool>,
550 release_on_drop: AtomicBool,
551}
552
553async fn fail_pre_dispatch_activation(
557 actor_store: &bamboo_storage::SessionStoreV2,
558 fence: &ActorActivationFence,
559) -> Result<(), bamboo_domain::ActorDirectoryError> {
560 actor_store
561 .finish_activation(fence, chrono::Utc::now(), ActorActivationFinish::Failed)
562 .await
563 .map(|_| ())
564}
565
566async fn send_actor_run(
570 client: &mut dyn bamboo_subagent::ChildLink,
571 spec: RunSpec,
572 send_invoked: &mut bool,
573) -> Result<(), AgentError> {
574 *send_invoked = true;
575 client
576 .send(ParentFrame::Run(spec))
577 .await
578 .map_err(|error| AgentError::LLM(format!("actor run dispatch failed: {error}")))
579}
580
581fn remote_activation_finish(
582 result: &Result<Option<String>, AgentError>,
583 accepted_terminal: Option<TerminalStatus>,
584) -> ActorActivationFinish {
585 if result.is_ok() {
586 ActorActivationFinish::Succeeded
587 } else if matches!(result, Err(AgentError::Cancelled))
588 && accepted_terminal == Some(TerminalStatus::Cancelled)
589 {
590 ActorActivationFinish::Cancelled
591 } else {
592 ActorActivationFinish::Failed
593 }
594}
595
596async fn append_fenced_remote_reply(
600 actor_store: &bamboo_storage::SessionStoreV2,
601 fence: &ActorActivationFence,
602 session: &mut Session,
603 text: &str,
604) -> Result<(), AgentError> {
605 let message = bamboo_agent_core::Message::assistant(text, None);
606 let committed = actor_store
607 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
608 fence: fence.clone(),
609 expected_created_at: session.created_at,
610 expected_conversation_generation: session.conversation_generation_observation(),
611 expected_messages: session.messages.clone(),
612 expected_provider_transcript: session.provider_transcript.clone(),
613 messages: vec![message],
614 native_groups: Vec::new(),
615 })
616 .await
617 .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
618 session.messages = committed.messages;
619 session.provider_transcript = committed.provider_transcript;
620 session.updated_at = committed.updated_at;
621 Ok(())
622}
623
624impl ActivePlacementLease {
625 async fn reserve(
626 registry: bamboo_storage::v2::FileHostRegistry,
627 actor_store: Arc<bamboo_storage::SessionStoreV2>,
628 route: ScopedPlacementRoute,
629 mailbox: String,
630 actor_id: String,
631 run_id: String,
632 project_id: Option<bamboo_domain::ProjectId>,
633 required_tools: BTreeSet<String>,
634 parent_cancel: &CancellationToken,
635 ) -> Result<Self, AgentError> {
636 let scheduler = PlacementScheduler::new(registry.clone());
637 let mut query = route.query().await?;
638 let observed = query
639 .observe_host_capacity(&mailbox, &route.worker_role)
640 .await
641 .map_err(|_| remote_broker_unavailable())?
642 .ok_or_else(remote_broker_unavailable)?;
643 let host = scheduler
644 .observe_scoped_host(
645 &mut query,
646 &observed.host_ref,
647 &mailbox,
648 &route.worker_role,
649 chrono::Duration::seconds(110),
650 )
651 .await
652 .map_err(|_| remote_broker_unavailable())?;
653 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
654 || host.connection_generation != observed.connection_generation
655 || route
656 .pinned_mailbox
657 .as_deref()
658 .is_some_and(|pinned| pinned != mailbox)
659 {
660 return Err(remote_broker_unavailable());
661 }
662 let now = chrono::Utc::now();
663 let request = HostPlacementRequest {
664 intent: HostPlacementIntent::PinnedHost {
665 host_ref: host.host_ref.clone(),
666 },
667 actor_id,
668 run_id,
669 project_id: project_id.clone(),
670 trust_zone: route.requirements.trust_zone.clone(),
671 workspace_label: route.requirements.workspace_label.clone(),
672 executor: "bamboo-runtime".into(),
673 required_tools,
674 network_zone: route.requirements.network_zone.clone(),
675 require_network_isolation: route.requirements.require_network_isolation,
676 preferred_host_ref: None,
677 now,
678 lease_expires_at: now + chrono::Duration::seconds(80),
679 };
680 if !request.eligible(&host) {
681 return Err(remote_broker_unavailable());
682 }
683 let lease = scheduler
684 .reserve(request)
685 .await
686 .map_err(|_| remote_broker_unavailable())?;
687 let placement_ref = bamboo_domain::ActorPlacementRef {
688 class: bamboo_domain::ActorPlacementClass::Remote,
689 lease_id: lease.lease_id.clone(),
690 slot_epoch: Some(lease.epoch),
691 };
692 let mut claimed_fence = None;
693 let actor_binding = async {
694 let actor = actor_store
695 .ensure_actor(&lease.actor_id)
696 .await
697 .map_err(|_| remote_broker_unavailable())?;
698 if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
699 return Err(remote_broker_unavailable());
700 }
701 let placement_intent = if route.pinned_mailbox.is_some() {
702 bamboo_domain::ActorPlacementIntent::PinnedHost {
703 host_ref: host.host_ref.clone(),
704 }
705 } else {
706 bamboo_domain::ActorPlacementIntent::Pool {
707 pool_id: route.worker_role.clone(),
708 }
709 };
710 actor_store
711 .bind_control_plane(
712 &lease.actor_id,
713 bamboo_domain::ActorControlPlaneBinding {
714 policy_revision: None,
715 placement_intent: Some(placement_intent),
716 },
717 )
718 .await
719 .map_err(|_| {
720 tracing::warn!(stage = "actor_bind", "remote placement admission failed");
721 remote_broker_unavailable()
722 })?;
723 let activation = actor_store
724 .claim_activation(&ActorActivationClaim {
725 actor_id: lease.actor_id.clone(),
726 run_id: lease.run_id.clone(),
727 lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
728 lease_expires_at: lease.expires_at,
729 inbox_generation: 0,
730 placement_ref: Some(placement_ref.clone()),
731 now: chrono::Utc::now(),
732 })
733 .await
734 .map_err(|_| {
735 tracing::warn!(stage = "actor_claim", "remote placement admission failed");
736 remote_broker_unavailable()
737 })?;
738 let fence = activation.fence();
739 claimed_fence = Some(fence.clone());
740 actor_store
741 .start_activation(&fence, chrono::Utc::now())
742 .await
743 .map_err(|_| {
744 tracing::warn!(stage = "actor_start", "remote placement admission failed");
745 remote_broker_unavailable()
746 })?;
747 let reservation = registry
748 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
749 placement_ref: placement_ref.clone(),
750 actor_id: lease.actor_id.clone(),
751 run_id: lease.run_id.clone(),
752 observed_host_ref: host.host_ref.clone(),
753 observed_connection_generation: host.connection_generation.clone(),
754 now: chrono::Utc::now(),
755 })
756 .await
757 .map_err(|error| {
758 tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
759 remote_broker_unavailable()
760 })?;
761 if reservation.mailbox != mailbox || reservation.lease != lease {
762 tracing::warn!(stage = "slot_binding", "remote placement admission failed");
763 return Err(remote_broker_unavailable());
764 }
765 Ok::<_, AgentError>(fence)
766 }
767 .await;
768 let fence = match actor_binding {
769 Ok(fence) => fence,
770 Err(error) => {
771 if let Some(fence) = claimed_fence.as_ref() {
772 if let Err(rollback_error) =
773 fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
774 {
775 tracing::warn!(
776 stage = "actor_pre_dispatch_rollback",
777 reason = %rollback_error,
778 "remote placement activation rollback failed"
779 );
780 }
781 }
782 let _ = registry.release_slot(&lease).await;
783 return Err(error);
784 }
785 };
786 let lease = Arc::new(tokio::sync::Mutex::new(lease));
787 let host_ref = host.host_ref.clone();
788 let lost = Arc::new(AtomicBool::new(false));
789 let run_cancel = parent_cancel.child_token();
790 let heartbeat = {
791 let registry = registry.clone();
792 let actor_store = actor_store.clone();
793 let route = route.clone();
794 let host_ref = host_ref.clone();
795 let mailbox = mailbox.clone();
796 let fence = fence.clone();
797 let placement_ref = placement_ref.clone();
798 let lease = lease.clone();
799 let lost = lost.clone();
800 let run_cancel = run_cancel.clone();
801 tokio::spawn(async move {
802 let mut interval = tokio::time::interval(Duration::from_secs(30));
803 interval.tick().await;
804 loop {
805 tokio::select! {
806 _ = run_cancel.cancelled() => break,
807 _ = interval.tick() => {}
808 }
809 let refreshed = async {
810 let mut query = route.query().await?;
811 let host = PlacementScheduler::new(registry.clone())
812 .observe_scoped_host(
813 &mut query,
814 &host_ref,
815 &mailbox,
816 &route.worker_role,
817 chrono::Duration::seconds(110),
818 )
819 .await
820 .map_err(|_| remote_broker_unavailable())?;
821 let current = lease.lock().await.clone();
822 if host.connection_generation != current.connection_generation {
823 return Err(remote_broker_unavailable());
824 }
825 let entry = actor_store
826 .inspect_actor(¤t.actor_id)
827 .await
828 .map_err(|_| remote_broker_unavailable())?;
829 if entry.activation.as_ref().is_none_or(|activation| {
830 activation.fence() != fence
831 || activation.placement_ref.as_ref() != Some(&placement_ref)
832 }) {
833 return Err(remote_broker_unavailable());
834 }
835 let now = chrono::Utc::now();
836 registry
837 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
838 placement_ref: placement_ref.clone(),
839 actor_id: current.actor_id.clone(),
840 run_id: current.run_id.clone(),
841 observed_host_ref: host.host_ref.clone(),
842 observed_connection_generation: host.connection_generation.clone(),
843 now,
844 })
845 .await
846 .map_err(|_| remote_broker_unavailable())?;
847 let renewed = registry
848 .renew_slot(¤t, now, now + chrono::Duration::seconds(80))
849 .await
850 .map_err(|_| remote_broker_unavailable())?;
851 *lease.lock().await = renewed;
852 let expiry = lease.lock().await.expires_at;
853 actor_store
854 .renew_activation(&fence, now, expiry)
855 .await
856 .map_err(|_| remote_broker_unavailable())?;
857 Ok::<(), AgentError>(())
858 }
859 .await;
860 if refreshed.is_err() {
861 lost.store(true, Ordering::SeqCst);
862 run_cancel.cancel();
863 break;
864 }
865 }
866 })
867 };
868 Ok(Self {
869 registry,
870 actor_store,
871 route,
872 mailbox,
873 host_ref,
874 fence,
875 placement_ref,
876 lease,
877 heartbeat,
878 run_cancel,
879 lost,
880 release_on_drop: AtomicBool::new(false),
881 })
882 }
883
884 fn lost(&self) -> bool {
885 self.lost.load(Ordering::SeqCst)
886 }
887
888 async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
889 if send_invoked {
890 return Ok(false);
891 }
892 self.abort_before_dispatch().await?;
893 Ok(true)
894 }
895
896 async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
897 self.heartbeat.abort();
898 let _ = (&mut self.heartbeat).await;
899 let current = self.lease.lock().await.clone();
900 let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
901 let released = self.registry.release_slot(¤t).await;
902 if let Err(error) = failed {
903 tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
904 return Err(remote_broker_unavailable());
905 }
906 if let Err(error) = released {
907 if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
908 tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
909 return Err(remote_broker_unavailable());
910 }
911 }
912 Ok(())
913 }
914
915 async fn complete(
916 &mut self,
917 outcome: ActorActivationFinish,
918 terminal_proven: bool,
919 ) -> Result<(), AgentError> {
920 if !terminal_proven {
921 return if outcome == ActorActivationFinish::Succeeded {
924 Err(remote_broker_unavailable())
925 } else {
926 Ok(())
927 };
928 }
929 self.heartbeat.abort();
932 let _ = (&mut self.heartbeat).await;
933 let current = self.lease.lock().await.clone();
934 let mut query = self.route.query().await?;
938 let observed = query
939 .observe_host_capacity(&self.mailbox, &self.route.worker_role)
940 .await
941 .map_err(|_| remote_broker_unavailable())?
942 .ok_or_else(remote_broker_unavailable)?;
943 if observed.host_ref != self.host_ref
944 || observed.connection_generation != current.connection_generation
945 || observed.mailbox != self.mailbox
946 || observed.role.as_deref() != Some(self.route.worker_role.as_str())
947 {
948 return Err(remote_broker_unavailable());
949 }
950 let entry = self
951 .actor_store
952 .inspect_actor(¤t.actor_id)
953 .await
954 .map_err(|_| remote_broker_unavailable())?;
955 if entry.activation.as_ref().is_none_or(|activation| {
956 activation.fence() != self.fence
957 || activation.placement_ref.as_ref() != Some(&self.placement_ref)
958 }) {
959 return Err(remote_broker_unavailable());
960 }
961 self.registry
962 .validate_slot(¤t, chrono::Utc::now())
963 .await
964 .map_err(|_| remote_broker_unavailable())?;
965 self.actor_store
966 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
967 .await
968 .map_err(|_| remote_broker_unavailable())?;
969 self.release_on_drop.store(true, Ordering::SeqCst);
972 match self.registry.release_slot(¤t).await {
973 Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
974 self.release_on_drop.store(false, Ordering::SeqCst);
975 Ok(())
976 }
977 Err(error) => {
978 tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
979 Err(remote_broker_unavailable())
980 }
981 }
982 }
983}
984
985impl Drop for ActivePlacementLease {
986 fn drop(&mut self) {
987 self.run_cancel.cancel();
988 self.heartbeat.abort();
989 if !self.release_on_drop.load(Ordering::SeqCst) {
990 return;
991 }
992 let registry = self.registry.clone();
993 let actor_store = self.actor_store.clone();
994 let fence = self.fence.clone();
995 let placement_ref = self.placement_ref.clone();
996 let lease = self.lease.clone();
997 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
998 runtime.spawn(async move {
999 let current = lease.lock().await.clone();
1000 if actor_store
1001 .inspect_actor(¤t.actor_id)
1002 .await
1003 .ok()
1004 .and_then(|entry| entry.activation)
1005 .is_some_and(|activation| {
1006 activation.fence() == fence
1007 && activation.placement_ref.as_ref() == Some(&placement_ref)
1008 })
1009 {
1010 let _ = registry.release_slot(¤t).await;
1011 }
1012 });
1013 }
1014 }
1015}
1016
1017pub struct BrokerTerminalReceiptReconciler {
1022 store: Arc<bamboo_storage::SessionStoreV2>,
1023 bus: Option<bamboo_subagent::BusEndpoint>,
1024 remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1025 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1026}
1027
1028struct ReceiptRemoteRoute {
1029 endpoint: String,
1030 token: String,
1031 ca_cert_file: PathBuf,
1032 parent: bamboo_subagent::AgentRef,
1033}
1034
1035#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1036pub struct BrokerReceiptRepairReport {
1037 pub candidates: usize,
1038 pub repaired: usize,
1039 pub blocked: usize,
1040}
1041
1042impl BrokerTerminalReceiptReconciler {
1043 pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1044 let subagents = config.subagents();
1045 let mut counts = HashMap::<&str, usize>::new();
1046 for placement in &subagents.remote_placements {
1047 *counts.entry(placement.role.as_str()).or_default() += 1;
1048 }
1049 let mut remote = HashMap::new();
1050 for placement in &subagents.remote_placements {
1051 let route = (counts.get(placement.role.as_str()) == Some(&1))
1052 .then(|| receipt_remote_route(placement))
1053 .flatten();
1054 remote.insert(placement.role.clone(), route);
1055 }
1056 Self {
1057 store,
1058 ticket_service: None,
1059 bus: subagents.broker.clone().and_then(|broker| {
1060 (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1061 bamboo_subagent::BusEndpoint {
1062 endpoint: broker.endpoint,
1063 token: broker.token,
1064 },
1065 )
1066 }),
1067 remote,
1068 }
1069 }
1070
1071 pub fn with_ticket_service(
1072 mut self,
1073 service: Option<Arc<bamboo_tickets::TicketService>>,
1074 ) -> Self {
1075 self.ticket_service = service;
1076 self
1077 }
1078
1079 pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1082 let candidates = match self
1083 .store
1084 .discover_unconfirmed_broker_terminal_children()
1085 .await
1086 {
1087 Ok(candidates) => candidates,
1088 Err(error) => {
1089 tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1090 return BrokerReceiptRepairReport {
1091 blocked: 1,
1092 ..BrokerReceiptRepairReport::default()
1093 };
1094 }
1095 };
1096 let mut report = BrokerReceiptRepairReport {
1097 candidates: candidates.len(),
1098 ..BrokerReceiptRepairReport::default()
1099 };
1100 for child in candidates {
1101 let result =
1102 tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1103 match result {
1104 Ok(Ok(())) => report.repaired += 1,
1105 Ok(Err(reason)) => {
1106 report.blocked += 1;
1107 tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1108 }
1109 Err(_) => {
1110 report.blocked += 1;
1111 tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1112 }
1113 }
1114 }
1115 report
1116 }
1117
1118 async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1119 if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1120 || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1121 || child.metadata.get("external.agent_id").map(String::as_str)
1122 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1123 {
1124 return Err("Child has no trusted built-in actor recovery route");
1125 }
1126 let current = self
1127 .store
1128 .load_session(&child.id)
1129 .await
1130 .map_err(|_| "canonical Child reload failed")?
1131 .ok_or("canonical Child disappeared")?;
1132 if current.kind != bamboo_domain::SessionKind::Child
1133 || current.id != child.id
1134 || current.created_at != child.created_at
1135 || current.parent_session_id != child.parent_session_id
1136 || current.root_session_id != child.root_session_id
1137 || current.project_id_meta() != child.project_id_meta()
1138 || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1139 || current
1140 .metadata
1141 .get("external.protocol")
1142 .map(String::as_str)
1143 != Some("actor")
1144 || current
1145 .metadata
1146 .get("external.agent_id")
1147 .map(String::as_str)
1148 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1149 || bamboo_domain::ActorSession::from_session(¤t).is_err()
1150 {
1151 return Err("canonical Child birth, Root, or Project changed");
1152 }
1153 let root = self
1154 .store
1155 .load_session(&child.root_session_id)
1156 .await
1157 .map_err(|_| "canonical Root reload failed")?
1158 .ok_or("canonical Root disappeared")?;
1159 if root.kind != bamboo_domain::SessionKind::Root
1160 || root.id != child.root_session_id
1161 || root.root_session_id != child.root_session_id
1162 || root.project_id_meta() != child.project_id_meta()
1163 {
1164 return Err("canonical Root identity changed");
1165 }
1166 let parent_id = child
1167 .parent_session_id
1168 .as_deref()
1169 .ok_or("Child parent identity missing")?;
1170 let parent = self
1171 .store
1172 .load_session(parent_id)
1173 .await
1174 .map_err(|_| "canonical parent reload failed")?
1175 .ok_or("canonical parent disappeared")?;
1176 if parent.id != parent_id
1177 || !matches!(
1178 parent.kind,
1179 bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1180 )
1181 || parent.root_session_id != child.root_session_id
1182 || parent.project_id_meta() != child.project_id_meta()
1183 || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1184 {
1185 return Err("canonical parent lineage changed");
1186 }
1187
1188 let receipts = self
1192 .store
1193 .recover_broker_terminal_receipts(¤t)
1194 .await
1195 .map_err(|_| "Host broker terminal checkpoint unverified")?;
1196 if receipts.is_empty() {
1197 return Ok(());
1198 }
1199 let role = current
1200 .metadata
1201 .get("subagent_type")
1202 .map(String::as_str)
1203 .unwrap_or("worker");
1204 for receipt in receipts {
1205 publish_recovered_completion_source(&self.store, &receipt)
1206 .await
1207 .map_err(|_| "committed Child completion source remains pending")?;
1208 if current
1209 .metadata
1210 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1211 {
1212 let service = self
1213 .ticket_service
1214 .as_ref()
1215 .ok_or("Ticket authority unavailable; no result ACK")?;
1216 crate::ticket_runtime::checkpoint_ticket_result(
1217 service,
1218 self.store.as_ref(),
1219 ¤t,
1220 &receipt.activation_run_id,
1221 )
1222 .await
1223 .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1224 }
1225 let local_mailbox = format!("p-{}", child.id);
1226 let mut link = if receipt.parent_mailbox == local_mailbox {
1229 let bus = self
1230 .bus
1231 .as_ref()
1232 .ok_or("local broker receipt bus unavailable")?;
1233 bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1234 &bus.endpoint,
1235 bamboo_subagent::AgentRef {
1236 session_id: local_mailbox,
1237 role: None,
1238 },
1239 &bus.token,
1240 )
1241 .await
1242 .map_err(|_| "local broker receipt connection unavailable")?
1243 } else if let Some(remote) = self.remote.get(role) {
1244 let route = remote
1245 .as_ref()
1246 .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1247 if receipt.parent_mailbox != route.parent.session_id {
1248 return Err("fixed remote parent mailbox changed since Host checkpoint");
1249 }
1250 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1251 .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1252 bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1253 &route.endpoint,
1254 route.parent.clone(),
1255 &route.token,
1256 tls,
1257 )
1258 .await
1259 .map_err(|_| "fixed remote broker receipt connection unavailable")?
1260 } else {
1261 return Err("committed broker receipt route is unavailable");
1262 };
1263 if link.parent_mailbox() != receipt.parent_mailbox {
1264 return Err("broker receipt link mailbox differs from Host checkpoint");
1265 }
1266 bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1267 &mut link,
1268 &receipt.broker_identity,
1269 &receipt.message_ids,
1270 )
1271 .await
1272 .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1273 self.store
1274 .clear_acknowledged_broker_terminal_receipt(
1275 &receipt.session_id,
1276 receipt.created_at,
1277 &receipt.activation_run_id,
1278 &receipt.parent_mailbox,
1279 )
1280 .await
1281 .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1282 }
1283 Ok(())
1284 }
1285}
1286
1287async fn publish_recovered_completion_source(
1291 store: &Arc<bamboo_storage::SessionStoreV2>,
1292 receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1293) -> Result<(), String> {
1294 let expected = store
1295 .load_session(&receipt.session_id)
1296 .await
1297 .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1298 .ok_or("Child completion recovery snapshot missing")?;
1299 if expected.id != receipt.session_id
1300 || expected.created_at != receipt.created_at
1301 || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1302 || expected.root_session_id != receipt.root_session_id
1303 || expected.project_id_meta() != receipt.project_id
1304 {
1305 return Err("Child completion recovery identity changed".into());
1306 }
1307 let mut candidate = expected.clone();
1308 if candidate.last_run_status().as_deref() == Some("running") {
1309 candidate.set_last_run_status(receipt.terminal_status.clone());
1310 if let Some(error) = &receipt.terminal_error {
1311 candidate.set_last_run_error(error.clone());
1312 } else {
1313 candidate.clear_last_run_error();
1314 }
1315 }
1316 if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1317 &mut candidate,
1318 &receipt.activation_run_id,
1319 )? {
1320 let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1321 .ok_or("Child completion recovery source invalid")?;
1322 let published = candidate
1323 .metadata
1324 .get("runtime.child_completion_source_v1")
1325 .ok_or("Child completion recovery source missing")?;
1326 let saved = store
1330 .publish_broker_terminal_source(receipt, &expected, published)
1331 .await
1332 .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1333 if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1334 != Some(&source)
1335 {
1336 return Err("Child completion recovery source changed".into());
1337 }
1338 }
1339 Ok(())
1340}
1341
1342fn receipt_remote_route(
1343 placement: &bamboo_config::RemoteActorPlacement,
1344) -> Option<ReceiptRemoteRoute> {
1345 let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1346 let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1347 if placement.endpoint.len() > 2048
1348 || endpoint.scheme() != "wss"
1349 || endpoint.host_str().is_none()
1350 || !endpoint.username().is_empty()
1351 || endpoint.password().is_some()
1352 || endpoint.query().is_some()
1353 || endpoint.fragment().is_some()
1354 {
1355 return None;
1356 }
1357 let token_env = placement
1358 .token_env
1359 .as_deref()
1360 .filter(|name| !name.is_empty() && name.len() <= 256)?;
1361 let token = std::env::var(token_env).ok()?;
1362 if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1363 return None;
1364 }
1365 let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1366 bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1367 Some(ReceiptRemoteRoute {
1368 endpoint: placement.endpoint.clone(),
1369 token,
1370 ca_cert_file,
1371 parent: bamboo_subagent::AgentRef {
1372 session_id: peer.parent_mailbox.clone(),
1373 role: peer.parent_role.clone(),
1374 },
1375 })
1376}
1377
1378enum PlacementKind {
1383 Local,
1384 Remote,
1385 Schedulable,
1386}
1387
1388fn ensure_ticket_placement_is_enforceable(
1390 session: &Session,
1391 spec: &ProvisionSpec,
1392) -> Result<(), AgentError> {
1393 let ticket = session
1394 .metadata
1395 .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1396 || session
1397 .metadata
1398 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1399 if ticket && !matches!(spec.placement, Placement::Local) {
1400 return Err(AgentError::LLM(
1401 "Ticket execution requires local placement and owned stop proof".into(),
1402 ));
1403 }
1404 Ok(())
1405}
1406
1407fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1416 if !spec.capabilities.read_only_enforced() {
1417 return Ok(());
1418 }
1419
1420 if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1421 return Err(AgentError::LLM(format!(
1422 "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1423 spec.identity.role
1424 )));
1425 }
1426
1427 if !matches!(spec.placement, Placement::Local) {
1428 return Err(AgentError::LLM(format!(
1429 "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1430 spec.identity.role
1431 )));
1432 }
1433
1434 Ok(())
1435}
1436
1437pub struct ActorChildRunner {
1439 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1440 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1441 agent_id: String,
1442 worker_bin: PathBuf,
1443 worker_args: Vec<String>,
1444 builtin_required_context_route: bool,
1445 native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1446 fabric_dir: PathBuf,
1447 executor: ExecutorSpec,
1448 credentials: Vec<ScopedCredential>,
1453 default_provider: String,
1455 max_spawn_depth: u32,
1457 live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1461 bus: Option<bamboo_subagent::BusEndpoint>,
1464 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1468 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1474 max_idle_per_key: usize,
1475 max_idle_total: usize,
1476 pool_reaper_started: AtomicBool,
1477 next_execution_epoch: AtomicU64,
1481 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1485 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1489 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1500 remote_placements: HashMap<String, ResolvedRemotePlacement>,
1506 strict_remote_subscription: tokio::sync::Mutex<()>,
1508 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1516 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1522 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1525 actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1526 canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1527 ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1528 actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1529 pending_durable_links: tokio::sync::Mutex<
1532 HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1533 >,
1534}
1535
1536struct PendingDurableChildLink {
1537 link: Box<dyn bamboo_subagent::ChildLink>,
1538 host_nested_wait_handoff: bool,
1541 strict_history: Option<StrictTerminalHistory>,
1543 strict_history_required: bool,
1544 strict_history_committed: bool,
1545}
1546
1547#[derive(Clone)]
1548struct StrictTerminalHistory {
1549 proof: bamboo_storage::v2::HostTerminalCompleteness,
1550 watermark: bamboo_subagent::ActorEventWatermark,
1551}
1552
1553fn effective_broker_terminal_status(
1554 raw: TerminalStatus,
1555 host_nested_wait_handoff: bool,
1556) -> Result<&'static str, String> {
1557 if host_nested_wait_handoff {
1558 if raw != TerminalStatus::Completed {
1559 return Err("nested wait cannot project a failed broker terminal".into());
1560 }
1561 Ok("suspended")
1562 } else {
1563 Ok(broker_terminal_status_label(raw))
1564 }
1565}
1566
1567fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1568 match status {
1569 TerminalStatus::Completed => "completed",
1570 TerminalStatus::Suspended => "suspended",
1571 TerminalStatus::Error => "error",
1572 TerminalStatus::Cancelled => "cancelled",
1573 }
1574}
1575
1576#[async_trait]
1586pub trait ChildApprovalDecider: Send + Sync {
1587 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1590}
1591
1592async fn decide_child_approval(
1595 decider: Option<&Arc<dyn ChildApprovalDecider>>,
1596 child_session_id: &str,
1597 request: &serde_json::Value,
1598) -> bool {
1599 match decider {
1600 Some(decider) => decider.decide(child_session_id, request).await,
1601 None => false,
1602 }
1603}
1604
1605const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1609
1610#[derive(Clone)]
1621pub struct ChildApprovalScope {
1622 stamp: serde_json::Value,
1623 deadline: chrono::DateTime<chrono::Utc>,
1624 parent: String,
1625 child: String,
1626 run: String,
1627 epoch: u64,
1628 current_epoch: Arc<AtomicU64>,
1629 cancel: CancellationToken,
1630 router: Arc<crate::SessionActivationRouter>,
1631 admission: Arc<AtomicUsize>,
1632}
1633
1634pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1635impl Drop for ChildApprovalAdmission {
1636 fn drop(&mut self) {
1637 self.0.store(2, Ordering::Release);
1638 }
1639}
1640
1641impl ChildApprovalScope {
1642 pub fn new(
1644 parent: &str,
1645 child: &str,
1646 transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1647 router: Arc<crate::SessionActivationRouter>,
1648 cancel: CancellationToken,
1649 current_epoch: Arc<AtomicU64>,
1650 ) -> Self {
1651 let (attempt, run, epoch, reply, deadline) = transport;
1652 let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1653 Self {
1654 stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1655 "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1656 "deadline": deadline}),
1657 deadline,
1658 parent: parent.into(),
1659 child: child.into(),
1660 run: run.into(),
1661 epoch,
1662 current_epoch,
1663 cancel,
1664 router,
1665 admission: Arc::new(AtomicUsize::new(0)),
1666 }
1667 }
1668 pub fn stamp(&self) -> &serde_json::Value {
1669 &self.stamp
1670 }
1671 pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1672 self.deadline
1673 }
1674 pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1675 self.admission
1676 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1677 .ok()
1678 .map(|_| ChildApprovalAdmission(self.admission.clone()))
1679 }
1680 pub fn is_admitting(&self) -> bool {
1681 self.admission.load(Ordering::Acquire) == 1
1682 }
1683 pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1684 self.parent == parent
1685 && self.child == child
1686 && !self.cancel.is_cancelled()
1687 && self.current_epoch.load(Ordering::Acquire) == self.epoch
1688 && self.router.owns_run(child, &self.run).await
1689 }
1690}
1691
1692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1693pub enum ChildApprovalReview {
1694 Reply(bool),
1695 NoReply,
1696}
1697
1698#[async_trait]
1699pub trait ChildApprovalReviewer: Send + Sync {
1700 async fn review(
1703 &self,
1704 parent_session_id: &str,
1705 child_session_id: &str,
1706 request: &serde_json::Value,
1707 ) -> bool;
1708
1709 async fn review_scoped(
1710 &self,
1711 parent: &str,
1712 child: &str,
1713 request: &serde_json::Value,
1714 _scope: &ChildApprovalScope,
1715 ) -> ChildApprovalReview {
1716 ChildApprovalReview::Reply(self.review(parent, child, request).await)
1717 }
1718}
1719
1720fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1721 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1722 &SLOT
1723}
1724
1725pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1727 let _ = child_approval_reviewer_slot().set(reviewer);
1728}
1729
1730pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1732 child_approval_reviewer_slot().get().cloned()
1733}
1734
1735impl ActorChildRunner {
1736 #[allow(clippy::too_many_arguments)]
1737 pub fn new(
1738 agent_id: String,
1739 worker_bin: PathBuf,
1740 worker_args: Vec<String>,
1741 fabric_dir: PathBuf,
1742 executor: ExecutorSpec,
1743 credentials: Vec<ScopedCredential>,
1744 default_provider: String,
1745 max_concurrent: usize,
1746 ) -> Self {
1747 Self {
1748 approval_registry: None,
1749 permission_config: None,
1750 agent_id,
1751 worker_bin,
1752 worker_args,
1753 builtin_required_context_route: false,
1754 fabric_dir,
1755 executor,
1756 credentials,
1757 default_provider,
1758 max_spawn_depth: MAX_SPAWN_DEPTH,
1759 live_provider_config: None,
1760 native_tool_ceiling: None,
1761 bus: None,
1762 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1763 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1764 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1765 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1766 pool_reaper_started: AtomicBool::new(false),
1767 next_execution_epoch: AtomicU64::new(0),
1768 approval_decider: None,
1769 approval_reviewer: None,
1770 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1771 remote_placements: HashMap::new(),
1772 strict_remote_subscription: tokio::sync::Mutex::new(()),
1773 schedulable_placements: HashMap::new(),
1774 codex_run_tokens: None,
1775 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1776 actor_directory_store: std::sync::Mutex::new(None),
1777 canonical_subagent_tool: std::sync::Mutex::new(None),
1778 ticket_service: std::sync::Mutex::new(None),
1779 actor_event_observer: std::sync::Mutex::new(None),
1780 pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1781 }
1782 }
1783
1784 pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1789 self.builtin_required_context_route = supported;
1790 self
1791 }
1792
1793 pub(crate) fn with_native_tool_ceiling_source(
1794 mut self,
1795 source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1796 ) -> Self {
1797 self.native_tool_ceiling = source;
1798 self
1799 }
1800
1801 async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1802 bamboo_subagent::fleet::require_worker_capability(
1803 &self.worker_bin,
1804 &self.worker_args,
1805 bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1806 )
1807 .await
1808 .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1809 }
1810
1811 pub fn with_live_provider_config(
1812 mut self,
1813 config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1814 ) -> Self {
1815 self.live_provider_config = Some(config);
1816 self
1817 }
1818
1819 pub fn with_max_spawn_depth(mut self, max_spawn_depth: u32) -> Self {
1820 self.max_spawn_depth = max_spawn_depth;
1821 self
1822 }
1823
1824 pub fn with_approval_registry(
1825 mut self,
1826 registry: super::approval_registry::SharedApprovalRegistry,
1827 ) -> Self {
1828 self.approval_registry = Some(registry);
1829 self
1830 }
1831
1832 pub fn with_permission_config(
1833 mut self,
1834 config: Arc<bamboo_tools::permission::PermissionConfig>,
1835 ) -> Self {
1836 self.permission_config = Some(config);
1837 self
1838 }
1839
1840 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1845 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1846 self
1847 }
1848
1849 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1852 self.approval_decider = Some(decider);
1853 self
1854 }
1855
1856 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1857 self.approval_reviewer = Some(reviewer);
1858 self
1859 }
1860
1861 pub fn with_codex_run_tokens(
1862 mut self,
1863 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1864 ) -> Self {
1865 self.codex_run_tokens = authority;
1866 self
1867 }
1868
1869 pub fn with_remote_placements(
1875 mut self,
1876 placements: HashMap<String, ResolvedRemotePlacement>,
1877 ) -> Self {
1878 self.remote_placements = placements;
1879 self
1880 }
1881
1882 pub fn with_schedulable_placements(
1888 mut self,
1889 placements: HashMap<String, ResolvedSchedulablePlacement>,
1890 ) -> Self {
1891 self.schedulable_placements = placements;
1892 self
1893 }
1894
1895 fn fingerprint(spec: &ProvisionSpec) -> String {
1914 let role = spec.identity.role.as_str();
1915 let (provider, model) = spec
1916 .model
1917 .as_ref()
1918 .map(|m| (m.provider.as_str(), m.model.as_str()))
1919 .unwrap_or(("", ""));
1920 let workspace = spec.workspace.as_deref().unwrap_or("");
1921 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1922 tools.sort();
1923 let caps = &spec.capabilities;
1924 let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1929 "none".to_string()
1930 } else {
1931 let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1932 .expect("scoped provider credentials are serializable");
1933 hex::encode(Sha256::digest(serialized))
1934 };
1935 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1938 format!(
1939 "{role}\u{1}{provider}\u{1}{model}\u{1}cred={credential_revision}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}pr={}\u{1}pe={}\u{1}by={}\u{1}auto={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}ro={}\u{1}gro={}\u{1}ci={}\u{1}executor={executor}",
1940 tools.join(","),
1941 spec.identity.depth,
1942 caps.nested_spawn,
1943 caps.permission_requested_mode,
1944 caps.permission_effective_mode,
1945 caps.bypass,
1946 caps.auto_approve_permissions,
1947 caps.enforce_permissions,
1948 caps.max_spawn_depth.unwrap_or(0),
1949 caps.no_human_approver,
1956 caps.read_only,
1959 caps.guardian_read_only,
1962 caps.child_creation_identity,
1963 )
1964 }
1965
1966 fn ensure_pool_reaper(&self) {
1969 if self
1970 .pool_reaper_started
1971 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1972 .is_err()
1973 {
1974 return;
1975 }
1976 let pool = Arc::downgrade(&self.pool);
1977 tokio::spawn(async move {
1978 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1979 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1980 interval.tick().await;
1983 loop {
1984 interval.tick().await;
1985 let Some(pool) = pool.upgrade() else {
1986 break;
1987 };
1988 let _ = reap_worker_pool_once(&pool).await;
1989 }
1990 });
1991 }
1992
1993 fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1994 let mut physical = spec.clone();
1995 physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1999 physical
2000 }
2001
2002 async fn acquire_bus_worker(
2008 &self,
2009 key: &str,
2010 spec: &ProvisionSpec,
2011 ) -> crate::runtime::runner::Result<PooledWorker> {
2012 if spec.capabilities.required_child_context {
2013 let physical = Self::fresh_required_worker_spec(spec);
2014 let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2015 .await
2016 .map_err(|error| {
2017 AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2018 })?;
2019 let mailbox_id = worker.record.agent_id.clone();
2020 return Ok(PooledWorker {
2021 worker,
2022 mailbox_id,
2023 parked_at: None,
2024 });
2025 }
2026 self.ensure_pool_reaper();
2027 loop {
2031 let candidate = {
2032 let mut pool = self.pool.lock().await;
2033 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2034 if pool.get(key).is_some_and(Vec::is_empty) {
2035 pool.remove(key);
2036 }
2037 candidate
2038 };
2039 let Some(mut candidate) = candidate else {
2040 break;
2041 };
2042 let now = Instant::now();
2043 let expired = parked_worker_expired(
2044 candidate.parked_at,
2045 now,
2046 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2047 );
2048 if candidate.worker.is_alive() && !expired {
2049 candidate.parked_at = None;
2050 return Ok(candidate);
2051 }
2052 tracing::info!(
2053 pool_key = key,
2054 mailbox_id = %candidate.mailbox_id,
2055 worker_pid = ?candidate.worker.pid(),
2056 shutdown_reason = if expired {
2057 PoolReapReason::IdleTimeout.as_str()
2058 } else {
2059 PoolReapReason::ProcessExited.as_str()
2060 },
2061 "discarding unusable parked sub-agent worker during checkout"
2062 );
2063 candidate.worker.kill().await;
2064 }
2065
2066 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2067 .await
2068 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2069 let mailbox_id = spawned.record.agent_id.clone();
2070 Ok(PooledWorker {
2071 worker: spawned,
2072 mailbox_id,
2073 parked_at: None,
2074 })
2075 }
2076
2077 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2081 self.ensure_pool_reaper();
2082 if !worker.worker.is_alive() {
2083 tracing::info!(
2084 pool_key = key,
2085 mailbox_id = %worker.mailbox_id,
2086 worker_pid = ?worker.worker.pid(),
2087 shutdown_reason = "process_exited_before_park",
2088 "discarding sub-agent worker instead of parking"
2089 );
2090 worker.worker.kill().await;
2091 return;
2092 }
2093 worker.parked_at = Some(Instant::now());
2094 let mut pool = self.pool.lock().await;
2095 let key_count = pool.get(key).map_or(0, Vec::len);
2096 let total_count = pool.values().map(Vec::len).sum::<usize>();
2097 let limit_reason =
2098 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2099 if let Some(shutdown_reason) = limit_reason {
2100 drop(pool);
2101 tracing::info!(
2102 pool_key = key,
2103 mailbox_id = %worker.mailbox_id,
2104 worker_pid = ?worker.worker.pid(),
2105 idle_workers_for_key = key_count,
2106 idle_workers_total = total_count,
2107 max_idle_per_key = self.max_idle_per_key,
2108 max_idle_total = self.max_idle_total,
2109 shutdown_reason,
2110 "discarding sub-agent worker because the warm pool is full"
2111 );
2112 worker.worker.kill().await;
2113 return;
2114 }
2115 pool.entry(key.to_string()).or_default().push(worker);
2116 }
2117
2118 fn build_spec_with_provider_config(
2121 &self,
2122 session: &Session,
2123 job: &SpawnJob,
2124 credentials: &[ScopedCredential],
2125 default_provider: &str,
2126 max_spawn_depth: u32,
2127 ) -> ProvisionSpec {
2128 let mut spec = ProvisionSpec::new(
2129 ChildIdentity {
2130 child_id: job.child_session_id.clone(),
2131 parent_id: Some(job.parent_session_id.clone()),
2132 project_key: None,
2133 role: session
2134 .metadata
2135 .get("subagent_type")
2136 .cloned()
2137 .unwrap_or_else(|| "worker".to_string()),
2138 depth: session.spawn_depth,
2143 },
2144 self.executor.clone(),
2145 self.fabric_dir.to_string_lossy().into_owned(),
2146 );
2147 spec.workspace = session.workspace.clone();
2148 if let ExecutorSpec::Codex {
2149 workspace_owned, ..
2150 } = &mut spec.executor
2151 {
2152 *workspace_owned = Some(
2153 spec.workspace
2154 .as_deref()
2155 .is_some_and(workspace_is_bamboo_owned),
2156 );
2157 }
2158 spec.bus = self.bus.clone();
2161 spec.model = session
2164 .model_ref
2165 .as_ref()
2166 .map(|r| ModelRefSpec {
2167 provider: r.provider.clone(),
2168 model: r.model.clone(),
2169 })
2170 .or_else(|| {
2171 let m = job.model.trim();
2172 (!m.is_empty()).then(|| ModelRefSpec {
2173 provider: default_provider.to_string(),
2174 model: m.to_string(),
2175 })
2176 });
2177 spec.disabled_tools = job.disabled_tools.clone();
2178 match &spec.executor {
2179 ExecutorSpec::Codex {
2185 auth_mode,
2186 provider_key_ref,
2187 ..
2188 } => {
2189 if auth_mode.as_deref() == Some("custom") {
2190 if let Some(reference) = provider_key_ref {
2191 if let Some(credential) = credentials.iter().find(|credential| {
2192 credential.credential_ref.as_deref() == Some(reference)
2193 }) {
2194 spec.secrets.provider_credentials.push(credential.clone());
2195 } else {
2196 tracing::warn!(
2197 "actor child {}: custom Codex credential reference '{}' did not resolve",
2198 job.child_session_id,
2199 reference
2200 );
2201 }
2202 } else {
2203 tracing::warn!(
2204 "actor child {}: custom Codex executor has no credential reference",
2205 job.child_session_id
2206 );
2207 }
2208 }
2209 }
2210 _ => {
2213 let provider = spec
2214 .model
2215 .as_ref()
2216 .map(|model| model.provider.as_str())
2217 .filter(|provider| !provider.trim().is_empty())
2218 .unwrap_or(default_provider);
2219 if let Some(credential) = credentials
2220 .iter()
2221 .find(|credential| credential.provider == provider)
2222 {
2223 spec.secrets.provider_credentials.push(credential.clone());
2224 } else {
2225 tracing::warn!(
2226 "actor child {}: no credential found for provider '{}'",
2227 job.child_session_id,
2228 provider
2229 );
2230 }
2231 }
2232 }
2233 spec.capabilities.nested_spawn = session.spawn_depth < max_spawn_depth;
2236 spec.capabilities.max_spawn_depth = Some(max_spawn_depth);
2237 spec.capabilities.enforce_permissions = true;
2243 let requested_permission_mode = session
2248 .agent_runtime_state
2249 .as_ref()
2250 .map(|state| state.effective_permission_mode())
2251 .unwrap_or_default();
2252 let configured_permission_mode = self
2253 .permission_config
2254 .as_ref()
2255 .map(|config| config.mode())
2256 .unwrap_or_default();
2257 spec.capabilities.no_human_approver = session
2262 .agent_runtime_state
2263 .as_ref()
2264 .is_some_and(|s| s.no_human_approver);
2265 let typed_read_only = session
2271 .agent_runtime_state
2272 .as_ref()
2273 .is_some_and(|state| state.read_only);
2274 let legacy_guardian_read_only = spec.identity.role == "guardian"
2275 && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2276 ["Edit", "Write", "SubAgent", "WebFetch"]
2277 .iter()
2278 .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2279 });
2280 let read_only = typed_read_only || legacy_guardian_read_only;
2281 spec.capabilities.read_only = read_only;
2282 spec.capabilities.guardian_read_only = read_only;
2286 if spec.capabilities.read_only_enforced() {
2287 spec.capabilities.nested_spawn = false;
2290 spec.disabled_tools
2295 .get_or_insert_with(Default::default)
2296 .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2297 match &mut spec.executor {
2298 ExecutorSpec::ClaudeCode {
2299 permission_mode,
2300 inherit_user_config,
2301 ..
2302 } => {
2303 *permission_mode = Some("plan".to_string());
2304 *inherit_user_config = Some(false);
2307 }
2308 ExecutorSpec::Codex {
2309 mode,
2310 sandbox,
2311 inherit_user_config,
2312 approval_policy,
2313 network_access,
2314 allow_danger_bypass,
2315 permission_profile,
2316 ..
2317 } => {
2318 *sandbox = Some("read-only".to_string());
2319 *inherit_user_config = Some(false);
2320 *approval_policy = Some(
2323 if mode.as_deref() == Some("app_server") {
2324 "on-request"
2325 } else {
2326 "never"
2327 }
2328 .to_string(),
2329 );
2330 *network_access = Some(false);
2331 *allow_danger_bypass = Some(false);
2332 *permission_profile = Some("read-only".to_string());
2333 }
2334 _ => {}
2335 }
2336 }
2337 let read_only_overlay = session
2338 .agent_runtime_state
2339 .as_ref()
2340 .is_some_and(|state| state.plan_mode.is_some())
2341 || spec.capabilities.read_only_enforced()
2342 || executor_has_read_only_permission_profile(&spec.executor);
2343 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2344 requested_permission_mode,
2345 configured_permission_mode,
2346 read_only_overlay,
2347 );
2348 spec.capabilities.bypass = permission_resolution.bypass_permissions();
2349 spec.capabilities.auto_approve_permissions =
2350 permission_resolution.suppress_approval_prompts();
2351 spec.capabilities.permission_requested_mode =
2352 permission_resolution.requested.as_str().to_string();
2353 spec.capabilities.permission_effective_mode =
2354 permission_resolution.effective.as_str().to_string();
2355 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2362 spec.placement = Placement::Remote {
2363 endpoint: placement.endpoint.clone(),
2364 };
2365 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2366 {
2367 spec.placement = Placement::Schedulable {
2375 pool: placement.pool.clone(),
2376 };
2377 }
2378 spec
2379 }
2380
2381 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2384 self.build_spec_with_provider_config(
2385 session,
2386 job,
2387 &self.credentials,
2388 &self.default_provider,
2389 self.max_spawn_depth,
2390 )
2391 }
2392
2393 async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2398 let Some(config) = self.live_provider_config.as_ref() else {
2399 return self.build_spec(session, job);
2400 };
2401
2402 let config = config.read().await;
2403 let credentials = super::runtime::extract_provider_credentials(&config);
2404 let default_provider = config.effective_default_provider().to_string();
2405 self.build_spec_with_provider_config(
2406 session,
2407 job,
2408 &credentials,
2409 &default_provider,
2410 config.subagents().effective_max_spawn_depth(),
2411 )
2412 }
2413
2414 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2420 let host_label = match &spec.placement {
2421 Placement::Remote { .. } => self
2422 .remote_placements
2423 .get(spec.identity.role.as_str())
2424 .and_then(|p| p.host_label.as_deref()),
2425 Placement::Schedulable { .. } => self
2426 .schedulable_placements
2427 .get(spec.identity.role.as_str())
2428 .and_then(|p| p.host_label.as_deref()),
2429 Placement::Local => None,
2430 };
2431 placement_metadata(&spec.placement, host_label)
2432 }
2433
2434 fn scoped_placement_route(
2435 &self,
2436 spec: &ProvisionSpec,
2437 ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2438 match &spec.placement {
2439 Placement::Local => Ok(None),
2440 Placement::Remote { .. } => {
2441 let placement = self
2442 .remote_placements
2443 .get(&spec.identity.role)
2444 .ok_or_else(remote_broker_unavailable)?;
2445 let peer = placement
2446 .broker_peer
2447 .as_ref()
2448 .and_then(|peer| peer.as_ref().ok())
2449 .ok_or_else(remote_broker_unavailable)?;
2450 let worker_role = peer
2451 .worker_role
2452 .clone()
2453 .ok_or_else(remote_broker_unavailable)?;
2454 Ok(Some(ScopedPlacementRoute {
2455 endpoint: placement.endpoint.clone(),
2456 token: placement
2457 .token
2458 .clone()
2459 .ok_or_else(remote_broker_unavailable)?,
2460 ca_cert_file: placement
2461 .ca_cert_file
2462 .clone()
2463 .ok_or_else(remote_broker_unavailable)?,
2464 parent: bamboo_subagent::AgentRef {
2465 session_id: peer.parent_mailbox.clone(),
2466 role: peer.parent_role.clone(),
2467 },
2468 worker_role,
2469 pinned_mailbox: Some(peer.worker_mailbox.clone()),
2470 requirements: placement
2471 .requirements
2472 .clone()
2473 .ok_or_else(remote_broker_unavailable)?,
2474 }))
2475 }
2476 Placement::Schedulable { .. } => {
2477 let placement = self
2478 .schedulable_placements
2479 .get(&spec.identity.role)
2480 .ok_or_else(remote_broker_unavailable)?;
2481 let parent = placement
2482 .broker_parent
2483 .as_ref()
2484 .ok_or_else(remote_broker_unavailable)?;
2485 Ok(Some(ScopedPlacementRoute {
2486 endpoint: placement.endpoint.clone(),
2487 token: placement
2488 .token
2489 .clone()
2490 .ok_or_else(remote_broker_unavailable)?,
2491 ca_cert_file: placement
2492 .ca_cert_file
2493 .clone()
2494 .ok_or_else(remote_broker_unavailable)?,
2495 parent: bamboo_subagent::AgentRef {
2496 session_id: parent.parent_mailbox.clone(),
2497 role: Some(parent.parent_role.clone()),
2498 },
2499 worker_role: placement.pool.clone(),
2500 pinned_mailbox: None,
2501 requirements: placement
2502 .requirements
2503 .clone()
2504 .ok_or_else(remote_broker_unavailable)?,
2505 }))
2506 }
2507 }
2508 }
2509
2510 async fn select_scoped_worker(
2511 &self,
2512 route: &ScopedPlacementRoute,
2513 registry: &bamboo_storage::v2::FileHostRegistry,
2514 actor_id: &str,
2515 project_id: Option<bamboo_domain::ProjectId>,
2516 required_tools: &BTreeSet<String>,
2517 ) -> Result<String, AgentError> {
2518 if let Some(mailbox) = &route.pinned_mailbox {
2519 return Ok(mailbox.clone());
2520 }
2521 let mut query = route.query().await?;
2522 let candidates = query
2523 .list_connected(&route.worker_role)
2524 .await
2525 .map_err(|_| remote_broker_unavailable())?;
2526 let scheduler = PlacementScheduler::new(registry.clone());
2527 let mut selected: Option<(String, usize, u16)> = None;
2528 for mailbox in candidates {
2529 let Some(observed) = query
2530 .observe_host_capacity(&mailbox, &route.worker_role)
2531 .await
2532 .map_err(|_| remote_broker_unavailable())?
2533 else {
2534 continue;
2535 };
2536 let Ok(host) = scheduler
2537 .observe_scoped_host(
2538 &mut query,
2539 &observed.host_ref,
2540 &mailbox,
2541 &route.worker_role,
2542 chrono::Duration::seconds(110),
2543 )
2544 .await
2545 else {
2546 continue;
2547 };
2548 let now = chrono::Utc::now();
2549 let request = HostPlacementRequest {
2550 intent: HostPlacementIntent::Pool {
2551 pool_id: route.worker_role.clone(),
2552 },
2553 actor_id: actor_id.to_owned(),
2554 run_id: "selection".into(),
2555 project_id: project_id.clone(),
2556 trust_zone: route.requirements.trust_zone.clone(),
2557 workspace_label: route.requirements.workspace_label.clone(),
2558 executor: "bamboo-runtime".into(),
2559 required_tools: required_tools.clone(),
2560 network_zone: route.requirements.network_zone.clone(),
2561 require_network_isolation: route.requirements.require_network_isolation,
2562 preferred_host_ref: None,
2563 now,
2564 lease_expires_at: now + chrono::Duration::seconds(80),
2565 };
2566 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2567 || !request.eligible(&host)
2568 {
2569 continue;
2570 }
2571 let load = host.active_slots(now);
2572 if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2573 load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2574 }) {
2575 selected = Some((mailbox, load, host.max_slots));
2576 }
2577 }
2578 selected
2579 .map(|(mailbox, _, _)| mailbox)
2580 .ok_or_else(remote_broker_unavailable)
2581 }
2582}
2583
2584#[async_trait]
2585impl ExternalChildRunner for ActorChildRunner {
2586 async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2587 let role = session
2588 .metadata
2589 .get("subagent_type")
2590 .map(String::as_str)
2591 .unwrap_or("worker");
2592 let current = std::env::current_exe()
2593 .ok()
2594 .and_then(|path| path.canonicalize().ok());
2595 if self.native_tool_ceiling.is_none()
2596 || !self.builtin_required_context_route
2597 || !self.should_handle(session).await
2598 || current.is_none()
2599 || self.worker_bin.canonicalize().ok() != current
2600 || self.worker_args != ["subagent-worker".to_string()]
2601 || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2602 || self.bus.is_none()
2603 || self.remote_placements.contains_key(role)
2604 || self.schedulable_placements.contains_key(role)
2605 {
2606 return Err(
2607 "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2608 .into(),
2609 );
2610 }
2611 bamboo_subagent::fleet::require_worker_capability(
2612 &self.worker_bin,
2613 &self.worker_args,
2614 bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2615 )
2616 .await
2617 .map_err(|_| {
2618 "required_child_context_unsupported: worker capability unconfirmed".to_string()
2619 })?;
2620 bamboo_subagent::fleet::require_worker_capability(
2621 &self.worker_bin,
2622 &self.worker_args,
2623 bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2624 )
2625 .await
2626 .map_err(|_| {
2627 "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2628 })?;
2629 bamboo_subagent::fleet::require_worker_capability(
2630 &self.worker_bin,
2631 &self.worker_args,
2632 bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2633 )
2634 .await
2635 .map_err(|_| {
2636 "required_child_context_unsupported: worker birth capability unconfirmed".into()
2637 })
2638 }
2639 async fn should_handle(&self, session: &Session) -> bool {
2640 session.metadata.get("runtime.kind") == Some(&"external".to_string())
2641 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2642 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2643 }
2644
2645 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2646 *self.escalation_bridge.lock().recover_poison() = bridge;
2647 }
2648
2649 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2650 *self.session_inbox_runtime.lock().recover_poison() = binding;
2651 }
2652
2653 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2654 *self.actor_directory_store.lock().recover_poison() = store;
2655 }
2656
2657 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2658 *self.canonical_subagent_tool.lock().recover_poison() = tool;
2659 }
2660
2661 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2662 *self.ticket_service.lock().recover_poison() = service;
2663 }
2664
2665 fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2666 *self.actor_event_observer.lock().recover_poison() = observer;
2667 }
2668
2669 async fn prepare_durable_child_delivery(
2670 &self,
2671 session: &Session,
2672 activation_run_id: &str,
2673 ) -> Result<bool, String> {
2674 let key = (
2675 session.id.clone(),
2676 session.created_at,
2677 activation_run_id.to_owned(),
2678 );
2679 let (delivery, host_nested_wait_handoff, strict_history) = {
2680 let pending = self.pending_durable_links.lock().await;
2681 let Some(pending) = pending.get(&key) else {
2682 return Ok(false);
2683 };
2684 if pending.strict_history_required && pending.strict_history.is_none() {
2685 return Err("Host strict terminal completeness proof missing".into());
2686 }
2687 (
2688 pending
2689 .link
2690 .durable_delivery_receipt()
2691 .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2692 pending.host_nested_wait_handoff,
2693 pending.strict_history.clone(),
2694 )
2695 };
2696 let expected_status =
2697 effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2698 if session.last_run_status().as_deref() != Some(expected_status) {
2699 tracing::warn!(
2700 child_session_id = %session.id,
2701 activation_run_id,
2702 expected_status,
2703 actual_status = ?session.last_run_status(),
2704 "Host final status differs from accepted broker terminal"
2705 );
2706 return Err("Host final status differs from accepted broker terminal".into());
2707 }
2708 let store = self
2709 .actor_directory_store
2710 .lock()
2711 .recover_poison()
2712 .clone()
2713 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2714 let binding = self
2715 .session_inbox_runtime
2716 .lock()
2717 .recover_poison()
2718 .clone()
2719 .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2720 if !binding
2721 .router
2722 .owns_run(&session.id, activation_run_id)
2723 .await
2724 {
2725 return Err("stale Child activation cannot prepare broker receipt".into());
2726 }
2727 let ticket_service = self.ticket_service.lock().recover_poison().clone();
2728 let yielded_call = if expected_status == "completed"
2729 && session
2730 .metadata
2731 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2732 {
2733 let service = ticket_service
2734 .as_deref()
2735 .ok_or("Ticket authority unavailable for broker question")?;
2736 crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2737 .map_err(|e| e.to_string())?
2738 } else {
2739 None
2740 };
2741 let route = bamboo_storage::v2::BrokerTerminalRoute {
2742 activation_run_id,
2743 broker_identity: &delivery.broker_identity,
2744 parent_mailbox: &delivery.parent_mailbox,
2745 broker_correlation_id: &delivery.correlation_id,
2746 message_ids: &delivery.message_ids,
2747 };
2748 if let Some(call_id) = yielded_call {
2749 let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2750 session,
2751 activation_run_id,
2752 &call_id,
2753 )
2754 .map_err(|error| error.to_string())?;
2755 if let Some(history) = strict_history.as_ref() {
2756 store
2757 .prepare_broker_tool_yield_receipt_with_completeness(
2758 session,
2759 route,
2760 &proof,
2761 &history.proof,
2762 )
2763 .await
2764 } else {
2765 store
2766 .prepare_broker_tool_yield_receipt(session, route, &proof)
2767 .await
2768 }
2769 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2770 } else {
2771 if let Some(history) = strict_history.as_ref() {
2772 store
2773 .prepare_broker_terminal_receipt_with_completeness(
2774 session,
2775 route,
2776 &history.proof,
2777 )
2778 .await
2779 } else {
2780 store
2781 .prepare_broker_terminal_receipt(
2782 session,
2783 activation_run_id,
2784 &delivery.broker_identity,
2785 &delivery.parent_mailbox,
2786 &delivery.correlation_id,
2787 &delivery.message_ids,
2788 )
2789 .await
2790 }
2791 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2792 }
2793 Ok(true)
2794 }
2795
2796 async fn commit_durable_child_delivery(
2797 &self,
2798 session: &Session,
2799 activation_run_id: &str,
2800 ) -> Result<(), String> {
2801 let key = (
2802 session.id.clone(),
2803 session.created_at,
2804 activation_run_id.to_owned(),
2805 );
2806 let mut pending_links = self.pending_durable_links.lock().await;
2807 let Some(pending) = pending_links.get_mut(&key) else {
2808 return Ok(());
2809 };
2810 let delivery = pending
2811 .link
2812 .durable_delivery_receipt()
2813 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2814 let expected_status = effective_broker_terminal_status(
2815 delivery.terminal_status,
2816 pending.host_nested_wait_handoff,
2817 )?;
2818 if session.last_run_status().as_deref() != Some(expected_status) {
2819 return Err("Host final status differs from accepted broker terminal".into());
2820 }
2821 if pending.strict_history_required && pending.strict_history.is_none() {
2822 return Err("Host strict terminal completeness proof missing".into());
2823 }
2824 let store = self
2825 .actor_directory_store
2826 .lock()
2827 .recover_poison()
2828 .clone()
2829 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2830 let committed = if let Some(history) = pending.strict_history.as_ref() {
2831 store
2832 .commit_broker_terminal_receipt_with_completeness(
2833 session,
2834 activation_run_id,
2835 &history.proof,
2836 )
2837 .await
2838 } else {
2839 store
2840 .commit_broker_terminal_receipt(session, activation_run_id)
2841 .await
2842 }
2843 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2844 if committed.broker_identity != delivery.broker_identity
2845 || committed.parent_mailbox != delivery.parent_mailbox
2846 || committed.broker_correlation_id != delivery.correlation_id
2847 || committed.message_ids != delivery.message_ids
2848 || committed.terminal_status != expected_status
2849 {
2850 return Err("Host broker receipt changed before completion".into());
2851 }
2852 if let Some(history) = pending.strict_history.as_ref() {
2853 let proof = committed
2854 .terminal_completeness
2855 .as_ref()
2856 .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2857 let creation = bamboo_subagent::proto::ChildCreationIdentity {
2858 created_at: session.created_at,
2859 spawn_depth: session.spawn_depth,
2860 };
2861 validate_strict_history_terminal(
2862 Some(&history.watermark),
2863 session,
2864 session
2865 .parent_session_id
2866 .as_deref()
2867 .ok_or("Child parent missing")?,
2868 Some(activation_run_id),
2869 proof.execution_epoch,
2870 Some(&creation),
2871 proof.contiguous_applied_seq,
2872 )
2873 .map_err(|error| error.to_string())?;
2874 }
2875 publish_recovered_completion_source(&store, &committed).await?;
2879 pending.strict_history_committed = true;
2880 Ok(())
2881 }
2882
2883 async fn confirm_durable_child_delivery(
2884 &self,
2885 session: &Session,
2886 activation_run_id: &str,
2887 save_succeeded: bool,
2888 ) -> Result<(), String> {
2889 if save_succeeded
2890 && session
2891 .metadata
2892 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2893 {
2894 let service = self
2895 .ticket_service
2896 .lock()
2897 .recover_poison()
2898 .clone()
2899 .ok_or_else(|| {
2900 "Ticket authority unavailable for terminal checkpoint".to_string()
2901 })?;
2902 let runtime = self
2903 .session_inbox_runtime
2904 .lock()
2905 .recover_poison()
2906 .clone()
2907 .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2908 crate::ticket_runtime::checkpoint_ticket_result(
2909 &service,
2910 runtime.storage.as_ref(),
2911 session,
2912 activation_run_id,
2913 )
2914 .await
2915 .map_err(|e| e.to_string())?;
2916 }
2917 let key = (
2918 session.id.clone(),
2919 session.created_at,
2920 activation_run_id.to_owned(),
2921 );
2922 let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2923 return Ok(());
2924 };
2925 let delivery = pending
2928 .link
2929 .durable_delivery_receipt()
2930 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2931 let expected_status = effective_broker_terminal_status(
2932 delivery.terminal_status,
2933 pending.host_nested_wait_handoff,
2934 )?;
2935 if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2936 return Ok(());
2937 }
2938 let store = self
2939 .actor_directory_store
2940 .lock()
2941 .recover_poison()
2942 .clone()
2943 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2944 let committed = if pending.strict_history_required {
2945 if !pending.strict_history_committed {
2946 return Err("Host strict completeness was not committed before ACK".into());
2947 }
2948 let history = pending
2949 .strict_history
2950 .as_ref()
2951 .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2952 store
2953 .commit_broker_terminal_receipt_with_completeness(
2954 session,
2955 activation_run_id,
2956 &history.proof,
2957 )
2958 .await
2959 } else {
2960 store
2961 .commit_broker_terminal_receipt(session, activation_run_id)
2962 .await
2963 }
2964 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2965 if committed.broker_identity != delivery.broker_identity
2966 || committed.parent_mailbox != delivery.parent_mailbox
2967 || committed.broker_correlation_id != delivery.correlation_id
2968 || committed.message_ids != delivery.message_ids
2969 || committed.terminal_status != expected_status
2970 {
2971 return Err("Host broker receipt changed before ACK".into());
2972 }
2973 pending
2974 .link
2975 .acknowledge_durable_frames()
2976 .await
2977 .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2978 store
2979 .clear_acknowledged_broker_terminal_receipt(
2980 &session.id,
2981 session.created_at,
2982 activation_run_id,
2983 &delivery.parent_mailbox,
2984 )
2985 .await
2986 .map_err(|error| {
2987 format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2988 })
2989 }
2990
2991 #[tracing::instrument(
2992 name = "actor_child_run",
2993 level = "warn",
2994 skip_all,
2995 fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2996 )]
2997 async fn execute_external_child(
2998 &self,
2999 session: &mut Session,
3000 job: &SpawnJob,
3001 event_tx: mpsc::Sender<AgentEvent>,
3002 cancel_token: CancellationToken,
3003 ) -> crate::runtime::runner::Result<()> {
3004 crate::session_app::child_session::named_profile::validate_named_profile(session)
3005 .map_err(|error| AgentError::LLM(error.to_string()))?;
3006 let escalation = self.escalation_bridge.lock().recover_poison().clone();
3015 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3016 let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3017 let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3018 let ticket_service = self.ticket_service.lock().recover_poison().clone();
3019 let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3020 let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3021 .map_err(|error| AgentError::Budget(error.to_string()))?;
3022 if crate::session_app::child_session::named_profile::has_named_profile(session)
3023 && required_context.is_none()
3024 {
3025 return Err(AgentError::LLM(
3026 "named_profile_requires_strict_context_route".into(),
3027 ));
3028 }
3029 let assignment = required_context
3030 .as_ref()
3031 .map(|binding| binding.payload.required_assignment.clone())
3032 .unwrap_or_else(|| extract_assignment(session));
3033 let mut spec = self.build_live_spec(session, job).await;
3034 ensure_ticket_placement_is_enforceable(session, &spec)?;
3037 let scoped_route = self.scoped_placement_route(&spec)?;
3038 let strict_remote = scoped_route.as_ref();
3039 if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3040 return Err(remote_broker_unavailable());
3041 }
3042 let lineage = if strict_remote.is_some() {
3043 Some(
3044 remote_canonical_lineage(
3045 session_inbox_runtime
3046 .as_ref()
3047 .ok_or_else(remote_broker_unavailable)?,
3048 session,
3049 )
3050 .await?,
3051 )
3052 } else {
3053 None
3054 };
3055 let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3056 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3057 {
3058 let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3059 AgentError::LLM(
3060 "child creation identity unavailable: canonical host Storage required".into(),
3061 )
3062 })?;
3063 spec.capabilities.child_creation_identity = true;
3064 Some(canonical_child_creation(binding, session, job).await?)
3065 } else {
3066 None
3067 };
3068 spec.reusable = required_context.is_none();
3071 spec.capabilities.required_child_context = required_context.is_some();
3072 if required_context.is_some() {
3073 self.validate_required_child_context_route(session)
3074 .await
3075 .map_err(AgentError::LLM)?;
3076 }
3077 if required_context.is_some() {
3078 let names = self
3079 .native_tool_ceiling
3080 .as_ref()
3081 .unwrap()
3082 .observe(session)
3083 .await
3084 .map_err(AgentError::LLM)?;
3085 let birth = creation
3086 .as_ref()
3087 .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3088 spec.capabilities.native_tool_ceiling_required = true;
3089 spec.capabilities.native_tool_ceiling =
3090 Some(bamboo_subagent::proto::NativeToolCeiling {
3091 version: 1,
3092 child_session_id: session.id.clone(),
3093 parent_session_id: job.parent_session_id.clone(),
3094 root_session_id: session.root_session_id.clone(),
3095 created_at: birth.created_at,
3096 spawn_depth: birth.spawn_depth,
3097 project_id: project_id_for_actor_run(session)?,
3098 tools: names,
3099 });
3100 spec.capabilities.mcp = None;
3102 spec.capabilities.mcp_proxy = None;
3103 spec.capabilities.skills_dir = None;
3104 spec.capabilities.nested_spawn = false;
3105 spec.validate()
3106 .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3107 }
3108 let first_reply_store = actor_directory_store.clone().filter(|_| {
3111 required_context.is_some()
3112 && crate::session_app::child_session::named_profile::has_named_profile(session)
3113 && matches!(spec.placement, Placement::Local)
3114 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3115 && spec.capabilities.native_tool_ceiling_required
3116 && spec
3117 .capabilities
3118 .native_tool_ceiling
3119 .as_ref()
3120 .is_some_and(|ceiling| {
3121 ceiling.tools.is_empty()
3122 || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3123 })
3124 });
3125 let plain_actor_store = if let Some(store) = first_reply_store {
3126 let root = bamboo_agent_core::storage::Storage::load_session(
3127 store.as_ref(),
3128 &session.root_session_id,
3129 )
3130 .await
3131 .map_err(|_| plain_actor_unsupported())?
3132 .ok_or_else(|| plain_actor_unsupported())?;
3133 if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3134 return Err(plain_actor_unsupported());
3135 }
3136 if root.root_orchestration_only_enabled() {
3137 if !plain_initial_history(session)
3138 && (spec
3139 .capabilities
3140 .native_tool_ceiling
3141 .as_ref()
3142 .is_none_or(|ceiling| !ceiling.tools.is_empty())
3143 || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3144 {
3145 return Err(plain_actor_unsupported());
3146 }
3147 Some(store)
3148 } else if spec
3149 .capabilities
3150 .native_tool_ceiling
3151 .as_ref()
3152 .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3153 {
3154 require_unowned_glob(store.as_ref(), session).await?;
3157 None
3158 } else {
3159 let observed = store
3162 .actor_subtree_snapshot(
3163 ActorSnapshotPrincipal::host_owner(),
3164 &session.root_session_id,
3165 &session.id,
3166 ActorSnapshotLimits::default(),
3167 )
3168 .await
3169 .map_err(|_| plain_actor_unsupported())?;
3170 if observed
3171 .nodes
3172 .iter()
3173 .any(|node| node.actor_id == session.id && node.activation.is_some())
3174 {
3175 return Err(plain_actor_unsupported());
3176 }
3177 None
3178 }
3179 } else {
3180 None
3181 };
3182 let readonly_actor = plain_actor_store.is_some()
3183 && spec
3184 .capabilities
3185 .native_tool_ceiling
3186 .as_ref()
3187 .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3188 let local_history_tools = (plain_actor_store.is_none()
3189 && required_context.is_some()
3190 && (crate::session_app::child_session::named_profile::has_named_profile(session)
3191 || session
3192 .metadata
3193 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3194 && matches!(spec.placement, Placement::Local)
3195 && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3196 .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3197 .flatten()
3198 .filter(|ceiling| {
3199 bamboo_subagent::proto::LocalToolMessages::supports_tools(
3200 &ceiling.tools,
3201 spec.capabilities.read_only_enforced(),
3202 )
3203 })
3204 .map(|ceiling| ceiling.tools.clone());
3205 if plain_actor_store.is_some() && !readonly_actor {
3206 self.require_initial_input_release_worker().await?;
3207 spec.capabilities.initial_input_release_required = true;
3208 spec.validate().map_err(|_| plain_actor_unsupported())?;
3209 }
3210 let mut readonly_messages = Vec::new();
3211 let mut plain_activation = None;
3212 if spec.limits.idle_timeout_secs.is_none() {
3213 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3214 }
3215 ensure_read_only_activation_is_enforceable(&spec)?;
3216 let pool_key = Self::fingerprint(&spec);
3217
3218 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3223 {
3224 return Err(AgentError::LLM(
3225 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3226 .to_string(),
3227 ));
3228 }
3229 let project_id = project_id_for_actor_run(session)?;
3230 let requested_permission_mode = session
3231 .agent_runtime_state
3232 .as_ref()
3233 .map(|state| state.effective_permission_mode())
3234 .unwrap_or_default();
3235 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3240 ensure_no_active_scoped_session_denies(config, &session.id)?;
3241 let read_only_overlay = session
3242 .agent_runtime_state
3243 .as_ref()
3244 .is_some_and(|state| state.plan_mode.is_some())
3245 || spec.capabilities.read_only_enforced()
3246 || executor_has_read_only_permission_profile(&spec.executor);
3247 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3248 requested_permission_mode,
3249 config.mode(),
3250 read_only_overlay,
3251 );
3252 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3253 AgentError::LLM(format!(
3254 "failed to serialize permission policy for external executor: {error}"
3255 ))
3256 })?;
3257 Some(PermissionPolicyContext {
3258 revision: config.policy_revision(),
3259 requested_mode: resolution.requested.as_str().to_string(),
3260 effective_mode: resolution.effective.as_str().to_string(),
3261 bypass_permissions: resolution.bypass_permissions(),
3262 auto_approve_permissions: resolution.suppress_approval_prompts(),
3263 session_id: session.id.clone(),
3264 workspace_path: session.workspace.clone(),
3265 environment_lease: None,
3266 inherit_session_grants: false,
3267 policy,
3268 })
3269 } else {
3270 None
3271 };
3272 let provisioned_permission =
3273 spec.capabilities.permission_resolution().map_err(|error| {
3274 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3275 })?;
3276 let policy_resolution = permission_policy
3277 .as_ref()
3278 .map(|context| {
3279 context.resolved_modes().map(|(requested, effective)| {
3280 bamboo_domain::PermissionModeResolution {
3281 requested,
3282 effective,
3283 }
3284 })
3285 })
3286 .transpose()
3287 .map_err(|error| {
3288 AgentError::LLM(format!("invalid host permission posture: {error}"))
3289 })?;
3290 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3291 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3292 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3293 .is_some()
3294 });
3295 let expected_executor_mapping = expected_permission_executor_mapping(
3296 &spec.executor,
3297 policy_resolution.unwrap_or(provisioned_permission),
3298 has_explicit_deny,
3299 )?;
3300 let mut expected_permission_posture =
3301 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3302 policy_revision: permission_policy
3303 .as_ref()
3304 .map(|context| context.revision)
3305 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3306 .unwrap_or_default(),
3307 resolution: policy_resolution.unwrap_or(provisioned_permission),
3308 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3309 executor_mapping,
3310 });
3311
3312 let _slot = if strict_remote.is_some() {
3317 tokio::select! { biased;
3318 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3319 permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3320 }
3321 } else {
3322 self.concurrency
3323 .acquire()
3324 .await
3325 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3326 };
3327 let _remote_subscription = if strict_remote.is_some() {
3328 Some(tokio::select! { biased;
3329 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3330 guard = self.strict_remote_subscription.lock() => guard,
3331 })
3332 } else {
3333 None
3334 };
3335
3336 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3343 &spec.executor,
3344 self.codex_run_tokens.clone(),
3345 &job.child_session_id,
3346 )?;
3347
3348 let kind = match spec.placement {
3356 Placement::Remote { .. } => PlacementKind::Remote,
3357 Placement::Schedulable { .. } => PlacementKind::Schedulable,
3358 Placement::Local => PlacementKind::Local,
3359 };
3360 let remote = !matches!(kind, PlacementKind::Local);
3361 let scoped_registry = if remote {
3362 let store = actor_directory_store
3363 .as_ref()
3364 .ok_or_else(remote_broker_unavailable)?;
3365 Some(
3366 bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3367 .await
3368 .map_err(|_| remote_broker_unavailable())?,
3369 )
3370 } else {
3371 None
3372 };
3373 let scoped_tools = scoped_route.as_ref().map(|route| {
3374 let mut tools = route.requirements.required_tools.clone();
3375 if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3376 tools.extend(ceiling.tools.iter().cloned());
3377 }
3378 tools
3379 });
3380
3381 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3387 session
3388 .metadata
3389 .insert("placement".to_string(), placement_meta);
3390 }
3391
3392 let mut attempt = 0u8;
3399 let (result, actor) = loop {
3400 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3401 let (actor, mut client) = match kind {
3402 PlacementKind::Remote => {
3403 let route = scoped_route
3404 .as_ref()
3405 .ok_or_else(remote_broker_unavailable)?;
3406 let mailbox_id = route
3407 .pinned_mailbox
3408 .clone()
3409 .ok_or_else(remote_broker_unavailable)?;
3410 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3411 .map_err(|_| remote_broker_unavailable())?;
3412 let connect =
3413 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3414 &route.endpoint,
3415 route.parent.clone(),
3416 &route.token,
3417 bamboo_subagent::AgentRef {
3418 session_id: mailbox_id.clone(),
3419 role: Some(route.worker_role.clone()),
3420 },
3421 tls,
3422 );
3423 let link = tokio::select! { biased;
3424 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3425 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3426 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3427 };
3428 let record = AgentRecord {
3429 agent_id: job.child_session_id.clone(),
3430 role: spec.identity.role.clone(),
3431 labels: Vec::new(),
3432 endpoint: String::new(),
3433 pid: 0,
3434 version: String::new(),
3435 started_at: chrono::Utc::now(),
3436 lease_expires_at: chrono::Utc::now(),
3437 };
3438 (
3439 PooledWorker {
3440 worker: SpawnedChild::remote(record),
3441 mailbox_id,
3442 parked_at: None,
3443 },
3444 Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3445 )
3446 }
3447 PlacementKind::Schedulable => {
3448 let route = scoped_route
3449 .as_ref()
3450 .ok_or_else(remote_broker_unavailable)?;
3451 let registry = scoped_registry
3452 .as_ref()
3453 .ok_or_else(remote_broker_unavailable)?;
3454 let mailbox_id = self
3455 .select_scoped_worker(
3456 route,
3457 registry,
3458 &session.id,
3459 project_id.clone(),
3460 scoped_tools
3461 .as_ref()
3462 .ok_or_else(remote_broker_unavailable)?,
3463 )
3464 .await?;
3465 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3466 .map_err(|_| remote_broker_unavailable())?;
3467 let connect =
3468 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3469 &route.endpoint,
3470 route.parent.clone(),
3471 &route.token,
3472 bamboo_subagent::AgentRef {
3473 session_id: mailbox_id.clone(),
3474 role: Some(route.worker_role.clone()),
3475 },
3476 tls,
3477 );
3478 let link = tokio::select! { biased;
3479 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3480 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3481 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3482 };
3483 let actor = PooledWorker {
3484 worker: SpawnedChild::remote(AgentRecord {
3485 agent_id: mailbox_id.clone(),
3486 role: spec.identity.role.clone(),
3487 labels: Vec::new(),
3488 endpoint: route.endpoint.clone(),
3489 pid: 0,
3490 version: String::new(),
3491 started_at: chrono::Utc::now(),
3492 lease_expires_at: chrono::Utc::now(),
3493 }),
3494 mailbox_id,
3495 parked_at: None,
3496 };
3497 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3498 (actor, client)
3499 }
3500 PlacementKind::Local => {
3501 let bus = self.bus.as_ref().ok_or_else(|| {
3508 AgentError::LLM(
3509 "local sub-agents require a mailbox bus (subagents.broker); none is \
3510 configured and the bus could not be embedded"
3511 .to_string(),
3512 )
3513 })?;
3514 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3515 let parent = bamboo_subagent::AgentRef {
3516 session_id: format!("p-{}", job.child_session_id),
3517 role: None,
3518 };
3519 let link = bamboo_broker::BrokerChildLink::connect(
3520 &bus.endpoint,
3521 parent,
3522 &bus.token,
3523 actor.mailbox_id.clone(),
3524 )
3525 .await
3526 .map_err(|e| {
3527 AgentError::LLM(format!("broker child link connect failed: {e}"))
3528 })?;
3529 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3530 (actor, client)
3531 }
3532 };
3533
3534 if let Some(store) = actor_directory_store.as_ref() {
3540 let recovery = async {
3541 let receipts = store
3542 .recover_broker_terminal_receipts(session)
3543 .await
3544 .map_err(|error| {
3545 format!("Host broker receipt recovery blocked: {error}")
3546 })?;
3547 for receipt in receipts {
3548 if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3552 return Err(
3553 "old Child broker parent mailbox changed before ACK".to_string()
3554 );
3555 }
3556 client
3557 .acknowledge_recovered_durable_frames(
3558 &receipt.broker_identity,
3559 &receipt.message_ids,
3560 )
3561 .await
3562 .map_err(|error| {
3563 format!("old Child broker ACK unconfirmed: {error}")
3564 })?;
3565 store
3566 .clear_acknowledged_broker_terminal_receipt(
3567 &receipt.session_id,
3568 receipt.created_at,
3569 &receipt.activation_run_id,
3570 &receipt.parent_mailbox,
3571 )
3572 .await
3573 .map_err(|error| {
3574 format!("old Child broker ACK cleanup failed: {error}")
3575 })?;
3576 }
3577 Ok::<(), String>(())
3578 }
3579 .await;
3580 if let Err(error) = recovery {
3581 if !remote {
3582 actor.worker.kill().await;
3583 }
3584 return Err(AgentError::LLM(error));
3585 }
3586 }
3587
3588 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3593 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3594 Some(binding) => {
3595 let run_id = binding
3596 .router
3597 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3598 .await;
3599 if run_id.is_none() {
3600 tracing::debug!(
3601 session_id = %job.child_session_id,
3602 "actor driver had no current SessionInbox activation owner to bind"
3603 );
3604 }
3605 run_id
3606 }
3607 None => None,
3608 };
3609 tracing::Span::current().record(
3610 "activation_run_id",
3611 bound_activation_run_id.as_deref().unwrap_or("unbound"),
3612 );
3613 drop(delivery_tx);
3614 let initial_pairs = match (
3615 session_inbox_runtime.as_ref(),
3616 bound_activation_run_id.as_deref(),
3617 ) {
3618 (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3619 match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3620 .await
3621 {
3622 Ok(deliveries) => deliveries,
3623 Err(error) => {
3624 binding
3625 .router
3626 .detach_delivery_sink(&job.child_session_id, run_id)
3627 .await;
3628 if !remote {
3629 actor.worker.kill().await;
3630 }
3631 return Err(error);
3632 }
3633 }
3634 }
3635 _ => Vec::new(),
3636 };
3637 let mut initial_session_messages = initial_pairs
3638 .iter()
3639 .map(|(_, delivery)| delivery.clone())
3640 .collect::<Vec<_>>();
3641 let mut initial_inflight_claims = initial_pairs
3642 .into_iter()
3643 .map(|(claim, _)| claim)
3644 .collect::<VecDeque<_>>();
3645 let mut messages = if let Some(binding) = &required_context {
3648 binding
3649 .validate_messages(&session.id, &session.messages)
3650 .map_err(|error| AgentError::Budget(error.to_string()))?;
3651 session
3652 .messages
3653 .iter()
3654 .map(serde_json::to_value)
3655 .collect::<std::result::Result<Vec<_>, _>>()
3656 .map_err(|_| {
3657 AgentError::Budget(
3658 bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3659 )
3660 })?
3661 } else {
3662 session
3663 .messages
3664 .iter()
3665 .filter_map(|message| serde_json::to_value(message).ok())
3666 .collect()
3667 };
3668
3669 if strict_remote.is_some() {
3670 let current =
3671 remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3672 .await;
3673 if current.as_ref().ok() != lineage.as_ref()
3674 || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3675 || cancel_token.is_cancelled()
3676 {
3677 if let (Some(binding), Some(run_id)) = (
3678 session_inbox_runtime.as_ref(),
3679 bound_activation_run_id.as_deref(),
3680 ) {
3681 binding
3682 .router
3683 .detach_delivery_sink(&job.child_session_id, run_id)
3684 .await;
3685 }
3686 return Err(if cancel_token.is_cancelled() {
3687 AgentError::Cancelled
3688 } else {
3689 remote_broker_unavailable()
3690 });
3691 }
3692 }
3693 if let Some(expected) = creation.as_ref() {
3694 let checked =
3695 canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3696 .await;
3697 if checked.as_ref().ok() != Some(expected) {
3698 if let (Some(binding), Some(run_id)) = (
3699 session_inbox_runtime.as_ref(),
3700 bound_activation_run_id.as_deref(),
3701 ) {
3702 binding
3703 .router
3704 .detach_delivery_sink(&job.child_session_id, run_id)
3705 .await;
3706 }
3707 actor.worker.kill().await;
3708 return Err(AgentError::LLM(
3709 "child creation identity changed before dispatch".into(),
3710 ));
3711 }
3712 }
3713 if let Some(store) = &plain_actor_store {
3714 let started = async {
3715 let policy = permission_policy
3720 .as_ref()
3721 .ok_or_else(|| plain_actor_unsupported())?;
3722 let config = self
3723 .permission_config
3724 .as_ref()
3725 .ok_or_else(|| plain_actor_unsupported())?;
3726 if config.policy_revision() != policy.revision
3727 || !matches!(spec.placement, Placement::Local)
3728 {
3729 return Err(plain_actor_unsupported());
3730 }
3731 store
3732 .bind_control_plane(
3733 &session.id,
3734 bamboo_domain::ActorControlPlaneBinding {
3735 policy_revision: Some(policy.revision),
3736 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3737 },
3738 )
3739 .await
3740 .map_err(|error| AgentError::LLM(error.to_string()))?;
3741 PlainActorActivation::start_and_prepare(
3742 store.clone(),
3743 session,
3744 session_inbox_runtime.as_ref().unwrap(),
3745 bound_activation_run_id.as_deref(),
3746 self.permission_config.clone(),
3747 !readonly_actor,
3748 spec.capabilities
3749 .initial_input_release_required
3750 .then_some(actor.mailbox_id.as_str()),
3751 )
3752 .await
3753 }
3754 .await;
3755 match started {
3756 Ok((activation, prepared)) => {
3757 if let Some((prefix, deliveries)) = prepared {
3758 messages = prefix;
3759 initial_session_messages = deliveries;
3760 }
3761 plain_activation = Some(activation);
3762 }
3763 Err(error) => {
3764 if let Some(run_id) = bound_activation_run_id.as_deref() {
3765 session_inbox_runtime
3766 .as_ref()
3767 .unwrap()
3768 .router
3769 .detach_delivery_sink(&job.child_session_id, run_id)
3770 .await;
3771 }
3772 actor.worker.kill().await;
3773 return Err(error);
3774 }
3775 }
3776 }
3777 let mut logical_identity = logical_identity_for_actor_run(session, job);
3778 logical_identity.creation = creation.clone();
3779 let run_permission_policy = if strict_remote.is_some() {
3780 let prepared = async {
3781 let mut policy = permission_policy.clone().ok_or_else(|| {
3782 AgentError::LLM("remote_environment_permission_policy_missing".into())
3783 })?;
3784 let workspace = session.workspace.as_deref().ok_or_else(|| {
3785 AgentError::LLM("remote_environment_workspace_missing".into())
3786 })?;
3787 let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3788 AgentError::LLM("remote_environment_activation_missing".into())
3789 })?;
3790 let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3791 workspace,
3792 &session.id,
3793 run_id,
3794 execution_epoch,
3795 )
3796 .await
3797 .map_err(|error| AgentError::LLM(error.into()))?;
3798 policy.workspace_path = None;
3799 policy.environment_lease = Some(lease);
3800 Ok::<_, AgentError>(Some(policy))
3801 }
3802 .await;
3803 match prepared {
3804 Ok(policy) => policy,
3805 Err(error) => {
3806 if let (Some(binding), Some(run_id)) = (
3807 session_inbox_runtime.as_ref(),
3808 bound_activation_run_id.as_deref(),
3809 ) {
3810 binding
3811 .router
3812 .detach_delivery_sink(&job.child_session_id, run_id)
3813 .await;
3814 }
3815 return Err(error);
3816 }
3817 }
3818 } else {
3819 permission_policy.clone()
3820 };
3821 if session
3822 .metadata
3823 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3824 {
3825 let admission = async {
3826 let service = ticket_service
3827 .as_ref()
3828 .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3829 let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3830 AgentError::LLM("Ticket activation runtime unavailable".into())
3831 })?;
3832 let run_id = bound_activation_run_id
3833 .as_deref()
3834 .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3835 crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3836 .await
3837 .map_err(|e| AgentError::LLM(e.to_string()))
3838 }
3839 .await;
3840 if let Err(error) = admission {
3841 if let (Some(runtime), Some(run_id)) = (
3842 session_inbox_runtime.as_ref(),
3843 bound_activation_run_id.as_deref(),
3844 ) {
3845 runtime
3846 .router
3847 .detach_delivery_sink(&job.child_session_id, run_id)
3848 .await;
3849 }
3850 actor.worker.kill().await;
3851 return Err(error);
3852 }
3853 }
3854 let mut run_spec = RunSpec {
3855 assignment: assignment.clone(),
3857 logical_session: Some(logical_identity),
3858 project_id: project_id.clone(),
3859 reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3863 {
3864 session
3865 .reasoning_effort
3866 .map(|effort| effort.as_str().to_owned())
3867 } else {
3868 None
3869 },
3870 permission_policy: run_permission_policy,
3871 messages,
3872 activation_run_id: bound_activation_run_id.clone(),
3873 execution_epoch,
3874 initial_session_messages,
3875 secrets: run_secrets.clone(),
3876 };
3877 let mut active_lease = if remote {
3878 let reserve = ActivePlacementLease::reserve(
3879 scoped_registry
3880 .as_ref()
3881 .ok_or_else(remote_broker_unavailable)?
3882 .clone(),
3883 actor_directory_store
3884 .as_ref()
3885 .ok_or_else(remote_broker_unavailable)?
3886 .clone(),
3887 scoped_route
3888 .as_ref()
3889 .ok_or_else(remote_broker_unavailable)?
3890 .clone(),
3891 actor.mailbox_id.clone(),
3892 session.id.clone(),
3893 bound_activation_run_id
3894 .clone()
3895 .ok_or_else(remote_broker_unavailable)?,
3896 project_id.clone(),
3897 scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3898 &cancel_token,
3899 )
3900 .await;
3901 match reserve {
3902 Ok(lease) => Some(lease),
3903 Err(error) => {
3904 if let (Some(binding), Some(run_id)) = (
3905 session_inbox_runtime.as_ref(),
3906 bound_activation_run_id.as_deref(),
3907 ) {
3908 binding
3909 .router
3910 .detach_delivery_sink(&job.child_session_id, run_id)
3911 .await;
3912 }
3913 return Err(error);
3914 }
3915 }
3916 } else {
3917 None
3918 };
3919 if let Some(lease) = active_lease.as_mut() {
3920 let seeded = async {
3921 let binding = session_inbox_runtime
3922 .as_ref()
3923 .ok_or_else(remote_broker_unavailable)?;
3924 let run_id = bound_activation_run_id
3925 .as_deref()
3926 .ok_or_else(remote_broker_unavailable)?;
3927 let pairs = claim_canonical_deliveries(
3928 binding,
3929 session,
3930 run_id,
3931 usize::MAX,
3932 Some((lease.actor_store.as_ref(), &lease.fence)),
3933 )
3934 .await?;
3935 if let Some(binding) = &required_context {
3936 binding
3937 .validate_messages(&session.id, &session.messages)
3938 .map_err(|error| AgentError::Budget(error.to_string()))?;
3939 }
3940 run_spec.messages = session
3941 .messages
3942 .iter()
3943 .map(serde_json::to_value)
3944 .collect::<std::result::Result<Vec<_>, _>>()
3945 .map_err(|_| remote_broker_unavailable())?;
3946 run_spec.initial_session_messages =
3947 pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3948 initial_inflight_claims = pairs
3949 .into_iter()
3950 .map(|(claim, _)| claim)
3951 .collect::<VecDeque<_>>();
3952 Ok::<(), AgentError>(())
3953 }
3954 .await;
3955 if let Err(error) = seeded {
3956 if let Err(rollback_error) = lease.abort_before_dispatch().await {
3957 tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3958 }
3959 if let (Some(binding), Some(run_id)) = (
3960 session_inbox_runtime.as_ref(),
3961 bound_activation_run_id.as_deref(),
3962 ) {
3963 binding
3964 .router
3965 .detach_delivery_sink(&job.child_session_id, run_id)
3966 .await;
3967 }
3968 return Err(error);
3969 }
3970 }
3971 let mut run_send_invoked = false;
3972 let dispatch = async {
3973 if let Some(activation) = plain_activation
3974 .as_ref()
3975 .filter(|activation| !activation.initial_inputs.is_empty())
3976 {
3977 activation.refresh_continuation_posture(
3978 session,
3979 &run_spec,
3980 &mut expected_permission_posture,
3981 )?;
3982 }
3983 send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3984 }
3985 .await;
3986 if let Err(e) = dispatch {
3987 if remote {
3988 tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3989 }
3990 if let (Some(binding), Some(run_id)) = (
3991 session_inbox_runtime.as_ref(),
3992 bound_activation_run_id.as_deref(),
3993 ) {
3994 binding
3995 .router
3996 .detach_delivery_sink(&job.child_session_id, run_id)
3997 .await;
3998 }
3999 if !remote {
4000 actor.worker.kill().await;
4001 }
4002 if let Some(activation) = &plain_activation {
4003 activation.finish(ActorActivationFinish::Failed).await?;
4004 }
4005 if let Some(lease) = active_lease.as_mut() {
4006 match lease.abort_if_run_unsent(run_send_invoked).await {
4007 Ok(false) => {
4008 tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4009 }
4010 Ok(true) => {}
4011 Err(rollback_error) => {
4012 tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4013 }
4014 }
4015 }
4016 return Err(e);
4017 }
4018
4019 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4023 let live_guard = super::live::register(
4024 &job.child_session_id,
4025 live_tx,
4026 attempt as u32,
4027 self.approval_registry.clone(),
4028 );
4029
4030 let mut strict_history = None;
4031 let result = drive(ActorDriveContext {
4032 client: &mut *client,
4033 parent_session_id: &job.parent_session_id,
4034 child_session_id: &job.child_session_id,
4035 child_attempt: attempt as u32,
4036 approval_registry: self.approval_registry.as_ref(),
4037 approval_decider: self.approval_decider.as_ref(),
4038 approval_reviewer: self.approval_reviewer.as_ref(),
4039 escalation_bridge: escalation.clone(),
4040 event_tx: &event_tx,
4041 cancel_token: active_lease
4042 .as_ref()
4043 .map_or(&cancel_token, |lease| &lease.run_cancel),
4044 live_rx: &mut live_rx,
4045 delivery_rx: &mut delivery_rx,
4046 logical_session: session,
4047 expected_permission_posture: expected_permission_posture.clone(),
4048 expected_creation: creation.as_ref(),
4049 session_inbox_runtime: session_inbox_runtime.as_ref(),
4050 actor_directory_store: actor_directory_store.as_deref(),
4051 canonical_subagent_tool: canonical_subagent_tool.clone(),
4052 ticket_service: ticket_service.clone(),
4053 activation_run_id: bound_activation_run_id.as_deref(),
4054 execution_epoch,
4055 expected_source_actor_id: &actor.mailbox_id,
4056 initial_inflight_claims,
4057 plain_actor: plain_activation.is_some(),
4064 remote_environment_lease: strict_remote.is_some(),
4065 readonly_output: if readonly_actor {
4066 Some(&mut readonly_messages)
4067 } else {
4068 None
4069 },
4070 local_history_tools: local_history_tools.as_deref(),
4071 local_history_read_only: spec.capabilities.read_only_enforced(),
4072 strict_history_output: Some(&mut strict_history),
4073 plain_input: plain_activation.as_ref(),
4074 canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4075 canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4076 actor_event_observer: actor_event_observer.as_deref(),
4077 plain_run: self
4080 .bus
4081 .as_ref()
4082 .map(|_| (&run_spec, &self.next_execution_epoch)),
4083 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4084 })
4085 .await;
4086 let mut result = if active_lease
4087 .as_ref()
4088 .is_some_and(ActivePlacementLease::lost)
4089 {
4090 Err(remote_broker_unavailable())
4091 } else {
4092 result
4093 };
4094 if local_history_tools.is_none() {
4095 let reply = result
4096 .as_ref()
4097 .ok()
4098 .and_then(|reply| reply.as_deref())
4099 .filter(|reply| !reply.is_empty())
4100 .map(str::to_owned);
4101 if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4102 if let Err(error) = append_fenced_remote_reply(
4103 lease.actor_store.as_ref(),
4104 &lease.fence,
4105 session,
4106 &reply,
4107 )
4108 .await
4109 {
4110 result = Err(error);
4111 }
4112 }
4113 }
4114 if remote {
4115 if let Err(error) = &result {
4116 let code = match error {
4117 AgentError::LLM(message) if message.starts_with("actor event") => {
4118 "actor_event"
4119 }
4120 AgentError::LLM(message) if message.starts_with("remote_actor") => {
4121 "remote_actor"
4122 }
4123 AgentError::LLM(message) if message.starts_with("remote_environment") => {
4124 if message.len() <= 128
4125 && message.bytes().all(|byte| {
4126 byte.is_ascii_lowercase()
4127 || byte.is_ascii_digit()
4128 || byte == b'_'
4129 })
4130 {
4131 message.as_str()
4132 } else {
4133 "environment"
4134 }
4135 }
4136 AgentError::LLM(message) if message.contains("permission posture") => {
4137 "permission_posture"
4138 }
4139 AgentError::LLM(message) if message.contains("broker") => "broker",
4140 AgentError::LLM(_) => "other_llm",
4141 AgentError::WorkerUnresponsive(_) => "unresponsive",
4142 AgentError::Cancelled => "cancelled",
4143 _ => "other",
4144 };
4145 tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4146 }
4147 }
4148 if let Some(lease) = active_lease.as_mut() {
4149 let accepted_terminal = client
4150 .durable_delivery_receipt()
4151 .map(|receipt| receipt.terminal_status);
4152 if let Err(error) = lease
4153 .complete(
4154 remote_activation_finish(&result, accepted_terminal),
4155 client.has_pending_durable_terminal(),
4156 )
4157 .await
4158 {
4159 tracing::warn!(
4160 stage = "actor_complete",
4161 "remote placement admission failed"
4162 );
4163 if let (Some(binding), Some(run_id)) = (
4164 session_inbox_runtime.as_ref(),
4165 bound_activation_run_id.as_deref(),
4166 ) {
4167 binding
4168 .router
4169 .detach_delivery_sink(&job.child_session_id, run_id)
4170 .await;
4171 }
4172 drop(live_guard);
4173 drop(client); drop(active_lease);
4175 return Err(error);
4176 }
4177 }
4178 drop(active_lease);
4179 if let (Some(binding), Some(run_id)) = (
4180 session_inbox_runtime.as_ref(),
4181 bound_activation_run_id.as_deref(),
4182 ) {
4183 binding
4184 .router
4185 .detach_delivery_sink(&job.child_session_id, run_id)
4186 .await;
4187 }
4188 drop(live_guard);
4194 if client.has_pending_durable_terminal() {
4198 if let Some(run_id) = bound_activation_run_id.as_deref() {
4199 let key = (session.id.clone(), session.created_at, run_id.to_owned());
4200 let host_nested_wait_handoff = session
4201 .metadata
4202 .get("runtime.actor_nested_wait_handoff")
4203 .is_some_and(|value| value == "true");
4204 let mut pending = self.pending_durable_links.lock().await;
4205 if pending.contains_key(&key) {
4206 return Err(AgentError::LLM(
4207 "child broker terminal receipt already awaits Host checkpoint".into(),
4208 ));
4209 }
4210 pending.insert(
4211 key,
4212 PendingDurableChildLink {
4213 link: client,
4214 host_nested_wait_handoff,
4215 strict_history,
4216 strict_history_required: local_history_tools.is_some(),
4217 strict_history_committed: false,
4218 },
4219 );
4220 } else {
4221 drop(client);
4223 }
4224 } else {
4225 drop(client);
4226 }
4227
4228 if required_context.is_none()
4232 && attempt == 0
4233 && matches!(kind, PlacementKind::Local)
4234 && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4235 {
4236 tracing::warn!(
4237 "actor child {} got no first frame; reaping the worker and respawning once",
4238 job.child_session_id
4239 );
4240 actor.worker.kill().await;
4241 attempt += 1;
4242 continue;
4243 }
4244 break (result, actor);
4245 };
4246
4247 let result = if strict_remote.is_some() {
4248 result.map_err(|error| match &error {
4249 AgentError::Cancelled => error,
4250 AgentError::LLM(message) => remote_environment_error_code(message)
4251 .map(|code| AgentError::LLM(code.to_owned()))
4252 .unwrap_or_else(remote_broker_unavailable),
4253 _ => remote_broker_unavailable(),
4254 })
4255 } else {
4256 result
4257 };
4258
4259 if remote {
4263 drop(actor);
4264 } else if required_context.is_some() {
4265 if session
4266 .metadata
4267 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4268 {
4269 let service = self.ticket_service.lock().recover_poison().clone();
4270 let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4271 let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4272 .ok()
4273 .flatten()
4274 .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4275 match actor.worker.kill_confirmed().await {
4276 Ok(proof) => {
4277 if let (Some(service), Some(runtime), Some(run_id)) =
4278 (service, runtime, owned_run_id.as_deref())
4279 {
4280 if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4281 &service,
4282 &runtime,
4283 session,
4284 run_id,
4285 &proof,
4286 result.is_ok(),
4287 )
4288 .await
4289 {
4290 tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4293 }
4294 }
4295 }
4296 Err(error) => {
4297 tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4298 }
4299 }
4300 } else {
4301 actor.worker.kill().await;
4302 }
4303 } else {
4304 match &result {
4305 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4306 Err(_) => actor.worker.kill().await,
4307 }
4308 }
4309
4310 if let Some(activation) = plain_activation {
4311 return match result {
4312 Ok(Some(text)) if !text.is_empty() => {
4313 if readonly_actor {
4314 let messages = std::mem::take(&mut readonly_messages);
4315 let committed = activation
4316 .store
4317 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4318 fence: activation.fence.clone(),
4319 expected_created_at: activation.created_at,
4320 expected_conversation_generation: activation
4321 .conversation_generation
4322 .clone(),
4323 expected_messages: session.messages.clone(),
4324 expected_provider_transcript: activation
4325 .provider_transcript
4326 .clone(),
4327 messages: messages.clone(),
4328 native_groups: Vec::new(),
4329 })
4330 .await;
4331 return match committed {
4332 Ok(committed) => {
4333 *session = committed;
4334 for message in messages {
4335 let _ = event_tx
4336 .send(AgentEvent::message_appended(&session.id, &message))
4337 .await;
4338 }
4339 activation
4340 .finish_with_observer(
4341 ActorActivationFinish::Succeeded,
4342 actor_event_observer.as_deref(),
4343 )
4344 .await
4345 }
4346 Err(error) => {
4347 let _ = activation.finish(ActorActivationFinish::Failed).await;
4348 Err(AgentError::LLM(format!(
4349 "actor reply commit failed: {error}"
4350 )))
4351 }
4352 };
4353 }
4354 match activation.append_reply(session, text, &event_tx).await {
4355 Ok(()) => {
4356 activation
4357 .finish_with_observer(
4358 ActorActivationFinish::Succeeded,
4359 actor_event_observer.as_deref(),
4360 )
4361 .await
4362 }
4363 Err(error) => {
4364 let _ = activation.finish(ActorActivationFinish::Failed).await;
4366 Err(AgentError::LLM(format!(
4367 "actor reply commit failed: {error}"
4368 )))
4369 }
4370 }
4371 }
4372 other => {
4373 let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4374 ActorActivationFinish::Cancelled
4375 } else {
4376 ActorActivationFinish::Failed
4377 };
4378 activation.finish(outcome).await?;
4379 match other {
4380 Err(error) => Err(error),
4381 _ => Err(plain_actor_unsupported()),
4382 }
4383 }
4384 };
4385 }
4386
4387 match result {
4391 Ok(Some(text)) => {
4392 if !remote && local_history_tools.is_none() && !text.is_empty() {
4393 session.add_message(bamboo_agent_core::Message::assistant(text, None));
4394 }
4395 Ok(())
4396 }
4397 Ok(None) => Ok(()),
4398 Err(e) => Err(e),
4399 }
4400 }
4401}
4402
4403async fn require_unowned_glob(
4406 store: &bamboo_storage::SessionStoreV2,
4407 session: &Session,
4408) -> Result<(), AgentError> {
4409 let rel = store
4410 .resolve_rel_path(&session.id)
4411 .await
4412 .ok_or_else(|| plain_actor_unsupported())?;
4413 if !store.bamboo_home_dir().is_absolute()
4414 || rel
4415 != format!(
4416 "sessions/{}/children/{}",
4417 session.root_session_id, session.id
4418 )
4419 {
4420 return Err(plain_actor_unsupported());
4421 }
4422 let directory = store.bamboo_home_dir().join(rel);
4423 let mut checked = PathBuf::new();
4424 for component in directory.components() {
4425 if !matches!(
4426 component,
4427 std::path::Component::RootDir | std::path::Component::Normal(_)
4428 ) {
4429 return Err(plain_actor_unsupported());
4430 }
4431 checked.push(component.as_os_str());
4432 if !tokio::fs::symlink_metadata(&checked)
4433 .await
4434 .map_err(|_| plain_actor_unsupported())?
4435 .is_dir()
4436 {
4437 return Err(plain_actor_unsupported());
4438 }
4439 }
4440 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4441 match tokio::fs::symlink_metadata(directory.join(name)).await {
4442 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4443 _ => return Err(plain_actor_unsupported()),
4444 }
4445 }
4446 Ok(())
4447}
4448
4449struct PlainActorActivation {
4455 store: Arc<bamboo_storage::SessionStoreV2>,
4456 fence: ActorActivationFence,
4457 inbox_generation: u64,
4458 created_at: chrono::DateTime<chrono::Utc>,
4459 conversation_generation: bamboo_domain::ConversationGenerationObservation,
4460 provider_transcript: bamboo_domain::ProviderTranscriptState,
4461 input_consumer: SessionInboxConsumerId,
4462 input_deadline: chrono::DateTime<chrono::Utc>,
4463 actor_deadline: chrono::DateTime<chrono::Utc>,
4464 initial_release_required: bool,
4465 input_inbox: bamboo_storage::FileSessionInbox,
4466 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4467 initial_inputs: Vec<SessionInboxOwnedClaim>,
4468 initial_input_count: usize,
4469 recovering_pre_ack: bool,
4470 completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4471}
4472type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4473type PlainInitialBatch = (Vec<serde_json::Value>, Vec<SessionMessageDelivery>);
4474#[track_caller]
4475fn plain_actor_unsupported() -> AgentError {
4476 let source = std::panic::Location::caller();
4477 tracing::warn!(
4478 process_id = std::process::id(),
4479 source_file = source.file(),
4480 source_line = source.line(),
4481 source_column = source.column(),
4482 "plain Actor continuation unsupported"
4483 );
4484 AgentError::LLM("This Actor Child supports a fresh plain activation, a completed plain Child with a bounded batch of new inputs, two bounded corrections while Running, a Failed retry with one new input, an expired initial Local owner with one new input, or verified expired pre-ACK input recovery through run(reset_to_last_user=false). Other continuation is unsupported; durable history is preserved.".into())
4485}
4486fn plain_initial_history(session: &Session) -> bool {
4487 !session.messages.iter().any(|message| {
4488 matches!(message.role, Role::Assistant | Role::Tool)
4489 || message
4490 .metadata
4491 .as_ref()
4492 .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4493 }) && session.provider_transcript.is_empty()
4494 && session.session_inbox_admission().is_none()
4495 && session.pending_injected_messages().is_none()
4496}
4497fn plain_completed_history(session: &Session) -> bool {
4500 session.provider_transcript.is_empty()
4501 && session.pending_injected_messages().is_none()
4502 && session
4503 .messages
4504 .last()
4505 .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4506 && !session
4507 .messages
4508 .iter()
4509 .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4510}
4511fn pre_ack_input(
4514 session: &Session,
4515) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4516 let Some(message) = session.messages.last() else {
4517 return Ok(None);
4518 };
4519 let Some(proof) = message
4520 .metadata
4521 .as_ref()
4522 .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4523 else {
4524 return Ok(None);
4525 };
4526 let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4527 .map_err(|_| plain_actor_unsupported())?;
4528 let generation = proof
4529 .get("generation")
4530 .and_then(serde_json::Value::as_u64)
4531 .filter(|g| *g > 0)
4532 .ok_or_else(|| plain_actor_unsupported())?;
4533 if message.role != Role::User
4534 || session
4535 .messages
4536 .iter()
4537 .filter(|m| m.id == message.id)
4538 .count()
4539 != 1
4540 || !session
4541 .session_inbox_admission()
4542 .is_some_and(|cursor| cursor.contains(&id))
4543 || !session.provider_transcript.is_empty()
4544 || session.pending_injected_messages().is_some()
4545 || session
4546 .messages
4547 .iter()
4548 .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4549 {
4550 return Err(plain_actor_unsupported());
4551 }
4552 Ok(Some((id, generation)))
4553}
4554fn qualified_pre_ack_owner(
4555 entry: &bamboo_domain::ActorDirectoryEntry,
4556 now: chrono::DateTime<chrono::Utc>,
4557) -> bool {
4558 let Some(old) = entry.activation.as_ref() else {
4559 return false;
4560 };
4561 let recoverable = (entry.actor.state == ActorLogicalState::Failed
4562 && old.status == bamboo_domain::ActorActivationStatus::Failed)
4563 || (entry.actor.state == ActorLogicalState::Active
4564 && old.status.is_live()
4565 && old.lease_expires_at <= now);
4566 let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4567 placement.class == bamboo_domain::ActorPlacementClass::Local
4568 && placement
4569 .lease_id
4570 .strip_prefix("owned-initial-release-v1:")
4571 .is_some_and(|worker| {
4572 !worker.is_empty()
4573 && worker.len() <= 256
4574 && !worker.contains("..")
4575 && worker.bytes().all(|b| {
4576 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4577 })
4578 })
4579 });
4580 recoverable && trusted
4581}
4582async fn expired_pre_ack_input(
4583 inbox: &bamboo_storage::FileSessionInbox,
4584 session: &Session,
4585) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4586 let Some((id, generation)) = pre_ack_input(session)? else {
4587 return Ok(None);
4588 };
4589 let backlog = inbox
4590 .inspect(&session.id)
4591 .await
4592 .map_err(|_| plain_actor_unsupported())?;
4593 let leases = inbox
4594 .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4595 .await
4596 .map_err(|_| plain_actor_unsupported())?;
4597 if backlog.pending != 0
4598 || backlog.claimed != 1
4599 || backlog.generation != generation
4600 || backlog.oldest_generation != Some(generation)
4601 || !backlog.activation_pending()
4602 || leases.len() != 1
4603 || !leases[0].expired
4604 || leases[0].generation != generation
4605 || inbox
4606 .was_admitted(&session.id, &id)
4607 .await
4608 .map_err(|_| plain_actor_unsupported())?
4609 {
4610 return Err(plain_actor_unsupported());
4611 }
4612 Ok(Some((id, generation)))
4613}
4614impl PlainActorActivation {
4615 fn start<'a>(
4618 store: Arc<bamboo_storage::SessionStoreV2>,
4619 session: &'a Session,
4620 binding: &'a SessionInboxRuntimeBinding,
4621 run_id: Option<&'a str>,
4622 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4623 allow_failed_retry: bool,
4624 release_worker: Option<&'a str>,
4625 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4626 {
4627 Box::pin(async move {
4628 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4629 let backlog = binding
4630 .inbox
4631 .inspect(&session.id)
4632 .await
4633 .map_err(|_| plain_actor_unsupported())?;
4634 let entry = store
4635 .ensure_actor(&session.id)
4636 .await
4637 .map_err(|error| AgentError::LLM(error.to_string()))?;
4638 let config = permission_config
4639 .as_ref()
4640 .ok_or_else(|| plain_actor_unsupported())?;
4641 if entry.actor.policy_revision != Some(config.policy_revision())
4642 || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4643 {
4644 return Err(plain_actor_unsupported());
4645 }
4646 let fresh = entry.actor.current_attempt == 0
4647 && entry.actor.state == ActorLogicalState::Cold
4648 && entry.activation.is_none()
4649 && backlog.pending == 0
4650 && backlog.claimed == 0
4651 && backlog.generation == 0;
4652 let new_input = backlog.pending == 1
4653 && backlog.claimed == 0
4654 && backlog.oldest_generation == Some(backlog.generation)
4655 && backlog.activation_pending();
4656 let retry = allow_failed_retry
4657 && entry.actor.state == ActorLogicalState::Failed
4658 && entry.actor.current_attempt > 0
4659 && entry.activation.as_ref().is_some_and(|old| {
4660 old.status == bamboo_domain::ActorActivationStatus::Failed
4661 && backlog.generation > old.inbox_generation
4662 })
4663 && new_input;
4664 let new_batch = backlog.pending > 1
4668 && backlog.pending <= bamboo_domain::SessionInboxLimits::default().max_claim_batch
4669 && backlog.claimed == 0
4670 && backlog
4671 .oldest_generation
4672 .is_some_and(|oldest| oldest < backlog.generation)
4673 && backlog.activation_pending();
4674 let continuation = allow_failed_retry
4675 && release_worker.is_some()
4676 && entry.actor.state == ActorLogicalState::Cold
4677 && entry.actor.current_attempt > 0
4678 && entry.activation.as_ref().is_some_and(|old| {
4679 old.status == bamboo_domain::ActorActivationStatus::Succeeded
4680 && backlog
4681 .oldest_generation
4682 .is_some_and(|oldest| oldest > old.inbox_generation)
4683 })
4684 && (new_input || new_batch)
4685 && plain_completed_history(session);
4686 let input_inbox = bamboo_storage::FileSessionInbox::new(
4687 store.clone(),
4688 bamboo_domain::SessionInboxLimits::default(),
4689 );
4690 let now = chrono::Utc::now();
4691 let replacement = allow_failed_retry
4695 && release_worker.is_some()
4696 && entry.actor.state == ActorLogicalState::Active
4697 && qualified_pre_ack_owner(&entry, now)
4698 && entry.activation.as_ref().is_some_and(|old| {
4699 old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4700 })
4701 && new_input
4702 && plain_initial_history(session);
4703 let recovery = if allow_failed_retry
4704 && release_worker.is_some()
4705 && qualified_pre_ack_owner(&entry, now)
4706 {
4707 expired_pre_ack_input(&input_inbox, session).await?
4708 } else {
4709 None
4710 };
4711 if (recovery.is_some() || continuation || replacement)
4712 && (!entry.actor.matches_session(session)
4713 || entry.actor.project_id
4714 != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4715 {
4716 return Err(plain_actor_unsupported());
4717 }
4718 if recovery.is_none()
4719 && !continuation
4720 && !replacement
4721 && ((!fresh && !retry) || !plain_initial_history(session))
4722 {
4723 return Err(plain_actor_unsupported());
4724 }
4725 let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4726 .max_total_secs
4727 .checked_add(60)
4728 .and_then(chrono::Duration::try_seconds)
4729 .ok_or_else(|| plain_actor_unsupported())?;
4730 let expires = now
4731 .checked_add_signed(duration)
4732 .ok_or_else(|| plain_actor_unsupported())?;
4733 let input_consumer = SessionInboxConsumerId::new();
4734 let input_deadline = expires.min(now + chrono::Duration::hours(1));
4735 let recovered = if let Some((id, generation)) = &recovery {
4736 if !binding.router.owns_run(&session.id, run_id).await {
4737 return Err(plain_actor_unsupported());
4738 }
4739 let lease = SessionInboxLeaseRequest {
4740 consumer: input_consumer.clone(),
4741 now,
4742 duration: input_deadline - now,
4743 };
4744 let mut claims = input_inbox
4745 .claim_owned(&session.id, 1, Some(run_id), &lease)
4746 .await
4747 .map_err(|_| plain_actor_unsupported())?;
4748 let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4749 if claim.claim.envelope.id != *id
4750 || claim.claim.generation != *generation
4751 || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4752 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4753 {
4754 return Err(plain_actor_unsupported());
4755 }
4756 Some(claim)
4757 } else {
4758 None
4759 };
4760 let claimed = store
4761 .claim_activation(&ActorActivationClaim {
4762 actor_id: session.id.clone(),
4763 run_id: run_id.into(),
4764 lease_owner: input_consumer.as_str().into(),
4765 lease_expires_at: expires,
4766 inbox_generation: recovered
4767 .as_ref()
4768 .map_or(backlog.generation, |claim| claim.claim.generation),
4769 placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4770 class: bamboo_domain::ActorPlacementClass::Local,
4771 lease_id: format!("owned-initial-release-v1:{worker}"),
4774 slot_epoch: None,
4775 }),
4776 now,
4777 })
4778 .await
4779 .map_err(|error| AgentError::LLM(error.to_string()))?;
4780 let activation = Self {
4781 store,
4782 fence: claimed.fence(),
4783 inbox_generation: claimed.inbox_generation,
4784 created_at: session.created_at,
4785 conversation_generation: session.conversation_generation_observation(),
4786 provider_transcript: session.provider_transcript.clone(),
4787 input_consumer,
4788 input_deadline,
4789 actor_deadline: claimed.lease_expires_at,
4790 initial_release_required: release_worker.is_some(),
4791 input_inbox,
4792 permission_config,
4793 initial_inputs: recovered.into_iter().collect(),
4794 initial_input_count: if continuation {
4795 backlog.pending
4796 } else {
4797 usize::from(claimed.inbox_generation > 0)
4798 },
4799 recovering_pre_ack: recovery.is_some(),
4800 completion_router: std::sync::Mutex::new(None),
4801 };
4802 if let Err(error) = activation
4803 .store
4804 .start_activation(&activation.fence, chrono::Utc::now())
4805 .await
4806 {
4807 let _ = activation.finish(ActorActivationFinish::Failed).await;
4808 return Err(AgentError::LLM(error.to_string()));
4809 }
4810 Ok(activation)
4811 })
4812 }
4813 fn start_and_prepare<'a>(
4814 store: Arc<bamboo_storage::SessionStoreV2>,
4815 session: &'a mut Session,
4816 binding: &'a SessionInboxRuntimeBinding,
4817 run_id: Option<&'a str>,
4818 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4819 allow_failed_retry: bool,
4820 release_worker: Option<&'a str>,
4821 ) -> std::pin::Pin<
4822 Box<
4823 impl std::future::Future<Output = Result<(Self, Option<PlainInitialBatch>), AgentError>>
4824 + Send
4825 + 'a,
4826 >,
4827 > {
4828 Box::pin(async move {
4829 let mut activation = Self::start(
4830 store,
4831 session,
4832 binding,
4833 run_id,
4834 permission_config,
4835 allow_failed_retry,
4836 release_worker,
4837 )
4838 .await?;
4839 if activation.inbox_generation == 0 {
4840 return Ok((activation, None));
4841 }
4842 let prepared = async {
4843 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4844 let backlog = activation
4845 .input_inbox
4846 .inspect(&session.id)
4847 .await
4848 .map_err(|_| plain_actor_unsupported())?;
4849 let expected = if activation.recovering_pre_ack {
4850 (0, 1)
4851 } else {
4852 (activation.initial_input_count, 0)
4853 };
4854 if (backlog.pending, backlog.claimed) != expected
4855 || backlog.generation != activation.inbox_generation
4856 {
4857 return Err(plain_actor_unsupported());
4858 }
4859 let claims = if activation.initial_inputs.is_empty() {
4860 activation
4864 .claim_inputs(binding, session, run_id, activation.initial_input_count)
4865 .await?
4866 } else {
4867 activation.initial_inputs.clone()
4868 };
4869 if claims.len() != activation.initial_input_count
4870 || claims.last().map(|claim| claim.claim.generation)
4871 != Some(activation.inbox_generation)
4872 || claims
4873 .windows(2)
4874 .any(|pair| pair[0].claim.generation >= pair[1].claim.generation)
4875 {
4876 return Err(plain_actor_unsupported());
4877 }
4878 let mut prefix = None;
4883 let mut deliveries = Vec::with_capacity(claims.len());
4884 for claim in &claims {
4885 let (messages, delivery) =
4886 activation.checkpoint_input(session, run_id, claim).await?;
4887 prefix.get_or_insert(messages);
4888 deliveries.push(delivery);
4889 }
4890 activation.initial_inputs = claims;
4891 Ok((prefix.ok_or_else(|| plain_actor_unsupported())?, deliveries))
4892 }
4893 .await;
4894 match prepared {
4895 Ok(delivery) => Ok((activation, Some(delivery))),
4896 Err(error) => {
4897 activation.finish(ActorActivationFinish::Failed).await?;
4898 Err(error)
4899 }
4900 }
4901 })
4902 }
4903 fn claim_input<'a>(
4904 &'a self,
4905 binding: &'a SessionInboxRuntimeBinding,
4906 session: &'a Session,
4907 run_id: &'a str,
4908 ) -> std::pin::Pin<
4909 Box<
4910 impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4911 + Send
4912 + 'a,
4913 >,
4914 > {
4915 Box::pin(async move { Ok(self.claim_inputs(binding, session, run_id, 1).await?.pop()) })
4916 }
4917 fn claim_inputs<'a>(
4918 &'a self,
4919 binding: &'a SessionInboxRuntimeBinding,
4920 session: &'a Session,
4921 run_id: &'a str,
4922 limit: usize,
4923 ) -> std::pin::Pin<
4924 Box<
4925 impl std::future::Future<Output = Result<Vec<SessionInboxOwnedClaim>, AgentError>>
4926 + Send
4927 + 'a,
4928 >,
4929 > {
4930 Box::pin(async move {
4931 if limit == 0 || limit > bamboo_domain::SessionInboxLimits::default().max_claim_batch {
4932 return Err(plain_actor_unsupported());
4933 }
4934 if !self
4935 .input_inbox
4936 .inspect(&session.id)
4937 .await
4938 .map_err(|_| plain_actor_unsupported())?
4939 .activation_pending()
4940 {
4941 return Ok(Vec::new());
4942 }
4943 if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4944 return Err(plain_actor_unsupported());
4945 }
4946 let now = chrono::Utc::now();
4947 if now >= self.input_deadline {
4948 return Err(plain_actor_unsupported());
4949 }
4950 let lease = SessionInboxLeaseRequest {
4951 consumer: self.input_consumer.clone(),
4952 now,
4953 duration: self.input_deadline - now,
4954 };
4955 let claims = self
4956 .input_inbox
4957 .claim_owned(&session.id, limit, Some(run_id), &lease)
4958 .await
4959 .map_err(|error| {
4960 tracing::warn!(%error, "Actor correction claim unconfirmed");
4961 AgentError::LLM(
4962 "Actor correction claim unconfirmed; durable input is preserved".into(),
4963 )
4964 })?;
4965 for claim in &claims {
4966 if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4967 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4968 {
4969 self.input_inbox
4970 .fail_owned(
4971 &session.id,
4972 claim,
4973 &SessionInboxFailureReport {
4974 now: chrono::Utc::now(),
4975 error_code: "unsupported_actor_input".into(),
4976 },
4977 )
4978 .await
4979 .map_err(|error| {
4980 tracing::warn!(%error, "Actor correction failure report unconfirmed");
4981 AgentError::LLM(
4982 "Actor correction failure report unconfirmed; durable input is preserved".into(),
4983 )
4984 })?;
4985 return Err(plain_actor_unsupported());
4986 }
4987 }
4988 Ok(claims)
4989 })
4990 }
4991 fn checkpoint_input<'a>(
4992 &'a self,
4993 session: &'a mut Session,
4994 run_id: &'a str,
4995 claim: &'a SessionInboxOwnedClaim,
4996 ) -> std::pin::Pin<
4997 Box<
4998 impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4999 >,
5000 > {
5001 Box::pin(async move {
5002 let request = bamboo_storage::ActorInputCheckpoint {
5005 fence: self.fence.clone(),
5006 expected_created_at: self.created_at,
5007 expected_conversation_generation: self.conversation_generation.clone(),
5008 claim: claim.clone(),
5009 expected_messages: session.messages.clone(),
5010 expected_provider_transcript: session.provider_transcript.clone(),
5011 expected_admission: session.session_inbox_admission().cloned(),
5012 };
5013 let mut retries = 0;
5014 let committed = loop {
5015 let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
5016 if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
5017 && retries < 3
5018 {
5019 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5025 retries += 1;
5026 continue;
5027 }
5028 break result;
5029 }.map_err(|error| {
5030 tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
5031 AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
5032 })?;
5033 if self.recovering_pre_ack
5034 && committed.status
5035 != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5036 {
5037 return Err(plain_actor_unsupported());
5038 }
5039 let messages = session
5040 .messages
5041 .iter()
5042 .map(|message| {
5043 let mut worker = message.clone();
5044 if committed.status
5045 == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5046 && worker.id == committed.envelope.id.as_str()
5047 {
5048 worker
5049 .metadata
5050 .as_mut()
5051 .and_then(serde_json::Value::as_object_mut)
5052 .ok_or_else(|| plain_actor_unsupported())?
5053 .remove("_bamboo_owned_input_checkpoint");
5054 if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
5055 {
5056 return Err(plain_actor_unsupported());
5057 }
5058 }
5059 serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
5060 })
5061 .collect::<Result<Vec<_>, _>>()?;
5062 *session = committed.session;
5063 Ok((
5064 messages,
5065 SessionMessageDelivery {
5066 target_session_id: session.id.clone(),
5067 envelope: committed.envelope,
5068 canonical_claim_generation: committed.generation,
5069 activation_run_id: run_id.into(),
5070 activation_policy: claim.claim.activation_policy,
5071 },
5072 ))
5073 })
5074 }
5075 fn append_reply<'a>(
5076 &'a self,
5077 session: &'a mut Session,
5078 text: String,
5079 event_tx: &'a mpsc::Sender<AgentEvent>,
5080 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5081 {
5082 Box::pin(async move {
5083 let message = bamboo_agent_core::Message::assistant(text, None);
5084 let request = bamboo_storage::ActorTranscriptAppend {
5085 fence: self.fence.clone(),
5086 expected_created_at: self.created_at,
5087 expected_conversation_generation: self.conversation_generation.clone(),
5088 expected_messages: session.messages.clone(),
5089 expected_provider_transcript: self.provider_transcript.clone(),
5090 messages: vec![message.clone()],
5091 native_groups: Vec::new(),
5092 };
5093 let mut retries = 0;
5094 *session = loop {
5095 let result = self.store.append_actor_transcript(request.clone()).await;
5096 if matches!(
5097 &result,
5098 Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5099 ) && retries < 3
5100 {
5101 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5106 retries += 1;
5107 continue;
5108 }
5109 break result;
5110 }
5111 .map_err(|error| {
5112 tracing::warn!(
5113 %error,
5114 "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5115 );
5116 AgentError::LLM(format!("actor reply commit failed: {error}"))
5117 })?;
5118 let _ = event_tx
5119 .send(AgentEvent::message_appended(&session.id, &message))
5120 .await;
5121 Ok(())
5122 })
5123 }
5124 fn continue_input<'a>(
5128 &'a self,
5129 client: &'a mut dyn bamboo_subagent::ChildLink,
5130 binding: &'a SessionInboxRuntimeBinding,
5131 session: &'a mut Session,
5132 run_id: &'a str,
5133 run: Option<(&'a RunSpec, &'a AtomicU64)>,
5134 expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5135 text: String,
5136 event_tx: &'a mpsc::Sender<AgentEvent>,
5137 ) -> std::pin::Pin<
5138 Box<
5139 impl std::future::Future<
5140 Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5141 > + Send
5142 + 'a,
5143 >,
5144 > {
5145 Box::pin(async move {
5146 let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5147 return Ok(None);
5148 };
5149 let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5150 self.append_reply(session, text, event_tx).await?;
5151 let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5152 self.refresh_continuation_posture(session, run, expected_posture)?;
5154 let epoch = epochs
5155 .fetch_add(1, Ordering::Relaxed)
5156 .checked_add(1)
5157 .ok_or_else(|| plain_actor_unsupported())?;
5158 let mut next = run.clone();
5159 next.messages = messages;
5160 next.initial_session_messages = vec![delivery];
5161 next.execution_epoch = epoch;
5162 client.send(ParentFrame::Run(next)).await.map_err(|_| {
5163 AgentError::LLM(
5164 "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5165 )
5166 })?;
5167 Ok(Some((claim, epoch)))
5168 })
5169 }
5170 fn refresh_continuation_posture(
5171 &self,
5172 session: &Session,
5173 run: &RunSpec,
5174 expected: &mut Option<ExpectedPermissionPosture>,
5175 ) -> Result<(), AgentError> {
5176 let Some(expected) = expected.as_mut() else {
5177 return Err(plain_actor_unsupported());
5178 };
5179 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5180 .ok_or_else(|| plain_actor_unsupported())?;
5181 if audit.policy_revision != expected.policy_revision
5182 || audit.resolution != expected.resolution
5183 || audit.executor_mapping != expected.executor_mapping
5184 {
5185 return Err(plain_actor_unsupported());
5186 }
5187 match (&self.permission_config, &run.permission_policy) {
5188 (Some(config), Some(policy)) => {
5189 ensure_no_active_scoped_session_denies(config, &session.id)?;
5190 if config.policy_revision() != policy.revision
5191 || policy.revision != audit.policy_revision
5192 || policy.session_id != session.id
5193 || policy.workspace_path != session.workspace
5194 || policy.inherit_session_grants
5195 || serde_json::to_value(config.to_serializable())
5196 .map_err(|_| plain_actor_unsupported())?
5197 != policy.policy
5198 {
5199 return Err(plain_actor_unsupported());
5200 }
5201 }
5202 (None, None) => {}
5203 _ => return Err(plain_actor_unsupported()),
5204 }
5205 expected.expected_audit_revision = Some(audit.audit_revision);
5206 Ok(())
5207 }
5208 fn release_initial_input<'a>(
5209 &'a self,
5210 binding: &'a SessionInboxRuntimeBinding,
5211 session: &'a Session,
5212 run: &'a RunSpec,
5213 epoch: u64,
5214 claim: &'a SessionInboxOwnedClaim,
5215 request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5216 expected: &'a mut Option<ExpectedPermissionPosture>,
5217 previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5218 ) -> std::pin::Pin<
5219 Box<
5220 impl std::future::Future<
5221 Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5222 > + Send
5223 + 'a,
5224 >,
5225 > {
5226 Box::pin(async move {
5227 if !self.initial_release_required {
5228 return Err(plain_actor_unsupported());
5229 }
5230 let delivery = SessionMessageDelivery {
5231 target_session_id: session.id.clone(),
5232 envelope: claim.claim.envelope.clone(),
5233 canonical_claim_generation: claim.claim.generation,
5234 activation_run_id: self.fence.run_id.clone(),
5235 activation_policy: claim.claim.activation_policy,
5236 };
5237 let mut current_run = run.clone();
5238 current_run.execution_epoch = epoch;
5239 let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5240 ¤t_run,
5241 &delivery,
5242 request.nonce.clone(),
5243 )
5244 .map_err(|_| plain_actor_unsupported())?;
5245 if &exact != request
5246 || request.child_id != session.id
5247 || request.created_at != session.created_at
5248 || request.spawn_depth != session.spawn_depth
5249 || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5250 || request.root_id != session.root_session_id
5251 || request.project_id != project_id_for_actor_run(session)?
5252 || !binding
5253 .router
5254 .owns_run(&session.id, &self.fence.run_id)
5255 .await
5256 {
5257 return Err(plain_actor_unsupported());
5258 }
5259 let deadline = self
5260 .actor_deadline
5261 .min(self.input_deadline)
5262 .min(claim.lease.expires_at);
5263 if chrono::Utc::now() >= deadline {
5264 return Err(plain_actor_unsupported());
5265 }
5266 self.store
5267 .validate_fence(&self.fence, chrono::Utc::now())
5268 .await
5269 .map_err(|_| plain_actor_unsupported())?;
5270 let actual =
5271 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5272 .await
5273 .map_err(|_| plain_actor_unsupported())?
5274 .ok_or_else(|| plain_actor_unsupported())?;
5275 if actual.created_at != session.created_at
5276 || actual.root_session_id != session.root_session_id
5277 || actual.parent_session_id != session.parent_session_id
5278 || actual.spawn_depth != session.spawn_depth
5279 || project_id_for_actor_run(&actual)? != request.project_id
5280 || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5281 != serde_json::to_value(&session.messages)
5282 .map_err(|_| plain_actor_unsupported())?
5283 || serde_json::to_value(&actual.provider_transcript)
5284 .map_err(|_| plain_actor_unsupported())?
5285 != serde_json::to_value(&session.provider_transcript)
5286 .map_err(|_| plain_actor_unsupported())?
5287 || serde_json::to_value(actual.session_inbox_admission())
5288 .map_err(|_| plain_actor_unsupported())?
5289 != serde_json::to_value(session.session_inbox_admission())
5290 .map_err(|_| plain_actor_unsupported())?
5291 {
5292 return Err(plain_actor_unsupported());
5293 }
5294 self.refresh_continuation_posture(&actual, ¤t_run, expected)?;
5295 if let Some(previous) = previous {
5296 if !previous.permits(request, chrono::Utc::now())
5297 || previous.expires_at != deadline
5298 || !self
5299 .input_inbox
5300 .was_admitted(&session.id, &claim.claim.envelope.id)
5301 .await
5302 .map_err(|_| plain_actor_unsupported())?
5303 {
5304 return Err(plain_actor_unsupported());
5305 }
5306 } else {
5307 let checkpoint = bamboo_storage::ActorInputCheckpoint {
5310 fence: self.fence.clone(),
5311 expected_created_at: self.created_at,
5312 expected_conversation_generation: self.conversation_generation.clone(),
5313 claim: claim.clone(),
5314 expected_messages: actual.messages.clone(),
5315 expected_provider_transcript: actual.provider_transcript.clone(),
5316 expected_admission: actual.session_inbox_admission().cloned(),
5317 };
5318 let mut retries = 0;
5319 loop {
5320 let result = self
5321 .input_inbox
5322 .checkpoint_actor_input(checkpoint.clone())
5323 .await;
5324 if matches!(
5325 &result,
5326 Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5327 ) && retries < 3
5328 {
5329 tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5333 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5334 retries += 1;
5335 continue;
5336 }
5337 result.map_err(|error| {
5338 tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5339 plain_actor_unsupported()
5340 })?;
5341 break;
5342 }
5343 self.confirm_input(
5344 binding,
5345 &actual,
5346 &self.fence.run_id,
5347 claim,
5348 &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5349 target_session_id: request.child_id.clone(),
5350 envelope_id: request.envelope_id.clone(),
5351 canonical_claim_generation: request.generation,
5352 activation_run_id: request.activation_run_id.clone(),
5353 },
5354 )
5355 .await?;
5356 }
5357 if chrono::Utc::now() >= deadline
5360 || !binding
5361 .router
5362 .owns_run(&session.id, &self.fence.run_id)
5363 .await
5364 {
5365 return Err(plain_actor_unsupported());
5366 }
5367 self.store
5368 .validate_fence(&self.fence, chrono::Utc::now())
5369 .await
5370 .map_err(|_| plain_actor_unsupported())?;
5371 let final_session =
5372 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5373 .await
5374 .map_err(|_| plain_actor_unsupported())?
5375 .ok_or_else(|| plain_actor_unsupported())?;
5376 self.store
5377 .validate_fence(&self.fence, chrono::Utc::now())
5378 .await
5379 .map_err(|_| plain_actor_unsupported())?;
5380 self.refresh_continuation_posture(&final_session, ¤t_run, expected)?;
5381 if chrono::Utc::now() >= deadline {
5382 return Err(plain_actor_unsupported());
5383 }
5384 Ok(bamboo_subagent::proto::InitialInputRelease {
5385 request: request.clone(),
5386 expires_at: deadline,
5387 })
5388 })
5389 }
5390 async fn confirm_input(
5391 &self,
5392 binding: &SessionInboxRuntimeBinding,
5393 session: &Session,
5394 run_id: &str,
5395 claim: &SessionInboxOwnedClaim,
5396 confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5397 ) -> Result<(), AgentError> {
5398 if confirmation.target_session_id != session.id
5399 || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5400 || confirmation.canonical_claim_generation != claim.claim.generation
5401 || confirmation.activation_run_id != run_id
5402 || run_id != self.fence.run_id
5403 || !binding.router.owns_run(&session.id, run_id).await
5404 {
5405 return Err(plain_actor_unsupported());
5406 }
5407 self.input_inbox
5408 .ack_owned(&session.id, claim, chrono::Utc::now())
5409 .await
5410 .map_err(|error| {
5411 tracing::warn!(%error, "Actor correction ACK unresolved");
5412 AgentError::LLM(
5413 "Actor correction ACK unresolved; durable input is preserved".into(),
5414 )
5415 })
5416 }
5417 async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5418 self.finish_with_observer(outcome, None).await
5419 }
5420
5421 async fn finish_with_observer(
5422 &self,
5423 outcome: ActorActivationFinish,
5424 observer: Option<&dyn ActorEventObserver>,
5425 ) -> Result<(), AgentError> {
5426 let committed = self
5427 .store
5428 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5429 .await
5430 .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5431 let router = self.completion_router.lock().recover_poison().take();
5434 if let (Some(observer), Some(router)) = (observer, router) {
5435 match self.store.inspect_actor(&self.fence.actor_id).await {
5439 Ok(entry) => match router.into_host_completion(&entry, &committed) {
5440 Ok(event) => observer.publish(event),
5441 Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5442 "Actor completion notification superseded"),
5443 },
5444 Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5445 "Actor completion committed; notification snapshot unavailable"),
5446 }
5447 }
5448 Ok(())
5449 }
5450}
5451fn local_tool_history_unsupported() -> AgentError {
5452 AgentError::LLM("local_tool_history_unsupported".into())
5453}
5454
5455#[derive(Clone, Copy)]
5456enum LocalToolTerminal<'a> {
5457 Completed(Option<&'a str>),
5458 Cancelled,
5461}
5462
5463impl LocalToolTerminal<'_> {
5464 fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5465 match self {
5466 Self::Completed(_) => !cancel.is_cancelled(),
5467 Self::Cancelled => cancel.is_cancelled(),
5468 }
5469 }
5470}
5471
5472#[derive(Default)]
5473struct LocalToolCollector {
5474 messages: Option<Vec<bamboo_agent_core::Message>>,
5475 starts: HashMap<String, (String, serde_json::Value)>,
5476 outcomes: HashMap<String, (bool, String)>,
5477 ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5480}
5481
5482impl LocalToolCollector {
5483 fn ticket_event(
5484 &self,
5485 value: &serde_json::Value,
5486 committed: &HashMap<String, serde_json::Value>,
5487 ) -> Result<bool, AgentError> {
5488 if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5489 return Ok(true);
5490 }
5491 match serde_json::from_value::<AgentEvent>(value.clone())
5492 .map_err(|_| local_tool_history_unsupported())?
5493 {
5494 AgentEvent::ToolStart { tool_name, .. }
5495 if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5496 {
5497 Err(local_tool_history_unsupported())
5498 }
5499 AgentEvent::ToolComplete {
5500 tool_call_id,
5501 result,
5502 } if result.success => {
5503 let Some((name, args)) = self.starts.get(&tool_call_id) else {
5504 return Err(local_tool_history_unsupported());
5505 };
5506 let valid = if name == "Task" {
5507 committed.get(&tool_call_id) == Some(args)
5508 } else {
5509 committed.get(&tool_call_id).is_some_and(|proof| {
5510 proof["file_tool"] == *name
5511 && proof["arguments"] == *args
5512 && proof["result"] == result.result
5513 })
5514 };
5515 if !valid {
5516 return Err(AgentError::LLM(
5517 "LocalPlan success has no exact Host receipt".into(),
5518 ));
5519 }
5520 Ok(true)
5521 }
5522 AgentEvent::TaskListUpdated { .. } => Ok(false),
5524 _ => Ok(true),
5525 }
5526 }
5527
5528 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5529 use bamboo_subagent::proto::LocalToolMessages;
5530 if self.messages.is_some() {
5531 return Err(local_tool_history_unsupported());
5532 }
5533 if value["type"] == LocalToolMessages::TYPE {
5534 let observation: LocalToolMessages = serde_json::from_value(value.clone())
5535 .map_err(|_| local_tool_history_unsupported())?;
5536 self.messages = Some(
5537 observation
5538 .validate()
5539 .map_err(|_| local_tool_history_unsupported())?,
5540 );
5541 return Ok(false);
5542 }
5543 let event: AgentEvent =
5544 serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5545 match event {
5546 AgentEvent::ToolStart {
5547 tool_call_id,
5548 tool_name,
5549 arguments,
5550 } => {
5551 if !arguments.is_object()
5552 || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5553 || tool_call_id.is_empty()
5554 || tool_call_id.len() > 128
5555 || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5556 || self.outcomes.contains_key(&tool_call_id)
5557 || self
5558 .starts
5559 .insert(tool_call_id, (tool_name, arguments))
5560 .is_some()
5561 {
5562 return Err(local_tool_history_unsupported());
5563 }
5564 }
5565 AgentEvent::ToolComplete {
5566 tool_call_id,
5567 result,
5568 } => {
5569 if !result.images.is_empty() {
5570 return Err(local_tool_history_unsupported());
5571 }
5572 self.outcome(tool_call_id, result.success, result.result)?;
5573 }
5574 AgentEvent::ToolError {
5575 tool_call_id,
5576 error,
5577 } => {
5578 self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5579 }
5580 AgentEvent::ToolToken { tool_call_id, .. } => {
5581 if !self.starts.contains_key(&tool_call_id)
5582 || self.outcomes.contains_key(&tool_call_id)
5583 {
5584 return Err(local_tool_history_unsupported());
5585 }
5586 }
5587 AgentEvent::ToolLifecycle {
5588 tool_call_id,
5589 tool_name,
5590 is_mutating,
5591 auto_approved,
5592 phase,
5593 ..
5594 } => {
5595 if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5596 || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5597 || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5598 || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5599 {
5600 return Err(local_tool_history_unsupported());
5601 }
5602 }
5603 AgentEvent::ReasoningToken { .. } => {
5604 }
5608 AgentEvent::ContextSummarized { .. }
5609 | AgentEvent::ContextArchived { .. }
5610 | AgentEvent::ContextCompressionStatus { .. }
5611 | AgentEvent::NeedClarification { .. }
5612 | AgentEvent::SubAgentStarted { .. }
5613 | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5614 AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5616 return Ok(false)
5617 }
5618 _ => {}
5619 }
5620 Ok(true)
5621 }
5622
5623 fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5624 if id.is_empty()
5625 || id.len() > 128
5626 || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5627 || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5628 || self.outcomes.insert(id, (success, text)).is_some()
5629 {
5630 return Err(local_tool_history_unsupported());
5631 }
5632 Ok(())
5633 }
5634
5635 fn suffix(
5636 self,
5637 host: &Session,
5638 tools: &[String],
5639 read_only: bool,
5640 terminal: Option<&str>,
5641 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5642 self.suffix_for_terminal(
5643 host,
5644 tools,
5645 read_only,
5646 LocalToolTerminal::Completed(terminal),
5647 )
5648 }
5649
5650 fn suffix_for_terminal(
5651 self,
5652 host: &Session,
5653 tools: &[String],
5654 read_only: bool,
5655 terminal: LocalToolTerminal<'_>,
5656 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5657 use bamboo_subagent::proto::LocalToolMessages;
5658 let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5659 if cancelled
5660 && (!read_only
5661 || tools.is_empty()
5662 || tools
5663 .iter()
5664 .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5665 || self.ticket_yield.is_some())
5666 {
5667 return Err(local_tool_history_unsupported());
5668 }
5669 let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5670 let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5671 let prefix: Vec<_> = host
5672 .messages
5673 .iter()
5674 .filter(|m| m.role != Role::System)
5675 .collect();
5676 if !host.provider_transcript.groups().is_empty()
5677 || candidate.len() < prefix.len()
5678 || (!cancelled && candidate.len() == prefix.len())
5679 || serde_json::to_value(&candidate[..prefix.len()])
5680 .map_err(|_| local_tool_history_unsupported())?
5681 != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5682 {
5683 return Err(local_tool_history_unsupported());
5684 }
5685 let mut ids = std::collections::HashSet::new();
5686 let mut calls = std::collections::HashSet::new();
5687 for message in &host.messages {
5688 if !ids.insert(message.id.clone()) {
5689 return Err(local_tool_history_unsupported());
5690 }
5691 for call in message.tool_calls.iter().flatten() {
5692 calls.insert(call.id.clone());
5693 }
5694 if let Some(id) = &message.tool_call_id {
5695 calls.insert(id.clone());
5696 }
5697 }
5698 let suffix: Vec<_> = candidate[prefix.len()..]
5699 .iter()
5700 .map(|message| (**message).clone())
5701 .collect();
5702 let mut pending = HashMap::new();
5703 let mut used = std::collections::HashSet::new();
5704 for message in &suffix {
5705 if !ids.insert(message.id.clone()) || message.never_compress {
5706 return Err(local_tool_history_unsupported());
5707 }
5708 match message.role {
5709 Role::Assistant => {
5710 if !pending.is_empty()
5711 || message.metadata.is_some()
5712 || message.tool_call_id.is_some()
5713 || message.tool_success.is_some()
5714 {
5715 return Err(local_tool_history_unsupported());
5716 }
5717 if let Some(batch) = &message.tool_calls {
5718 if batch.is_empty() {
5719 return Err(local_tool_history_unsupported());
5720 }
5721 for call in batch {
5722 let arguments =
5723 serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5724 .ok()
5725 .filter(|arguments| arguments.is_object())
5726 .ok_or_else(local_tool_history_unsupported)?;
5727 if call.id.is_empty()
5728 || call.id.len() > 128
5729 || call.tool_type != "function"
5730 || !calls.insert(call.id.clone())
5731 || calls.len() > LocalToolMessages::MAX_MESSAGES
5732 || !matches!(
5733 call.function.name.as_str(),
5734 "Read" | "Glob" | "Write" | "Task"
5735 )
5736 || (cancelled
5737 && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5738 || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5739 name != &call.function.name || actual != &arguments
5740 })
5741 {
5742 return Err(local_tool_history_unsupported());
5743 }
5744 pending.insert(call.id.clone(), call.function.name.clone());
5745 }
5746 } else if message.content.trim().is_empty() {
5747 return Err(local_tool_history_unsupported());
5748 }
5749 }
5750 Role::Tool => {
5751 let id = message
5752 .tool_call_id
5753 .as_ref()
5754 .ok_or_else(local_tool_history_unsupported)?;
5755 let name = pending
5756 .remove(id)
5757 .ok_or_else(local_tool_history_unsupported)?;
5758 let success = message
5759 .tool_success
5760 .ok_or_else(local_tool_history_unsupported)?;
5761 if message.tool_calls.is_some()
5762 || message.phase.is_some()
5763 || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5764 || !used.insert(id.clone())
5765 || used.len() > LocalToolMessages::MAX_PAIRS
5766 {
5767 return Err(local_tool_history_unsupported());
5768 }
5769 let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5770 if denied_write {
5771 if success
5772 || !(message.content.contains("native_tool_ceiling_denied")
5773 || message
5774 .content
5775 .contains("not callable at the current conversation position"))
5776 {
5777 return Err(local_tool_history_unsupported());
5778 }
5779 } else if !tools.contains(&name)
5780 || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5781 {
5782 return Err(local_tool_history_unsupported());
5783 }
5784 if let Some(metadata) = &message.metadata {
5785 let object = metadata
5786 .as_object()
5787 .ok_or_else(local_tool_history_unsupported)?;
5788 if object.len() != 5
5789 || metadata["elapsed_ms"].as_u64().is_none()
5790 || metadata["tool_name"].as_str() != Some(name.as_str())
5791 || metadata["is_mutating"].as_bool()
5792 != Some(matches!(name.as_str(), "Write" | "Task"))
5793 || metadata["auto_approved"].as_bool()
5794 != Some(!matches!(name.as_str(), "Write" | "Task"))
5795 || metadata["success"].as_bool() != Some(success)
5796 {
5797 return Err(local_tool_history_unsupported());
5798 }
5799 }
5800 }
5801 _ => return Err(local_tool_history_unsupported()),
5802 }
5803 }
5804 let last = suffix.last();
5805 let completed_text = match terminal {
5806 LocalToolTerminal::Completed(text) => text,
5807 LocalToolTerminal::Cancelled => None,
5808 };
5809 let question_terminal =
5810 self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5811 last.is_some_and(|last| last.role == Role::Tool
5812 && last.tool_call_id.as_ref() == Some(id)
5813 && last.tool_success == Some(true)
5814 && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5815 && completed_text.is_none_or(str::is_empty)
5816 && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5817 == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5818 });
5819 let report_terminal = last.is_some_and(|last| {
5820 last.role == Role::Assistant
5821 && last.tool_calls.is_none()
5822 && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5823 && completed_text == Some(last.content.as_str())
5824 });
5825 if !pending.is_empty()
5826 || used.len() != self.outcomes.len()
5827 || self.starts.keys().any(|id| !used.contains(id))
5828 || !(cancelled || report_terminal || question_terminal)
5829 {
5830 return Err(local_tool_history_unsupported());
5831 }
5832 Ok(suffix)
5833 }
5834}
5835
5836async fn validate_local_tool_checkpoint(
5837 session: &Session,
5838 binding: &SessionInboxRuntimeBinding,
5839 run_id: &str,
5840 terminal: LocalToolTerminal<'_>,
5841 cancel: &CancellationToken,
5842) -> Result<(), AgentError> {
5843 if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5844 return Err(local_tool_history_unsupported());
5845 }
5846 if matches!(terminal, LocalToolTerminal::Cancelled) {
5847 let backlog = binding
5848 .inbox
5849 .inspect(&session.id)
5850 .await
5851 .map_err(|_| local_tool_history_unsupported())?;
5852 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5853 return Err(local_tool_history_unsupported());
5856 }
5857 }
5858 Ok(())
5859}
5860
5861async fn commit_local_tool_history(
5862 collector: LocalToolCollector,
5863 session: &mut Session,
5864 binding: &SessionInboxRuntimeBinding,
5865 run_id: &str,
5866 selection: (&[String], bool),
5867 terminal: LocalToolTerminal<'_>,
5868 cancel: &CancellationToken,
5869) -> Result<(), AgentError> {
5870 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5871 let latest = binding
5872 .storage
5873 .load_session(&session.id)
5874 .await
5875 .map_err(|_| local_tool_history_unsupported())?
5876 .ok_or_else(local_tool_history_unsupported)?;
5877 if latest.id != session.id
5878 || latest.kind != session.kind
5879 || latest.created_at != session.created_at
5880 || latest.root_session_id != session.root_session_id
5881 || latest.parent_session_id != session.parent_session_id
5882 || latest.spawn_depth != session.spawn_depth
5883 || latest.project_id_meta() != session.project_id_meta()
5884 || session.check_conversation_generation(&latest).is_err()
5885 || serde_json::to_value(&latest.provider_transcript)
5886 .map_err(|_| local_tool_history_unsupported())?
5887 != serde_json::to_value(&session.provider_transcript)
5888 .map_err(|_| local_tool_history_unsupported())?
5889 {
5890 return Err(local_tool_history_unsupported());
5891 }
5892 let suffix = match terminal {
5893 LocalToolTerminal::Completed(text) => {
5894 collector.suffix(&latest, selection.0, selection.1, text)?
5895 }
5896 LocalToolTerminal::Cancelled => {
5897 collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5898 }
5899 };
5900 let before = session.clone();
5901 let mut staged = session.clone();
5902 staged.messages = latest.messages.clone();
5903 staged.messages.extend(suffix.iter().cloned());
5904 staged.updated_at = chrono::Utc::now();
5905 let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5906 Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5907 } else {
5908 None
5909 };
5910 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5911 binding
5912 .persistence
5913 .checkpoint_runtime_session(&mut staged)
5914 .await
5915 .map_err(|_| local_tool_history_unsupported())?;
5916 let saved = binding
5917 .storage
5918 .load_session(&session.id)
5919 .await
5920 .map_err(|_| local_tool_history_unsupported())?
5921 .ok_or_else(local_tool_history_unsupported)?;
5922 if session.check_conversation_generation(&saved).is_err()
5923 || serde_json::to_value(&saved.provider_transcript)
5924 .map_err(|_| local_tool_history_unsupported())?
5925 != serde_json::to_value(&latest.provider_transcript)
5926 .map_err(|_| local_tool_history_unsupported())?
5927 {
5928 return Err(local_tool_history_unsupported());
5929 }
5930 let start = match suffix.first() {
5931 Some(first) => saved
5932 .messages
5933 .iter()
5934 .position(|message| message.id == first.id)
5935 .ok_or_else(local_tool_history_unsupported)?,
5936 None => latest.messages.len(),
5937 };
5938 if cancelled_messages.as_ref().is_some_and(|expected| {
5939 serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5940 }) {
5941 return Err(local_tool_history_unsupported());
5944 }
5945 if saved.messages.len() < latest.messages.len()
5946 || serde_json::to_value(&saved.messages[..latest.messages.len()])
5947 .map_err(|_| local_tool_history_unsupported())?
5948 != serde_json::to_value(&latest.messages)
5949 .map_err(|_| local_tool_history_unsupported())?
5950 || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5951 .map_err(|_| local_tool_history_unsupported())?
5952 != serde_json::to_value(Some(&suffix[..]))
5953 .map_err(|_| local_tool_history_unsupported())?
5954 {
5955 return Err(local_tool_history_unsupported());
5956 }
5957 let expected = latest.messages.iter().chain(&suffix);
5960 for message in expected {
5961 let matches: Vec<_> = saved
5962 .messages
5963 .iter()
5964 .filter(|current| current.id == message.id)
5965 .collect();
5966 if matches.len() != 1
5967 || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5968 != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5969 {
5970 *session = before;
5971 return Err(local_tool_history_unsupported());
5972 }
5973 }
5974 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5975 staged.messages = saved.messages;
5976 *session = staged;
5977 Ok(())
5978}
5979
5980#[derive(Default)]
5981struct ReadOnlyActorCollector {
5982 start: Option<(String, serde_json::Value)>,
5983 result: Option<String>,
5984 messages: Option<Vec<bamboo_agent_core::Message>>,
5985}
5986impl ReadOnlyActorCollector {
5987 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5988 if self.messages.is_some() {
5989 return Err(plain_actor_unsupported());
5990 }
5991 if value["type"] == "owned_readonly_transcript" {
5992 let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5993 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5994 tail.validate().map_err(|_| plain_actor_unsupported())?;
5995 let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5996 let typed: Vec<bamboo_agent_core::Message> = messages
5997 .iter()
5998 .map(|raw| {
5999 let message: bamboo_agent_core::Message =
6000 serde_json::from_value(raw.clone())
6001 .map_err(|_| plain_actor_unsupported())?;
6002 if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
6003 != *raw
6004 {
6005 return Err(plain_actor_unsupported());
6006 }
6007 Ok(message)
6008 })
6009 .collect::<Result<_, _>>()?;
6010 let (id, args) = self
6011 .start
6012 .as_ref()
6013 .ok_or_else(|| plain_actor_unsupported())?;
6014 let call = typed[0]
6015 .tool_calls
6016 .as_ref()
6017 .and_then(|calls| {
6018 if calls.len() == 1 {
6019 calls.first()
6020 } else {
6021 None
6022 }
6023 })
6024 .ok_or_else(|| plain_actor_unsupported())?;
6025 if call.id != *id
6026 || call.function.name != "Glob"
6027 || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
6028 .map_err(|_| plain_actor_unsupported())?
6029 != *args
6030 || typed[1].tool_call_id.as_ref() != Some(id)
6031 || typed[1].tool_success != Some(true)
6032 || self.result.as_ref() != Some(&typed[1].content)
6033 {
6034 return Err(plain_actor_unsupported());
6035 }
6036 self.messages = Some(typed);
6037 return Ok(false);
6038 }
6039 let event: AgentEvent =
6040 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6041 match event {
6042 AgentEvent::ToolStart {
6043 tool_call_id,
6044 tool_name,
6045 arguments,
6046 } => {
6047 if tool_name != "Glob"
6048 || tool_call_id.trim().is_empty()
6049 || self.start.is_some()
6050 || !arguments.is_object()
6051 {
6052 return Err(plain_actor_unsupported());
6053 }
6054 self.start = Some((tool_call_id, arguments));
6055 Ok(true)
6056 }
6057 AgentEvent::ToolComplete {
6058 tool_call_id,
6059 result,
6060 } => {
6061 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6062 || self.result.is_some()
6063 || !result.success
6064 || !result.images.is_empty()
6065 {
6066 return Err(plain_actor_unsupported());
6067 }
6068 self.result = Some(result.result);
6069 Ok(true)
6070 }
6071 AgentEvent::ToolToken { tool_call_id, .. } => {
6072 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6073 {
6074 return Err(plain_actor_unsupported());
6075 }
6076 Ok(true)
6077 }
6078 AgentEvent::ToolLifecycle {
6079 tool_call_id,
6080 tool_name,
6081 is_mutating,
6082 phase,
6083 error,
6084 ..
6085 } => {
6086 if tool_name != "Glob"
6087 || is_mutating
6088 || !matches!(phase.as_str(), "begin" | "finished")
6089 || error.is_some()
6090 || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6091 {
6092 return Err(plain_actor_unsupported());
6093 }
6094 Ok(true)
6095 }
6096 _ => plain_actor_event(value),
6097 }
6098 }
6099 fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6100 let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6101 if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6102 return Err(plain_actor_unsupported());
6103 }
6104 Ok(messages)
6105 }
6106}
6107
6108fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6109 let event: AgentEvent =
6110 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6111 match event {
6112 AgentEvent::ReasoningToken { .. }
6113 | AgentEvent::ToolToken { .. }
6114 | AgentEvent::ToolStart { .. }
6115 | AgentEvent::ToolComplete { .. }
6116 | AgentEvent::ToolError { .. }
6117 | AgentEvent::ToolLifecycle { .. }
6118 | AgentEvent::ContextSummarized { .. }
6119 | AgentEvent::ContextArchived { .. }
6120 | AgentEvent::ContextCompressionStatus { .. }
6121 | AgentEvent::NeedClarification { .. }
6122 | AgentEvent::SubAgentStarted { .. }
6123 | AgentEvent::SubAgentEvent { .. }
6124 | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6125 AgentEvent::MessageAppended { .. }
6128 | AgentEvent::SessionHistoryCommitted { .. }
6129 | AgentEvent::VisibleMessageStart { .. }
6130 | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6131 event if event.is_durable_change() => Ok(false),
6132 _ => Ok(true),
6133 }
6134}
6135
6136fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6142 let value = match placement {
6145 Placement::Local => return None,
6146 Placement::Remote { endpoint } => serde_json::json!({
6147 "kind": "remote",
6148 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6149 }),
6150 Placement::Schedulable { pool } => serde_json::json!({
6151 "kind": "remote",
6152 "host": host_label.unwrap_or(pool),
6153 }),
6154 };
6155 serde_json::to_string(&value).ok()
6156}
6157
6158fn host_of_endpoint(endpoint: &str) -> String {
6160 endpoint
6161 .trim()
6162 .trim_start_matches("wss://")
6163 .trim_start_matches("ws://")
6164 .split(['/', ':'])
6165 .next()
6166 .unwrap_or(endpoint)
6167 .to_string()
6168}
6169
6170async fn reconcile_already_admitted_claim(
6171 binding: &SessionInboxRuntimeBinding,
6172 session: &mut Session,
6173 claim: &SessionInboxClaim,
6174) -> crate::runtime::runner::Result<()> {
6175 let latest = binding
6176 .storage
6177 .load_session(&session.id)
6178 .await
6179 .map_err(|error| {
6180 AgentError::LLM(format!(
6181 "load canonical SessionInbox checkpoint for {}: {error}",
6182 session.id
6183 ))
6184 })?
6185 .ok_or_else(|| {
6186 AgentError::LLM(format!(
6187 "canonical SessionInbox target disappeared: {}",
6188 session.id
6189 ))
6190 })?;
6191 session
6192 .check_conversation_generation(&latest)
6193 .map_err(|error| {
6194 AgentError::LLM(format!(
6195 "canonical SessionInbox conversation changed: {error}"
6196 ))
6197 })?;
6198 let Some(message) = latest
6199 .messages
6200 .iter()
6201 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6202 .cloned()
6203 else {
6204 return Err(AgentError::LLM(format!(
6205 "canonical admitted receipt for {} exists without transcript message {}",
6206 session.id, claim.envelope.id
6207 )));
6208 };
6209 if let Some(existing) = session
6210 .messages
6211 .iter_mut()
6212 .find(|existing| existing.id == message.id)
6213 {
6214 *existing = message;
6215 } else {
6216 session.add_message(message);
6217 }
6218 bamboo_domain::merge_session_inbox_admission(session, &latest);
6219 binding
6220 .inbox
6221 .ack(&session.id, claim)
6222 .await
6223 .map_err(|error| {
6224 AgentError::LLM(format!(
6225 "ack recovered canonical SessionInbox claim {}: {error}",
6226 claim.envelope.id
6227 ))
6228 })
6229}
6230
6231async fn checkpoint_and_ack_canonical_claim(
6235 binding: &SessionInboxRuntimeBinding,
6236 session: &mut Session,
6237 claim: &SessionInboxClaim,
6238 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6239) -> crate::runtime::runner::Result<()> {
6240 if claim.envelope.target_session_id != session.id {
6241 return Err(AgentError::LLM(format!(
6242 "canonical SessionInbox claim target {} does not match active logical session {}",
6243 claim.envelope.target_session_id, session.id
6244 )));
6245 }
6246 if binding
6247 .inbox
6248 .was_admitted(&session.id, &claim.envelope.id)
6249 .await
6250 .map_err(|error| {
6251 AgentError::LLM(format!(
6252 "inspect canonical admitted receipt {}: {error}",
6253 claim.envelope.id
6254 ))
6255 })?
6256 {
6257 return reconcile_already_admitted_claim(binding, session, claim).await;
6258 }
6259
6260 if let Some((store, fence)) = fenced {
6261 let committed = store
6262 .confirm_actor_claim_context(
6263 concrete_file_inbox(binding)?,
6264 bamboo_storage::ActorClaimContextSeed {
6265 fence: fence.clone(),
6266 expected_created_at: session.created_at,
6267 expected_conversation_generation: session.conversation_generation_observation(),
6268 expected_messages: session.messages.clone(),
6269 expected_provider_transcript: session.provider_transcript.clone(),
6270 expected_admission: session.session_inbox_admission().cloned(),
6271 claims: vec![claim.clone()],
6272 },
6273 )
6274 .await;
6275 let committed = match committed {
6276 Ok(committed) => committed,
6277 Err(error) => {
6278 adopt_latest_actor_context(store, session).await;
6282 return Err(AgentError::LLM(format!(
6283 "confirm fenced Actor claim context unconfirmed: {error}"
6284 )));
6285 }
6286 };
6287 bamboo_domain::merge_session_inbox_admission(session, &committed);
6288 session.messages = committed.messages;
6289 session.provider_transcript = committed.provider_transcript;
6290 session.updated_at = committed.updated_at;
6291 if !session
6292 .messages
6293 .iter()
6294 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6295 || !session
6296 .session_inbox_admission()
6297 .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6298 {
6299 return Err(AgentError::LLM(
6300 "fenced Actor claim confirmation lost durable proof".into(),
6301 ));
6302 }
6303 return binding
6304 .inbox
6305 .ack(&session.id, claim)
6306 .await
6307 .map_err(|error| {
6308 AgentError::LLM(format!(
6309 "ack fenced Actor claim {} after confirmation: {error}",
6310 claim.envelope.id
6311 ))
6312 });
6313 }
6314
6315 let transcript_has_id = session
6316 .messages
6317 .iter()
6318 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6319 if session
6320 .messages
6321 .iter()
6322 .any(|message| message.id == claim.envelope.id.as_str())
6323 && !transcript_has_id
6324 {
6325 return Err(AgentError::LLM(format!(
6326 "canonical SessionInbox id {} collides with a non-matching transcript message",
6327 claim.envelope.id
6328 )));
6329 }
6330 let cursor_has_id = session
6331 .session_inbox_admission()
6332 .is_some_and(|state| state.contains(&claim.envelope.id));
6333 if cursor_has_id && !transcript_has_id {
6334 return Err(AgentError::LLM(format!(
6335 "canonical SessionInbox cursor exists without transcript message {}",
6336 claim.envelope.id
6337 )));
6338 }
6339 let before = session.clone();
6340 if !transcript_has_id {
6341 let message = claim.envelope.to_provider_message().map_err(|error| {
6342 AgentError::LLM(format!(
6343 "translate canonical SessionInbox envelope {}: {error}",
6344 claim.envelope.id
6345 ))
6346 })?;
6347 session.add_message(message);
6348 }
6349 session
6350 .session_inbox_admission_mut()
6351 .record(claim.envelope.id.clone(), claim.generation);
6352 session.updated_at = chrono::Utc::now();
6353
6354 if let Err(error) = binding
6355 .persistence
6356 .checkpoint_runtime_session(session)
6357 .await
6358 {
6359 *session = before;
6360 return Err(AgentError::LLM(format!(
6361 "checkpoint canonical SessionInbox claim {}: {error}",
6362 claim.envelope.id
6363 )));
6364 }
6365 if !session
6366 .messages
6367 .iter()
6368 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6369 {
6370 *session = before;
6371 return Err(AgentError::LLM(format!(
6372 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6373 claim.envelope.id
6374 )));
6375 }
6376 binding
6377 .inbox
6378 .ack(&session.id, claim)
6379 .await
6380 .map_err(|error| {
6381 AgentError::LLM(format!(
6382 "ack canonical SessionInbox claim {} after checkpoint: {error}",
6383 claim.envelope.id
6384 ))
6385 })
6386}
6387
6388fn concrete_file_inbox(
6389 binding: &SessionInboxRuntimeBinding,
6390) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6391 binding
6392 .inbox
6393 .as_any()
6394 .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6395 .cloned()
6396 .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6397}
6398
6399async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6400 if let Ok(Some(latest)) = store.load_session(&session.id).await {
6401 if latest.created_at == session.created_at
6402 && session.check_conversation_generation(&latest).is_ok()
6403 {
6404 bamboo_domain::merge_session_inbox_admission(session, &latest);
6405 session.messages = latest.messages;
6406 session.provider_transcript = latest.provider_transcript;
6407 session.updated_at = latest.updated_at;
6408 }
6409 }
6410}
6411
6412async fn checkpoint_claim_context_before_dispatch(
6423 binding: &SessionInboxRuntimeBinding,
6424 session: &mut Session,
6425 claims: &[SessionInboxClaim],
6426 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6427) -> crate::runtime::runner::Result<()> {
6428 if claims.is_empty() {
6429 return Ok(());
6430 }
6431 let before = session.clone();
6432 let mut additions = Vec::new();
6433 for claim in claims {
6434 if claim.envelope.target_session_id != session.id {
6435 return Err(AgentError::LLM(format!(
6436 "canonical SessionInbox claim target {} does not match actor session {}",
6437 claim.envelope.target_session_id, session.id
6438 )));
6439 }
6440 let matching = session
6441 .messages
6442 .iter()
6443 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6444 if session
6445 .messages
6446 .iter()
6447 .any(|message| message.id == claim.envelope.id.as_str())
6448 && !matching
6449 {
6450 return Err(AgentError::LLM(format!(
6451 "canonical SessionInbox id {} collides before actor dispatch",
6452 claim.envelope.id
6453 )));
6454 }
6455 if session
6456 .session_inbox_admission()
6457 .is_some_and(|state| state.contains(&claim.envelope.id))
6458 && !matching
6459 {
6460 return Err(AgentError::LLM(format!(
6461 "canonical SessionInbox cursor exists without transcript proof for {}",
6462 claim.envelope.id
6463 )));
6464 }
6465 if !matching {
6466 let message = claim.envelope.to_provider_message().map_err(|error| {
6467 AgentError::LLM(format!(
6468 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6469 claim.envelope.id
6470 ))
6471 })?;
6472 additions.push(message);
6473 }
6474 }
6475 let fenced_seed = fenced.is_some();
6476 if let Some((store, fence)) = fenced {
6477 let committed = store
6478 .seed_actor_claim_context(
6479 concrete_file_inbox(binding)?,
6480 bamboo_storage::ActorClaimContextSeed {
6481 fence: fence.clone(),
6482 expected_created_at: session.created_at,
6483 expected_conversation_generation: session.conversation_generation_observation(),
6484 expected_messages: session.messages.clone(),
6485 expected_provider_transcript: session.provider_transcript.clone(),
6486 expected_admission: session.session_inbox_admission().cloned(),
6487 claims: claims.to_vec(),
6488 },
6489 )
6490 .await;
6491 let committed = match committed {
6492 Ok(committed) => committed,
6493 Err(error) => {
6494 adopt_latest_actor_context(store, session).await;
6498 return Err(AgentError::LLM(format!(
6499 "seed fenced Actor claim context unconfirmed: {error}"
6500 )));
6501 }
6502 };
6503 bamboo_domain::merge_session_inbox_admission(session, &committed);
6504 session.messages = committed.messages;
6505 session.provider_transcript = committed.provider_transcript;
6506 session.updated_at = committed.updated_at;
6507 } else {
6508 for message in additions {
6509 session.add_message(message);
6510 }
6511 session.updated_at = chrono::Utc::now();
6512 if let Err(error) = binding
6513 .persistence
6514 .checkpoint_runtime_session(session)
6515 .await
6516 {
6517 *session = before;
6518 return Err(AgentError::LLM(format!(
6519 "checkpoint canonical SessionInbox actor context: {error}"
6520 )));
6521 }
6522 }
6523 for claim in claims {
6524 if !session
6525 .messages
6526 .iter()
6527 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6528 {
6529 if !fenced_seed {
6530 *session = before;
6531 }
6532 return Err(AgentError::LLM(format!(
6533 "actor context checkpoint lost typed transcript proof for {}",
6534 claim.envelope.id
6535 )));
6536 }
6537 }
6538 Ok(())
6539}
6540
6541async fn claim_canonical_deliveries(
6542 binding: &SessionInboxRuntimeBinding,
6543 session: &mut Session,
6544 activation_run_id: &str,
6545 limit: usize,
6546 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6547) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6548 let claims = binding
6549 .inbox
6550 .claim_for_turn(&session.id, limit, Some(activation_run_id))
6551 .await
6552 .map_err(|error| {
6553 AgentError::LLM(format!(
6554 "claim canonical SessionInbox for active actor {}: {error}",
6555 session.id
6556 ))
6557 })?;
6558 if claims.is_empty() {
6559 return Ok(Vec::new());
6560 }
6561 let mut unconfirmed = Vec::with_capacity(claims.len());
6562 for claim in claims {
6563 if binding
6564 .inbox
6565 .was_admitted(&session.id, &claim.envelope.id)
6566 .await
6567 .map_err(|error| {
6568 AgentError::LLM(format!(
6569 "inspect canonical SessionInbox claim {}: {error}",
6570 claim.envelope.id
6571 ))
6572 })?
6573 {
6574 reconcile_already_admitted_claim(binding, session, &claim).await?;
6575 continue;
6576 }
6577 unconfirmed.push(claim);
6578 }
6579 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6580
6581 let mut deliveries = Vec::with_capacity(unconfirmed.len());
6582 for claim in unconfirmed {
6583 if session
6588 .session_inbox_admission()
6589 .is_some_and(|state| state.contains(&claim.envelope.id))
6590 {
6591 binding
6592 .inbox
6593 .ack(&session.id, &claim)
6594 .await
6595 .map_err(|error| {
6596 AgentError::LLM(format!(
6597 "finish confirmed canonical SessionInbox ack {}: {error}",
6598 claim.envelope.id
6599 ))
6600 })?;
6601 continue;
6602 }
6603 let delivery = SessionMessageDelivery {
6604 target_session_id: session.id.clone(),
6605 envelope: claim.envelope.clone(),
6606 canonical_claim_generation: claim.generation,
6607 activation_run_id: activation_run_id.to_string(),
6608 activation_policy: claim.activation_policy,
6609 };
6610 deliveries.push((claim, delivery));
6611 }
6612 Ok(deliveries)
6613}
6614
6615async fn forward_next_canonical_claim(
6616 client: &mut dyn bamboo_subagent::ChildLink,
6617 binding: &SessionInboxRuntimeBinding,
6618 session: &mut Session,
6619 activation_run_id: &str,
6620 inflight: &mut VecDeque<SessionInboxClaim>,
6621 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6622) -> crate::runtime::runner::Result<()> {
6623 if !inflight.is_empty() {
6624 return Ok(());
6625 }
6626 let Some((claim, delivery)) =
6627 claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6628 .await?
6629 .pop()
6630 else {
6631 return Ok(());
6632 };
6633 client
6634 .send(ParentFrame::SessionMessage { delivery })
6635 .await
6636 .map_err(|error| {
6637 AgentError::LLM(format!(
6638 "forward canonical SessionInbox claim {} to active actor: {error}",
6639 claim.envelope.id
6640 ))
6641 })?;
6642 inflight.push_back(claim);
6643 Ok(())
6644}
6645
6646struct ActorDriveContext<'a> {
6648 client: &'a mut dyn bamboo_subagent::ChildLink,
6649 parent_session_id: &'a str,
6650 child_session_id: &'a str,
6651 child_attempt: u32,
6652 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6653 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6654 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6655 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6656 event_tx: &'a mpsc::Sender<AgentEvent>,
6657 cancel_token: &'a CancellationToken,
6658 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6659 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6660 logical_session: &'a mut Session,
6661 expected_permission_posture: Option<ExpectedPermissionPosture>,
6662 expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6663 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6664 actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6665 canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6666 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6667 activation_run_id: Option<&'a str>,
6668 execution_epoch: u64,
6669 expected_source_actor_id: &'a str,
6670 initial_inflight_claims: VecDeque<SessionInboxClaim>,
6671 plain_actor: bool,
6672 remote_environment_lease: bool,
6673 readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6674 local_history_tools: Option<&'a [String]>,
6675 local_history_read_only: bool,
6676 strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6677 plain_input: Option<&'a PlainActorActivation>,
6678 canonical_activation: Option<&'a ActorActivationFence>,
6679 canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6680 actor_event_observer: Option<&'a dyn ActorEventObserver>,
6681 plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6682 first_frame_timeout: Option<Duration>,
6683}
6684
6685#[derive(Debug, Clone, PartialEq, Eq)]
6686struct ExpectedPermissionPosture {
6687 policy_revision: u64,
6688 resolution: bamboo_domain::PermissionModeResolution,
6689 expected_audit_revision: Option<u64>,
6690 executor_mapping: String,
6691}
6692
6693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6694enum PermissionPostureHandshake {
6695 NotRequired,
6697 Awaiting,
6700 Confirmed,
6702}
6703
6704impl PermissionPostureHandshake {
6705 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6706 if expected.is_some() {
6707 Self::Awaiting
6708 } else {
6709 Self::NotRequired
6710 }
6711 }
6712
6713 fn is_awaiting(self) -> bool {
6714 self == Self::Awaiting
6715 }
6716
6717 fn posture_was_confirmed(self) -> bool {
6718 self == Self::Confirmed
6719 }
6720}
6721
6722fn permission_posture_seed_from_event(
6723 session: &Session,
6724 event: &AgentEvent,
6725) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6726 let AgentEvent::PermissionPostureActivated {
6727 session_id,
6728 policy_revision,
6729 requested_mode,
6730 effective_mode,
6731 executor_mapping,
6732 } = event
6733 else {
6734 return Ok(None);
6735 };
6736 if session_id != &session.id {
6737 return Err("permission posture event targets a different logical session".to_string());
6738 }
6739 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6740 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6741 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6742 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6743 let resolution = bamboo_domain::PermissionModeResolution {
6744 requested,
6745 effective,
6746 };
6747 if !resolution.is_consistent() {
6748 return Err("permission posture event has an inconsistent mode pair".to_string());
6749 }
6750 let current_requested = session
6751 .agent_runtime_state
6752 .as_ref()
6753 .map(|state| state.effective_permission_mode())
6754 .unwrap_or_default();
6755 if current_requested != requested {
6756 return Err("permission posture event is stale for the host typed mode".to_string());
6757 }
6758 let mapping_chars = executor_mapping.chars().count();
6759 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6760 return Err("permission posture event has an invalid executor mapping".to_string());
6761 }
6762 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6763 *policy_revision,
6764 resolution,
6765 executor_mapping,
6766 )))
6767}
6768
6769fn actor_event_logical_identity(
6770 session: &Session,
6771 parent_session_id: &str,
6772 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6773) -> LogicalSessionIdentity {
6774 LogicalSessionIdentity {
6775 creation: creation.cloned(),
6776 session_id: session.id.clone(),
6777 parent_session_id: session
6778 .parent_session_id
6779 .clone()
6780 .or_else(|| Some(parent_session_id.to_string())),
6781 root_session_id: if session.root_session_id.trim().is_empty() {
6782 parent_session_id.to_string()
6783 } else {
6784 session.root_session_id.clone()
6785 },
6786 }
6787}
6788
6789fn validate_strict_history_terminal(
6790 watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6791 session: &Session,
6792 parent_session_id: &str,
6793 activation_run_id: Option<&str>,
6794 execution_epoch: u64,
6795 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6796 consumed_seq: u64,
6797) -> Result<(), AgentError> {
6798 let identity = actor_event_logical_identity(session, parent_session_id, creation);
6799 if watermark
6800 .zip(activation_run_id)
6801 .is_some_and(|(watermark, run_id)| {
6802 watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6803 })
6804 {
6805 Ok(())
6806 } else {
6807 Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6808 }
6809}
6810
6811fn validate_actor_event_batch(
6812 batch: &ActorEventBatch,
6813 logical_session: &Session,
6814 parent_session_id: &str,
6815 activation_run_id: Option<&str>,
6816 execution_epoch: u64,
6817 expected_source_actor_id: &str,
6818 expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6819) -> Result<(), AgentError> {
6820 batch
6821 .validate()
6822 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6823 let identity = batch.logical_session.as_ref().ok_or_else(|| {
6824 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6825 })?;
6826 let expected_identity =
6831 actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6832 if identity != &expected_identity {
6833 return Err(AgentError::LLM(
6834 "actor event batch targets a different logical session".to_string(),
6835 ));
6836 }
6837 if batch.activation_id.as_deref() != activation_run_id {
6838 return Err(AgentError::LLM(
6839 "actor event batch belongs to a stale activation".to_string(),
6840 ));
6841 }
6842 if batch.execution_epoch != execution_epoch {
6843 return Err(AgentError::LLM(
6844 "actor event batch belongs to a stale execution epoch".to_string(),
6845 ));
6846 }
6847 if batch
6848 .source_actor_id
6849 .as_deref()
6850 .is_some_and(|source| source != expected_source_actor_id)
6851 {
6852 return Err(AgentError::LLM(
6853 "actor event batch declares a different physical actor".to_string(),
6854 ));
6855 }
6856 Ok(())
6857}
6858
6859async fn validate_directory_actor_event(
6863 store: &bamboo_storage::SessionStoreV2,
6864 fence: &ActorActivationFence,
6865 session: &Session,
6866 run_id: Option<&str>,
6867) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6868 store
6869 .validate_fence(fence, chrono::Utc::now())
6870 .await
6871 .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6872 let entry = store
6873 .inspect_actor(&fence.actor_id)
6874 .await
6875 .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6876 if !entry.actor.matches_session(session)
6877 || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6878 || !entry.activation.as_ref().is_some_and(|current| {
6879 current.matches_fence(fence)
6880 && current.status == bamboo_domain::ActorActivationStatus::Running
6881 })
6882 || run_id.is_some_and(|id| id != fence.run_id)
6883 {
6884 return Err(AgentError::LLM("actor event authority changed".into()));
6885 }
6886 Ok(entry)
6887}
6888
6889async fn inspect_active_owned_tree(
6892 store: &bamboo_storage::SessionStoreV2,
6893 binding: &SessionInboxRuntimeBinding,
6894 run_id: &str,
6895 child_id: &str,
6896 parent_id: &str,
6897 logical_session: &Session,
6898 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6899 plain_input: Option<&PlainActorActivation>,
6900 cursor: Option<&str>,
6901) -> Option<serde_json::Value> {
6902 use bamboo_agent_core::storage::Storage;
6903 let current = match store.load_session(child_id).await {
6904 Ok(Some(current)) => current,
6905 _ => {
6906 tracing::warn!("owned tree denied: canonical caller unavailable");
6907 return None;
6908 }
6909 };
6910 let current_project = match project_id_for_actor_run(¤t) {
6911 Ok(project) => project,
6912 Err(_) => {
6913 tracing::warn!("owned tree denied: invalid canonical Project");
6914 return None;
6915 }
6916 };
6917 let logical_project = match project_id_for_actor_run(logical_session) {
6918 Ok(project) => project,
6919 Err(_) => {
6920 tracing::warn!("owned tree denied: invalid logical Project");
6921 return None;
6922 }
6923 };
6924 if current.id != child_id
6925 || current.kind != bamboo_domain::SessionKind::Child
6926 || current.parent_session_id.as_deref() != Some(parent_id)
6927 || current.parent_session_id != logical_session.parent_session_id
6928 || current.root_session_id != logical_session.root_session_id
6929 || current.created_at != creation.created_at
6930 || current.created_at != logical_session.created_at
6931 || current.spawn_depth != creation.spawn_depth
6932 || current.spawn_depth != logical_session.spawn_depth
6933 || current_project != logical_project
6934 {
6935 tracing::warn!("owned tree denied: caller lifetime or Project changed");
6936 return None;
6937 }
6938 if let Some(activation) = plain_input {
6939 validate_directory_actor_event(
6940 activation.store.as_ref(),
6941 &activation.fence,
6942 ¤t,
6943 Some(run_id),
6944 )
6945 .await
6946 .ok()?;
6947 } else if !binding.router.owns_run(child_id, run_id).await {
6948 tracing::warn!("owned tree denied: activation owner changed");
6949 return None;
6950 }
6951 let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6952 store, child_id, cursor,
6953 )
6954 .await
6955 {
6956 Ok(page) => page,
6957 Err(error) => {
6958 tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6959 return None;
6960 }
6961 };
6962 let again = match store.load_session(child_id).await {
6963 Ok(Some(again)) => again,
6964 _ => {
6965 tracing::warn!("owned tree denied: canonical caller disappeared");
6966 return None;
6967 }
6968 };
6969 if again.created_at != current.created_at
6970 || again.parent_session_id != current.parent_session_id
6971 || again.root_session_id != current.root_session_id
6972 || again.spawn_depth != current.spawn_depth
6973 || project_id_for_actor_run(&again).ok()? != current_project
6974 {
6975 tracing::warn!("owned tree denied: caller changed during inspection");
6976 return None;
6977 }
6978 if let Some(activation) = plain_input {
6979 validate_directory_actor_event(
6980 activation.store.as_ref(),
6981 &activation.fence,
6982 &again,
6983 Some(run_id),
6984 )
6985 .await
6986 .ok()?;
6987 } else if !binding.router.owns_run(child_id, run_id).await {
6988 tracing::warn!("owned tree denied: activation owner changed after inspection");
6989 return None;
6990 }
6991 serde_json::to_vec(&page)
6992 .ok()
6993 .filter(|bytes| bytes.len() <= 8192)?;
6994 Some(page)
6995}
6996
6997async fn load_active_subagent_caller(
7001 store: &bamboo_storage::SessionStoreV2,
7002 binding: &SessionInboxRuntimeBinding,
7003 run_id: &str,
7004 child_id: &str,
7005 parent_id: &str,
7006 logical_session: &Session,
7007 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7008 plain_input: Option<&PlainActorActivation>,
7009) -> Option<Session> {
7010 use bamboo_agent_core::storage::Storage;
7011 let current = store.load_session(child_id).await.ok().flatten()?;
7012 let current_project = project_id_for_actor_run(¤t).ok()?;
7013 let logical_project = project_id_for_actor_run(logical_session).ok()?;
7014 if current.id != child_id
7015 || current.kind != bamboo_domain::SessionKind::Child
7016 || current.parent_session_id.as_deref() != Some(parent_id)
7017 || current.parent_session_id != logical_session.parent_session_id
7018 || current.root_session_id != logical_session.root_session_id
7019 || current.created_at != creation.created_at
7020 || current.created_at != logical_session.created_at
7021 || current.spawn_depth != creation.spawn_depth
7022 || current.spawn_depth != logical_session.spawn_depth
7023 || current_project != logical_project
7024 || bamboo_domain::ActorSession::from_session(¤t).is_err()
7025 {
7026 return None;
7027 }
7028 if let Some(activation) = plain_input {
7029 validate_directory_actor_event(
7030 activation.store.as_ref(),
7031 &activation.fence,
7032 ¤t,
7033 Some(run_id),
7034 )
7035 .await
7036 .ok()?;
7037 } else if !binding.router.owns_run(child_id, run_id).await {
7038 return None;
7039 }
7040 Some(current)
7041}
7042
7043async fn invoke_active_subagent(
7044 tool: &dyn bamboo_agent_core::tools::Tool,
7045 caller: &Session,
7046 args: serde_json::Value,
7047 tool_call_id: &str,
7048) -> serde_json::Value {
7049 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7050 let mut ctx = ToolCtx::none(tool_call_id.to_owned());
7051 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7052 match tool.invoke(args, ctx).await {
7053 Ok(ToolOutcome::Completed(result))
7054 if result.images.is_empty()
7055 && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
7056 {
7057 serde_json::json!({"result":result})
7058 }
7059 _ => serde_json::json!({
7060 "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
7061 }),
7062 }
7063}
7064
7065#[allow(clippy::too_many_arguments)]
7068async fn checkpoint_active_parent_question(
7069 store: &bamboo_storage::SessionStoreV2,
7070 binding: &SessionInboxRuntimeBinding,
7071 run_id: &str,
7072 child_id: &str,
7073 parent_id: &str,
7074 logical_session: &mut Session,
7075 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7076 plain_input: Option<&PlainActorActivation>,
7077 observation: ParentQuestionCheckpointV1,
7078) -> Option<String> {
7079 use bamboo_agent_core::storage::Storage;
7080 if plain_input.is_some() || observation.validate_shape().is_err() {
7081 return None;
7082 }
7083 let _session_guard = binding
7087 .parent_question_lock
7088 .as_ref()?
7089 .acquire_lock(child_id)
7090 .await;
7091 let caller = load_active_subagent_caller(
7092 store,
7093 binding,
7094 run_id,
7095 child_id,
7096 parent_id,
7097 logical_session,
7098 creation,
7099 plain_input,
7100 )
7101 .await?;
7102 let parent = store.load_session(parent_id).await.ok().flatten()?;
7103 if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7104 return None;
7105 }
7106 let (saved, question) = store
7107 .checkpoint_parent_question(&caller, &parent, &observation)
7108 .await
7109 .ok()
7110 .flatten()?;
7111 logical_session.messages = saved.messages;
7114 logical_session.pending_question = saved.pending_question;
7115 if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7116 logical_session
7117 .metadata
7118 .insert("runtime.suspend_reason".into(), reason.clone());
7119 }
7120 if let Some(request) = saved
7121 .metadata
7122 .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7123 {
7124 logical_session.metadata.insert(
7125 bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7126 request.clone(),
7127 );
7128 }
7129 logical_session.metadata.insert(
7130 "runtime.actor_parent_question_handoff".into(),
7131 "true".into(),
7132 );
7133 Some(question.id.as_str().to_owned())
7134}
7135
7136async fn ensure_active_nested_wait(
7141 tool: &dyn bamboo_agent_core::tools::Tool,
7142 store: &bamboo_storage::SessionStoreV2,
7143 binding: &SessionInboxRuntimeBinding,
7144 run_id: &str,
7145 child_id: &str,
7146 parent_id: &str,
7147 logical_session: &Session,
7148 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7149 plain_input: Option<&PlainActorActivation>,
7150) -> Result<bool, AgentError> {
7151 use bamboo_agent_core::storage::Storage;
7152 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7153
7154 let caller = load_active_subagent_caller(
7155 store,
7156 binding,
7157 run_id,
7158 child_id,
7159 parent_id,
7160 logical_session,
7161 creation,
7162 plain_input,
7163 )
7164 .await
7165 .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7166 if caller
7167 .agent_runtime_state
7168 .as_ref()
7169 .and_then(|state| state.waiting_for_children.as_ref())
7170 .is_some()
7171 {
7172 return Ok(true);
7173 }
7174
7175 let active = store
7176 .list_child_run_statuses(child_id)
7177 .await
7178 .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?
7179 .into_iter()
7180 .filter(|(_, status)| {
7181 !status.as_deref().is_some_and(|status| {
7182 matches!(
7183 status,
7184 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7185 )
7186 })
7187 })
7188 .map(|(id, _)| id)
7189 .collect::<Vec<_>>();
7190 if active.is_empty() {
7191 return Ok(false);
7192 }
7193
7194 let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7195 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7196 let outcome = tokio::time::timeout(
7197 Duration::from_secs(30),
7198 tool.invoke(
7199 serde_json::json!({"action":"wait", "child_session_ids":active}),
7200 ctx,
7201 ),
7202 )
7203 .await
7204 .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7205 .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7206 let wait_registered = match outcome {
7207 ToolOutcome::Completed(result) if result.success => {
7208 let payload: serde_json::Value = serde_json::from_str(&result.result)
7209 .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7210 payload["status"] == "waiting"
7211 }
7212 _ => {
7213 return Err(AgentError::LLM(
7214 "nested wait registration was not completed".into(),
7215 ))
7216 }
7217 };
7218
7219 let latest = load_active_subagent_caller(
7222 store,
7223 binding,
7224 run_id,
7225 child_id,
7226 parent_id,
7227 logical_session,
7228 creation,
7229 plain_input,
7230 )
7231 .await
7232 .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7233 if latest
7234 .agent_runtime_state
7235 .as_ref()
7236 .and_then(|state| state.waiting_for_children.as_ref())
7237 .is_some()
7238 {
7239 return Ok(true);
7240 }
7241 let still_active = store
7242 .list_child_run_statuses(child_id)
7243 .await
7244 .map_err(|error| {
7245 AgentError::LLM(format!("nested child wait verification failed: {error}"))
7246 })?
7247 .into_iter()
7248 .any(|(_, status)| {
7249 !status.as_deref().is_some_and(|status| {
7250 matches!(
7251 status,
7252 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7253 )
7254 })
7255 });
7256 if still_active {
7257 return Err(AgentError::LLM(
7258 "nested child remains active without a durable parent wait".into(),
7259 ));
7260 }
7261 Ok(wait_registered)
7266}
7267
7268fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7269 let message = match error {
7270 ActorEventRouteError::SequenceGap { .. } => {
7271 "actor event sequence gap; authoritative session snapshot is required"
7272 }
7273 ActorEventRouteError::LifecycleMissing => {
7274 "actor emitted live content before its activation lifecycle event"
7275 }
7276 ActorEventRouteError::ForgedSession => {
7277 "actor event payload targets a different canonical session"
7278 }
7279 ActorEventRouteError::ConflictingReplay => {
7280 "actor replay reused an event sequence with different content"
7281 }
7282 ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7283 ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7284 };
7285 AgentError::LLM(message.into())
7286}
7287
7288const MAX_DISPLAY_CALLS: usize = 4_096;
7289const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7290const MAX_DISPLAY_ID_BYTES: usize = 256;
7291const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7292
7293fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7294 if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7295 return None;
7296 }
7297 Some(match result {
7298 HookResult::Continue => HookResult::Continue,
7299 HookResult::Mutated => HookResult::Mutated,
7300 HookResult::Allow => HookResult::Allow,
7301 HookResult::Ask => HookResult::Ask,
7302 HookResult::Deny { .. } => HookResult::Deny {
7303 reason: "Hook denied".into(),
7304 },
7305 HookResult::InjectContext { .. } => HookResult::InjectContext {
7306 text: "Hook context hidden".into(),
7307 },
7308 HookResult::WithContext { result, .. } => HookResult::WithContext {
7309 result: Box::new(hook_result_for_display(*result, depth + 1)?),
7310 text: "Hook context hidden".into(),
7311 },
7312 HookResult::Suspend { .. } => HookResult::Suspend {
7313 reason: "Hook suspended".into(),
7314 },
7315 HookResult::Abort { .. } => HookResult::Abort {
7316 reason: "Hook aborted".into(),
7317 },
7318 })
7319}
7320
7321#[derive(Clone, Copy, PartialEq, Eq)]
7322enum DisplayCallKind {
7323 Ordinary,
7324 PrivateBrowser,
7325 Reused,
7326}
7327
7328#[derive(Clone, Copy)]
7329struct DisplayCall {
7330 kind: DisplayCallKind,
7331 started: bool,
7332}
7333
7334#[derive(Default)]
7335struct ActorEventDisplay {
7336 calls: HashMap<String, DisplayCall>,
7337 hook_names: HashMap<String, usize>,
7338 children: HashMap<(String, String), Box<ActorEventDisplay>>,
7339 identities: Arc<AtomicUsize>,
7342 overflowed: bool,
7343 nested_overflowed: bool,
7344}
7345
7346fn is_browser_tool_name(tool_name: &str) -> bool {
7347 tool_name
7348 .trim()
7349 .rsplit("::")
7350 .next()
7351 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7352}
7353
7354fn known_browser_action(args: &serde_json::Value) -> bool {
7355 matches!(
7356 args.get("action").and_then(serde_json::Value::as_str),
7357 Some(
7358 "tabs"
7359 | "new_tab"
7360 | "activate_tab"
7361 | "close_tab"
7362 | "navigate"
7363 | "history"
7364 | "viewport"
7365 | "snapshot"
7366 | "click"
7367 | "click_at"
7368 | "hover"
7369 | "drag"
7370 | "fill"
7371 | "select_option"
7372 | "set_file_input"
7373 | "type"
7374 | "press"
7375 | "key"
7376 | "scroll"
7377 | "download"
7378 | "screenshot"
7379 | "dialog_respond"
7380 )
7381 )
7382}
7383
7384fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7385 is_browser_tool_name(tool_name)
7386 && (!known_browser_action(args)
7387 || args
7388 .get("action")
7389 .and_then(serde_json::Value::as_str)
7390 .is_some_and(|action| {
7391 action.eq_ignore_ascii_case("download") || action == "set_file_input"
7392 })
7393 || args.get("data_base64").is_some())
7394}
7395
7396fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7397 if is_browser_tool_name(tool_name) {
7398 if !known_browser_action(args) {
7401 return serde_json::json!({"details":"[redacted]"});
7402 }
7403 }
7404 tool_start_arguments_for_display(tool_name, args)
7405}
7406
7407impl ActorEventDisplay {
7408 fn fail_closed(&mut self) {
7409 self.identities
7410 .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7411 self.overflowed = true;
7412 self.calls.clear();
7413 self.hook_names.clear();
7414 self.nested_overflowed = true;
7415 self.children.clear();
7416 }
7417
7418 fn reserve_identity(&mut self) -> bool {
7419 if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7420 true
7421 } else {
7422 self.fail_closed();
7423 false
7424 }
7425 }
7426
7427 fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7428 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7429 return DisplayCallKind::Reused;
7430 }
7431 let kind = if private {
7432 DisplayCallKind::PrivateBrowser
7433 } else {
7434 DisplayCallKind::Ordinary
7435 };
7436 if let Some(call) = self.calls.get_mut(id) {
7437 if call.started || call.kind != kind {
7438 call.kind = DisplayCallKind::Reused;
7439 }
7440 call.started = true;
7441 return call.kind;
7442 }
7443 if !self.reserve_identity() {
7444 return DisplayCallKind::Reused;
7445 }
7446 self.calls.insert(
7447 id.to_string(),
7448 DisplayCall {
7449 kind,
7450 started: true,
7451 },
7452 );
7453 kind
7454 }
7455
7456 fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7457 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7458 return DisplayCallKind::Reused;
7459 }
7460 let kind = if private {
7461 DisplayCallKind::PrivateBrowser
7462 } else {
7463 DisplayCallKind::Ordinary
7464 };
7465 if let Some(call) = self.calls.get_mut(id) {
7466 if call.kind != kind {
7467 call.kind = DisplayCallKind::Reused;
7468 }
7469 return call.kind;
7470 }
7471 if !self.reserve_identity() {
7472 return DisplayCallKind::Reused;
7473 }
7474 self.calls.insert(
7475 id.to_string(),
7476 DisplayCall {
7477 kind,
7478 started: false,
7479 },
7480 );
7481 kind
7482 }
7483
7484 fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7485 if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7486 return None;
7487 }
7488 self.calls
7489 .get(id)
7490 .filter(|call| call.started)
7491 .map(|call| call.kind)
7492 }
7493
7494 fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7495 if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7496 self.fail_closed();
7497 return None;
7498 }
7499 if let Some(index) = self.hook_names.get(hook_name) {
7500 return Some(format!("actor-hook-{index}"));
7501 }
7502 if !self.reserve_identity() {
7503 return None;
7504 }
7505 let index = self.hook_names.len() + 1;
7506 self.hook_names.insert(hook_name.to_string(), index);
7507 Some(format!("actor-hook-{index}"))
7508 }
7509
7510 fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7511 let call_id = match &event {
7512 AgentEvent::ToolStart { tool_call_id, .. }
7513 | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7514 | AgentEvent::ToolToken { tool_call_id, .. }
7515 | AgentEvent::ToolComplete { tool_call_id, .. }
7516 | AgentEvent::ToolError { tool_call_id, .. }
7517 | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7518 _ => None,
7519 };
7520 if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7521 self.fail_closed();
7522 return None;
7523 }
7524 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7525 self.fail_closed();
7526 }
7527 Some(match event {
7528 AgentEvent::ToolStart {
7529 tool_call_id,
7530 tool_name,
7531 arguments,
7532 } => {
7533 let private = is_private_browser_call(&tool_name, &arguments);
7534 let kind = self.remember_start(&tool_call_id, private);
7535 let arguments = if kind == DisplayCallKind::Reused {
7536 serde_json::json!({"details":"[redacted]"})
7537 } else {
7538 actor_arguments_for_display(&tool_name, &arguments)
7539 };
7540 AgentEvent::ToolStart {
7541 tool_call_id,
7542 tool_name: match kind {
7543 DisplayCallKind::Reused => "unknown".into(),
7544 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7545 _ => tool_name,
7546 },
7547 arguments,
7548 }
7549 }
7550 AgentEvent::ToolApprovalRequested {
7551 tool_call_id,
7552 tool_name,
7553 parameters,
7554 } => {
7555 let private = is_private_browser_call(&tool_name, ¶meters);
7556 let kind = self.remember_approval(&tool_call_id, private);
7557 let parameters = if kind == DisplayCallKind::Reused {
7558 serde_json::json!({"details":"[redacted]"})
7559 } else {
7560 actor_arguments_for_display(&tool_name, ¶meters)
7561 };
7562 AgentEvent::ToolApprovalRequested {
7563 tool_call_id,
7564 tool_name: match kind {
7565 DisplayCallKind::Reused => "unknown".into(),
7566 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7567 _ => tool_name,
7568 },
7569 parameters,
7570 }
7571 }
7572 AgentEvent::ToolToken {
7573 tool_call_id,
7574 content,
7575 } => {
7576 let content = match self.known_kind(&tool_call_id) {
7577 Some(DisplayCallKind::Ordinary) => content,
7578 Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7579 _ => "Tool output hidden".into(),
7580 };
7581 AgentEvent::ToolToken {
7582 tool_call_id,
7583 content,
7584 }
7585 }
7586 AgentEvent::ToolComplete {
7587 tool_call_id,
7588 result,
7589 } => {
7590 let result = match self.known_kind(&tool_call_id) {
7591 Some(DisplayCallKind::Ordinary) => result,
7592 Some(DisplayCallKind::PrivateBrowser) => {
7593 bamboo_agent_core::tools::ToolResult::text(
7594 result.success,
7595 "Browser tool result hidden",
7596 )
7597 }
7598 _ => bamboo_agent_core::tools::ToolResult::text(
7599 result.success,
7600 "Tool result hidden",
7601 ),
7602 };
7603 AgentEvent::ToolComplete {
7604 tool_call_id,
7605 result,
7606 }
7607 }
7608 AgentEvent::ToolError {
7609 tool_call_id,
7610 error,
7611 } => {
7612 let error = match self.known_kind(&tool_call_id) {
7613 Some(DisplayCallKind::Ordinary) => error,
7614 Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7615 _ => "Tool failed".into(),
7616 };
7617 AgentEvent::ToolError {
7618 tool_call_id,
7619 error,
7620 }
7621 }
7622 AgentEvent::ToolLifecycle {
7623 tool_call_id,
7624 tool_name,
7625 phase,
7626 elapsed_ms,
7627 is_mutating,
7628 auto_approved,
7629 summary,
7630 error,
7631 } => {
7632 let kind = self.known_kind(&tool_call_id);
7633 let (summary, error) = match kind {
7634 Some(DisplayCallKind::Ordinary) => (summary, error),
7635 Some(DisplayCallKind::PrivateBrowser) => (
7636 summary.map(|_| "Browser tool activity hidden".into()),
7637 error.map(|_| "Browser tool failed".into()),
7638 ),
7639 _ => (
7640 summary.map(|_| "Tool activity hidden".into()),
7641 error.map(|_| "Tool failed".into()),
7642 ),
7643 };
7644 AgentEvent::ToolLifecycle {
7645 tool_call_id,
7646 tool_name: match kind {
7647 Some(DisplayCallKind::Ordinary) => tool_name,
7648 Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7649 _ => "unknown".into(),
7650 },
7651 phase: if kind == Some(DisplayCallKind::Ordinary)
7652 || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7653 {
7654 phase
7655 } else {
7656 "event".into()
7657 },
7658 elapsed_ms,
7659 is_mutating,
7660 auto_approved,
7661 summary,
7662 error,
7663 }
7664 }
7665 AgentEvent::HookLifecycle {
7666 hook_name,
7667 point,
7668 phase,
7669 duration_ms,
7670 decision,
7671 } => {
7672 let Some(decision) = hook_result_for_display(decision, 0) else {
7673 self.fail_closed();
7674 return None;
7675 };
7676 let hook_name = self.display_hook_name(&hook_name)?;
7677 let phase = match phase.as_str() {
7678 "completed" | "started" | "error" => phase,
7679 _ => "event".into(),
7680 };
7681 AgentEvent::HookLifecycle {
7682 hook_name,
7683 point,
7684 phase,
7685 duration_ms,
7686 decision,
7687 }
7688 }
7689 AgentEvent::ChildApprovalRequested {
7690 child_session_id,
7691 request_id,
7692 tool_name,
7693 permission,
7694 resource,
7695 } => {
7696 let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7697 (
7698 "browser".into(),
7699 "Browser interaction approval".into(),
7700 "[redacted]".into(),
7701 )
7702 } else {
7703 (tool_name, permission, resource)
7704 };
7705 AgentEvent::ChildApprovalRequested {
7706 child_session_id,
7707 request_id,
7708 tool_name,
7709 permission,
7710 resource,
7711 }
7712 }
7713 AgentEvent::ChildApprovalChanged { tool_name, .. }
7714 if is_browser_tool_name(&tool_name) =>
7715 {
7716 return None;
7720 }
7721 AgentEvent::SubAgentEvent {
7722 parent_session_id,
7723 child_session_id,
7724 event,
7725 } => {
7726 if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7727 return None;
7728 }
7729 if parent_session_id.is_empty()
7730 || child_session_id.is_empty()
7731 || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7732 || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7733 {
7734 self.fail_closed();
7735 return None;
7736 }
7737 let key = (parent_session_id.clone(), child_session_id.clone());
7738 if !self.children.contains_key(&key) && !self.reserve_identity() {
7739 return None;
7740 }
7741 let identities = self.identities.clone();
7742 let child = self.children.entry(key).or_insert_with(|| {
7743 Box::new(ActorEventDisplay {
7744 identities,
7745 ..Default::default()
7746 })
7747 });
7748 let event = child.project(*event, depth + 1)?;
7749 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7750 self.fail_closed();
7751 return None;
7752 }
7753 AgentEvent::SubAgentEvent {
7754 parent_session_id,
7755 child_session_id,
7756 event: Box::new(event),
7757 }
7758 }
7759 other => other,
7760 })
7761 }
7762}
7763
7764#[allow(clippy::too_many_arguments)]
7765async fn process_actor_event(
7766 event: serde_json::Value,
7767 strict_permission_events: bool,
7768 permission_handshake: &mut PermissionPostureHandshake,
7769 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7770 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7771 logical_session: &mut Session,
7772 event_tx: &mpsc::Sender<AgentEvent>,
7773 display: &mut ActorEventDisplay,
7774) -> crate::runtime::runner::Result<()> {
7775 let event = match serde_json::from_value::<AgentEvent>(event) {
7776 Ok(event) => event,
7777 Err(_) if strict_permission_events => {
7778 return Err(AgentError::LLM(
7782 "actor emitted malformed AgentEvent under a typed permission posture contract"
7783 .to_string(),
7784 ));
7785 }
7786 Err(_) => {
7787 display.fail_closed();
7791 return Ok(());
7792 }
7793 };
7794 process_typed_actor_event(
7795 event,
7796 permission_handshake,
7797 expected_permission_posture,
7798 session_inbox_runtime,
7799 logical_session,
7800 event_tx,
7801 display,
7802 )
7803 .await
7804}
7805
7806#[allow(clippy::too_many_arguments)]
7807async fn process_typed_actor_event(
7808 event: AgentEvent,
7809 permission_handshake: &mut PermissionPostureHandshake,
7810 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7811 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7812 logical_session: &mut Session,
7813 event_tx: &mpsc::Sender<AgentEvent>,
7814 display: &mut ActorEventDisplay,
7815) -> crate::runtime::runner::Result<()> {
7816 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7817 if permission_handshake.posture_was_confirmed() {
7818 return Err(AgentError::LLM(
7819 "actor emitted a duplicate permission posture activation".to_string(),
7820 ));
7821 }
7822 let seed = permission_posture_seed_from_event(logical_session, &event)
7823 .map_err(AgentError::LLM)?
7824 .ok_or_else(|| {
7825 AgentError::LLM(
7826 "actor permission posture event did not decode as a posture".to_string(),
7827 )
7828 })?;
7829 if let Some(expected) = expected_permission_posture {
7830 if seed.policy_revision != expected.policy_revision
7831 || seed.resolution != expected.resolution
7832 {
7833 return Err(AgentError::LLM(
7834 "permission posture event does not match the host-dispatched policy"
7835 .to_string(),
7836 ));
7837 }
7838 if seed.executor_mapping() != expected.executor_mapping {
7839 return Err(AgentError::LLM(
7840 "permission posture event does not match the host-dispatched executor mapping"
7841 .to_string(),
7842 ));
7843 }
7844 }
7845 if let Some(binding) = session_inbox_runtime {
7846 let saved = binding
7847 .persistence
7848 .record_permission_posture_activation(
7849 &logical_session.id,
7850 expected_permission_posture
7851 .and_then(|expected| expected.expected_audit_revision),
7852 &seed,
7853 )
7854 .await
7855 .map_err(|error| {
7856 AgentError::LLM(format!(
7857 "persist child permission posture bootstrap: {error}"
7858 ))
7859 })?
7860 .ok_or_else(|| {
7861 AgentError::LLM(
7862 "persist child permission posture bootstrap: session not found".to_string(),
7863 )
7864 })?;
7865 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7866 .ok_or_else(|| {
7867 AgentError::LLM(
7868 "persisted child permission posture audit is incomplete".to_string(),
7869 )
7870 })?;
7871 snapshot.write_to(&mut logical_session.metadata);
7872 } else {
7873 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7876 .map_err(|error| {
7877 AgentError::LLM(format!(
7878 "record in-memory child permission posture: {error}"
7879 ))
7880 })?;
7881 }
7882 *permission_handshake = PermissionPostureHandshake::Confirmed;
7885 } else if permission_handshake.is_awaiting() {
7886 return Err(AgentError::LLM(
7887 "actor emitted an execution event before permission posture confirmation".to_string(),
7888 ));
7889 }
7890 if let Some(event) = display.project(event, 0) {
7891 let _ = event_tx.send(event).await;
7892 }
7893 Ok(())
7894}
7895
7896async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7907 let ActorDriveContext {
7908 client,
7909 parent_session_id,
7910 child_session_id,
7911 child_attempt,
7912 approval_registry,
7913 approval_decider,
7914 approval_reviewer,
7915 escalation_bridge,
7916 event_tx,
7917 cancel_token,
7918 live_rx,
7919 delivery_rx,
7920 logical_session,
7921 mut expected_permission_posture,
7922 expected_creation,
7923 session_inbox_runtime,
7924 actor_directory_store,
7925 canonical_subagent_tool,
7926 ticket_service,
7927 activation_run_id,
7928 execution_epoch,
7929 expected_source_actor_id,
7930 initial_inflight_claims,
7931 first_frame_timeout,
7932 plain_actor,
7933 remote_environment_lease,
7934 mut readonly_output,
7935 local_history_tools,
7936 local_history_read_only,
7937 mut strict_history_output,
7938 plain_input,
7939 canonical_activation,
7940 canonical_placement_ref,
7941 actor_event_observer,
7942 plain_run,
7943 } = context;
7944
7945 logical_session
7949 .metadata
7950 .remove("runtime.actor_nested_wait_handoff");
7951
7952 let mut got_first_frame = false;
7959 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7960 let mut inflight_claims = initial_inflight_claims;
7961 let mut owned_inputs = plain_input
7962 .map(|activation| VecDeque::from(activation.initial_inputs.clone()))
7963 .unwrap_or_default();
7964 let continuation_limit = if owned_inputs.is_empty() { 2 } else { 1 };
7967 let mut continuations = usize::from(!owned_inputs.is_empty());
7968 let mut released_inputs: Vec<(
7969 SessionInboxOwnedClaim,
7970 bamboo_subagent::proto::InitialInputRelease,
7971 )> = Vec::new();
7972 let mut current_epoch = execution_epoch;
7973 let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7974 let approval_cancel = cancel_token.child_token();
7975 let _approval_lifetime = approval_cancel.clone().drop_guard();
7976 let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7977 HashMap::new();
7978 let mut owned_tree_requests = 0usize;
7979 let mut canonical_subagent_requests = 0usize;
7980 let mut committed_plan_calls = HashMap::new();
7981 let mut nested_wait_registered_this_run = false;
7985 let mut parent_question_checkpoint_id: Option<String> = None;
7986 let strict_permission_events = expected_permission_posture.is_some();
7987 let mut permission_handshake =
7988 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
7989 let mut next_actor_event_seq = 1u64;
7990 let mut canonical_router: Option<ActorEventRouter> = None;
7991 let canonical_fence = plain_input
7992 .map(|activation| &activation.fence)
7993 .or(canonical_activation);
7994 let canonical_store = plain_input
7995 .map(|activation| activation.store.as_ref())
7996 .or(actor_directory_store);
7997 let remote_claim_fence = actor_directory_store.zip(canonical_activation);
7998 let workflow_usage_requested = logical_session
7999 .metadata
8000 .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
8001 .is_some_and(|v| v == "true");
8002 if workflow_usage_requested {
8003 logical_session
8004 .metadata
8005 .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
8006 logical_session
8007 .metadata
8008 .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
8009 }
8010 let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
8011 let mut display = ActorEventDisplay::default();
8012 let mut readonly = readonly_output
8013 .as_ref()
8014 .map(|_| ReadOnlyActorCollector::default());
8015 let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
8016 loop {
8017 tokio::select! {
8018 _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
8019 if remote_claim_fence.is_some() || workflow_usage_requested {
8020 let _ = client.send(ParentFrame::Cancel).await;
8026 remote_cancel_deadline = Some(
8027 tokio::time::Instant::now() + Duration::from_secs(10)
8028 );
8029 continue;
8030 }
8031 break;
8033 }
8034 _ = async {
8035 if let Some(deadline) = remote_cancel_deadline {
8036 tokio::time::sleep_until(deadline).await;
8037 } else {
8038 std::future::pending::<()>().await;
8039 }
8040 } => {
8041 return Err(AgentError::Cancelled);
8042 }
8043 _ = async {
8044 match first_frame_watch.as_mut() {
8045 Some(s) => s.as_mut().await,
8046 None => std::future::pending::<()>().await,
8047 }
8048 }, if !got_first_frame => {
8049 return Err(AgentError::WorkerUnresponsive(format!(
8050 "child {child_session_id} produced no frame within {:?}",
8051 first_frame_timeout.unwrap_or_default()
8052 )));
8053 }
8054 Some(_generation) = delivery_rx.recv(),
8055 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
8056 {
8057 if plain_input.is_some() {
8058 continue;
8061 }
8062 forward_next_canonical_claim(
8063 client,
8064 session_inbox_runtime.expect("guarded"),
8065 logical_session,
8066 activation_run_id.expect("guarded"),
8067 &mut inflight_claims,
8068 remote_claim_fence,
8069 )
8070 .await?;
8071 }
8072 Some(frame) = live_rx.recv() => {
8073 if plain_actor { return Err(plain_actor_unsupported()); }
8074 if client.send(frame).await.is_err() {
8076 tracing::warn!("live steering frame could not be sent; connection failing");
8077 }
8078 }
8079 frame = client.next_frame() => {
8080 got_first_frame = true;
8083 first_frame_watch = None;
8084 match frame {
8085 Ok(Some(ChildFrame::Event { event })) => {
8086 if canonical_activation.is_some() {
8087 return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8088 }
8089 if local_history.is_some() {
8090 return Err(local_tool_history_unsupported());
8091 }
8092 if event.get("initial_input_control").is_some() {
8093 let bamboo_subagent::proto::InitialInputControl::Request { request } =
8094 bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8095 else { return Err(plain_actor_unsupported()); };
8096 if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8097 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8098 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8099 let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8100 let previous = released_inputs.iter()
8104 .find(|(_, release)| release.request == request);
8105 let (claim, previous) = match previous {
8106 Some((claim, release)) => (claim.clone(), Some(release.clone())),
8107 None => (owned_inputs.front().cloned()
8108 .ok_or_else(|| plain_actor_unsupported())?, None),
8109 };
8110 let release = activation.release_initial_input(binding, logical_session, run, current_epoch, &claim,
8111 &request, &mut expected_permission_posture, previous.as_ref()).await?;
8112 if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8113 client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8114 .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8115 if previous.is_none() {
8116 let _ = owned_inputs.pop_front();
8117 released_inputs.push((claim, release));
8118 }
8119 continue;
8120 }
8121 if expected_creation.is_some() {
8122 return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8123 }
8124 if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8125 if event["type"] == "owned_readonly_transcript" {
8126 if plain_actor { return Err(plain_actor_unsupported()); }
8127 continue;
8128 }
8129 if plain_actor && !plain_actor_event(&event)? { continue; }
8130 if let Some(activation) = plain_input {
8131 validate_directory_actor_event(
8132 activation.store.as_ref(),
8133 &activation.fence,
8134 logical_session,
8135 None,
8136 ).await?;
8137 }
8138 process_actor_event(
8142 event,
8143 strict_permission_events,
8144 &mut permission_handshake,
8145 expected_permission_posture.as_ref(),
8146 session_inbox_runtime,
8147 logical_session,
8148 event_tx,
8149 &mut display,
8150 )
8151 .await?;
8152 }
8153 Ok(Some(ChildFrame::EventBatch { batch })) => {
8154 validate_actor_event_batch(
8155 &batch,
8156 logical_session,
8157 parent_session_id,
8158 activation_run_id,
8159 current_epoch,
8160 expected_source_actor_id,
8161 expected_creation,
8162 )?;
8163 let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8164 let entry = validate_directory_actor_event(
8165 store, fence, logical_session, batch.activation_id.as_deref(),
8166 ).await?;
8167 if canonical_placement_ref.is_some_and(|expected| {
8168 entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8169 }) {
8170 return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8171 }
8172 Some(entry)
8173 } else { None };
8174 if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8175 if canonical_router.is_none() {
8176 canonical_router = Some(
8177 ActorEventRouter::new(
8178 entry,
8179 fence,
8180 current_epoch,
8181 strict_permission_events && permission_handshake.is_awaiting(),
8182 )
8183 .map_err(actor_event_route_error)?,
8184 );
8185 }
8186 }
8187 if local_history_tools.is_some()
8188 && (batch.first_seq != next_actor_event_seq
8189 || batch.qos != bamboo_subagent::ActorEventQos::Durable
8190 || batch.last_seq == u64::MAX) {
8191 return Err(local_tool_history_unsupported());
8192 }
8193 if batch.last_seq < next_actor_event_seq {
8194 continue;
8195 }
8196 if batch.first_seq > next_actor_event_seq {
8197 if plain_actor {
8198 return Err(AgentError::LLM(
8199 "actor event sequence gap; authoritative session snapshot is required".into(),
8200 ));
8201 }
8202 tracing::warn!(
8203 child_session_id,
8204 expected_seq = next_actor_event_seq,
8205 received_seq = batch.first_seq,
8206 "actor event sequence gap; authoritative session snapshot is required"
8207 );
8208 display.fail_closed();
8212 }
8213 let skip = next_actor_event_seq
8214 .saturating_sub(batch.first_seq)
8215 .min(batch.events.len() as u64) as usize;
8216 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8217 let seq = batch.first_seq + offset as u64;
8218 let mut publish = true;
8219 if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8220 if workflow_usage_requested {
8221 if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8222 let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8223 let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8224 if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8225 if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8226 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8227 }
8228 publish = false;
8229 }
8230 if publish { if let Some(collector) = local_history.as_mut() {
8231 if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8232 return Err(local_tool_history_unsupported());
8233 }
8234 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8235 publish = collector.ticket_event(&event, &committed_plan_calls)?;
8236 }
8237 publish &= collector.event(&event)?;
8238 } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8239 publish = false;
8241 }}
8242 if publish { publish = if let Some(collector) = readonly.as_mut() {
8243 if permission_handshake.is_awaiting()
8244 && event["type"] != "permission_posture_activated" {
8245 return Err(plain_actor_unsupported());
8246 }
8247 collector.event(&event)?
8248 } else if event["type"] == "owned_readonly_transcript" {
8249 if plain_actor { return Err(plain_actor_unsupported()); }
8250 false } else if plain_actor { plain_actor_event(&event)? } else { true };
8252 }
8253 if let (Some(router), Some(entry), Some(fence)) =
8254 (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8255 {
8256 let typed = if publish {
8257 Some(serde_json::from_value::<AgentEvent>(event.clone())
8258 .map_err(|_| plain_actor_unsupported())?)
8259 } else {
8260 None
8261 };
8262 match router.route(entry, fence, current_epoch, seq, typed)
8263 .map_err(actor_event_route_error)?
8264 {
8265 ActorEventRoute::Publish(envelope) => {
8266 let public_event = actor_event_observer
8267 .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8268 .map(|_| PublicActorEvent::from(envelope.as_ref()));
8269 tracing::trace!(
8270 actor_id = %envelope.actor_id,
8271 event_id = %envelope.event_id,
8272 class = ?envelope.class,
8273 "publishing canonical Actor event"
8274 );
8275 process_typed_actor_event(
8276 envelope.payload,
8277 &mut permission_handshake,
8278 expected_permission_posture.as_ref(),
8279 session_inbox_runtime,
8280 logical_session,
8281 event_tx,
8282 &mut display,
8283 ).await?;
8284 if let (Some(observer), Some(public_event)) =
8285 (actor_event_observer, public_event)
8286 {
8287 observer.publish(public_event);
8288 }
8289 }
8290 ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8291 }
8292 next_actor_event_seq = seq.saturating_add(1);
8293 continue;
8294 }
8295 if !publish {
8296 next_actor_event_seq = seq.saturating_add(1);
8297 continue;
8298 }
8299 process_actor_event(
8300 event,
8301 strict_permission_events,
8302 &mut permission_handshake,
8303 expected_permission_posture.as_ref(),
8304 session_inbox_runtime,
8305 logical_session,
8306 event_tx,
8307 &mut display,
8308 )
8309 .await?;
8310 next_actor_event_seq = seq.saturating_add(1);
8311 }
8312 }
8313 Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8314 owned_tree_requests += 1;
8315 if owned_tree_requests > 16 {
8316 return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8317 }
8318 let page = if id.len() <= 128
8319 && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8320 && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8321 && !cancel_token.is_cancelled()
8322 {
8323 match (actor_directory_store, session_inbox_runtime,
8324 activation_run_id, expected_creation) {
8325 (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8326 tokio::time::timeout(
8327 Duration::from_secs(8),
8328 inspect_active_owned_tree(
8329 store, binding, run_id, child_session_id,
8330 parent_session_id, logical_session, creation,
8331 plain_input, cursor.as_deref(),
8332 ),
8333 ).await.ok().flatten()
8334 }
8335 _ => {
8336 tracing::warn!("owned tree denied: canonical Host scope unavailable");
8337 None
8338 },
8339 }
8340 } else {
8341 tracing::warn!("owned tree denied: request bounds or cancellation");
8342 None
8343 };
8344 if cancel_token.is_cancelled() {
8345 return Err(AgentError::Cancelled);
8346 }
8347 client.send(ParentFrame::OwnedTreeReply { id, page }).await
8348 .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8349 }
8350 Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8351 canonical_subagent_requests += 1;
8352 if canonical_subagent_requests > 64 {
8353 return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8354 }
8355 let valid_shape = !id.is_empty() && id.len() <= 128
8356 && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8357 && args.is_object()
8358 && serde_json::to_vec(&args)
8359 .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8360 let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8361 Some("wait"))
8362 || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8363 && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8364 let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8365 let ticket_bootstrap_pending = valid_shape
8366 && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8367 && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8368 && permission_handshake.is_awaiting()
8369 && !cancel_token.is_cancelled();
8370 let result = if ticket_bootstrap_pending {
8371 serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8375 } else if valid_shape
8376 && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8377 || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8378 || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8379 && !cancel_token.is_cancelled()
8380 && !permission_handshake.is_awaiting()
8381 {
8382 if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8383 match (ticket_service.as_deref(), actor_directory_store,
8384 session_inbox_runtime, activation_run_id, expected_creation) {
8385 (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8386 if let Some(caller) = load_active_subagent_caller(
8387 store, binding, run_id, child_session_id,
8388 parent_session_id, logical_session, creation, plain_input,
8389 ).await {
8390 match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8391 Ok(projection) => {
8392 logical_session.task_list = projection.task_list.clone();
8393 logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8394 if let Some(question) = &projection.question {
8395 let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8396 logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8397 if let Some(collector) = local_history.as_mut() {
8398 collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8399 }
8400 }
8401 if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8402 committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8403 if let Some(task_list) = projection.task_list.clone() {
8404 let _ = event_tx.send(AgentEvent::TaskListUpdated {
8405 task_list,
8406 version: Some(projection.plan_revision),
8407 }).await;
8408 }
8409 }
8410 serde_json::json!({"result":projection})
8411 }
8412 Err(error) => serde_json::json!({"error":error.to_string()}),
8413 }
8414 } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8415 }
8416 _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8417 }
8418 } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8419 match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8420 activation_run_id, expected_creation, local_history_tools) {
8421 (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8422 if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8423 match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8424 Ok(reply)=>{
8425 let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8426 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())?}));
8427 serde_json::json!({"result":reply})
8428 },
8429 Err(error)=>serde_json::json!({"error":error.to_string()}),
8430 }
8431 } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8432 },
8433 _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8434 }
8435 } else if let Some(payload) = checkpoint_payload {
8436 let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8437 .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8438 .flatten()
8439 .filter(|observation| observation.tool_call_id == tool_call_id);
8440 match (parsed, actor_directory_store, session_inbox_runtime,
8441 activation_run_id, expected_creation) {
8442 (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8443 let receipt = tokio::time::timeout(Duration::from_secs(25),
8444 checkpoint_active_parent_question(
8445 store, binding, run_id, child_session_id,
8446 parent_session_id, logical_session, creation,
8447 plain_input, observation,
8448 )).await.ok().flatten();
8449 if let Some(request_id) = receipt {
8450 parent_question_checkpoint_id = Some(request_id.clone());
8451 serde_json::json!({"result":{"request_id":request_id}})
8452 } else {
8453 serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8454 }
8455 }
8456 _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8457 }
8458 } else {
8459 match (canonical_subagent_tool.as_deref(), actor_directory_store,
8460 session_inbox_runtime, activation_run_id, expected_creation) {
8461 (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8462 if let Some(caller) = load_active_subagent_caller(
8463 store, binding, run_id, child_session_id,
8464 parent_session_id, logical_session, creation,
8465 plain_input,
8466 ).await {
8467 tokio::time::timeout(
8468 Duration::from_secs(30),
8469 invoke_active_subagent(tool, &caller, args, &tool_call_id),
8470 ).await.unwrap_or_else(|_| serde_json::json!({
8471 "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8472 }))
8473 } else {
8474 serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8475 }
8476 }
8477 _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8478 }
8479 }
8480 } else {
8481 serde_json::json!({"error":"canonical SubAgent request rejected"})
8482 };
8483 if registers_wait
8484 && result["result"]["success"] == true
8485 && result["result"]["result"]
8486 .as_str()
8487 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8488 .is_some_and(|value| value["status"] == "waiting")
8489 {
8490 nested_wait_registered_this_run = true;
8491 }
8492 if cancel_token.is_cancelled() {
8493 return Err(AgentError::Cancelled);
8494 }
8495 client.send(ParentFrame::SubAgentReply { id, result }).await
8496 .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8497 }
8498 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8499 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8500 return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8501 }
8502 if plain_actor { return Err(plain_actor_unsupported()); }
8503 if permission_handshake.is_awaiting() {
8504 return Err(AgentError::LLM(
8505 "actor requested approval before permission posture confirmation"
8506 .to_string(),
8507 ));
8508 }
8509 if let Some(reviewer) = approval_reviewer
8515 .cloned()
8516 .or_else(child_approval_reviewer)
8517 {
8518 let child = child_session_id.to_string();
8526 let parent = parent_session_id.to_string();
8527 let req_id = id.clone();
8528 let body = body.clone();
8529 let registry = approval_registry.cloned();
8530 let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8531 tokio::spawn(async move {
8534 let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8535 reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8536 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8537 child_attempt, &req_id, approved);
8538 });
8539 continue;
8540 };
8541 let generation = body.pointer("/permission_request/request_generation")
8542 .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8543 let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8544 if original != &body || scope.stamp()["reply"] != req_id {
8545 return Err(AgentError::LLM("approval generation changed in live scope".into()));
8546 }
8547 scope.clone()
8548 } else {
8549 if approval_scopes.len() >= 64 {
8550 return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8551 }
8552 let scope = ChildApprovalScope::new(&parent, &child,
8553 (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8554 approval_cancel.clone(), approval_epoch.clone());
8555 approval_scopes.insert(generation, (body.clone(), scope.clone()));
8556 scope
8557 };
8558 tokio::spawn(async move {
8559 let result = tokio::time::timeout(
8560 CHILD_APPROVAL_TIMEOUT,
8561 reviewer.review_scoped(&parent, &child, &body, &scope),
8562 ).await.unwrap_or(ChildApprovalReview::Reply(false));
8563 if let ChildApprovalReview::Reply(approved) = result {
8564 if scope.is_current(&parent, &child).await {
8565 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8566 child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8567 }
8568 }
8569 });
8570 } else if approval_decider.is_some() {
8571 let approved =
8575 decide_child_approval(approval_decider, child_session_id, &body)
8576 .await;
8577 if client
8578 .send(ParentFrame::ApprovalReply { id, approved })
8579 .await
8580 .is_err()
8581 {
8582 tracing::warn!(
8583 "failed to answer approval_request; connection failing"
8584 );
8585 }
8586 } else if let Some(host) = escalation_bridge.clone() {
8587 let child = child_session_id.to_string();
8594 let req_id = id.clone();
8595 let body = body.clone();
8596 let registry = approval_registry.cloned();
8597 tokio::spawn(async move {
8598 let approved = match tokio::time::timeout(
8599 CHILD_APPROVAL_TIMEOUT,
8600 host.approval_call(body),
8601 )
8602 .await
8603 {
8604 Ok(Ok(reply)) => reply
8605 .get("approved")
8606 .and_then(|v| v.as_bool())
8607 .unwrap_or(false),
8608 _ => false,
8610 };
8611 super::live::deliver_approval_scoped(
8612 registry.as_ref(),
8613 &child,
8614 child_attempt,
8615 &req_id,
8616 approved,
8617 );
8618 });
8619 } else {
8620 tracing::warn!(
8624 parent_session_id,
8625 child_session_id,
8626 request_id = %id,
8627 "forced-ask request has no parent-agent reviewer; denying"
8628 );
8629 if client
8630 .send(ParentFrame::ApprovalReply {
8631 id,
8632 approved: false,
8633 })
8634 .await
8635 .is_err()
8636 {
8637 tracing::warn!(
8638 "failed to send fail-closed approval reply; connection failing"
8639 );
8640 }
8641 }
8642 }
8643 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8644 if let Some(activation) = plain_input {
8645 if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8646 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8647 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8648 let claim = owned_inputs.front().ok_or_else(|| plain_actor_unsupported())?;
8649 activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8650 let _ = owned_inputs.pop_front();
8651 continue;
8652 }
8653 let Some(binding) = session_inbox_runtime else {
8654 tracing::warn!(
8655 child_session_id,
8656 "ignoring SessionInbox confirmation without a runtime binding"
8657 );
8658 continue;
8659 };
8660 let Some(bound_run_id) = activation_run_id else {
8661 tracing::warn!(
8662 child_session_id,
8663 "ignoring SessionInbox confirmation without an activation owner"
8664 );
8665 continue;
8666 };
8667 let Some(claim) = inflight_claims.front() else {
8668 tracing::warn!(
8669 child_session_id,
8670 envelope_id = %confirmation.envelope_id,
8671 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8672 );
8673 continue;
8674 };
8675 let exact = confirmation.target_session_id == logical_session.id
8676 && confirmation.envelope_id == claim.envelope.id.as_str()
8677 && confirmation.canonical_claim_generation == claim.generation
8678 && confirmation.activation_run_id == bound_run_id;
8679 if !exact
8680 || !binding
8681 .router
8682 .owns_run(&logical_session.id, bound_run_id)
8683 .await
8684 {
8685 tracing::warn!(
8686 child_session_id,
8687 expected_target = %logical_session.id,
8688 received_target = %confirmation.target_session_id,
8689 expected_envelope_id = %claim.envelope.id,
8690 received_envelope_id = %confirmation.envelope_id,
8691 expected_generation = claim.generation,
8692 received_generation = confirmation.canonical_claim_generation,
8693 expected_run_id = bound_run_id,
8694 received_run_id = %confirmation.activation_run_id,
8695 "rejecting stale or mismatched SessionInbox admission confirmation"
8696 );
8697 continue;
8698 }
8699 let claim = inflight_claims
8700 .pop_front()
8701 .expect("validated in-flight canonical claim");
8702 checkpoint_and_ack_canonical_claim(
8705 binding,
8706 logical_session,
8707 &claim,
8708 remote_claim_fence,
8709 )
8710 .await?;
8711 if inflight_claims.is_empty() {
8715 forward_next_canonical_claim(
8716 client,
8717 binding,
8718 logical_session,
8719 bound_run_id,
8720 &mut inflight_claims,
8721 remote_claim_fence,
8722 )
8723 .await?;
8724 }
8725 }
8726 Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8727 if local_history_tools.is_some() {
8728 validate_strict_history_terminal(
8731 final_event_watermark.as_ref(), logical_session, parent_session_id,
8732 activation_run_id, current_epoch, expected_creation,
8733 next_actor_event_seq - 1,
8734 )?;
8735 }
8736 if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8737 if let Some(tools) = local_history_tools {
8738 if permission_handshake.is_awaiting()
8742 || !owned_inputs.is_empty()
8743 || !inflight_claims.is_empty()
8744 || parent_question_checkpoint_id.is_some()
8745 || nested_wait_registered_this_run
8746 || strict_history_output.is_none()
8747 {
8748 return Err(local_tool_history_unsupported());
8749 }
8750 commit_local_tool_history(
8751 local_history.take().ok_or_else(local_tool_history_unsupported)?,
8752 logical_session,
8753 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8754 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8755 (tools, local_history_read_only),
8756 LocalToolTerminal::Cancelled,
8757 cancel_token,
8758 ).await?;
8759 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8760 logical_session,
8761 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8762 current_epoch,
8763 next_actor_event_seq - 1,
8764 ).map_err(|_| local_tool_history_unsupported())?;
8765 *strict_history_output.as_deref_mut()
8766 .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8767 proof,
8768 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8769 });
8770 }
8771 client.accept_durable_terminal(status);
8777 if workflow_usage_requested {
8778 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8779 }
8780 return Err(AgentError::Cancelled);
8781 }
8782 if parent_question_checkpoint_id.is_some()
8783 && status != TerminalStatus::Suspended
8784 {
8785 return Err(AgentError::LLM(
8786 "Child question checkpoint must end in a suspended Run".into(),
8787 ));
8788 }
8789 if permission_handshake.is_awaiting() {
8790 if remote_environment_lease && status == TerminalStatus::Error {
8791 if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8792 client.accept_durable_terminal(status);
8799 if workflow_usage_requested {
8800 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8801 }
8802 return Err(AgentError::LLM(code.to_owned()));
8803 }
8804 }
8805 return Err(AgentError::LLM(
8806 "actor terminated before permission posture confirmation"
8807 .to_string(),
8808 ));
8809 }
8810 if !owned_inputs.is_empty() {
8811 return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8812 }
8813 if let Some(claim) = inflight_claims.front() {
8814 return Err(AgentError::LLM(format!(
8815 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8816 claim.envelope.id
8817 )));
8818 }
8819 if status == TerminalStatus::Completed {
8820 if let Some(collector) = local_history.take() {
8821 commit_local_tool_history(
8822 collector, logical_session,
8823 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8824 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8825 (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8826 LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8827 ).await?;
8828 }
8829 }
8830 if let Some(collector) = readonly.take() {
8831 if status == TerminalStatus::Completed {
8832 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8833 let backlog = activation.input_inbox.inspect(&logical_session.id).await
8834 .map_err(|_| plain_actor_unsupported())?;
8835 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8836 return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8837 }
8838 *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8839 collector.finish(result.as_deref())?;
8840 }
8841 } else if let Some(activation) = plain_input {
8842 if status == TerminalStatus::Completed {
8843 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8844 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8845 if continuations < continuation_limit {
8846 if let Some((claim, epoch)) = activation.continue_input(
8847 client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8848 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8849 ).await? {
8850 owned_inputs = VecDeque::from([claim]);
8851 released_inputs.clear();
8852 continuations += 1;
8853 current_epoch = epoch;
8854 approval_epoch.store(epoch, Ordering::Release);
8855 next_actor_event_seq = 1;
8858 canonical_router = None;
8859 permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8860 display = ActorEventDisplay::default();
8861 continue;
8862 }
8863 } else if activation.input_inbox.inspect(&logical_session.id).await
8864 .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8865 if continuation_limit == 1 {
8866 return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8867 }
8868 activation.append_reply(logical_session,
8873 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8874 event_tx).await?;
8875 return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8876 }
8877 }
8878 }
8879 if status == TerminalStatus::Completed {
8880 if let (Some(tool), Some(creation)) =
8885 (canonical_subagent_tool.as_deref(), expected_creation)
8886 {
8887 let (Some(store), Some(binding), Some(run_id)) =
8888 (actor_directory_store, session_inbox_runtime,
8889 activation_run_id)
8890 else {
8891 return Err(AgentError::LLM(
8892 "nested SubAgent Host authority unavailable at Child terminal"
8893 .into(),
8894 ));
8895 };
8896 let must_suspend = ensure_active_nested_wait(
8897 tool, store, binding, run_id, child_session_id,
8898 parent_session_id, logical_session, creation, plain_input,
8899 ).await?;
8900 if must_suspend || nested_wait_registered_this_run {
8901 logical_session.metadata.insert(
8908 "runtime.actor_nested_wait_handoff".into(),
8909 "true".into(),
8910 );
8911 }
8912 }
8913 }
8914 let terminal_result = match status {
8915 TerminalStatus::Completed => Ok(result),
8916 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8917 TerminalStatus::Error => Err(AgentError::LLM(
8918 error.unwrap_or_else(|| "actor child errored".to_string()),
8919 )),
8920 TerminalStatus::Suspended => {
8921 use bamboo_agent_core::storage::Storage;
8922 let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8923 (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8924 session_inbox_runtime, activation_run_id, expected_creation)
8925 else {
8926 return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8927 };
8928 if load_active_subagent_caller(
8929 store, binding, run_id, child_session_id,
8930 parent_session_id, logical_session, creation, plain_input,
8931 ).await.is_none() {
8932 return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8933 }
8934 let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8935 .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8936 let child = store.load_session(child_session_id).await
8937 .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8938 .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8939 let parent = store.load_session(parent_session_id).await
8940 .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8941 .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8942 let pending = ParentQuestion::for_pending(&parent, &child)
8943 .is_some_and(|question| question.id == id);
8944 let answered = bamboo_domain::ParentQuestionResolution::from_child(
8945 &parent, &child, &id,
8946 ).is_some();
8947 if !pending && !answered {
8948 return Err(AgentError::LLM("canonical Child question proof changed".into()));
8949 }
8950 logical_session.metadata.insert(
8951 "runtime.actor_parent_question_handoff".into(), "true".into(),
8952 );
8953 Ok(None)
8954 }
8955 };
8956 if local_history_tools.is_some() {
8957 if status != TerminalStatus::Completed {
8961 return terminal_result;
8962 }
8963 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8964 logical_session,
8965 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8966 current_epoch,
8967 next_actor_event_seq - 1,
8968 ).map_err(|_| local_tool_history_unsupported())?;
8969 let history = StrictTerminalHistory {
8970 proof,
8971 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8972 };
8973 if let Some(output) = strict_history_output.as_deref_mut() {
8974 *output = Some(history);
8975 }
8976 }
8977 client.accept_durable_terminal(status);
8984 if workflow_usage_requested {
8985 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8986 }
8987 if status == TerminalStatus::Completed {
8988 if let Some(activation) = plain_input {
8989 *activation.completion_router.lock().recover_poison() =
8992 canonical_router.take();
8993 }
8994 }
8995 return terminal_result;
8996 }
8997 Ok(None) => {
8998 return Err(AgentError::LLM(
8999 "actor child closed before terminal".to_string(),
9000 ));
9001 }
9002 Err(e) => {
9003 return Err(AgentError::LLM(format!("actor transport error: {e}")));
9004 }
9005 }
9006 }
9007 }
9008 }
9009
9010 let _ = client.send(ParentFrame::Cancel).await;
9012 Err(AgentError::Cancelled)
9013}
9014
9015fn project_id_for_actor_run(
9017 session: &Session,
9018) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
9019 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
9020 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
9021 Ok(Some(project_id))
9022 }
9023 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
9024 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
9025 Err(AgentError::LLM(format!(
9026 "child session carries an invalid Project identity '{raw}': {message}"
9027 )))
9028 }
9029 }
9030}
9031
9032fn remote_broker_unavailable() -> AgentError {
9033 AgentError::LLM("remote_broker_activation_unavailable".into())
9034}
9035
9036fn remote_environment_error_code(value: &str) -> Option<&'static str> {
9040 const CODES: &[&str] = &[
9041 "remote_environment_lease_invalid",
9042 "remote_environment_snapshot_mismatch",
9043 "remote_environment_checkout_not_clean",
9044 "remote_environment_checkout_unsupported",
9045 "remote_environment_workspace_unavailable",
9046 "remote_environment_workspace_outside_git_root",
9047 "remote_environment_path_unsupported",
9048 "remote_environment_git_unavailable",
9049 "remote_environment_git_inspection_failed",
9050 "remote_environment_git_inspection_timed_out",
9051 "remote_environment_git_output_too_large",
9052 "remote_environment_snapshot_too_large",
9053 "remote_environment_snapshot_changed",
9054 "remote_environment_index_invalid",
9055 "remote_environment_index_unsupported",
9056 "remote_environment_host_path_or_grants_rejected",
9057 "remote_environment_worker_workspace_missing",
9058 "remote_environment_runspec_invalid",
9059 "remote_environment_lease_missing_or_host_path_present",
9060 ];
9061 CODES.iter().copied().find(|code| *code == value)
9062}
9063
9064async fn remote_canonical_lineage(
9067 binding: &SessionInboxRuntimeBinding,
9068 session: &Session,
9069) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
9070 let mut current = bamboo_domain::ActorSession::from_session(session)
9071 .map_err(|_| remote_broker_unavailable())?;
9072 let mut chain = Vec::new();
9073 loop {
9074 if chain.len() > session.spawn_depth as usize {
9075 return Err(remote_broker_unavailable());
9076 }
9077 let saved = binding
9078 .storage
9079 .load_session(¤t.actor_id)
9080 .await
9081 .map_err(|_| remote_broker_unavailable())?
9082 .ok_or_else(remote_broker_unavailable)?;
9083 let observed = bamboo_domain::ActorSession::from_session(&saved)
9084 .map_err(|_| remote_broker_unavailable())?;
9085 if observed.actor_id != current.actor_id
9086 || observed.session_created_at != current.session_created_at
9087 || observed.parent_actor_id != current.parent_actor_id
9088 || observed.root_actor_id != current.root_actor_id
9089 || observed.spawn_depth != current.spawn_depth
9090 || observed.project_id != current.project_id
9091 {
9092 return Err(remote_broker_unavailable());
9093 }
9094 chain.push(observed.clone());
9095 let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9096 if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9097 return Err(remote_broker_unavailable());
9098 }
9099 return Ok(chain);
9100 };
9101 let parent = binding
9102 .storage
9103 .load_session(parent_id)
9104 .await
9105 .map_err(|_| remote_broker_unavailable())?
9106 .ok_or_else(remote_broker_unavailable)?;
9107 let parent = bamboo_domain::ActorSession::from_session(&parent)
9108 .map_err(|_| remote_broker_unavailable())?;
9109 if parent.root_actor_id != observed.root_actor_id
9110 || parent.project_id != observed.project_id
9111 || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9112 || parent.session_created_at > observed.session_created_at
9113 {
9114 return Err(remote_broker_unavailable());
9115 }
9116 current = parent;
9117 }
9118}
9119
9120async fn canonical_child_creation(
9121 binding: &SessionInboxRuntimeBinding,
9122 session: &Session,
9123 job: &SpawnJob,
9124) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9125 let conflict = || {
9126 AgentError::LLM(
9127 "child creation identity unavailable or changed in canonical host Storage".into(),
9128 )
9129 };
9130 let current = binding
9131 .storage
9132 .load_session(&job.child_session_id)
9133 .await
9134 .map_err(|_| conflict())?
9135 .ok_or_else(conflict)?;
9136 if bamboo_domain::ActorSession::from_session(¤t).is_err()
9137 || bamboo_domain::ActorSession::from_session(session).is_err()
9138 || current.kind != bamboo_domain::SessionKind::Child
9139 || session.kind != current.kind
9140 || current.id != session.id
9141 || current.id != job.child_session_id
9142 || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9143 || current.parent_session_id != session.parent_session_id
9144 || current.root_session_id.trim().is_empty()
9145 || current.root_session_id != session.root_session_id
9146 || current.created_at != session.created_at
9147 || current.spawn_depth != session.spawn_depth
9148 || current.spawn_depth == 0
9149 || project_id_for_actor_run(¤t)? != project_id_for_actor_run(session)?
9150 {
9151 return Err(conflict());
9152 }
9153 Ok(bamboo_subagent::proto::ChildCreationIdentity {
9154 created_at: current.created_at,
9155 spawn_depth: current.spawn_depth,
9156 })
9157}
9158
9159fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9160 LogicalSessionIdentity {
9161 creation: None,
9162 session_id: session.id.clone(),
9163 parent_session_id: session
9164 .parent_session_id
9165 .clone()
9166 .or_else(|| Some(job.parent_session_id.clone())),
9167 root_session_id: if session.root_session_id.trim().is_empty() {
9168 job.parent_session_id.clone()
9169 } else {
9170 session.root_session_id.clone()
9171 },
9172 }
9173}
9174
9175fn extract_assignment(session: &Session) -> String {
9176 session
9177 .messages
9178 .iter()
9179 .rev()
9180 .find(|m| matches!(m.role, Role::User))
9181 .map(|m| m.content.clone())
9182 .unwrap_or_else(|| {
9183 session
9184 .metadata
9185 .get("title")
9186 .cloned()
9187 .unwrap_or_else(|| "Execute task".to_string())
9188 })
9189}
9190
9191#[cfg(test)]
9192mod tests {
9193 use super::*;
9194 use crate::SessionActivationRouter;
9195 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9196
9197 include!("expired_plain_owner_tests.rs");
9198
9199 include!("owned_cold_input_tests.rs");
9200
9201 #[test]
9202 fn plain_rejection_logs_branch_and_run_without_changing_error() {
9203 let capture_interest_guard = tracing::Dispatch::new(
9205 tracing_subscriber::fmt()
9206 .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9207 .with_writer(std::io::sink)
9208 .finish(),
9209 );
9210 let output = tempfile::NamedTempFile::new().unwrap();
9211 let subscriber = tracing_subscriber::fmt()
9212 .with_ansi(false)
9213 .without_time()
9214 .with_writer(output.reopen().unwrap())
9215 .finish();
9216 let branch_line = std::cell::Cell::new(0);
9217 let error = tracing::subscriber::with_default(subscriber, || {
9218 std::thread::spawn(|| drop(plain_actor_unsupported()))
9220 .join()
9221 .expect("unscoped rejection warning thread panicked");
9222 let span = tracing::warn_span!(
9223 "actor_child_run",
9224 child_session_id = %"diagnostic-child",
9225 activation_run_id = tracing::field::Empty
9226 );
9227 let _entered = span.enter();
9228 span.record("activation_run_id", "diagnostic-run");
9229 None::<()>
9230 .ok_or_else(|| {
9231 let caller_line = line!() + 1;
9232 let error = plain_actor_unsupported();
9233 branch_line.set(caller_line);
9234 error
9235 })
9236 .unwrap_err()
9237 });
9238 assert!(matches!(&error, AgentError::LLM(message)
9239 if message.starts_with("This Actor Child supports a fresh plain activation,")
9240 && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9241 let log = std::fs::read_to_string(output.path()).unwrap();
9242 assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9243 assert!(
9244 log.contains("activation_run_id=\"diagnostic-run\""),
9245 "{log}"
9246 );
9247 assert!(
9248 log.contains(&format!("process_id={}", std::process::id())),
9249 "{log}"
9250 );
9251 assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9252 assert!(
9253 log.contains(&format!("source_line={}", branch_line.get())),
9254 "{log}"
9255 );
9256 assert!(!error.to_string().contains("diagnostic-run"));
9257 drop(capture_interest_guard);
9258 }
9259
9260 #[tokio::test]
9261 async fn completed_plain_continuation_requires_bounded_inputs_and_current_identity() {
9262 for case in [
9263 "valid",
9264 "over-batch-limit",
9265 "tool-history",
9266 "stale-lineage",
9267 "no-release",
9268 ] {
9269 let temp = tempfile::tempdir().unwrap();
9270 let store = Arc::new(
9271 bamboo_storage::SessionStoreV2::new(temp.path().into())
9272 .await
9273 .unwrap(),
9274 );
9275 let parent = Session::new("continuation-parent", "model");
9276 store.save_session(&parent).await.unwrap();
9277 let mut child =
9278 Session::new_child_of("continuation-child", &parent, "model", "first task");
9279 child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9280 if case == "tool-history" {
9281 let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9282 tool.role = Role::Tool;
9283 child.add_message(tool);
9284 }
9285 child.set_last_run_status("completed");
9286 store.save_session(&child).await.unwrap();
9287 store.ensure_actor(&child.id).await.unwrap();
9288 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9289 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9290 let now = chrono::Utc::now();
9291 let old = store
9292 .claim_activation(&ActorActivationClaim {
9293 actor_id: child.id.clone(),
9294 run_id: "first-run".into(),
9295 lease_owner: "first-owner".into(),
9296 lease_expires_at: now + chrono::Duration::minutes(1),
9297 inbox_generation: 0,
9298 placement_ref: None,
9299 now,
9300 })
9301 .await
9302 .unwrap();
9303 store.start_activation(&old.fence(), now).await.unwrap();
9304 store
9305 .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9306 .await
9307 .unwrap();
9308 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9309 store.clone(),
9310 Default::default(),
9311 ));
9312 for slot in 0..if case == "over-batch-limit" {
9313 bamboo_domain::SessionInboxLimits::default().max_claim_batch + 1
9314 } else {
9315 1
9316 } {
9317 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9318 &child.id,
9319 format!("new task {slot}"),
9320 );
9321 let receipt = inbox.deliver(&envelope).await.unwrap();
9322 inbox
9323 .mark_activation_eligible(
9324 &child.id,
9325 receipt.generation,
9326 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9327 )
9328 .await
9329 .unwrap();
9330 }
9331 let storage: Arc<dyn Storage> = store.clone();
9332 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9333 let binding = actor_binding(store.clone(), inbox, persistence);
9334 let _registration = binding
9335 .router
9336 .register_run(&child.id, "second-run")
9337 .await
9338 .unwrap();
9339 if case == "stale-lineage" {
9340 child.parent_session_id = Some("foreign-parent".into());
9341 }
9342 let result = PlainActorActivation::start(
9343 store.clone(),
9344 &child,
9345 &binding,
9346 Some("second-run"),
9347 Some(policy),
9348 true,
9349 (case != "no-release").then_some("second-worker"),
9350 )
9351 .await;
9352 if case == "valid" {
9353 let activation = result.expect("one new input on a completed plain Child");
9354 assert_eq!(activation.fence.attempt, old.attempt + 1);
9355 assert_eq!(activation.fence.actor_id, child.id);
9356 assert_eq!(activation.inbox_generation, 1);
9357 activation
9358 .finish(ActorActivationFinish::Failed)
9359 .await
9360 .unwrap();
9361 } else {
9362 assert!(
9363 result.is_err(),
9364 "unsupported continuation {case} must fail before claim"
9365 );
9366 assert_eq!(
9367 store
9368 .inspect_actor(&child.id)
9369 .await
9370 .unwrap()
9371 .actor
9372 .current_attempt,
9373 old.attempt
9374 );
9375 }
9376 let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9377 assert_eq!(
9378 canonical.parent_session_id.as_deref(),
9379 Some(parent.id.as_str())
9380 );
9381 assert!(canonical
9382 .messages
9383 .iter()
9384 .any(|message| message.content == "first result"));
9385 }
9386 }
9387
9388 #[tokio::test]
9389 async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9390 let temp = tempfile::tempdir().unwrap();
9391 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9392 .await
9393 .unwrap();
9394 let parent = Session::new("placement-parent", "model");
9395 let child = Session::new_child_of("placement-child", &parent, "model", "task");
9396 store.save_session(&parent).await.unwrap();
9397 store.save_session(&child).await.unwrap();
9398 store.ensure_actor(&child.id).await.unwrap();
9399 store
9400 .bind_control_plane(
9401 &child.id,
9402 bamboo_domain::ActorControlPlaneBinding {
9403 policy_revision: None,
9404 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9405 pool_id: "remote-worker".into(),
9406 }),
9407 },
9408 )
9409 .await
9410 .unwrap();
9411
9412 let claim = |run_id: &str| ActorActivationClaim {
9413 actor_id: child.id.clone(),
9414 run_id: run_id.into(),
9415 lease_owner: format!("host-{run_id}"),
9416 lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9417 inbox_generation: 0,
9418 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9419 class: bamboo_domain::ActorPlacementClass::Remote,
9420 lease_id: format!("slot-{run_id}"),
9421 slot_epoch: Some(1),
9422 }),
9423 now: chrono::Utc::now(),
9424 };
9425 let first = store.claim_activation(&claim("first")).await.unwrap();
9426 store
9427 .start_activation(&first.fence(), chrono::Utc::now())
9428 .await
9429 .unwrap();
9430 assert!(store.claim_activation(&claim("retry")).await.is_err());
9431
9432 fail_pre_dispatch_activation(&store, &first.fence())
9435 .await
9436 .unwrap();
9437 let failed = store.inspect_actor(&child.id).await.unwrap();
9438 assert_eq!(
9439 failed.activation.unwrap().status,
9440 bamboo_domain::ActorActivationStatus::Failed
9441 );
9442 let retry = store.claim_activation(&claim("retry")).await.unwrap();
9443 assert_eq!(retry.attempt, first.attempt + 1);
9444 assert!(fail_pre_dispatch_activation(&store, &first.fence())
9445 .await
9446 .is_err());
9447 assert_eq!(
9448 store.inspect_actor(&child.id).await.unwrap().activation,
9449 Some(retry)
9450 );
9451 }
9452
9453 #[tokio::test]
9454 async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9455 struct AcceptedWithoutReceipt {
9456 published: bool,
9457 }
9458
9459 #[async_trait]
9460 impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9461 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9462 assert!(matches!(frame, ParentFrame::Run(_)));
9463 self.published = true;
9464 Err(bamboo_subagent::TransportError::Protocol(
9465 "broker delivery receipt lost after publication".into(),
9466 ))
9467 }
9468
9469 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9470 std::future::pending().await
9471 }
9472 }
9473
9474 let temp = tempfile::tempdir().unwrap();
9475 let store = Arc::new(
9476 bamboo_storage::SessionStoreV2::new(temp.path().into())
9477 .await
9478 .unwrap(),
9479 );
9480 let parent = Session::new("dispatch-parent", "model");
9481 let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9482 store.save_session(&parent).await.unwrap();
9483 store.save_session(&child).await.unwrap();
9484 store.ensure_actor(&child.id).await.unwrap();
9485 let now = chrono::Utc::now();
9486 let claim = ActorActivationClaim {
9487 actor_id: child.id.clone(),
9488 run_id: "dispatch-run".into(),
9489 lease_owner: "dispatch-host".into(),
9490 lease_expires_at: now + chrono::Duration::seconds(80),
9491 inbox_generation: 0,
9492 placement_ref: None,
9493 now,
9494 };
9495 let activation = store.claim_activation(&claim).await.unwrap();
9496 store
9497 .start_activation(&activation.fence(), chrono::Utc::now())
9498 .await
9499 .unwrap();
9500 let mut link = AcceptedWithoutReceipt { published: false };
9501 let mut send_invoked = false;
9502 let run = RunSpec {
9503 assignment: "task".into(),
9504 logical_session: None,
9505 project_id: None,
9506 reasoning_effort: None,
9507 permission_policy: None,
9508 messages: Vec::new(),
9509 activation_run_id: Some(claim.run_id.clone()),
9510 execution_epoch: 1,
9511 initial_session_messages: Vec::new(),
9512 secrets: Default::default(),
9513 };
9514 assert!(send_actor_run(&mut link, run, &mut send_invoked)
9515 .await
9516 .is_err());
9517 assert!(send_invoked && link.published);
9518 let slot = WorkerSlotLease {
9519 lease_id: "dispatch-slot".into(),
9520 host_ref: "dispatch-worker".into(),
9521 connection_generation: "dispatch-generation".into(),
9522 slot: 0,
9523 epoch: 1,
9524 actor_id: child.id.clone(),
9525 run_id: claim.run_id.clone(),
9526 expires_at: claim.lease_expires_at,
9527 };
9528 let mut lease = ActivePlacementLease {
9529 registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9530 .await
9531 .unwrap(),
9532 actor_store: store.clone(),
9533 route: ScopedPlacementRoute {
9534 endpoint: String::new(),
9535 token: String::new(),
9536 ca_cert_file: PathBuf::new(),
9537 parent: bamboo_subagent::AgentRef {
9538 session_id: "dispatch-parent".into(),
9539 role: Some("host".into()),
9540 },
9541 worker_role: "worker".into(),
9542 pinned_mailbox: None,
9543 requirements: Default::default(),
9544 },
9545 mailbox: "dispatch-worker".into(),
9546 host_ref: "dispatch-worker".into(),
9547 fence: activation.fence(),
9548 placement_ref: bamboo_domain::ActorPlacementRef {
9549 class: bamboo_domain::ActorPlacementClass::Remote,
9550 lease_id: slot.lease_id.clone(),
9551 slot_epoch: Some(slot.epoch),
9552 },
9553 lease: Arc::new(tokio::sync::Mutex::new(slot)),
9554 heartbeat: tokio::spawn(std::future::pending()),
9555 run_cancel: CancellationToken::new(),
9556 lost: Arc::new(AtomicBool::new(false)),
9557 release_on_drop: AtomicBool::new(false),
9558 };
9559 assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9560 assert_eq!(
9561 store
9562 .inspect_actor(&child.id)
9563 .await
9564 .unwrap()
9565 .activation
9566 .unwrap()
9567 .status,
9568 bamboo_domain::ActorActivationStatus::Running
9569 );
9570 let mut retry = claim.clone();
9571 retry.run_id = "dispatch-retry".into();
9572 retry.now = chrono::Utc::now();
9573 retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9574 assert!(matches!(
9575 store.claim_activation(&retry).await,
9576 Err(bamboo_domain::ActorDirectoryError::Busy)
9577 ));
9578 }
9579
9580 #[test]
9581 fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9582 let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9583 assert_eq!(
9584 remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9585 ActorActivationFinish::Cancelled
9586 );
9587 assert_eq!(
9588 remote_activation_finish(&cancelled, None),
9589 ActorActivationFinish::Failed
9590 );
9591 assert_eq!(
9592 remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9593 ActorActivationFinish::Failed
9594 );
9595 }
9596
9597 #[tokio::test]
9598 async fn remote_reply_is_fenced_before_terminal_status_save() {
9599 let temp = tempfile::tempdir().unwrap();
9600 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9601 .await
9602 .unwrap();
9603 let parent = Session::new("remote-reply-parent", "model");
9604 let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9605 child.add_message(bamboo_agent_core::Message::user("task"));
9606 store.save_session(&parent).await.unwrap();
9607 store.save_session(&child).await.unwrap();
9608 store.ensure_actor(&child.id).await.unwrap();
9609 store
9610 .bind_control_plane(
9611 &child.id,
9612 bamboo_domain::ActorControlPlaneBinding {
9613 policy_revision: None,
9614 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9615 pool_id: "remote-worker".into(),
9616 }),
9617 },
9618 )
9619 .await
9620 .unwrap();
9621 let now = chrono::Utc::now();
9622 let activation = store
9623 .claim_activation(&ActorActivationClaim {
9624 actor_id: child.id.clone(),
9625 run_id: "remote-reply-run".into(),
9626 lease_owner: "remote-worker".into(),
9627 lease_expires_at: now + chrono::Duration::seconds(80),
9628 inbox_generation: 0,
9629 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9630 class: bamboo_domain::ActorPlacementClass::Remote,
9631 lease_id: "remote-slot".into(),
9632 slot_epoch: Some(1),
9633 }),
9634 now,
9635 })
9636 .await
9637 .unwrap();
9638 let fence = activation.fence();
9639 store.start_activation(&fence, now).await.unwrap();
9640
9641 append_fenced_remote_reply(&store, &fence, &mut child, "done")
9642 .await
9643 .unwrap();
9644 store
9645 .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9646 .await
9647 .unwrap();
9648 child.set_last_run_status("completed");
9649 store.save_session(&child).await.unwrap();
9650 let saved = store.load_session(&child.id).await.unwrap().unwrap();
9651 assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9652 assert_eq!(saved.messages.len(), 2);
9653 assert_eq!(saved.messages.last().unwrap().content, "done");
9654 assert!(
9655 append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9656 .await
9657 .is_err()
9658 );
9659 assert_eq!(
9660 store
9661 .load_session(&child.id)
9662 .await
9663 .unwrap()
9664 .unwrap()
9665 .messages
9666 .len(),
9667 2
9668 );
9669 }
9670
9671 async fn start_bus() -> (String, tempfile::TempDir) {
9672 let dir = tempfile::tempdir().unwrap();
9673 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9674 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9675 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9676 let addr = listener.local_addr().unwrap();
9677 tokio::spawn(async move {
9678 let _ = server.serve(listener).await;
9679 });
9680 (format!("ws://{addr}"), dir)
9681 }
9682
9683 struct DurableAckProbe {
9684 calls: Arc<AtomicUsize>,
9685 fail: bool,
9686 terminal_status: TerminalStatus,
9687 parent_mailbox: String,
9688 }
9689
9690 #[async_trait]
9691 impl bamboo_subagent::ChildLink for DurableAckProbe {
9692 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9693 Ok(())
9694 }
9695
9696 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9697 Ok(None)
9698 }
9699
9700 fn has_pending_durable_terminal(&self) -> bool {
9701 true
9702 }
9703
9704 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9705 Some(bamboo_subagent::DurableChildDeliveryReceipt {
9706 broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9707 parent_mailbox: self.parent_mailbox.clone(),
9708 correlation_id: "broker-run-1".into(),
9709 message_ids: vec!["broker-outcome-1".into()],
9710 terminal_status: self.terminal_status,
9711 })
9712 }
9713
9714 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9715 self.calls.fetch_add(1, Ordering::SeqCst);
9716 if self.fail {
9717 Err(bamboo_subagent::TransportError::Protocol(
9718 "injected ACK delivery failure".into(),
9719 ))
9720 } else {
9721 Ok(())
9722 }
9723 }
9724 }
9725
9726 async fn insert_ack_probe(
9727 runner: &ActorChildRunner,
9728 session: &Session,
9729 calls: &Arc<AtomicUsize>,
9730 terminal_status: TerminalStatus,
9731 ) {
9732 insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9733 }
9734
9735 async fn insert_ack_probe_with_nested_wait(
9736 runner: &ActorChildRunner,
9737 session: &Session,
9738 calls: &Arc<AtomicUsize>,
9739 terminal_status: TerminalStatus,
9740 host_nested_wait_handoff: bool,
9741 ) {
9742 runner.pending_durable_links.lock().await.insert(
9743 (session.id.clone(), session.created_at, "run-1".into()),
9744 PendingDurableChildLink {
9745 link: Box::new(DurableAckProbe {
9746 calls: calls.clone(),
9747 fail: false,
9748 terminal_status,
9749 parent_mailbox: format!("p-{}", session.id),
9750 }),
9751 host_nested_wait_handoff,
9752 strict_history: None,
9753 strict_history_required: false,
9754 strict_history_committed: false,
9755 },
9756 );
9757 }
9758
9759 #[tokio::test]
9760 async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9761 let (endpoint, broker_dir) = start_bus().await;
9762 let home = tempfile::tempdir().unwrap();
9763 let store = Arc::new(
9764 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9765 .await
9766 .unwrap(),
9767 );
9768 let root = Session::new("receipt-root", "model");
9769 store.save_session(&root).await.unwrap();
9770 let mut sender = bamboo_broker::BrokerClient::connect(
9771 &endpoint,
9772 bamboo_subagent::AgentRef {
9773 session_id: "receipt-worker".into(),
9774 role: None,
9775 },
9776 "t",
9777 )
9778 .await
9779 .unwrap();
9780 let broker_identity = sender.durable_broker_identity().await.unwrap();
9781 let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9782 "00000000-0000-4000-8000-000000000002"
9783 } else {
9784 "00000000-0000-4000-8000-000000000001"
9785 };
9786
9787 for (id, receipt_mailbox, receipt_broker) in [
9788 ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9789 (
9790 "receipt-wrong-mailbox",
9791 "p-another-child",
9792 broker_identity.as_str(),
9793 ),
9794 (
9795 "receipt-wrong-broker",
9796 "p-receipt-wrong-broker",
9797 wrong_broker_identity,
9798 ),
9799 (
9800 "receipt-local-role-changed",
9801 "p-receipt-local-role-changed",
9802 broker_identity.as_str(),
9803 ),
9804 (
9805 "receipt-remote-unproven",
9806 "remote-parent-unproven",
9807 broker_identity.as_str(),
9808 ),
9809 ] {
9810 let mut child = Session::new_child_of(id, &root, "model", "task");
9811 child
9812 .metadata
9813 .insert("runtime.kind".into(), "external".into());
9814 child
9815 .metadata
9816 .insert("external.protocol".into(), "actor".into());
9817 child.metadata.insert(
9818 "external.agent_id".into(),
9819 crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9820 );
9821 if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9822 child
9823 .metadata
9824 .insert("subagent_type".into(), "remote-worker".into());
9825 }
9826 child.add_message(bamboo_agent_core::Message::user("work"));
9827 store.save_session(&child).await.unwrap();
9828 let message_id = bamboo_subagent::MsgId::new();
9829 sender
9830 .deliver(
9831 &format!("p-{id}"),
9832 bamboo_subagent::InboxMessage {
9833 id: message_id.clone(),
9834 from: bamboo_subagent::AgentRef {
9835 session_id: "receipt-worker".into(),
9836 role: None,
9837 },
9838 kind: bamboo_subagent::InboxKind::Outcome,
9839 body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9840 "done",
9841 ))
9842 .unwrap(),
9843 created_at: chrono::Utc::now(),
9844 correlation_id: Some(bamboo_subagent::MsgId::new()),
9845 },
9846 )
9847 .await
9848 .unwrap();
9849 let mailbox =
9852 bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9853 assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9854 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9855 child.set_last_run_status("completed");
9856 if id == "receipt-good" {
9857 let prior_ids = child.messages[..child.messages.len() - 1]
9858 .iter()
9859 .map(|message| message.id.clone())
9860 .collect();
9861 crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9862 assert!(
9863 crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9864 &mut child, "run-1"
9865 )
9866 .unwrap()
9867 );
9868 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9869 &child, "run-1", 7, 1,
9870 )
9871 .unwrap();
9872 store
9873 .prepare_broker_terminal_receipt_with_completeness(
9874 &child,
9875 bamboo_storage::v2::BrokerTerminalRoute {
9876 activation_run_id: "run-1",
9877 broker_identity: receipt_broker,
9878 parent_mailbox: receipt_mailbox,
9879 broker_correlation_id: "correlation-1",
9880 message_ids: &[message_id.as_str().to_owned()],
9881 },
9882 &proof,
9883 )
9884 .await
9885 .unwrap();
9886 let mut staged = child.clone();
9887 staged.set_last_run_status("running");
9888 staged.clear_last_run_error();
9889 store.save_session(&staged).await.unwrap();
9890 store
9891 .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9892 .await
9893 .unwrap();
9894 } else {
9895 store
9896 .prepare_broker_terminal_receipt(
9897 &child,
9898 "run-1",
9899 receipt_broker,
9900 receipt_mailbox,
9901 "correlation-1",
9902 &[message_id.as_str().to_owned()],
9903 )
9904 .await
9905 .unwrap();
9906 store.save_session(&child).await.unwrap();
9907 }
9908 }
9909
9910 let mut config = bamboo_config::Config::default();
9911 config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9912 endpoint,
9913 token: "t".into(),
9914 ..Default::default()
9915 });
9916 config
9917 .subagents_mut()
9918 .remote_placements
9919 .push(bamboo_config::RemoteActorPlacement {
9920 role: "remote-worker".into(),
9921 endpoint: "ws://unscoped.example.invalid".into(),
9922 token_env: None,
9923 ca_cert_file: None,
9924 broker_peer: None,
9925 placement_requirements: None,
9926 });
9927 let cold_store = Arc::new(
9930 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9931 .await
9932 .unwrap(),
9933 );
9934 let before = cold_store
9935 .load_session("receipt-good")
9936 .await
9937 .unwrap()
9938 .unwrap();
9939 assert!(crate::execution::ChildCompletionSource::has_source_record(
9940 &before
9941 ));
9942 assert_eq!(before.last_run_status().as_deref(), Some("running"));
9943 assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9944 let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9945 let report = repair.reconcile_once().await;
9946 assert_eq!(report.candidates, 5);
9947 assert_eq!(report.repaired, 2);
9948 assert_eq!(report.blocked, 3);
9949 let after = cold_store
9950 .load_session("receipt-good")
9951 .await
9952 .unwrap()
9953 .unwrap();
9954 assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9955 assert_eq!(
9956 serde_json::to_value(before.messages).unwrap(),
9957 serde_json::to_value(after.messages).unwrap()
9958 );
9959 let retry = repair.reconcile_once().await;
9960 assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9961
9962 let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9963 let wrong = bamboo_subagent::Mailbox::at(
9964 broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9965 );
9966 let wrong_broker = bamboo_subagent::Mailbox::at(
9967 broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9968 );
9969 let local_role_changed = bamboo_subagent::Mailbox::at(
9970 broker_dir
9971 .path()
9972 .join("mailboxes/p-receipt-local-role-changed"),
9973 );
9974 let remote_unproven = bamboo_subagent::Mailbox::at(
9975 broker_dir
9976 .path()
9977 .join("mailboxes/p-receipt-remote-unproven"),
9978 );
9979 assert_eq!(good.pending_count().await.unwrap(), 0);
9980 assert_eq!(wrong.pending_count().await.unwrap(), 1);
9981 assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9982 assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9983 assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9984 let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9985 let wrong_child = store
9986 .load_session("receipt-wrong-mailbox")
9987 .await
9988 .unwrap()
9989 .unwrap();
9990 let wrong_broker_child = store
9991 .load_session("receipt-wrong-broker")
9992 .await
9993 .unwrap()
9994 .unwrap();
9995 let local_role_changed_child = store
9996 .load_session("receipt-local-role-changed")
9997 .await
9998 .unwrap()
9999 .unwrap();
10000 let remote_unproven_child = store
10001 .load_session("receipt-remote-unproven")
10002 .await
10003 .unwrap()
10004 .unwrap();
10005 assert!(store
10006 .recover_broker_terminal_receipts(&good_child)
10007 .await
10008 .unwrap()
10009 .is_empty());
10010 assert_eq!(
10011 store
10012 .recover_broker_terminal_receipts(&wrong_child)
10013 .await
10014 .unwrap()
10015 .len(),
10016 1
10017 );
10018 assert_eq!(
10019 store
10020 .recover_broker_terminal_receipts(&wrong_broker_child)
10021 .await
10022 .unwrap()
10023 .len(),
10024 1
10025 );
10026 assert!(store
10027 .recover_broker_terminal_receipts(&local_role_changed_child)
10028 .await
10029 .unwrap()
10030 .is_empty());
10031 assert_eq!(
10032 store
10033 .recover_broker_terminal_receipts(&remote_unproven_child)
10034 .await
10035 .unwrap()
10036 .len(),
10037 1
10038 );
10039 }
10040
10041 #[tokio::test]
10042 async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
10043 let runner = ActorChildRunner::new(
10044 "test".into(),
10045 PathBuf::new(),
10046 vec![],
10047 PathBuf::new(),
10048 ExecutorSpec::BambooRuntime,
10049 vec![],
10050 "test".into(),
10051 1,
10052 );
10053 let home = tempfile::tempdir().unwrap();
10054 let store = Arc::new(
10055 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10056 .await
10057 .unwrap(),
10058 );
10059 let root = Session::new("ack-parent", "model");
10060 store.save_session(&root).await.unwrap();
10061 let mut child = Session::new_child_of("ack-child", &root, "model", "task");
10062 child.add_message(bamboo_agent_core::Message::user("work"));
10063 store.save_session(&child).await.unwrap();
10064 child.add_message(bamboo_agent_core::Message::assistant("done", None));
10065 child.set_last_run_status("completed");
10066 store
10067 .prepare_broker_terminal_receipt(
10068 &child,
10069 "run-1",
10070 "00000000-0000-4000-8000-000000000001",
10071 "p-ack-child",
10072 "broker-run-1",
10073 &["broker-outcome-1".into()],
10074 )
10075 .await
10076 .unwrap();
10077 store.save_session(&child).await.unwrap();
10078 runner.set_actor_directory_store(Some(store));
10079 let calls = Arc::new(AtomicUsize::new(0));
10080 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10081 runner
10082 .confirm_durable_child_delivery(&child, "wrong-run", true)
10083 .await
10084 .unwrap();
10085 let mut wrong_birth = child.clone();
10086 wrong_birth.created_at += chrono::Duration::seconds(1);
10087 runner
10088 .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
10089 .await
10090 .unwrap();
10091 assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
10092 child.set_last_run_status("completed");
10093 runner
10094 .confirm_durable_child_delivery(&child, "run-1", false)
10095 .await
10096 .unwrap();
10097 assert_eq!(calls.load(Ordering::SeqCst), 0);
10098
10099 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10100 child.set_last_run_status("error");
10101 runner
10102 .confirm_durable_child_delivery(&child, "run-1", true)
10103 .await
10104 .unwrap();
10105 assert_eq!(calls.load(Ordering::SeqCst), 0);
10106
10107 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10108 child.set_last_run_status("completed");
10109 runner
10110 .confirm_durable_child_delivery(&child, "run-1", true)
10111 .await
10112 .unwrap();
10113 assert_eq!(calls.load(Ordering::SeqCst), 1);
10114 }
10115
10116 #[tokio::test]
10117 async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10118 for commit in [false, true] {
10119 let runner = ActorChildRunner::new(
10120 "test".into(),
10121 PathBuf::new(),
10122 vec![],
10123 PathBuf::new(),
10124 ExecutorSpec::BambooRuntime,
10125 vec![],
10126 "test".into(),
10127 1,
10128 );
10129 let home = tempfile::tempdir().unwrap();
10130 let store = Arc::new(
10131 bamboo_storage::SessionStoreV2::new(home.path().into())
10132 .await
10133 .unwrap(),
10134 );
10135 let root = Session::new("strict-ack-parent", "model");
10136 store.save_session(&root).await.unwrap();
10137 let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10138 child.add_message(bamboo_agent_core::Message::user("work"));
10139 store.save_session(&child).await.unwrap();
10140 child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10141 child.set_last_run_status("completed");
10142 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10143 &child, "run-1", 7, 2,
10144 )
10145 .unwrap();
10146 let creation = bamboo_subagent::proto::ChildCreationIdentity {
10147 created_at: child.created_at,
10148 spawn_depth: child.spawn_depth,
10149 };
10150 let history = StrictTerminalHistory {
10151 proof: proof.clone(),
10152 watermark: bamboo_subagent::ActorEventWatermark {
10153 version: 1,
10154 logical_session: Some(actor_event_logical_identity(
10155 &child,
10156 &root.id,
10157 Some(&creation),
10158 )),
10159 activation_id: Some("run-1".into()),
10160 execution_epoch: 7,
10161 final_seq: 2,
10162 },
10163 };
10164 let parent_mailbox = "p-strict-ack-child";
10165 store
10166 .prepare_broker_terminal_receipt_with_completeness(
10167 &child,
10168 bamboo_storage::v2::BrokerTerminalRoute {
10169 activation_run_id: "run-1",
10170 broker_identity: "00000000-0000-4000-8000-000000000001",
10171 parent_mailbox,
10172 broker_correlation_id: "broker-run-1",
10173 message_ids: &["broker-outcome-1".into()],
10174 },
10175 &proof,
10176 )
10177 .await
10178 .unwrap();
10179 store.save_session(&child).await.unwrap();
10180 runner.set_actor_directory_store(Some(store.clone()));
10181 let calls = Arc::new(AtomicUsize::new(0));
10182 runner.pending_durable_links.lock().await.insert(
10183 (child.id.clone(), child.created_at, "run-1".into()),
10184 PendingDurableChildLink {
10185 link: Box::new(DurableAckProbe {
10186 calls: calls.clone(),
10187 fail: true,
10188 terminal_status: TerminalStatus::Completed,
10189 parent_mailbox: parent_mailbox.into(),
10190 }),
10191 host_nested_wait_handoff: false,
10192 strict_history: Some(history),
10193 strict_history_required: true,
10194 strict_history_committed: false,
10195 },
10196 );
10197 if commit {
10198 runner
10199 .commit_durable_child_delivery(&child, "run-1")
10200 .await
10201 .unwrap();
10202 assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10203 }
10204 assert!(runner
10205 .confirm_durable_child_delivery(&child, "run-1", true)
10206 .await
10207 .is_err());
10208 assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10209 let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10210 .await
10211 .unwrap();
10212 let recovered = cold.recover_broker_terminal_receipts(&child).await;
10213 if commit {
10214 let receipts = recovered.unwrap();
10215 assert_eq!(receipts.len(), 1);
10216 assert_eq!(
10217 receipts[0]
10218 .terminal_completeness
10219 .as_ref()
10220 .unwrap()
10221 .contiguous_applied_seq,
10222 2
10223 );
10224 assert_eq!(
10225 cold.load_session(&child.id)
10226 .await
10227 .unwrap()
10228 .unwrap()
10229 .last_run_status()
10230 .as_deref(),
10231 Some("completed")
10232 );
10233 } else {
10234 assert!(
10235 recovered.is_err(),
10236 "matching canonical history is not proof commitment"
10237 );
10238 }
10239 assert_eq!(
10240 serde_json::to_value(
10241 cold.load_session(&child.id)
10242 .await
10243 .unwrap()
10244 .unwrap()
10245 .messages
10246 )
10247 .unwrap(),
10248 serde_json::to_value(&child.messages).unwrap(),
10249 );
10250 }
10251 }
10252
10253 #[tokio::test]
10254 async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10255 for (terminal_status, status) in [
10256 (TerminalStatus::Error, "error"),
10257 (TerminalStatus::Cancelled, "cancelled"),
10258 ] {
10259 let runner = ActorChildRunner::new(
10260 "test".into(),
10261 PathBuf::new(),
10262 vec![],
10263 PathBuf::new(),
10264 ExecutorSpec::BambooRuntime,
10265 vec![],
10266 "test".into(),
10267 1,
10268 );
10269 let home = tempfile::tempdir().unwrap();
10270 let store = Arc::new(
10271 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10272 .await
10273 .unwrap(),
10274 );
10275 let root = Session::new("failed-ack-parent", "model");
10276 store.save_session(&root).await.unwrap();
10277 let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10278 child.add_message(bamboo_agent_core::Message::user("work"));
10279 store.save_session(&child).await.unwrap();
10280 child.set_last_run_status(status);
10281 child.set_last_run_error(format!("worker {status}"));
10282 store
10283 .prepare_broker_terminal_receipt(
10284 &child,
10285 "run-1",
10286 "00000000-0000-4000-8000-000000000001",
10287 "p-failed-ack-child",
10288 "broker-run-1",
10289 &["broker-outcome-1".into()],
10290 )
10291 .await
10292 .unwrap();
10293 store.save_session(&child).await.unwrap();
10294 runner.set_actor_directory_store(Some(store));
10295 let calls = Arc::new(AtomicUsize::new(0));
10296 insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10297 runner
10298 .confirm_durable_child_delivery(&child, "run-1", true)
10299 .await
10300 .unwrap();
10301 assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10302 }
10303 }
10304
10305 #[tokio::test]
10306 async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10307 assert_eq!(
10308 effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10309 "suspended"
10310 );
10311 for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10312 assert!(effective_broker_terminal_status(raw, true).is_err());
10313 }
10314 let runner = ActorChildRunner::new(
10315 "test".into(),
10316 PathBuf::new(),
10317 vec![],
10318 PathBuf::new(),
10319 ExecutorSpec::BambooRuntime,
10320 vec![],
10321 "test".into(),
10322 1,
10323 );
10324 let home = tempfile::tempdir().unwrap();
10325 let store = Arc::new(
10326 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10327 .await
10328 .unwrap(),
10329 );
10330 let root = Session::new("nested-ack-parent", "model");
10331 store.save_session(&root).await.unwrap();
10332 let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10333 child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10334 store.save_session(&child).await.unwrap();
10335 child.set_last_run_status("suspended");
10336 store
10337 .prepare_broker_terminal_receipt(
10338 &child,
10339 "run-1",
10340 "00000000-0000-4000-8000-000000000001",
10341 "p-nested-ack-child",
10342 "broker-run-1",
10343 &["broker-outcome-1".into()],
10344 )
10345 .await
10346 .unwrap();
10347 store.save_session(&child).await.unwrap();
10348 runner.set_actor_directory_store(Some(store.clone()));
10349 let calls = Arc::new(AtomicUsize::new(0));
10350
10351 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10352 runner
10353 .confirm_durable_child_delivery(&child, "run-1", true)
10354 .await
10355 .unwrap();
10356 assert_eq!(calls.load(Ordering::SeqCst), 0);
10357
10358 insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10359 .await;
10360 runner
10361 .confirm_durable_child_delivery(&child, "run-1", true)
10362 .await
10363 .unwrap();
10364 assert_eq!(calls.load(Ordering::SeqCst), 1);
10365 assert_eq!(
10366 store
10367 .load_session(&child.id)
10368 .await
10369 .unwrap()
10370 .unwrap()
10371 .last_run_status()
10372 .as_deref(),
10373 Some("suspended")
10374 );
10375 assert!(store
10376 .recover_broker_terminal_receipts(&child)
10377 .await
10378 .unwrap()
10379 .is_empty());
10380 }
10381
10382 #[tokio::test]
10383 async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10384 struct CommittedObserver {
10385 authority: PathBuf,
10386 events: std::sync::Mutex<Vec<PublicActorEvent>>,
10387 }
10388 impl ActorEventObserver for CommittedObserver {
10389 fn publish(&self, event: PublicActorEvent) {
10390 let entry: bamboo_domain::ActorDirectoryEntry =
10392 serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10393 let committed = entry.activation.unwrap();
10394 assert_eq!(
10395 committed.status,
10396 bamboo_domain::ActorActivationStatus::Succeeded
10397 );
10398 assert_eq!(committed.activation_id, event.activation_id);
10399 assert!(committed.finished_at.is_some());
10400 self.events.lock().unwrap().push(event);
10401 }
10402 }
10403 struct CompletionLink(VecDeque<ChildFrame>);
10404 #[async_trait]
10405 impl bamboo_subagent::ChildLink for CompletionLink {
10406 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10407 Ok(())
10408 }
10409 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10410 Ok(self.0.pop_front())
10411 }
10412 }
10413 for cut in [
10414 "success",
10415 "no_terminal",
10416 "invalid_terminal",
10417 "stale",
10418 "storage_failure",
10419 "append_failure",
10420 ] {
10421 let temp = tempfile::tempdir().unwrap();
10422 let store = Arc::new(
10423 bamboo_storage::SessionStoreV2::new(temp.path().into())
10424 .await
10425 .unwrap(),
10426 );
10427 let root = Session::new("notify-root", "model");
10428 store.save_session(&root).await.unwrap();
10429 let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10430 store.save_session(&child).await.unwrap();
10431 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10432 store.clone(),
10433 bamboo_domain::SessionInboxLimits::default(),
10434 ));
10435 let binding = actor_binding(
10436 store.clone(),
10437 inbox,
10438 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10439 );
10440 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10441 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10442 let activation = PlainActorActivation::start(
10443 store.clone(),
10444 &child,
10445 &binding,
10446 Some("notify-run"),
10447 Some(policy),
10448 false,
10449 None,
10450 )
10451 .await
10452 .unwrap();
10453 let _owner = binding
10454 .router
10455 .register_run(&child.id, "notify-run")
10456 .await
10457 .unwrap();
10458 let batch = ActorEventBatch {
10459 logical_session: Some(LogicalSessionIdentity {
10460 session_id: child.id.clone(),
10461 parent_session_id: child.parent_session_id.clone(),
10462 root_session_id: child.root_session_id.clone(),
10463 creation: None,
10464 }),
10465 activation_id: Some("notify-run".into()),
10466 execution_epoch: 9,
10467 source_node_id: None,
10468 source_actor_id: Some("notify-worker".into()),
10469 first_seq: 1,
10470 last_seq: 2,
10471 qos: bamboo_subagent::ActorEventQos::Durable,
10472 events: vec![
10473 serde_json::to_value(AgentEvent::Token {
10474 content: "PRIVATE_PROVIDER_TOKEN".into(),
10475 })
10476 .unwrap(),
10477 serde_json::to_value(AgentEvent::message_appended(
10478 &child.id,
10479 &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10480 ))
10481 .unwrap(),
10482 ],
10483 };
10484 let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10485 if cut != "no_terminal" {
10486 let mut terminal = completed_actor_frame();
10487 if cut == "invalid_terminal" {
10488 if let ChildFrame::Terminal { status, .. } = &mut terminal {
10489 *status = TerminalStatus::Suspended;
10490 }
10491 }
10492 frames.push_back(terminal);
10493 }
10494 let mut link = CompletionLink(frames);
10495 let (tx, mut rx) = mpsc::channel(8);
10496 let (_live, mut live_rx) = mpsc::unbounded_channel();
10497 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10498 let cancel = CancellationToken::new();
10499 let outcome = drive(ActorDriveContext {
10500 client: &mut link,
10501 parent_session_id: "notify-root",
10502 child_session_id: "notify-child",
10503 child_attempt: 0,
10504 approval_registry: None,
10505 approval_decider: None,
10506 approval_reviewer: None,
10507 escalation_bridge: None,
10508 event_tx: &tx,
10509 cancel_token: &cancel,
10510 live_rx: &mut live_rx,
10511 delivery_rx: &mut delivery_rx,
10512 logical_session: &mut child,
10513 expected_permission_posture: None,
10514 expected_creation: None,
10515 session_inbox_runtime: Some(&binding),
10516 actor_directory_store: Some(store.as_ref()),
10517 canonical_subagent_tool: None,
10518 ticket_service: None,
10519 activation_run_id: Some("notify-run"),
10520 execution_epoch: 9,
10521 expected_source_actor_id: "notify-worker",
10522 initial_inflight_claims: VecDeque::new(),
10523 plain_actor: true,
10524 remote_environment_lease: false,
10525 readonly_output: None,
10526 local_history_tools: None,
10527 local_history_read_only: false,
10528 strict_history_output: None,
10529 plain_input: Some(&activation),
10530 canonical_activation: None,
10531 canonical_placement_ref: None,
10532 actor_event_observer: None,
10533 plain_run: None,
10534 first_frame_timeout: Some(Duration::from_secs(1)),
10535 })
10536 .await;
10537 let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10538 assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10539 assert_eq!(
10540 activation.completion_router.lock().unwrap().is_some(),
10541 admitted,
10542 "{cut}"
10543 );
10544 while rx.try_recv().is_ok() {}
10545 let authority = temp
10546 .path()
10547 .join(store.resolve_rel_path(&child.id).await.unwrap())
10548 .join("actor-authority.json");
10549 let observer = CommittedObserver {
10550 authority: authority.clone(),
10551 events: Default::default(),
10552 };
10553 if cut == "append_failure" {
10554 child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10555 assert!(activation
10556 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10557 .await
10558 .is_err());
10559 activation
10560 .finish(ActorActivationFinish::Failed)
10561 .await
10562 .unwrap();
10563 assert!(observer.events.lock().unwrap().is_empty());
10564 continue;
10565 }
10566 activation
10567 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10568 .await
10569 .unwrap();
10570 assert!(
10571 rx.try_recv().is_ok(),
10572 "legacy content notification retained"
10573 );
10574 assert!(
10575 observer.events.lock().unwrap().is_empty(),
10576 "reply alone must not publish"
10577 );
10578 if cut == "stale" {
10579 store
10580 .retire_actor(&child.id, chrono::Utc::now())
10581 .await
10582 .unwrap();
10583 } else if cut == "storage_failure" {
10584 std::fs::remove_file(&authority).unwrap();
10587 std::fs::create_dir(&authority).unwrap();
10588 }
10589 let result = activation
10590 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10591 .await;
10592 assert_eq!(
10593 result.is_ok(),
10594 matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10595 "{cut}"
10596 );
10597 let events = observer.events.lock().unwrap().clone();
10598 assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10599 if let Some(event) = events.first() {
10600 assert_eq!(event.actor_id, child.id);
10601 assert_eq!(event.root_actor_id, root.id);
10602 assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10603 assert_eq!(event.source_order.sequence, 3);
10604 let json = serde_json::to_string(event).unwrap();
10605 assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10606 let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10607 assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10608 }
10609 assert!(activation
10610 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10611 .await
10612 .is_err());
10613 assert_eq!(
10614 observer.events.lock().unwrap().len(),
10615 events.len(),
10616 "repeat finish cannot publish"
10617 );
10618 }
10619 }
10620
10621 async fn bind_local_control_plane(
10622 store: &bamboo_storage::SessionStoreV2,
10623 actor_id: &str,
10624 policy: &bamboo_tools::permission::PermissionConfig,
10625 ) {
10626 store
10627 .bind_control_plane(
10628 actor_id,
10629 bamboo_domain::ActorControlPlaneBinding {
10630 policy_revision: Some(policy.policy_revision()),
10631 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10632 },
10633 )
10634 .await
10635 .unwrap();
10636 }
10637
10638 fn local_tool_sample(
10639 host: &Session,
10640 denied_write: bool,
10641 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10642 local_tool_sample_with_reasoning(host, denied_write, None)
10643 }
10644
10645 fn local_tool_sample_with_reasoning(
10646 host: &Session,
10647 denied_write: bool,
10648 reasoning: Option<&str>,
10649 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10650 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10651 let calls = ["Read", "Write"]
10652 .map(|name| ToolCall {
10653 id: format!("local-{name}"),
10654 tool_type: "function".into(),
10655 function: FunctionCall {
10656 name: name.into(),
10657 arguments: "{\"file_path\":\"answer.txt\"}".into(),
10658 },
10659 })
10660 .to_vec();
10661 let mut rows = vec![Message::assistant("checking", Some(calls))];
10662 rows[0].reasoning = reasoning.map(str::to_owned);
10663 let mut collector = LocalToolCollector::default();
10664 if let Some(reasoning) = reasoning {
10665 assert!(collector
10666 .event(
10667 &serde_json::to_value(AgentEvent::ReasoningToken {
10668 content: reasoning.into(),
10669 })
10670 .unwrap()
10671 )
10672 .unwrap());
10673 }
10674 for name in ["Read", "Write"] {
10675 let denied = name == "Write" && denied_write;
10676 let id = format!("local-{name}");
10677 if !denied {
10678 collector
10679 .event(
10680 &serde_json::to_value(AgentEvent::ToolStart {
10681 tool_call_id: id.clone(),
10682 tool_name: name.into(),
10683 arguments: serde_json::json!({"file_path":"answer.txt"}),
10684 })
10685 .unwrap(),
10686 )
10687 .unwrap();
10688 }
10689 let text = if denied {
10690 "Error: native_tool_ceiling_denied: Write"
10691 } else {
10692 "actual file result"
10693 };
10694 let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10695 message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10696 "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10697 rows.push(message);
10698 let event = if denied {
10699 AgentEvent::ToolError {
10700 tool_call_id: id,
10701 error: "native_tool_ceiling_denied: Write".into(),
10702 }
10703 } else {
10704 AgentEvent::ToolComplete {
10705 tool_call_id: id,
10706 result: bamboo_agent_core::tools::ToolResult::text(true, text),
10707 }
10708 };
10709 collector
10710 .event(&serde_json::to_value(event).unwrap())
10711 .unwrap();
10712 }
10713 rows.push(Message::assistant("complete exact report", None));
10714 rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10715 if let Some(reasoning) = reasoning {
10716 assert!(collector
10717 .event(
10718 &serde_json::to_value(AgentEvent::ReasoningToken {
10719 content: reasoning.into(),
10720 })
10721 .unwrap()
10722 )
10723 .unwrap());
10724 }
10725 let mut messages = host.messages.clone();
10726 if let Some(system) = messages
10727 .iter_mut()
10728 .find(|message| message.role == Role::System)
10729 {
10730 system.content = "worker-private system never imported".into();
10731 }
10732 messages.extend(rows.iter().cloned());
10733 let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10734 version: 1,
10735 messages: messages
10736 .iter()
10737 .map(|message| serde_json::to_value(message).unwrap())
10738 .collect(),
10739 };
10740 assert!(!collector
10741 .event(&serde_json::to_value(data).unwrap())
10742 .unwrap());
10743 (collector, rows)
10744 }
10745
10746 #[test]
10747 fn ticket_task_event_requires_exact_host_callback_receipt() {
10748 let mut collector = LocalToolCollector::default();
10749 let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10750 let start = serde_json::to_value(AgentEvent::ToolStart {
10751 tool_call_id: "task-call".into(),
10752 tool_name: "Task".into(),
10753 arguments: args.clone(),
10754 })
10755 .unwrap();
10756 let mut receipts = HashMap::new();
10757 assert!(collector.ticket_event(&start, &receipts).unwrap());
10758 collector.event(&start).unwrap();
10759 let complete = serde_json::to_value(AgentEvent::ToolComplete {
10760 tool_call_id: "task-call".into(),
10761 result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10762 })
10763 .unwrap();
10764 assert!(collector.ticket_event(&complete, &receipts).is_err());
10765 receipts.insert("other-call".into(), args.clone());
10766 assert!(collector.ticket_event(&complete, &receipts).is_err());
10767 receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10768 assert!(collector.ticket_event(&complete, &receipts).is_err());
10769 receipts.insert("task-call".into(), args);
10770 assert!(collector.ticket_event(&complete, &receipts).unwrap());
10771 let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10772 task_list: bamboo_tools::TaskTool::task_list_from_args(
10773 &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10774 "root",
10775 )
10776 .unwrap(),
10777 version: Some(999),
10778 })
10779 .unwrap();
10780 assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10781 }
10782
10783 #[test]
10784 fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10785 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10786 let mut host = Session::new("question-child", "model");
10787 host.messages.push(Message::user("own assignment"));
10788 let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10789 "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10790 "generation":1, "contract_revision":1, "prompt_revision":1,
10791 "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10792 "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10793 })).unwrap();
10794 let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10795 for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10796 let mut returned =
10797 serde_json::json!({"status":"waiting_for_answer", "request":question});
10798 if case == "forged_request" {
10799 returned["request"]["id"] = serde_json::json!("sibling-question");
10800 }
10801 let text = returned.to_string();
10802 let rows = [
10803 Message::assistant(
10804 "",
10805 Some(vec![ToolCall {
10806 id: "own-call".into(),
10807 tool_type: "function".into(),
10808 function: FunctionCall {
10809 name: "Task".into(),
10810 arguments: args.to_string(),
10811 },
10812 }]),
10813 ),
10814 Message::tool_result_with_status("own-call", &text, true),
10815 ];
10816 let collector = LocalToolCollector {
10817 messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10818 starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10819 outcomes: HashMap::from([("own-call".into(), (true, text))]),
10820 ticket_yield: (case != "no_host_receipt")
10821 .then(|| ("own-call".into(), question.clone())),
10822 };
10823 let terminal = if case == "fake_report" {
10824 Some("fake delivery")
10825 } else {
10826 Some("")
10827 };
10828 assert_eq!(
10829 collector
10830 .suffix(&host, &["Task".into()], false, terminal)
10831 .is_ok(),
10832 case == "exact",
10833 "{case}"
10834 );
10835 }
10836 }
10837
10838 #[test]
10839 fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10840 let mut host = Session::new("tool-history-prefix", "model");
10841 host.messages
10842 .push(bamboo_agent_core::Message::user("assignment"));
10843 let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10844 confirmed.metadata = Some(
10845 serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10846 );
10847 host.messages.push(confirmed);
10848 let tools = vec!["Read".into(), "Write".into()];
10849 let (good, rows) = local_tool_sample(&host, false);
10850 assert_eq!(
10851 serde_json::to_value(
10852 good.suffix(&host, &tools, false, Some("complete exact report"))
10853 .unwrap()
10854 )
10855 .unwrap(),
10856 serde_json::to_value(&rows).unwrap()
10857 );
10858 for case in [
10859 "missing",
10860 "changed",
10861 "metadata",
10862 "system",
10863 "user",
10864 "collision",
10865 "dangling",
10866 "result",
10867 "terminal",
10868 "tool_metadata",
10869 "grant",
10870 "start_arguments",
10871 "ledger",
10872 ] {
10873 let (mut collector, _) = local_tool_sample(&host, false);
10874 let mut current = host.clone();
10875 let messages = collector.messages.as_mut().unwrap();
10876 match case {
10877 "missing" => {
10878 messages.remove(host.messages.len() - 1);
10879 }
10880 "changed" => messages[0].content.push('!'),
10881 "metadata" => messages[1].metadata = None,
10882 "system" => messages.last_mut().unwrap().role = Role::System,
10883 "user" => messages.last_mut().unwrap().role = Role::User,
10884 "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10885 "dangling" => {
10886 messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10887 }
10888 "result" => messages[host.messages.len() + 1].content.push('!'),
10889 "tool_metadata" => {
10890 messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10891 true.into()
10892 }
10893 "grant" => {
10894 messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10895 .function
10896 .name = "Bash".into()
10897 }
10898 "start_arguments" => {
10899 collector.starts.get_mut("local-Read").unwrap().1 =
10900 serde_json::json!({"file_path":"different.txt"})
10901 }
10902 "ledger" => {
10903 current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10904 bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10905 bamboo_domain::ProviderFamily::OpenAi,
10906 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10907 bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10908 bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10909 serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10910 ).unwrap(),
10911 ]).unwrap();
10912 }
10913 "terminal" => {}
10914 _ => unreachable!(),
10915 }
10916 let terminal = if case == "terminal" {
10917 "different"
10918 } else {
10919 "complete exact report"
10920 };
10921 assert!(
10922 collector
10923 .suffix(¤t, &tools, false, Some(terminal))
10924 .is_err(),
10925 "{case}"
10926 );
10927 }
10928 let (denied, _) = local_tool_sample(&host, true);
10929 assert!(denied
10930 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10931 .is_ok());
10932 let (mut contradicted, _) = local_tool_sample(&host, true);
10933 contradicted.starts.insert(
10934 "local-Write".into(),
10935 ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10936 );
10937 assert!(contradicted
10938 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10939 .is_err());
10940 let (successful, _) = local_tool_sample(&host, false);
10941 assert!(successful
10942 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10943 .is_err());
10944 let mut empty = LocalToolCollector::default();
10945 assert!(empty
10946 .event(
10947 &serde_json::to_value(AgentEvent::ReasoningToken {
10948 content: "private".into()
10949 })
10950 .unwrap()
10951 )
10952 .unwrap());
10953 assert!(
10954 empty
10955 .suffix(&host, &tools, false, Some("complete exact report"))
10956 .is_err(),
10957 "stream observations alone cannot commit history"
10958 );
10959 let (mut completed, _) = local_tool_sample(&host, false);
10960 assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10961 }
10962
10963 struct LocalToolConcurrentCheckpoint {
10964 real: SessionInboxRuntimeBinding,
10965 claim: SessionInboxClaim,
10966 }
10967 #[async_trait]
10968 impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10969 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10970 self.real.persistence.save_runtime_session(session).await
10971 }
10972 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10973 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10976 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10977 .await
10978 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10979 self.real
10980 .persistence
10981 .checkpoint_runtime_session(session)
10982 .await
10983 }
10984 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10985 self.real.storage.load_session(id).await
10986 }
10987 }
10988
10989 #[tokio::test]
10990 async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
10991 for case in [
10992 "success",
10993 "reasoning",
10994 "concurrent_input",
10995 "checkpoint_error",
10996 "stale_prefix",
10997 "cancelled",
10998 "lost_owner",
10999 ] {
11000 let temp = tempfile::tempdir().unwrap();
11001 let store = Arc::new(
11002 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11003 .await
11004 .unwrap(),
11005 );
11006 let root = Session::new("local-tool-root", "model");
11007 store.save_session(&root).await.unwrap();
11008 let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
11009 let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
11010 system.role = Role::System;
11011 child.messages.push(system);
11012 child
11013 .messages
11014 .push(bamboo_agent_core::Message::user("assignment"));
11015 store.save_session(&child).await.unwrap();
11016 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11017 store.clone(),
11018 bamboo_domain::SessionInboxLimits::default(),
11019 ));
11020 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11021 let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11022 let mut owner = Some(
11023 binding
11024 .router
11025 .register_run(&child.id, "current-run")
11026 .await
11027 .unwrap(),
11028 );
11029 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
11030 &child.id,
11031 "current confirmed User",
11032 );
11033 let receipt = inbox.deliver(&envelope).await.unwrap();
11034 inbox
11035 .mark_activation_eligible(
11036 &child.id,
11037 receipt.generation,
11038 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11039 )
11040 .await
11041 .unwrap();
11042 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11043 checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
11044 .await
11045 .unwrap();
11046 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
11047 let before = serde_json::to_value(&child).unwrap();
11048 let reasoning = (case == "reasoning")
11049 .then_some("Complete plain reasoning before Read and the final reply 🪷.");
11050 let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
11051 let mut binding = binding;
11052 let late = bamboo_domain::SessionMessageEnvelope::user_input(
11053 &child.id,
11054 "concurrent confirmed User",
11055 );
11056 if case == "concurrent_input" {
11057 let receipt = inbox.deliver(&late).await.unwrap();
11058 inbox
11059 .mark_activation_eligible(
11060 &child.id,
11061 receipt.generation,
11062 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11063 )
11064 .await
11065 .unwrap();
11066 let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11067 binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
11068 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11069 claim: late_claim,
11070 });
11071 }
11072 if case == "checkpoint_error" {
11073 binding.persistence = Arc::new(ActorFaultingPersistence {
11074 inner: locked.clone(),
11075 fail_checkpoint_once: AtomicBool::new(true),
11076 });
11077 }
11078 if case == "stale_prefix" {
11079 let mut latest = child.clone();
11080 latest
11081 .messages
11082 .push(bamboo_agent_core::Message::user("new confirmed prefix"));
11083 store.save_session(&latest).await.unwrap();
11084 }
11085 let cancel = CancellationToken::new();
11086 if case == "cancelled" {
11087 cancel.cancel();
11088 }
11089 if case == "lost_owner" {
11090 owner.take().unwrap().abandon().await;
11091 }
11092 let durable_before =
11093 serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11094 let result = commit_local_tool_history(
11095 collector,
11096 &mut child,
11097 &binding,
11098 "current-run",
11099 (&["Read".into(), "Write".into()], false),
11100 LocalToolTerminal::Completed(Some("complete exact report")),
11101 &cancel,
11102 )
11103 .await;
11104 let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11105 .await
11106 .unwrap()
11107 .load_session(&child.id)
11108 .await
11109 .unwrap()
11110 .unwrap();
11111 if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11112 assert!(result.is_err(), "{case}");
11113 assert_eq!(
11114 serde_json::to_value(&child).unwrap(),
11115 before,
11116 "{case}: no partial mutable success"
11117 );
11118 assert_eq!(
11119 serde_json::to_value(Some(&cold)).unwrap(),
11120 durable_before,
11121 "{case}: no write"
11122 );
11123 continue;
11124 }
11125 result.unwrap();
11126 assert_eq!(
11127 serde_json::to_value(&cold.messages[..3]).unwrap(),
11128 before["messages"]
11129 );
11130 let suffix_start = if case == "concurrent_input" {
11131 assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11132 assert_eq!(
11133 serde_json::to_value(&cold.messages[3]).unwrap(),
11134 serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11135 );
11136 assert!(child
11137 .messages
11138 .iter()
11139 .any(|message| message.id == late.id.as_str()));
11140 4
11141 } else {
11142 3
11143 };
11144 assert_eq!(
11145 serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11146 serde_json::to_value(rows).unwrap()
11147 );
11148 assert_eq!(
11149 cold.messages
11150 .iter()
11151 .filter(|message| message.id == envelope.id.as_str())
11152 .count(),
11153 1
11154 );
11155 assert_eq!(
11156 cold.messages
11157 .iter()
11158 .filter(|message| message.content == "complete exact report")
11159 .count(),
11160 1
11161 );
11162 assert_eq!(cold.metadata, child.metadata);
11163 assert!(cold.provider_transcript.is_empty());
11164 }
11165 }
11166
11167 #[tokio::test]
11168 async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11169 for case in [
11170 "legacy",
11171 "missing",
11172 "gap",
11173 "duplicate",
11174 "epoch",
11175 "source",
11176 "birth",
11177 "qos",
11178 "ticket_forged_success",
11179 ] {
11180 let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11181 session
11182 .messages
11183 .push(bamboo_agent_core::Message::user("original"));
11184 if case == "ticket_forged_success" {
11185 session.metadata.insert(
11186 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11187 "assignment".into(),
11188 );
11189 }
11190 let before = serde_json::to_value(&session).unwrap();
11191 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11192 created_at: session.created_at,
11193 spawn_depth: session.spawn_depth,
11194 };
11195 let mut messages = session.messages.clone();
11196 messages.push(bamboo_agent_core::Message::assistant("complete", None));
11197 let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11198 version: 1,
11199 messages: messages
11200 .iter()
11201 .map(|message| serde_json::to_value(message).unwrap())
11202 .collect(),
11203 })
11204 .unwrap();
11205 let mut batch = ActorEventBatch {
11206 logical_session: Some(LogicalSessionIdentity {
11207 session_id: session.id.clone(),
11208 parent_session_id: session.parent_session_id.clone(),
11209 root_session_id: session.root_session_id.clone(),
11210 creation: Some(creation.clone()),
11211 }),
11212 activation_id: Some("current".into()),
11213 execution_epoch: 7,
11214 source_node_id: None,
11215 source_actor_id: Some("selected".into()),
11216 first_seq: 1,
11217 last_seq: 1,
11218 qos: bamboo_subagent::proto::ActorEventQos::Durable,
11219 events: vec![event.clone()],
11220 };
11221 match case {
11222 "gap" => {
11223 batch.first_seq = 2;
11224 batch.last_seq = 2;
11225 }
11226 "epoch" => batch.execution_epoch = 6,
11227 "source" => batch.source_actor_id = Some("replaced".into()),
11228 "birth" => {
11229 batch
11230 .logical_session
11231 .as_mut()
11232 .unwrap()
11233 .creation
11234 .as_mut()
11235 .unwrap()
11236 .created_at += chrono::Duration::nanoseconds(1)
11237 }
11238 "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11239 "ticket_forged_success" => {
11240 batch.last_seq = 2;
11241 batch.events = vec![
11242 serde_json::to_value(AgentEvent::ToolStart {
11243 tool_call_id: "forged".into(), tool_name: "Task".into(),
11244 arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11245 }).unwrap(),
11246 serde_json::to_value(AgentEvent::ToolComplete {
11247 tool_call_id: "forged".into(),
11248 result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11249 }).unwrap(),
11250 ];
11251 }
11252 _ => {}
11253 }
11254 let terminal = ChildFrame::Terminal {
11255 status: TerminalStatus::Completed,
11256 result: Some("complete".into()),
11257 error: None,
11258 final_event_watermark: None,
11259 transcript: vec![],
11260 };
11261 let frames = match case {
11262 "legacy" => vec![ChildFrame::Event { event }, terminal],
11263 "missing" => vec![terminal],
11264 "duplicate" => vec![
11265 ChildFrame::EventBatch {
11266 batch: batch.clone(),
11267 },
11268 ChildFrame::EventBatch { batch },
11269 terminal,
11270 ],
11271 _ => vec![ChildFrame::EventBatch { batch }, terminal],
11272 };
11273 let mut link = ConfirmationSequenceLink {
11274 frames: frames.into(),
11275 sent: vec![],
11276 };
11277 let (tx, mut rx) = mpsc::channel(32);
11278 let (_live, mut live_rx) = mpsc::unbounded_channel();
11279 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11280 let cancel = CancellationToken::new();
11281 let tools = vec![if case == "ticket_forged_success" {
11282 "Task"
11283 } else {
11284 "Read"
11285 }
11286 .into()];
11287 let outcome = drive(ActorDriveContext {
11288 client: &mut link,
11289 parent_session_id: "frame-root",
11290 child_session_id: "frame-child",
11291 child_attempt: 0,
11292 approval_registry: None,
11293 approval_decider: None,
11294 approval_reviewer: None,
11295 escalation_bridge: None,
11296 event_tx: &tx,
11297 cancel_token: &cancel,
11298 live_rx: &mut live_rx,
11299 delivery_rx: &mut delivery_rx,
11300 logical_session: &mut session,
11301 expected_permission_posture: None,
11302 expected_creation: Some(&creation),
11303 session_inbox_runtime: None,
11304 actor_directory_store: None,
11305 canonical_subagent_tool: None,
11306 ticket_service: None,
11307 activation_run_id: Some("current"),
11308 execution_epoch: 7,
11309 expected_source_actor_id: "selected",
11310 initial_inflight_claims: vec![].into(),
11311 plain_actor: false,
11312 remote_environment_lease: false,
11313 readonly_output: None,
11314 local_history_tools: Some(&tools),
11315 local_history_read_only: true,
11316 strict_history_output: None,
11317 plain_input: None,
11318 canonical_activation: None,
11319 canonical_placement_ref: None,
11320 actor_event_observer: None,
11321 plain_run: None,
11322 first_frame_timeout: Some(Duration::from_secs(1)),
11323 })
11324 .await;
11325 assert!(outcome.is_err(), "{case}: no last-text success fallback");
11326 if case == "ticket_forged_success" {
11327 assert!(outcome
11328 .unwrap_err()
11329 .to_string()
11330 .contains("no exact Host receipt"));
11331 assert!(matches!(
11332 rx.try_recv().unwrap(),
11333 AgentEvent::ToolStart { .. }
11334 ));
11335 }
11336 assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11337 assert!(link.sent.is_empty());
11338 assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11339 }
11340 }
11341
11342 struct HistoryTerminalProbe {
11343 frames: VecDeque<ChildFrame>,
11344 accepted: Option<TerminalStatus>,
11345 acks: usize,
11346 }
11347
11348 #[async_trait]
11349 impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11350 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11351 Ok(())
11352 }
11353 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11354 Ok(self.frames.pop_front())
11355 }
11356 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11357 self.accepted = Some(status);
11358 }
11359 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11360 Some(bamboo_subagent::DurableChildDeliveryReceipt {
11361 broker_identity: "fixture".into(),
11362 parent_mailbox: "parent".into(),
11363 correlation_id: "current".into(),
11364 message_ids: vec!["tail".into(), "terminal".into()],
11365 terminal_status: self.accepted?,
11366 })
11367 }
11368 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11369 self.acks += 1;
11370 Ok(())
11371 }
11372 }
11373
11374 struct CancelConcurrentInputCheckpoint {
11375 real: SessionInboxRuntimeBinding,
11376 envelope: bamboo_domain::SessionMessageEnvelope,
11377 }
11378 #[async_trait]
11379 impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11380 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11381 self.real.persistence.save_runtime_session(session).await
11382 }
11383 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11384 let receipt = self
11385 .real
11386 .inbox
11387 .deliver(&self.envelope)
11388 .await
11389 .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11390 self.real
11391 .inbox
11392 .mark_activation_eligible(
11393 &session.id,
11394 receipt.generation,
11395 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11396 )
11397 .await
11398 .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11399 let claim = self
11400 .real
11401 .inbox
11402 .claim(&session.id, 1)
11403 .await
11404 .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11405 .remove(0);
11406 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11407 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11408 .await
11409 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11410 self.real
11411 .persistence
11412 .checkpoint_runtime_session(session)
11413 .await
11414 }
11415 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11416 self.real.storage.load_session(id).await
11417 }
11418 }
11419
11420 struct CancelHistoryProbe {
11421 inner: HistoryTerminalProbe,
11422 cancel_requested: bool,
11423 }
11424
11425 #[async_trait]
11426 impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11427 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11428 self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11429 Ok(())
11430 }
11431 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11432 if !self.cancel_requested {
11433 std::future::pending().await
11434 } else {
11435 self.inner.next_frame().await
11436 }
11437 }
11438 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11439 self.inner.accept_durable_terminal(status);
11440 }
11441 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11442 self.inner.durable_delivery_receipt()
11443 }
11444 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11445 self.inner.acknowledge_durable_frames().await
11446 }
11447 }
11448
11449 #[tokio::test]
11450 async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11451 use bamboo_subagent::ChildLink;
11452 for case in [
11453 "success",
11454 "empty",
11455 "missing",
11456 "unclosed",
11457 "unobserved",
11458 "prefix",
11459 "metadata",
11460 "write",
11461 "not_read_only",
11462 "watermark",
11463 "epoch",
11464 "birth",
11465 "permission",
11466 "inbox",
11467 "pending",
11468 "pending_eligible",
11469 "claimed",
11470 "inspect_error",
11471 "checkpoint_error",
11472 "lost_owner",
11473 "concurrent_input",
11474 ] {
11475 let temp = tempfile::tempdir().unwrap();
11476 let store = Arc::new(
11477 bamboo_storage::SessionStoreV2::new(temp.path().into())
11478 .await
11479 .unwrap(),
11480 );
11481 let root = Session::new("cancel-history-root", "model");
11482 store.save_session(&root).await.unwrap();
11483 let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11484 child
11485 .messages
11486 .push(bamboo_agent_core::Message::user("exact assignment"));
11487 child.metadata.insert(
11488 bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11489 "true".into(),
11490 );
11491 store.save_session(&child).await.unwrap();
11492 let before = serde_json::to_value(&child.messages).unwrap();
11493 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11494 store.clone(),
11495 bamboo_domain::SessionInboxLimits::default(),
11496 ));
11497 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11498 let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11499 let mut owner = Some(
11500 binding
11501 .router
11502 .register_run(&child.id, "current")
11503 .await
11504 .unwrap(),
11505 );
11506 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11507 created_at: child.created_at,
11508 spawn_depth: child.spawn_depth,
11509 };
11510 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11511 let tool = if case == "write" { "Write" } else { "Read" };
11512 let arguments = serde_json::json!({"file_path":"assigned.txt"});
11513 let call = bamboo_agent_core::ToolCall {
11514 id: "cancel-read".into(),
11515 tool_type: "function".into(),
11516 function: bamboo_agent_core::FunctionCall {
11517 name: tool.into(),
11518 arguments: arguments.to_string(),
11519 },
11520 };
11521 let mut rows = child.messages.clone();
11522 let mut events = Vec::new();
11523 if case != "empty" {
11524 rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11525 events.push(
11526 serde_json::to_value(AgentEvent::ToolStart {
11527 tool_call_id: "cancel-read".into(),
11528 tool_name: tool.into(),
11529 arguments,
11530 })
11531 .unwrap(),
11532 );
11533 if case != "unclosed" {
11534 let mut result = bamboo_agent_core::Message::tool_result_with_status(
11535 "cancel-read",
11536 "exact Read result",
11537 true,
11538 );
11539 if case == "metadata" {
11540 result.metadata = Some(serde_json::json!({"permission":true}));
11541 }
11542 rows.push(result);
11543 events.push(
11544 serde_json::to_value(AgentEvent::ToolComplete {
11545 tool_call_id: "cancel-read".into(),
11546 result: bamboo_agent_core::tools::ToolResult::text(
11547 true,
11548 "exact Read result",
11549 ),
11550 })
11551 .unwrap(),
11552 );
11553 }
11554 }
11555 if case == "unobserved" {
11556 events.clear();
11557 }
11558 if case == "prefix" {
11559 rows[0].content.push('!');
11560 }
11561 events.push(
11562 serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11563 version: 1,
11564 messages: rows
11565 .iter()
11566 .map(|row| serde_json::to_value(row).unwrap())
11567 .collect(),
11568 })
11569 .unwrap(),
11570 );
11571 if case == "missing" {
11572 events.clear();
11573 }
11574 let final_seq = events.len() as u64;
11575 let mut watermark = bamboo_subagent::ActorEventWatermark {
11576 version: 1,
11577 logical_session: Some(identity.clone()),
11578 activation_id: Some("current".into()),
11579 execution_epoch: 7,
11580 final_seq,
11581 };
11582 match case {
11583 "watermark" => watermark.final_seq += 1,
11584 "epoch" => watermark.execution_epoch -= 1,
11585 "birth" => {
11586 watermark
11587 .logical_session
11588 .as_mut()
11589 .unwrap()
11590 .creation
11591 .as_mut()
11592 .unwrap()
11593 .created_at += chrono::Duration::nanoseconds(1)
11594 }
11595 _ => {}
11596 }
11597 let mut frames = VecDeque::new();
11598 if !events.is_empty() {
11599 frames.push_back(ChildFrame::EventBatch {
11600 batch: ActorEventBatch {
11601 logical_session: Some(identity),
11602 activation_id: Some("current".into()),
11603 execution_epoch: 7,
11604 source_node_id: None,
11605 source_actor_id: Some("selected".into()),
11606 first_seq: 1,
11607 last_seq: final_seq,
11608 qos: bamboo_subagent::ActorEventQos::Durable,
11609 events,
11610 },
11611 });
11612 }
11613 frames.push_back(ChildFrame::Terminal {
11614 status: TerminalStatus::Cancelled,
11615 result: None,
11616 error: None,
11617 transcript: vec![],
11618 final_event_watermark: Some(watermark),
11619 });
11620 let mut claims = VecDeque::new();
11621 let pending_input =
11622 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11623 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11624 let receipt = inbox.deliver(&pending_input).await.unwrap();
11625 if case != "pending" {
11626 inbox
11627 .mark_activation_eligible(
11628 &child.id,
11629 receipt.generation,
11630 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11631 )
11632 .await
11633 .unwrap();
11634 }
11635 if matches!(case, "inbox" | "claimed") {
11636 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11637 if case == "inbox" {
11638 claims.push_back(claim);
11639 }
11640 }
11641 }
11642 if case == "concurrent_input" {
11643 binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11644 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11645 envelope: pending_input.clone(),
11646 });
11647 }
11648 if case == "inspect_error" {
11649 let rel = store.resolve_rel_path(&child.id).await.unwrap();
11650 let dir = store.bamboo_home_dir().join(rel).join("inbox");
11651 std::fs::create_dir_all(&dir).unwrap();
11652 std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11653 assert!(inbox.inspect(&child.id).await.is_err());
11654 }
11655 let backlog_before =
11656 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11657 Some(inbox.inspect(&child.id).await.unwrap())
11658 } else {
11659 None
11660 };
11661 if case == "checkpoint_error" {
11662 binding.persistence = Arc::new(ActorFaultingPersistence {
11663 inner: locked.clone(),
11664 fail_checkpoint_once: AtomicBool::new(true),
11665 });
11666 }
11667 if case == "lost_owner" {
11668 owner.take().unwrap().abandon().await;
11669 }
11670 let mut link = CancelHistoryProbe {
11671 inner: HistoryTerminalProbe {
11672 frames,
11673 accepted: None,
11674 acks: 0,
11675 },
11676 cancel_requested: false,
11677 };
11678 let (tx, mut rx) = mpsc::channel(32);
11679 let (_live, mut live_rx) = mpsc::unbounded_channel();
11680 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11681 let cancel = CancellationToken::new();
11682 cancel.cancel();
11683 let tools = vec!["Read".into(), "Glob".into()];
11684 let mut strict_history = None;
11685 let outcome = drive(ActorDriveContext {
11686 client: &mut link,
11687 parent_session_id: &root.id,
11688 child_session_id: &child.id.clone(),
11689 child_attempt: 0,
11690 approval_registry: None,
11691 approval_decider: None,
11692 approval_reviewer: None,
11693 escalation_bridge: None,
11694 event_tx: &tx,
11695 cancel_token: &cancel,
11696 live_rx: &mut live_rx,
11697 delivery_rx: &mut delivery_rx,
11698 logical_session: &mut child,
11699 expected_permission_posture: (case == "permission")
11700 .then(|| expected_default_permission_posture(7)),
11701 expected_creation: Some(&creation),
11702 session_inbox_runtime: Some(&binding),
11703 actor_directory_store: None,
11704 canonical_subagent_tool: None,
11705 ticket_service: None,
11706 activation_run_id: Some("current"),
11707 execution_epoch: 7,
11708 expected_source_actor_id: "selected",
11709 initial_inflight_claims: claims,
11710 plain_actor: false,
11711 remote_environment_lease: false,
11712 readonly_output: None,
11713 local_history_tools: Some(&tools),
11714 local_history_read_only: case != "not_read_only",
11715 strict_history_output: Some(&mut strict_history),
11716 plain_input: None,
11717 canonical_activation: None,
11718 canonical_placement_ref: None,
11719 actor_event_observer: None,
11720 plain_run: None,
11721 first_frame_timeout: None,
11722 })
11723 .await;
11724 let proven = matches!(case, "success" | "empty");
11725 assert_eq!(strict_history.is_some(), proven, "{case}");
11726 assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11727 assert_eq!(
11728 link.inner.acks, 0,
11729 "{case}: pump cannot ACK before SDK save/proof"
11730 );
11731 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11732 .await
11733 .unwrap()
11734 .load_session(&child.id)
11735 .await
11736 .unwrap()
11737 .unwrap();
11738 if proven {
11739 assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11740 assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11741 let history = strict_history.unwrap();
11742 assert_eq!(history.proof.execution_epoch(), 7);
11743 assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11744 assert_eq!(
11745 serde_json::to_value(&cold.messages).unwrap(),
11746 serde_json::to_value(&rows).unwrap()
11747 );
11748 } else {
11749 assert!(outcome.is_err(), "{case}");
11750 if case == "concurrent_input" {
11751 assert!(
11752 cold.messages
11753 .iter()
11754 .any(|message| message.id == pending_input.id.as_str()),
11755 "new input must survive refusal"
11756 );
11757 } else {
11758 assert_eq!(
11759 serde_json::to_value(&cold.messages).unwrap(),
11760 before,
11761 "{case}: no unvalidated history write"
11762 );
11763 }
11764 }
11765 if let Some(backlog) = backlog_before {
11766 assert!(!inbox
11767 .was_admitted(&child.id, &pending_input.id)
11768 .await
11769 .unwrap());
11770 assert_eq!(
11771 inbox.inspect(&child.id).await.unwrap(),
11772 backlog,
11773 "{case}: proof refusal must neither claim nor ACK the input"
11774 );
11775 }
11776 while let Ok(event) = rx.try_recv() {
11777 assert!(
11778 !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11779 "private DATA is not a canonical public barrier"
11780 );
11781 }
11782 }
11783 }
11784
11785 #[tokio::test]
11786 async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11787 use bamboo_subagent::ChildLink;
11788 for case in [
11789 "success",
11790 "tail",
11791 "short",
11792 "missing",
11793 "version",
11794 "activation",
11795 "epoch",
11796 "birth",
11797 "duplicate",
11798 "overlap",
11799 "reorder",
11800 "midgap",
11801 "qos",
11802 "zero_missing",
11803 "zero_valid",
11804 "error_tail",
11805 "cancelled_tail",
11806 "cancelled_valid",
11807 "cancelled_zero_valid",
11808 "error_valid",
11809 "error_zero_valid",
11810 "ordinary_lossy",
11811 ] {
11812 let temp = tempfile::tempdir().unwrap();
11813 let store = Arc::new(
11814 bamboo_storage::SessionStoreV2::new(temp.path().into())
11815 .await
11816 .unwrap(),
11817 );
11818 let root = Session::new("watermark-root", "model");
11819 store.save_session(&root).await.unwrap();
11820 let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11821 child
11822 .messages
11823 .push(bamboo_agent_core::Message::user("assignment"));
11824 store.save_session(&child).await.unwrap();
11825 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11826 store.clone(),
11827 bamboo_domain::SessionInboxLimits::default(),
11828 ));
11829 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11830 let binding = actor_binding(store.clone(), inbox, locked);
11831 let _owner = binding
11832 .router
11833 .register_run(&child.id, "current")
11834 .await
11835 .unwrap();
11836 let before = serde_json::to_value(&child).unwrap();
11837 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11838 created_at: child.created_at,
11839 spawn_depth: child.spawn_depth,
11840 };
11841 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11842 let mut rows = child.messages.clone();
11843 rows.push(bamboo_agent_core::Message::assistant("complete", None));
11844 let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11845 version: 1,
11846 messages: rows
11847 .iter()
11848 .map(|row| serde_json::to_value(row).unwrap())
11849 .collect(),
11850 })
11851 .unwrap();
11852 let batch = |seq, event, qos| ActorEventBatch {
11853 logical_session: Some(identity.clone()),
11854 activation_id: Some("current".into()),
11855 execution_epoch: 7,
11856 source_node_id: None,
11857 source_actor_id: Some("selected".into()),
11858 first_seq: seq,
11859 last_seq: seq,
11860 qos,
11861 events: vec![event],
11862 };
11863 let durable = bamboo_subagent::ActorEventQos::Durable;
11864 let token = serde_json::json!({"type":"token", "content":"observation"});
11865 let mut frames = vec![ChildFrame::EventBatch {
11866 batch: batch(1, data.clone(), durable),
11867 }];
11868 let mut marker = bamboo_subagent::ActorEventWatermark {
11869 version: 1,
11870 logical_session: Some(identity.clone()),
11871 activation_id: Some("current".into()),
11872 execution_epoch: 7,
11873 final_seq: 1,
11874 };
11875 match case {
11876 "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11877 "short" => marker.final_seq = 0,
11878 "version" => marker.version = 99,
11879 "activation" => marker.activation_id = Some("old".into()),
11880 "epoch" => marker.execution_epoch = 6,
11881 "birth" => {
11882 marker
11883 .logical_session
11884 .as_mut()
11885 .unwrap()
11886 .creation
11887 .as_mut()
11888 .unwrap()
11889 .created_at += chrono::Duration::nanoseconds(1)
11890 }
11891 "duplicate" => frames.push(frames[0].clone()),
11892 "overlap" | "midgap" | "qos" => {
11893 frames = vec![
11894 ChildFrame::EventBatch {
11895 batch: batch(
11896 1,
11897 token.clone(),
11898 if case == "qos" {
11899 bamboo_subagent::ActorEventQos::Ephemeral
11900 } else {
11901 durable
11902 },
11903 ),
11904 },
11905 ChildFrame::EventBatch {
11906 batch: batch(
11907 if case == "overlap" {
11908 1
11909 } else if case == "midgap" {
11910 3
11911 } else {
11912 2
11913 },
11914 data,
11915 durable,
11916 ),
11917 },
11918 ];
11919 marker.final_seq = 2;
11920 }
11921 "reorder" => {
11922 frames = vec![
11923 ChildFrame::EventBatch {
11924 batch: batch(2, data, durable),
11925 },
11926 ChildFrame::EventBatch {
11927 batch: batch(1, token, durable),
11928 },
11929 ]
11930 }
11931 "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11932 frames.clear();
11933 marker.final_seq = 0;
11934 }
11935 "ordinary_lossy" => {
11936 frames = vec![ChildFrame::EventBatch {
11937 batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11938 }]
11939 }
11940 _ => {}
11941 }
11942 let status = match case {
11943 "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11944 "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11945 TerminalStatus::Cancelled
11946 }
11947 _ => TerminalStatus::Completed,
11948 };
11949 frames.push(ChildFrame::Terminal {
11950 status,
11951 result: Some("complete".into()),
11952 error: Some("worker failed".into()),
11953 transcript: vec![],
11954 final_event_watermark: (!matches!(
11955 case,
11956 "missing" | "zero_missing" | "ordinary_lossy"
11957 ))
11958 .then_some(marker),
11959 });
11960 let mut link = HistoryTerminalProbe {
11961 frames: frames.into(),
11962 accepted: None,
11963 acks: 0,
11964 };
11965 let (tx, mut rx) = mpsc::channel(32);
11966 let (_live, mut live_rx) = mpsc::unbounded_channel();
11967 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11968 let cancel = CancellationToken::new();
11969 let tools = vec!["Read".into()];
11970 let mut strict_history = None;
11971 let outcome = drive(ActorDriveContext {
11972 client: &mut link,
11973 parent_session_id: &root.id,
11974 child_session_id: "watermark-child",
11975 child_attempt: 0,
11976 approval_registry: None,
11977 approval_decider: None,
11978 approval_reviewer: None,
11979 escalation_bridge: None,
11980 event_tx: &tx,
11981 cancel_token: &cancel,
11982 live_rx: &mut live_rx,
11983 delivery_rx: &mut delivery_rx,
11984 logical_session: &mut child,
11985 expected_permission_posture: None,
11986 expected_creation: Some(&creation),
11987 session_inbox_runtime: Some(&binding),
11988 actor_directory_store: None,
11989 canonical_subagent_tool: None,
11990 ticket_service: None,
11991 activation_run_id: Some("current"),
11992 execution_epoch: 7,
11993 expected_source_actor_id: "selected",
11994 initial_inflight_claims: vec![].into(),
11995 plain_actor: false,
11996 remote_environment_lease: false,
11997 readonly_output: None,
11998 local_history_tools: (case != "ordinary_lossy").then_some(&tools),
11999 local_history_read_only: true,
12000 strict_history_output: Some(&mut strict_history),
12001 plain_input: None,
12002 canonical_activation: None,
12003 canonical_placement_ref: None,
12004 actor_event_observer: None,
12005 plain_run: None,
12006 first_frame_timeout: Some(Duration::from_secs(1)),
12007 })
12008 .await;
12009 assert_eq!(strict_history.is_some(), case == "success", "{case}");
12010 if let Some(history) = strict_history {
12011 assert_eq!(history.proof.execution_epoch(), 7);
12012 assert_eq!(history.proof.contiguous_applied_seq(), 1);
12013 assert_eq!(history.watermark.final_seq, 1);
12014 }
12015 let cold = store.load_session(&child.id).await.unwrap().unwrap();
12016 if matches!(case, "success" | "ordinary_lossy") {
12017 assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
12018 } else {
12019 let error = outcome.unwrap_err();
12020 if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
12021 assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
12022 }
12023 let error = error.to_string();
12024 if !matches!(
12025 case,
12026 "duplicate"
12027 | "overlap"
12028 | "reorder"
12029 | "midgap"
12030 | "qos"
12031 | "zero_valid"
12032 | "error_valid"
12033 | "error_zero_valid"
12034 | "cancelled_valid"
12035 | "cancelled_zero_valid"
12036 ) {
12037 assert!(
12038 error.contains("actor_history_tail_incomplete"),
12039 "{case}: {error}"
12040 );
12041 }
12042 }
12043 if case == "success" {
12044 assert_eq!(
12045 serde_json::to_value(&cold.messages).unwrap(),
12046 serde_json::to_value(rows).unwrap()
12047 );
12048 } else {
12049 assert_eq!(
12050 serde_json::to_value(&cold).unwrap(),
12051 before,
12052 "{case}: no cold history commit"
12053 );
12054 assert_eq!(
12055 serde_json::to_value(&child).unwrap(),
12056 before,
12057 "{case}: no mutable history commit"
12058 );
12059 }
12060 let accepted = matches!(case, "success" | "ordinary_lossy");
12061 assert_eq!(link.accepted.is_some(), accepted, "{case}");
12062 assert_eq!(
12063 link.durable_delivery_receipt().is_some(),
12064 accepted,
12065 "{case}"
12066 );
12067 assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
12068 while let Ok(event) = rx.try_recv() {
12069 assert!(
12070 matches!(event, AgentEvent::Token { .. }),
12071 "typed DATA cannot leak"
12072 );
12073 }
12074 }
12075 }
12076
12077 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
12078 PooledWorker {
12079 worker: SpawnedChild::remote(AgentRecord {
12080 agent_id: mailbox_id.to_string(),
12081 role: "test".to_string(),
12082 labels: Vec::new(),
12083 endpoint: "ws://127.0.0.1:1".to_string(),
12084 pid: 0,
12085 version: String::new(),
12086 started_at: chrono::Utc::now(),
12087 lease_expires_at: chrono::Utc::now(),
12088 }),
12089 mailbox_id: mailbox_id.to_string(),
12090 parked_at: None,
12091 }
12092 }
12093
12094 #[test]
12095 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12096 let now = Instant::now();
12097 let timeout = Duration::from_secs(300);
12098 assert!(!parked_worker_expired(None, now, timeout));
12099 assert!(!parked_worker_expired(
12100 Some(now - timeout + Duration::from_millis(1)),
12101 now,
12102 timeout
12103 ));
12104 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12105 }
12106
12107 #[test]
12108 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12109 let mut pool = WorkerPool::new();
12110 pool.insert(
12111 "fingerprint-a".to_string(),
12112 vec![processless_pool_worker("a")],
12113 );
12114 pool.insert(
12115 "fingerprint-b".to_string(),
12116 vec![processless_pool_worker("b")],
12117 );
12118
12119 assert_eq!(
12120 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12121 Some("pool_global_limit")
12122 );
12123 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12124 assert_eq!(
12125 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12126 Some("pool_per_key_limit")
12127 );
12128 }
12129
12130 #[test]
12131 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12132 let mut pool = WorkerPool::new();
12133 pool.insert(
12134 "fingerprint-a".to_string(),
12135 vec![processless_pool_worker("dead")],
12136 );
12137
12138 let reaped = take_reapable_workers(
12139 &mut pool,
12140 Instant::now(),
12141 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12142 );
12143
12144 assert!(pool.is_empty());
12145 assert_eq!(reaped.len(), 1);
12146 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12147 }
12148
12149 #[test]
12150 fn actor_preflight_counts_only_current_session_scoped_denies() {
12151 let config = bamboo_tools::permission::PermissionConfig::new();
12152 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12153 config.deny_scoped_session_permission(
12154 "target-session",
12155 bamboo_tools::permission::PermissionType::ExecuteCommand,
12156 secret_matcher,
12157 );
12158 config.deny_scoped_session_permission(
12159 "other-session",
12160 bamboo_tools::permission::PermissionType::WriteFile,
12161 "/other/**",
12162 );
12163
12164 assert_eq!(
12165 active_scoped_session_deny_count(&config, "target-session"),
12166 1
12167 );
12168 assert_eq!(
12169 active_scoped_session_deny_count(&config, "clean-session"),
12170 0
12171 );
12172 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12173 .unwrap_err()
12174 .to_string();
12175 assert!(!error.contains(secret_matcher));
12176 ensure_no_active_scoped_session_denies(&config, "clean-session")
12177 .expect("another session's deny must not block this activation");
12178 }
12179
12180 #[test]
12181 fn permission_posture_event_rejects_oversized_executor_mapping() {
12182 let session = Session::new("mapping-bound", "model");
12183 let event = AgentEvent::PermissionPostureActivated {
12184 session_id: session.id.clone(),
12185 policy_revision: 1,
12186 requested_mode: "default".to_string(),
12187 effective_mode: "default".to_string(),
12188 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12189 };
12190
12191 assert!(permission_posture_seed_from_event(&session, &event)
12192 .unwrap_err()
12193 .contains("executor mapping"));
12194 }
12195
12196 #[test]
12197 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12198 let mut session = Session::new("host-resigns-audit", "model");
12199 let hostile_timestamp = "9".repeat(1024);
12200 let event: AgentEvent = serde_json::from_value(serde_json::json!({
12201 "type": "permission_posture_activated",
12202 "session_id": session.id.clone(),
12203 "policy_revision": 31,
12204 "requested_mode": "default",
12205 "effective_mode": "default",
12206 "executor_mapping": "codex_exec:approval_policy=never",
12207 "audit_revision": u64::MAX,
12208 "transitioned_at": hostile_timestamp,
12209 }))
12210 .expect("unknown remote audit fields are ignored by the typed event");
12211 let seed = permission_posture_seed_from_event(&session, &event)
12212 .unwrap()
12213 .expect("permission event");
12214
12215 let host_revision =
12216 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12217 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12218 .expect("host-generated complete audit");
12219 assert_eq!(host_audit.audit_revision, host_revision);
12220 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12221 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12222 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12223 }
12224
12225 struct ActorFaultingPersistence {
12226 inner: Arc<bamboo_storage::LockedSessionStore>,
12227 fail_checkpoint_once: std::sync::atomic::AtomicBool,
12228 }
12229
12230 #[async_trait]
12231 impl RuntimeSessionPersistence for ActorFaultingPersistence {
12232 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12233 self.inner.merge_save_runtime(session).await
12234 }
12235
12236 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12237 if self
12238 .fail_checkpoint_once
12239 .swap(false, std::sync::atomic::Ordering::SeqCst)
12240 {
12241 return Err(std::io::Error::other("injected actor checkpoint failure"));
12242 }
12243 self.inner.checkpoint_runtime_session(session).await
12244 }
12245
12246 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12247 self.inner.storage().load_session(session_id).await
12248 }
12249 }
12250
12251 struct ActorFailBeforeAckInbox {
12252 inner: Arc<dyn SessionInboxPort>,
12253 fail_once: std::sync::atomic::AtomicBool,
12254 }
12255
12256 #[async_trait]
12257 impl SessionInboxPort for ActorFailBeforeAckInbox {
12258 async fn deliver(
12259 &self,
12260 envelope: &bamboo_domain::SessionMessageEnvelope,
12261 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12262 self.inner.deliver(envelope).await
12263 }
12264
12265 async fn mark_activation_eligible(
12266 &self,
12267 target_session_id: &str,
12268 generation: u64,
12269 policy: bamboo_domain::SessionActivationPolicy,
12270 ) -> Result<(), bamboo_domain::SessionInboxError> {
12271 self.inner
12272 .mark_activation_eligible(target_session_id, generation, policy)
12273 .await
12274 }
12275
12276 async fn claim(
12277 &self,
12278 target_session_id: &str,
12279 limit: usize,
12280 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12281 self.inner.claim(target_session_id, limit).await
12282 }
12283
12284 async fn was_admitted(
12285 &self,
12286 target_session_id: &str,
12287 id: &bamboo_domain::SessionMessageId,
12288 ) -> Result<bool, bamboo_domain::SessionInboxError> {
12289 self.inner.was_admitted(target_session_id, id).await
12290 }
12291
12292 async fn ack(
12293 &self,
12294 target_session_id: &str,
12295 claim: &SessionInboxClaim,
12296 ) -> Result<(), bamboo_domain::SessionInboxError> {
12297 if self
12298 .fail_once
12299 .swap(false, std::sync::atomic::Ordering::SeqCst)
12300 {
12301 return Err(bamboo_domain::SessionInboxError::Storage(
12302 "injected actor pre-ack failure".to_string(),
12303 ));
12304 }
12305 self.inner.ack(target_session_id, claim).await
12306 }
12307
12308 async fn inspect(
12309 &self,
12310 target_session_id: &str,
12311 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12312 self.inner.inspect(target_session_id).await
12313 }
12314 }
12315
12316 async fn actor_inbox_fixture(
12317 session_id: &str,
12318 ) -> (
12319 tempfile::TempDir,
12320 Arc<bamboo_storage::SessionStoreV2>,
12321 Arc<bamboo_storage::LockedSessionStore>,
12322 Arc<dyn SessionInboxPort>,
12323 Session,
12324 SessionInboxClaim,
12325 ) {
12326 let temp = tempfile::tempdir().unwrap();
12327 let store = Arc::new(
12328 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12329 .await
12330 .unwrap(),
12331 );
12332 let storage: Arc<dyn Storage> = store.clone();
12333 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12334 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12335 store.clone(),
12336 bamboo_domain::SessionInboxLimits::default(),
12337 ));
12338 let session = Session::new(session_id, "model");
12339 store.save_session(&session).await.unwrap();
12340 let mut envelope =
12341 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12342 envelope.id =
12343 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12344 let receipt = inbox.deliver(&envelope).await.unwrap();
12345 inbox
12346 .mark_activation_eligible(
12347 session_id,
12348 receipt.generation,
12349 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12350 )
12351 .await
12352 .unwrap();
12353 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12354 (temp, store, locked, inbox, session, claim)
12355 }
12356
12357 fn actor_binding(
12358 store: Arc<bamboo_storage::SessionStoreV2>,
12359 inbox: Arc<dyn SessionInboxPort>,
12360 persistence: Arc<dyn RuntimeSessionPersistence>,
12361 ) -> SessionInboxRuntimeBinding {
12362 let storage: Arc<dyn Storage> = store;
12363 SessionInboxRuntimeBinding {
12364 router: SessionActivationRouter::new(),
12365 inbox,
12366 storage,
12367 persistence,
12368 parent_question_lock: None,
12369 }
12370 }
12371
12372 #[tokio::test]
12373 async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12374 let (_temp, store, locked, inbox, mut session, claim) =
12375 actor_inbox_fixture("unsupported-remote-inbox").await;
12376 let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12377 inner: inbox.clone(),
12378 fail_once: std::sync::atomic::AtomicBool::new(false),
12379 });
12380 let binding = actor_binding(store.clone(), wrapped, locked);
12381 let fence = ActorActivationFence {
12382 schema_version: 1,
12383 actor_id: session.id.clone(),
12384 activation_id: "unsupported-inbox-activation".into(),
12385 attempt: 1,
12386 run_id: "unsupported-inbox-run".into(),
12387 lease_owner: "unsupported-inbox-host".into(),
12388 lease_epoch: 1,
12389 };
12390 let before = session.clone();
12391
12392 let error = checkpoint_claim_context_before_dispatch(
12393 &binding,
12394 &mut session,
12395 &[claim],
12396 Some((store.as_ref(), &fence)),
12397 )
12398 .await
12399 .unwrap_err();
12400 assert!(error
12401 .to_string()
12402 .contains("requires canonical FileSessionInbox"));
12403 assert_eq!(
12404 serde_json::to_value(&session.messages).unwrap(),
12405 serde_json::to_value(&before.messages).unwrap()
12406 );
12407 assert_eq!(
12408 serde_json::to_value(
12409 &store
12410 .load_session(&session.id)
12411 .await
12412 .unwrap()
12413 .unwrap()
12414 .messages
12415 )
12416 .unwrap(),
12417 serde_json::to_value(&before.messages).unwrap()
12418 );
12419 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12420 }
12421
12422 #[tokio::test]
12423 async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12424 let (_temp, store, locked, inbox, parent, _claim) =
12425 actor_inbox_fixture("remote-parent").await;
12426 let child = Session::new_child_of("remote-child", &parent, "model", "child");
12427 store.save_session(&child).await.unwrap();
12428 let binding = actor_binding(store.clone(), inbox, locked);
12429 let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12430 assert_eq!(first.len(), 2);
12431 let mut changed = child.clone();
12432 changed.set_project_id_meta("different-project");
12433 assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12434 store.delete_session(&parent.id).await.unwrap();
12435 assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12436 }
12437
12438 #[tokio::test]
12439 async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12440 let (_temp, store, locked, inbox, root, _claim) =
12441 actor_inbox_fixture("deep-remote-root").await;
12442 let mut parent = root;
12443 for depth in 1..=6 {
12444 let child =
12445 Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12446 store.save_session(&child).await.unwrap();
12447 parent = child;
12448 }
12449 let binding = actor_binding(store, inbox, locked);
12450 let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12451 assert_eq!(chain.len(), 7);
12452 assert_eq!(chain.last().unwrap().spawn_depth, 0);
12453 }
12454
12455 #[tokio::test]
12456 async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12457 let (_temp, store, locked, inbox, parent, _claim) =
12458 actor_inbox_fixture("creation-parent").await;
12459 let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12460 child.set_project_id_meta("creation-project");
12461 store.save_session(&child).await.unwrap();
12462 let binding = actor_binding(store.clone(), inbox, locked);
12463 let job = SpawnJob {
12464 parent_session_id: parent.id.clone(),
12465 child_session_id: child.id.clone(),
12466 model: "model".into(),
12467 disabled_tools: None,
12468 };
12469 let creation = canonical_child_creation(&binding, &child, &job)
12470 .await
12471 .unwrap();
12472 assert_eq!(creation.created_at, child.created_at);
12473 assert_eq!(creation.spawn_depth, child.spawn_depth);
12474 for damage in 0..7 {
12475 let mut held = child.clone();
12476 match damage {
12477 0 => held.created_at += chrono::Duration::nanoseconds(1),
12478 1 => held.parent_session_id = Some("foreign-parent".into()),
12479 2 => held.root_session_id = "foreign-root".into(),
12480 3 => held.spawn_depth += 1,
12481 4 => held.set_project_id_meta("foreign-project"),
12482 5 => held.kind = bamboo_domain::SessionKind::Root,
12483 _ => held.id = " creation-child ".into(),
12484 }
12485 assert!(
12486 canonical_child_creation(&binding, &held, &job)
12487 .await
12488 .is_err(),
12489 "damage {damage}"
12490 );
12491 }
12492 store.delete_session(&child.id).await.unwrap();
12493 assert!(canonical_child_creation(&binding, &child, &job)
12494 .await
12495 .is_err());
12496 let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12497 replacement.set_project_id_meta("creation-project");
12498 assert_ne!(replacement.created_at, child.created_at);
12499 store.save_session(&replacement).await.unwrap();
12500 assert!(
12501 canonical_child_creation(&binding, &child, &job)
12502 .await
12503 .is_err(),
12504 "old held spec cannot adopt replacement birth"
12505 );
12506 canonical_child_creation(&binding, &replacement, &job)
12507 .await
12508 .unwrap();
12509 }
12510
12511 #[tokio::test]
12512 async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12513 use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12514 for policies in [
12515 [
12516 SessionActivationPolicy::RespectSpecificWait,
12517 SessionActivationPolicy::InterruptSpecificWait,
12518 ],
12519 [
12520 SessionActivationPolicy::InterruptSpecificWait,
12521 SessionActivationPolicy::RespectSpecificWait,
12522 ],
12523 ] {
12524 let temp = tempfile::tempdir().unwrap();
12525 let store = Arc::new(
12526 bamboo_storage::SessionStoreV2::new(temp.path().into())
12527 .await
12528 .unwrap(),
12529 );
12530 let session_id = "actor-mixed-intent-policy";
12531 store
12532 .save_session(&Session::new(session_id, "model"))
12533 .await
12534 .unwrap();
12535 let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12536 let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12537 let staged_receipt = inbox.deliver(&staged).await.unwrap();
12538 let mut sent = Vec::new();
12539 for policy in policies {
12540 let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12541 let receipt = inbox
12542 .deliver_with_activation_intent(&envelope, policy, None)
12543 .await
12544 .unwrap();
12545 sent.push((envelope, policy, receipt));
12546 }
12547 let restarted = Arc::new(
12548 bamboo_storage::SessionStoreV2::new(temp.path().into())
12549 .await
12550 .unwrap(),
12551 );
12552 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12553 restarted.clone(),
12554 SessionInboxLimits::default(),
12555 ));
12556 let binding = actor_binding(
12557 restarted.clone(),
12558 inbox.clone(),
12559 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12560 );
12561 let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12562 let pairs =
12563 claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12564 .await
12565 .unwrap();
12566 assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12567 for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12568 assert_eq!(claim.envelope.id, envelope.id);
12569 assert_eq!(claim.generation, receipt.generation);
12570 assert_eq!(claim.activation_policy, *policy);
12571 assert_eq!(delivery.activation_policy, *policy);
12572 checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12573 .await
12574 .unwrap();
12575 }
12576 let restarted = Arc::new(
12577 bamboo_storage::SessionStoreV2::new(temp.path().into())
12578 .await
12579 .unwrap(),
12580 );
12581 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12582 restarted.clone(),
12583 SessionInboxLimits::default(),
12584 ));
12585 let binding = actor_binding(
12586 restarted.clone(),
12587 inbox.clone(),
12588 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12589 );
12590 session = restarted.load_session(session_id).await.unwrap().unwrap();
12591 for (envelope, policy, receipt) in &sent {
12592 assert_eq!(
12593 inbox
12594 .deliver_with_activation_intent(envelope, *policy, None)
12595 .await
12596 .unwrap(),
12597 *receipt
12598 );
12599 }
12600 assert!(
12601 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12602 .await
12603 .unwrap()
12604 .is_empty()
12605 );
12606 inbox
12607 .mark_activation_eligible(
12608 session_id,
12609 staged_receipt.generation,
12610 SessionActivationPolicy::RespectSpecificWait,
12611 )
12612 .await
12613 .unwrap();
12614 let pairs =
12615 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12616 .await
12617 .unwrap();
12618 assert_eq!(pairs.len(), 1);
12619 assert_eq!(pairs[0].0.envelope.id, staged.id);
12620 assert_eq!(
12621 pairs[0].1.activation_policy,
12622 SessionActivationPolicy::RespectSpecificWait
12623 );
12624 }
12625 }
12626
12627 #[tokio::test]
12628 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12629 let (_temp, store, locked, inbox, mut session, claim) =
12630 actor_inbox_fixture("actor-live-id-collision").await;
12631 let mut forged = claim.envelope.to_provider_message().unwrap();
12632 forged.metadata = Some(serde_json::json!({
12633 "session_message": {
12634 "id": claim.envelope.id,
12635 "target_session_id": "different-session"
12636 }
12637 }));
12638 session.add_message(forged);
12639 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12640 let binding = actor_binding(store, inbox.clone(), persistence);
12641
12642 assert!(
12643 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12644 .await
12645 .is_err()
12646 );
12647 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12648 assert!(!inbox
12649 .was_admitted(&session.id, &claim.envelope.id)
12650 .await
12651 .unwrap());
12652 }
12653
12654 #[tokio::test]
12655 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12656 let (_temp, store, locked, inbox, mut session, claim) =
12657 actor_inbox_fixture("actor-durable-id-collision").await;
12658 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12659 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12660 forged.id = claim.envelope.id.to_string();
12661 concurrent.add_message(forged);
12662 store.save_session(&concurrent).await.unwrap();
12663 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12664 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12665
12666 assert!(
12667 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12668 .await
12669 .is_err()
12670 );
12671 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12672 assert!(!inbox
12673 .was_admitted(&session.id, &claim.envelope.id)
12674 .await
12675 .unwrap());
12676 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12677 assert!(!durable.messages.iter().any(|message| {
12678 bamboo_domain::is_matching_session_message(message, &claim.envelope)
12679 }));
12680 }
12681
12682 #[tokio::test]
12683 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12684 let (_temp, store, locked, inbox, mut session, claim) =
12685 actor_inbox_fixture("actor-durable-typed-body-collision").await;
12686 let mut different = claim.envelope.clone();
12687 different.body = bamboo_domain::SessionMessageBody::Content(
12688 bamboo_domain::SessionMessageContent::text("forged actor body"),
12689 );
12690 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12691 concurrent.add_message(different.to_provider_message().unwrap());
12692 store.save_session(&concurrent).await.unwrap();
12693 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12694 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12695
12696 assert!(
12697 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12698 .await
12699 .is_err()
12700 );
12701 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12702 assert!(!inbox
12703 .was_admitted(&session.id, &claim.envelope.id)
12704 .await
12705 .unwrap());
12706 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12707 assert!(!durable
12708 .messages
12709 .iter()
12710 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12711 }
12712
12713 #[tokio::test]
12714 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12715 let (_temp, store, locked, inbox, mut session, claim) =
12716 actor_inbox_fixture("actor-checkpoint-failure").await;
12717 let envelope_id = claim.envelope.id.clone();
12718 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12719 inner: locked.clone(),
12720 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12721 });
12722 let binding = actor_binding(store.clone(), inbox.clone(), fault);
12723
12724 assert!(
12725 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12726 .await
12727 .is_err()
12728 );
12729 assert!(!session
12730 .messages
12731 .iter()
12732 .any(|message| message.id == envelope_id.as_str()));
12733 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12734 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12735
12736 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12737 store.clone(),
12738 bamboo_domain::SessionInboxLimits::default(),
12739 ));
12740 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12741 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12742 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12743 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12744 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12745 .await
12746 .unwrap();
12747 assert_eq!(
12748 restarted
12749 .messages
12750 .iter()
12751 .filter(|message| message.id == envelope_id.as_str())
12752 .count(),
12753 1
12754 );
12755 assert!(reopened
12756 .was_admitted(&session.id, &envelope_id)
12757 .await
12758 .unwrap());
12759 let backlog = reopened.inspect(&session.id).await.unwrap();
12760 assert_eq!(backlog.pending + backlog.claimed, 0);
12761 }
12762
12763 #[tokio::test]
12764 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12765 let (_temp, store, locked, real_inbox, mut session, claim) =
12766 actor_inbox_fixture("actor-pre-ack-failure").await;
12767 let envelope_id = claim.envelope.id.clone();
12768 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12769 inner: real_inbox.clone(),
12770 fail_once: std::sync::atomic::AtomicBool::new(true),
12771 });
12772 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12773 let binding = actor_binding(store.clone(), faulted, persistence);
12774
12775 assert!(
12776 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12777 .await
12778 .is_err()
12779 );
12780 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12781 assert_eq!(
12782 durable
12783 .messages
12784 .iter()
12785 .filter(|message| message.id == envelope_id.as_str())
12786 .count(),
12787 1
12788 );
12789 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12790 assert!(!real_inbox
12791 .was_admitted(&session.id, &envelope_id)
12792 .await
12793 .unwrap());
12794
12795 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12796 store.clone(),
12797 bamboo_domain::SessionInboxLimits::default(),
12798 ));
12799 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12800 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12801 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12802 let mut restarted = durable;
12803 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12804 .await
12805 .unwrap();
12806 assert_eq!(
12807 restarted
12808 .messages
12809 .iter()
12810 .filter(|message| message.id == envelope_id.as_str())
12811 .count(),
12812 1
12813 );
12814 assert!(reopened
12815 .was_admitted(&session.id, &envelope_id)
12816 .await
12817 .unwrap());
12818 let backlog = reopened.inspect(&session.id).await.unwrap();
12819 assert_eq!(backlog.pending + backlog.claimed, 0);
12820 }
12821
12822 struct ConfirmationSequenceLink {
12823 frames: VecDeque<ChildFrame>,
12824 sent: Vec<ParentFrame>,
12825 }
12826
12827 #[async_trait]
12828 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12829 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12830 self.sent.push(frame);
12831 Ok(())
12832 }
12833
12834 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12835 match self.frames.pop_front() {
12836 Some(frame) => Ok(Some(frame)),
12837 None => std::future::pending().await,
12838 }
12839 }
12840 }
12841
12842 #[tokio::test]
12843 async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12844 for case in [
12845 "accepted",
12846 "readonly-refusal",
12847 "prefix-refusal",
12848 "old-claim",
12849 "old-checkpoint",
12850 "succeeded",
12851 "changed-birth",
12852 "oversize",
12853 ] {
12854 let temp = tempfile::tempdir().unwrap();
12855 let store = Arc::new(
12856 bamboo_storage::SessionStoreV2::new(temp.path().into())
12857 .await
12858 .unwrap(),
12859 );
12860 let parent = Session::new("retry-parent", "model");
12861 store.save_session(&parent).await.unwrap();
12862 let mut child =
12863 Session::new_child_of("retry-child", &parent, "model", "original assignment");
12864 child.add_message(bamboo_agent_core::Message::user("original assignment"));
12865 store.save_session(&child).await.unwrap();
12866 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12867 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12868 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12869 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12870 store.clone(),
12871 bamboo_domain::SessionInboxLimits::default(),
12872 ));
12873 let binding = actor_binding(store.clone(), inbox, locked);
12874 let registration = binding
12875 .router
12876 .register_run(&child.id, "first-run")
12877 .await
12878 .unwrap();
12879 let first = PlainActorActivation::start(
12880 store.clone(),
12881 &child,
12882 &binding,
12883 Some("first-run"),
12884 Some(policy.clone()),
12885 true,
12886 None,
12887 )
12888 .await
12889 .unwrap();
12890 if matches!(case, "old-claim" | "old-checkpoint") {
12891 let old = bamboo_domain::SessionMessageEnvelope::user_input(
12892 &child.id,
12893 "unconfirmed old input",
12894 );
12895 let receipt = first.input_inbox.deliver(&old).await.unwrap();
12896 first
12897 .input_inbox
12898 .mark_activation_eligible(
12899 &child.id,
12900 receipt.generation,
12901 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12902 )
12903 .await
12904 .unwrap();
12905 let claim = first
12906 .claim_input(&binding, &child, "first-run")
12907 .await
12908 .unwrap()
12909 .unwrap();
12910 if case == "old-checkpoint" {
12911 first
12912 .checkpoint_input(&mut child, "first-run", &claim)
12913 .await
12914 .unwrap();
12915 }
12916 }
12917 first
12918 .finish(if case == "succeeded" {
12919 ActorActivationFinish::Succeeded
12920 } else {
12921 ActorActivationFinish::Failed
12922 })
12923 .await
12924 .unwrap();
12925 drop(registration);
12926 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12927 &child.id,
12928 if case == "oversize" {
12929 "x".repeat(8193)
12930 } else {
12931 "NEW_CORRECTION".into()
12932 },
12933 );
12934 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12935 first
12936 .input_inbox
12937 .mark_activation_eligible(
12938 &child.id,
12939 receipt.generation,
12940 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12941 )
12942 .await
12943 .unwrap();
12944 let before = store.load_session(&child.id).await.unwrap().unwrap();
12945 let expected_messages = serde_json::to_value(&before.messages).unwrap();
12946 child = before.clone();
12947 if case == "prefix-refusal" {
12948 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12949 }
12950 if case == "changed-birth" {
12951 child.created_at += chrono::Duration::milliseconds(1);
12952 }
12953 let _retry_registration = binding
12954 .router
12955 .register_run(&child.id, "retry-run")
12956 .await
12957 .unwrap();
12958 let result = PlainActorActivation::start_and_prepare(
12959 store.clone(),
12960 &mut child,
12961 &binding,
12962 Some("retry-run"),
12963 Some(policy.clone()),
12964 case != "readonly-refusal",
12965 None,
12966 )
12967 .await;
12968 if case != "accepted" {
12969 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12970 let actual = store.load_session(&before.id).await.unwrap().unwrap();
12971 assert_eq!(
12972 serde_json::to_value(&actual.messages).unwrap(),
12973 expected_messages,
12974 "{case}: failed preparation preserves Main history"
12975 );
12976 assert!(
12977 !first
12978 .input_inbox
12979 .was_admitted(&before.id, &envelope.id)
12980 .await
12981 .unwrap(),
12982 "{case}: no ACK"
12983 );
12984 if case == "readonly-refusal" {
12985 let entry = store.inspect_actor(&before.id).await.unwrap();
12986 assert_eq!(entry.actor.current_attempt, 1);
12987 assert_eq!(
12988 entry.activation.unwrap().activation_id,
12989 first.fence.activation_id
12990 );
12991 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12992 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12993 }
12994 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
12995 assert_eq!(
12996 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
12997 1
12998 );
12999 assert_eq!(
13000 store
13001 .inspect_actor(&before.id)
13002 .await
13003 .unwrap()
13004 .activation
13005 .unwrap()
13006 .status,
13007 bamboo_domain::ActorActivationStatus::Failed
13008 );
13009 }
13010 if case == "oversize" {
13011 let reopened = Arc::new(
13012 bamboo_storage::SessionStoreV2::new(temp.path().into())
13013 .await
13014 .unwrap(),
13015 );
13016 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13017 reopened,
13018 bamboo_domain::SessionInboxLimits::default(),
13019 );
13020 let now = chrono::Utc::now();
13021 let leases = cold_inbox
13022 .inspect_owned_leases(&before.id, 2, now)
13023 .await
13024 .unwrap();
13025 assert_eq!(leases.len(), 1);
13026 assert_eq!(leases[0].failure_count, 1);
13027 assert_eq!(
13028 leases[0].last_error_code.as_deref(),
13029 Some("unsupported_actor_input")
13030 );
13031 let retry_after = leases[0].retry_after.unwrap();
13032 assert!(retry_after > now);
13033 let readiness = cold_inbox
13034 .inspect_wake_readiness(&before.id, now)
13035 .await
13036 .unwrap();
13037 assert!(
13038 readiness.ready.is_none(),
13039 "failed correction must not hot-loop"
13040 );
13041 assert_eq!(readiness.next_due_at, Some(retry_after));
13042 }
13043 continue;
13044 }
13045 let (retry, prepared) = result.unwrap();
13046 let (prefix, mut deliveries) = prepared.unwrap();
13047 assert_eq!(deliveries.len(), 1);
13048 let delivery = deliveries.pop().unwrap();
13049 assert_eq!(
13050 prefix,
13051 serde_json::to_value(&before.messages)
13052 .unwrap()
13053 .as_array()
13054 .unwrap()
13055 .clone()
13056 );
13057 assert_eq!(delivery.envelope, envelope);
13058 assert_eq!(delivery.activation_run_id, "retry-run");
13059 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13060 assert!(store
13061 .validate_fence(&first.fence, chrono::Utc::now())
13062 .await
13063 .is_err());
13064 let running = store.inspect_actor(&child.id).await.unwrap();
13065 assert_eq!(running.actor.current_attempt, 2);
13066 assert_eq!(
13067 running.activation.as_ref().unwrap().status,
13068 bamboo_domain::ActorActivationStatus::Running
13069 );
13070 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13071 let claim = retry.initial_inputs.first().unwrap();
13072 assert!(child
13073 .session_inbox_admission()
13074 .unwrap()
13075 .contains(&envelope.id));
13076 assert!(
13077 !retry
13078 .input_inbox
13079 .was_admitted(&child.id, &envelope.id)
13080 .await
13081 .unwrap(),
13082 "checkpoint is not startup confirmation"
13083 );
13084 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13085 confirmation.activation_run_id = "first-run".into();
13086 assert!(retry
13087 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13088 .await
13089 .is_err());
13090 assert!(!retry
13091 .input_inbox
13092 .was_admitted(&child.id, &envelope.id)
13093 .await
13094 .unwrap());
13095 confirmation.activation_run_id = "retry-run".into();
13096 retry
13097 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13098 .await
13099 .unwrap();
13100 let (tx, mut rx) = mpsc::channel(1);
13101 retry
13102 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13103 .await
13104 .unwrap();
13105 rx.recv().await.unwrap();
13106 retry
13107 .finish(ActorActivationFinish::Succeeded)
13108 .await
13109 .unwrap();
13110 let reopened = Arc::new(
13111 bamboo_storage::SessionStoreV2::new(temp.path().into())
13112 .await
13113 .unwrap(),
13114 );
13115 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13116 assert_eq!(cold.created_at, before.created_at);
13117 assert_eq!(cold.messages.len(), before.messages.len() + 2);
13118 assert_eq!(
13119 cold.messages[before.messages.len()].id,
13120 envelope.id.as_str()
13121 );
13122 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13123 assert_eq!(
13124 cold.messages
13125 .iter()
13126 .filter(|m| m.id == envelope.id.as_str())
13127 .count(),
13128 1
13129 );
13130 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13131 reopened,
13132 bamboo_domain::SessionInboxLimits::default(),
13133 );
13134 assert!(cold_inbox
13135 .was_admitted(&cold.id, &envelope.id)
13136 .await
13137 .unwrap());
13138 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13139 }
13140 }
13141
13142 #[tokio::test]
13143 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13144 for case in [
13145 "accepted",
13146 "actor-live",
13147 "claim-live",
13148 "legacy",
13149 "ack",
13150 "readonly",
13151 "prefix",
13152 "duplicate",
13153 "missing-marker",
13154 "mixed",
13155 "project",
13156 "birth",
13157 ] {
13158 let temp = tempfile::tempdir().unwrap();
13159 let store = Arc::new(
13160 bamboo_storage::SessionStoreV2::new(temp.path().into())
13161 .await
13162 .unwrap(),
13163 );
13164 let parent = Session::new("recovery-parent", "model");
13165 store.save_session(&parent).await.unwrap();
13166 let mut child =
13167 Session::new_child_of("recovery-child", &parent, "model", "original task");
13168 child.add_message(bamboo_agent_core::Message::user("original task"));
13169 store.save_session(&child).await.unwrap();
13170 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13171 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13172 let inbox = bamboo_storage::FileSessionInbox::new(
13173 store.clone(),
13174 bamboo_domain::SessionInboxLimits::default(),
13175 );
13176 let envelope =
13178 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13179 let receipt = inbox.deliver(&envelope).await.unwrap();
13180 inbox
13181 .mark_activation_eligible(
13182 &child.id,
13183 receipt.generation,
13184 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13185 )
13186 .await
13187 .unwrap();
13188 let now = chrono::Utc::now();
13189 let cutoff = now + chrono::Duration::seconds(10);
13190 let consumer = SessionInboxConsumerId::new();
13191 let old = store
13192 .claim_activation(&ActorActivationClaim {
13193 actor_id: child.id.clone(),
13194 run_id: "old-run".into(),
13195 lease_owner: consumer.as_str().into(),
13196 lease_expires_at: if case == "actor-live" {
13197 now + chrono::Duration::minutes(5)
13198 } else {
13199 cutoff
13200 },
13201 inbox_generation: 0,
13202 placement_ref: if case == "legacy" {
13203 None
13204 } else {
13205 Some(bamboo_domain::ActorPlacementRef {
13206 class: bamboo_domain::ActorPlacementClass::Local,
13207 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13208 slot_epoch: None,
13209 })
13210 },
13211 now,
13212 })
13213 .await
13214 .unwrap();
13215 store
13216 .start_activation(&old.fence(), chrono::Utc::now())
13217 .await
13218 .unwrap();
13219 let claim_now = chrono::Utc::now();
13220 let claim = inbox
13221 .claim_owned(
13222 &child.id,
13223 1,
13224 Some("old-run"),
13225 &SessionInboxLeaseRequest {
13226 consumer,
13227 now: claim_now,
13228 duration: if case == "claim-live" {
13229 chrono::Duration::minutes(5)
13230 } else {
13231 cutoff - claim_now
13232 },
13233 },
13234 )
13235 .await
13236 .unwrap()
13237 .pop()
13238 .unwrap();
13239 child = inbox
13240 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13241 fence: old.fence(),
13242 expected_created_at: child.created_at,
13243 expected_conversation_generation: child.conversation_generation_observation(),
13244 claim: claim.clone(),
13245 expected_messages: child.messages.clone(),
13246 expected_provider_transcript: child.provider_transcript.clone(),
13247 expected_admission: None,
13248 })
13249 .await
13250 .unwrap()
13251 .session;
13252 store
13254 .validate_fence(&old.fence(), chrono::Utc::now())
13255 .await
13256 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13257 let setup_leases = inbox
13258 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13259 .await
13260 .unwrap();
13261 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13262 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13263 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13264 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13265 let setup_at = chrono::Utc::now();
13266 assert!(
13267 setup_at < old.lease_expires_at,
13268 "{case}: setup Actor expired"
13269 );
13270 assert!(
13271 setup_at < claim.lease.expires_at,
13272 "{case}: setup claim expired"
13273 );
13274 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13275 if case == "ack" {
13276 inbox
13277 .ack_owned(&child.id, &claim, chrono::Utc::now())
13278 .await
13279 .unwrap();
13280 }
13281 if case == "mixed" {
13282 inbox
13283 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13284 &child.id,
13285 "different input",
13286 ))
13287 .await
13288 .unwrap();
13289 }
13290 let directory = store
13291 .bamboo_home_dir()
13292 .join(store.resolve_rel_path(&child.id).await.unwrap());
13293 let main = std::fs::read(directory.join("session.json")).unwrap();
13294 let canonical = child.clone();
13295 match case {
13296 "prefix" => child.messages[0].content.push_str(" stale"),
13297 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13298 "missing-marker" => {
13299 child
13300 .messages
13301 .last_mut()
13302 .unwrap()
13303 .metadata
13304 .as_mut()
13305 .unwrap()
13306 .as_object_mut()
13307 .unwrap()
13308 .remove("_bamboo_owned_input_checkpoint");
13309 }
13310 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13311 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13312 _ => {}
13313 }
13314 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13317 tokio::time::sleep(delay + Duration::from_millis(10)).await;
13318 let recovery_at = chrono::Utc::now();
13319 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13320 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13321 let original = before_recovery.activation.unwrap();
13322 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13323 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13324 assert_eq!(
13325 store
13326 .validate_fence(&old.fence(), recovery_at)
13327 .await
13328 .is_ok(),
13329 case == "actor-live",
13330 "{case}: Actor expiry control"
13331 );
13332 let recovery_leases = inbox
13333 .inspect_owned_leases(&child.id, 2, recovery_at)
13334 .await
13335 .unwrap();
13336 if case == "ack" {
13337 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13338 } else {
13339 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13340 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13341 assert_eq!(
13342 recovery_leases[0].expires_at, claim.lease.expires_at,
13343 "{case}"
13344 );
13345 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13346 }
13347 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13348 let second = Arc::new(
13349 bamboo_storage::SessionStoreV2::new(temp.path().into())
13350 .await
13351 .unwrap(),
13352 );
13353 let binding = actor_binding(
13354 second.clone(),
13355 Arc::new(bamboo_storage::FileSessionInbox::new(
13356 second.clone(),
13357 bamboo_domain::SessionInboxLimits::default(),
13358 )),
13359 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13360 );
13361 let _owner = binding
13362 .router
13363 .register_run(&child.id, "replacement-run")
13364 .await
13365 .unwrap();
13366 let prepared = PlainActorActivation::start_and_prepare(
13367 second.clone(),
13368 &mut child,
13369 &binding,
13370 Some("replacement-run"),
13371 Some(policy.clone()),
13372 case != "readonly",
13373 Some("probed-new-worker"),
13374 )
13375 .await;
13376 assert_eq!(
13377 std::fs::read(directory.join("session.json")).unwrap(),
13378 main,
13379 "{case}: Already or refusal never rewrites Main"
13380 );
13381 if case != "accepted" {
13382 assert!(prepared.is_err(), "{case}");
13383 assert_eq!(
13384 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13385 case == "ack"
13386 );
13387 continue;
13388 }
13389 let (replacement, delivery) = prepared.unwrap();
13390 assert!(replacement.recovering_pre_ack);
13391 let (messages, mut deliveries) = delivery.unwrap();
13392 assert_eq!(deliveries.len(), 1);
13393 let delivery = deliveries.pop().unwrap();
13394 let new_claim = replacement.initial_inputs.first().unwrap();
13395 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13396 assert_eq!(new_claim.claim.generation, claim.claim.generation);
13397 assert_eq!(
13398 new_claim.claim.activation_policy,
13399 claim.claim.activation_policy
13400 );
13401 assert!(new_claim.lease.epoch > claim.lease.epoch);
13402 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13403 assert_eq!(
13404 replacement.inbox_generation, 1,
13405 "old Actor generation was zero"
13406 );
13407 assert_eq!(
13408 second
13409 .inspect_actor(&child.id)
13410 .await
13411 .unwrap()
13412 .actor
13413 .current_attempt,
13414 2
13415 );
13416 assert!(second
13417 .validate_fence(&old.fence(), chrono::Utc::now())
13418 .await
13419 .is_err());
13420 assert!(inbox
13421 .ack_owned(&child.id, &claim, chrono::Utc::now())
13422 .await
13423 .is_err());
13424 let authority = second.inspect_actor(&child.id).await.unwrap();
13428 let leases = inbox
13429 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13430 .await
13431 .unwrap();
13432 let delayed = inbox
13433 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13434 fence: old.fence(),
13435 expected_created_at: child.created_at,
13436 expected_conversation_generation: child.conversation_generation_observation(),
13437 claim: claim.clone(),
13438 expected_messages: child.messages.clone(),
13439 expected_provider_transcript: child.provider_transcript.clone(),
13440 expected_admission: child.session_inbox_admission().cloned(),
13441 })
13442 .await;
13443 assert!(matches!(
13444 delayed,
13445 Err(bamboo_storage::ActorInputCheckpointError::Actor(
13446 bamboo_domain::ActorDirectoryError::StaleFence
13447 ))
13448 ));
13449 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13450 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13451 assert_eq!(
13452 inbox
13453 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13454 .await
13455 .unwrap(),
13456 leases
13457 );
13458 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13459 assert_eq!(
13460 serde_json::to_value(&child.messages).unwrap(),
13461 serde_json::to_value(&canonical.messages).unwrap()
13462 );
13463 let mut worker: Vec<bamboo_agent_core::Message> = messages
13464 .iter()
13465 .cloned()
13466 .map(|v| serde_json::from_value(v).unwrap())
13467 .collect();
13468 let target = worker.last_mut().unwrap();
13469 assert!(target
13470 .metadata
13471 .as_ref()
13472 .unwrap()
13473 .get("_bamboo_owned_input_checkpoint")
13474 .is_none());
13475 assert!(bamboo_domain::is_matching_session_message(
13476 target, &envelope
13477 ));
13478 let mut wanted = canonical.messages.last().unwrap().clone();
13479 wanted
13480 .metadata
13481 .as_mut()
13482 .unwrap()
13483 .as_object_mut()
13484 .unwrap()
13485 .remove("_bamboo_owned_input_checkpoint");
13486 assert_eq!(
13487 serde_json::to_value(target).unwrap(),
13488 serde_json::to_value(wanted).unwrap()
13489 );
13490 let run = RunSpec {
13491 assignment: "original task".into(),
13492 logical_session: Some(LogicalSessionIdentity {
13493 session_id: child.id.clone(),
13494 parent_session_id: child.parent_session_id.clone(),
13495 root_session_id: child.root_session_id.clone(),
13496 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13497 created_at: child.created_at,
13498 spawn_depth: child.spawn_depth,
13499 }),
13500 }),
13501 project_id: None,
13502 reasoning_effort: None,
13503 permission_policy: Some(PermissionPolicyContext {
13504 revision: policy.policy_revision(),
13505 requested_mode: "default".into(),
13506 effective_mode: "default".into(),
13507 bypass_permissions: false,
13508 auto_approve_permissions: false,
13509 session_id: child.id.clone(),
13510 workspace_path: child.workspace.clone(),
13511 environment_lease: None,
13512 inherit_session_grants: false,
13513 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13514 }),
13515 messages,
13516 activation_run_id: Some("replacement-run".into()),
13517 execution_epoch: 2,
13518 initial_session_messages: vec![delivery.clone()],
13519 secrets: Default::default(),
13520 };
13521 let policy_revision = policy.policy_revision();
13522 let mut expected = Some(expected_default_permission_posture(policy_revision));
13523 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13524 else {
13525 unreachable!()
13526 };
13527 let (events, mut rx) = mpsc::channel(1);
13528 process_actor_event(
13529 event,
13530 true,
13531 &mut PermissionPostureHandshake::Awaiting,
13532 expected.as_ref(),
13533 Some(&binding),
13534 &mut child,
13535 &events,
13536 &mut ActorEventDisplay::default(),
13537 )
13538 .await
13539 .unwrap();
13540 rx.recv().await.unwrap();
13541 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13542 &run,
13543 &delivery,
13544 uuid::Uuid::new_v4().to_string(),
13545 )
13546 .unwrap();
13547 replacement
13548 .release_initial_input(
13549 &binding,
13550 &child,
13551 &run,
13552 2,
13553 new_claim,
13554 &request,
13555 &mut expected,
13556 None,
13557 )
13558 .await
13559 .unwrap();
13560 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13561 replacement
13562 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13563 .await
13564 .unwrap();
13565 rx.recv().await.unwrap();
13566 replacement
13567 .finish(ActorActivationFinish::Succeeded)
13568 .await
13569 .unwrap();
13570 let cold = second.load_session(&child.id).await.unwrap().unwrap();
13571 assert_eq!(cold.created_at, canonical.created_at);
13572 assert_eq!(
13573 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13574 serde_json::to_value(&canonical.messages).unwrap()
13575 );
13576 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13577 assert_eq!(
13578 cold.messages
13579 .iter()
13580 .filter(|m| m.id == envelope.id.as_str())
13581 .count(),
13582 1
13583 );
13584 }
13585 }
13586
13587 #[cfg(unix)]
13588 #[tokio::test]
13589 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13590 use bamboo_subagent::provision::*;
13591 use std::os::unix::fs::PermissionsExt;
13592 let temp = tempfile::tempdir().unwrap();
13593 let worker = temp.path().join("mixed-worker");
13594 let spawned = temp.path().join("spawned");
13595 let run = temp.path().join("run");
13596 let probes = temp.path().join("probes");
13597 let fabric = temp.path().join("fabric");
13598 let mut report = WorkerCapabilityReport::current();
13599 report
13600 .capabilities
13601 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13602 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13603 let report = serde_json::to_string(&report).unwrap();
13604 std::fs::write(&worker, format!(
13605 "#!/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"
13606 )).unwrap();
13607 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13608 let args = vec![
13609 spawned.to_str().unwrap().into(),
13610 run.to_str().unwrap().into(),
13611 probes.to_str().unwrap().into(),
13612 ];
13613 for capability in [
13615 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13616 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13617 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13618 TYPED_READ_ONLY_WORKER_CAPABILITY,
13619 ] {
13620 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13621 .await
13622 .unwrap();
13623 }
13624 let runner = ActorChildRunner::new(
13625 "mixed".into(),
13626 worker,
13627 args,
13628 fabric.clone(),
13629 ExecutorSpec::BambooRuntime,
13630 vec![],
13631 "openai".into(),
13632 1,
13633 );
13634 let error = runner
13636 .require_initial_input_release_worker()
13637 .await
13638 .unwrap_err();
13639 assert!(error
13640 .to_string()
13641 .contains("owned initial release worker capability unconfirmed"));
13642 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13643 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13644 assert!(runner.pool.lock().await.is_empty());
13645 }
13646
13647 #[tokio::test]
13648 async fn ordinary_glob_only_observes_own_initialization_signals() {
13649 let temp = tempfile::tempdir().unwrap();
13650 let home = temp.path().canonicalize().unwrap();
13651 let store = bamboo_storage::SessionStoreV2::new(home.clone())
13652 .await
13653 .unwrap();
13654 let parent = Session::new("glob-standard-parent", "model");
13655 store.save_session(&parent).await.unwrap();
13656 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13657 store.save_session(&child).await.unwrap();
13658 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13659 let unrelated = directory.parent().unwrap().join("unrelated");
13662 std::fs::create_dir(&unrelated).unwrap();
13663 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13664 require_unowned_glob(&store, &child).await.unwrap();
13665 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13666 let path = directory.join(name);
13667 std::fs::write(&path, b"invalid prior signal").unwrap();
13668 assert!(require_unowned_glob(&store, &child).await.is_err());
13669 std::fs::remove_file(&path).unwrap();
13670 }
13671 #[cfg(unix)]
13672 {
13673 let marker = directory.join("actor-authority.initialized.json");
13674 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13675 assert!(require_unowned_glob(&store, &child).await.is_err());
13676 }
13677 }
13678
13679 #[test]
13680 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13681 let call = bamboo_domain::ToolCall {
13682 id: "glob".into(),
13683 tool_type: "function".into(),
13684 function: bamboo_domain::FunctionCall {
13685 name: "Glob".into(),
13686 arguments: r#"{"pattern":"marker.txt"}"#.into(),
13687 },
13688 };
13689 let messages = vec![
13690 bamboo_agent_core::Message::assistant("", Some(vec![call])),
13691 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13692 bamboo_agent_core::Message::assistant("done", None),
13693 ];
13694 let start = serde_json::to_value(AgentEvent::ToolStart {
13695 tool_call_id: "glob".into(),
13696 tool_name: "Glob".into(),
13697 arguments: serde_json::json!({"pattern":"marker.txt"}),
13698 })
13699 .unwrap();
13700 let complete = serde_json::to_value(AgentEvent::ToolComplete {
13701 tool_call_id: "glob".into(),
13702 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13703 })
13704 .unwrap();
13705 let tail =
13706 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13707 messages: messages
13708 .iter()
13709 .map(serde_json::to_value)
13710 .collect::<Result<_, _>>()
13711 .unwrap(),
13712 })
13713 .unwrap();
13714 let mut ordered = ReadOnlyActorCollector::default();
13715 assert!(ordered.event(&start).unwrap());
13716 assert!(ordered.event(&complete).unwrap());
13717 assert!(!ordered.event(&tail).unwrap());
13718 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13719 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13720 assert!(ReadOnlyActorCollector::default()
13721 .finish(Some("done"))
13722 .is_err());
13723 let mut unfinished = ReadOnlyActorCollector::default();
13724 unfinished.event(&start).unwrap();
13725 unfinished.event(&complete).unwrap();
13726 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13727 let mut closed = tail.clone();
13728 closed["grant"] = true.into();
13729 assert!(
13730 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13731 .is_err()
13732 );
13733 let mut oversized = tail.clone();
13734 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13735 assert!(
13736 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13737 .unwrap()
13738 .validate()
13739 .is_err()
13740 );
13741 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13742 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13743 let mut bad = start.clone();
13744 bad["tool_name"] = "Bash".into();
13745 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13746 let mut duplicate = ReadOnlyActorCollector::default();
13747 duplicate.event(&start).unwrap();
13748 assert!(duplicate.event(&start).is_err());
13749 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13750 content: "hidden".into(),
13751 })
13752 .unwrap();
13753 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13754 let mut mismatched = ReadOnlyActorCollector::default();
13755 mismatched.event(&start).unwrap();
13756 let mut bad = complete.clone();
13757 bad["tool_call_id"] = "foreign".into();
13758 assert!(mismatched.event(&bad).is_err());
13759 mismatched.event(&complete).unwrap();
13760 let mut changed = tail.clone();
13761 changed["messages"][1]["content"] = "fabricated".into();
13762 assert!(mismatched.event(&changed).is_err());
13763 }
13764
13765 #[cfg(unix)]
13766 #[tokio::test]
13767 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13768 let temp = tempfile::tempdir().unwrap();
13769 let store = Arc::new(
13770 bamboo_storage::SessionStoreV2::new(temp.path().into())
13771 .await
13772 .unwrap(),
13773 );
13774 let parent = Session::new("release-host-parent", "model");
13775 store.save_session(&parent).await.unwrap();
13776 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13777 store.save_session(&child).await.unwrap();
13778 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13779 store.clone(),
13780 bamboo_domain::SessionInboxLimits::default(),
13781 ));
13782 let binding = actor_binding(
13783 store.clone(),
13784 inbox,
13785 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13786 );
13787 let _owner = binding
13788 .router
13789 .register_run(&child.id, "release-host-run")
13790 .await
13791 .unwrap();
13792 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13793 let revision = policy.policy_revision();
13794 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13795 let activation = PlainActorActivation::start(
13796 store.clone(),
13797 &child,
13798 &binding,
13799 Some("release-host-run"),
13800 Some(policy.clone()),
13801 true,
13802 Some("probed-physical-worker"),
13803 )
13804 .await
13805 .unwrap();
13806 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13807 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13808 activation
13809 .input_inbox
13810 .mark_activation_eligible(
13811 &child.id,
13812 receipt.generation,
13813 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13814 )
13815 .await
13816 .unwrap();
13817 let claim = activation
13818 .claim_input(&binding, &child, "release-host-run")
13819 .await
13820 .unwrap()
13821 .unwrap();
13822 let (prefix, delivery) = activation
13823 .checkpoint_input(&mut child, "release-host-run", &claim)
13824 .await
13825 .unwrap();
13826 let mut expected = Some(expected_default_permission_posture(revision));
13827 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13828 unreachable!()
13829 };
13830 let (events, mut events_rx) = mpsc::channel(1);
13831 process_actor_event(
13832 event,
13833 true,
13834 &mut PermissionPostureHandshake::Awaiting,
13835 expected.as_ref(),
13836 Some(&binding),
13837 &mut child,
13838 &events,
13839 &mut ActorEventDisplay::default(),
13840 )
13841 .await
13842 .unwrap();
13843 events_rx.recv().await.unwrap();
13844 let run = RunSpec {
13845 assignment: "assignment".into(),
13846 logical_session: Some(LogicalSessionIdentity {
13847 session_id: child.id.clone(),
13848 parent_session_id: child.parent_session_id.clone(),
13849 root_session_id: child.root_session_id.clone(),
13850 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13851 created_at: child.created_at,
13852 spawn_depth: child.spawn_depth,
13853 }),
13854 }),
13855 project_id: None,
13856 reasoning_effort: None,
13857 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13858 revision,
13859 requested_mode: "default".into(),
13860 effective_mode: "default".into(),
13861 bypass_permissions: false,
13862 auto_approve_permissions: false,
13863 session_id: child.id.clone(),
13864 workspace_path: child.workspace.clone(),
13865 environment_lease: None,
13866 inherit_session_grants: false,
13867 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13868 }),
13869 messages: prefix,
13870 activation_run_id: Some("release-host-run".into()),
13871 execution_epoch: 9,
13872 initial_session_messages: vec![delivery.clone()],
13873 secrets: Default::default(),
13874 };
13875 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13876 &run,
13877 &delivery,
13878 uuid::Uuid::new_v4().to_string(),
13879 )
13880 .unwrap();
13881 let mut stale = child.clone();
13882 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13883 assert!(activation
13884 .release_initial_input(
13885 &binding,
13886 &stale,
13887 &run,
13888 9,
13889 &claim,
13890 &request,
13891 &mut expected,
13892 None
13893 )
13894 .await
13895 .is_err());
13896 assert!(!activation
13897 .input_inbox
13898 .was_admitted(&child.id, &envelope.id)
13899 .await
13900 .unwrap());
13901 use std::os::unix::fs::PermissionsExt;
13904 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13905 impl Drop for RestorePermissions {
13906 fn drop(&mut self) {
13907 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13908 }
13909 }
13910 let admitted = store
13911 .bamboo_home_dir()
13912 .join(store.resolve_rel_path(&child.id).await.unwrap())
13913 .join("inbox/admitted");
13914 std::fs::create_dir_all(&admitted).unwrap();
13915 let restore = RestorePermissions(
13916 admitted.clone(),
13917 std::fs::metadata(&admitted).unwrap().permissions(),
13918 );
13919 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13920 let verified = activation
13921 .input_inbox
13922 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13923 fence: activation.fence.clone(),
13924 expected_created_at: child.created_at,
13925 expected_conversation_generation: child.conversation_generation_observation(),
13926 claim: claim.clone(),
13927 expected_messages: child.messages.clone(),
13928 expected_provider_transcript: child.provider_transcript.clone(),
13929 expected_admission: child.session_inbox_admission().cloned(),
13930 })
13931 .await
13932 .unwrap();
13933 assert_eq!(
13934 verified.status,
13935 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13936 );
13937 let error = activation
13938 .release_initial_input(
13939 &binding,
13940 &child,
13941 &run,
13942 9,
13943 &claim,
13944 &request,
13945 &mut expected,
13946 None,
13947 )
13948 .await
13949 .unwrap_err();
13950 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13951 drop(restore);
13952 assert!(
13953 error.to_string().contains("ACK unresolved"),
13954 "actual release error (<=512B): {}",
13955 error.to_string().chars().take(128).collect::<String>()
13956 );
13957 let release = activation
13958 .release_initial_input(
13959 &binding,
13960 &child,
13961 &run,
13962 9,
13963 &claim,
13964 &request,
13965 &mut expected,
13966 None,
13967 )
13968 .await
13969 .unwrap();
13970 assert!(activation
13971 .input_inbox
13972 .was_admitted(&child.id, &envelope.id)
13973 .await
13974 .unwrap());
13975 assert_eq!(
13976 release.expires_at,
13977 activation
13978 .actor_deadline
13979 .min(claim.lease.expires_at)
13980 .min(activation.input_deadline)
13981 );
13982 assert_eq!(
13983 activation
13984 .release_initial_input(
13985 &binding,
13986 &child,
13987 &run,
13988 9,
13989 &claim,
13990 &request,
13991 &mut expected,
13992 Some(&release)
13993 )
13994 .await
13995 .unwrap(),
13996 release
13997 );
13998 let mut wrong = request.clone();
13999 wrong.nonce = uuid::Uuid::new_v4().to_string();
14000 assert!(activation
14001 .release_initial_input(
14002 &binding,
14003 &child,
14004 &run,
14005 9,
14006 &claim,
14007 &wrong,
14008 &mut expected,
14009 Some(&release)
14010 )
14011 .await
14012 .is_err());
14013 assert!(activation
14014 .release_initial_input(
14015 &binding,
14016 &child,
14017 &run,
14018 10,
14019 &claim,
14020 &request,
14021 &mut expected,
14022 Some(&release)
14023 )
14024 .await
14025 .is_err());
14026 let deadlines = (
14028 activation.fence.clone(),
14029 activation.actor_deadline,
14030 activation.input_deadline,
14031 );
14032 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14033 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14034 activation
14035 .input_inbox
14036 .mark_activation_eligible(
14037 &child.id,
14038 receipt.generation,
14039 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14040 )
14041 .await
14042 .unwrap();
14043 let mut link = ConfirmationSequenceLink {
14044 frames: VecDeque::new(),
14045 sent: Vec::new(),
14046 };
14047 let epochs = AtomicU64::new(9);
14048 let (claim2, epoch2) = activation
14049 .continue_input(
14050 &mut link,
14051 &binding,
14052 &mut child,
14053 "release-host-run",
14054 Some((&run, &epochs)),
14055 &mut expected,
14056 "SECOND_REPLY".into(),
14057 &events,
14058 )
14059 .await
14060 .unwrap()
14061 .unwrap();
14062 events_rx.recv().await.unwrap();
14063 assert_eq!(epoch2, 10);
14064 let ParentFrame::Run(next) = &link.sent[0] else {
14065 panic!("second correlated Run")
14066 };
14067 assert_eq!(next.logical_session, run.logical_session);
14068 assert_eq!(next.activation_run_id, run.activation_run_id);
14069 assert_eq!(next.initial_session_messages.len(), 1);
14070 assert!(next
14071 .messages
14072 .iter()
14073 .any(|m| m["id"] == envelope.id.as_str()));
14074 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14075 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14076 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14077 next,
14078 &next.initial_session_messages[0],
14079 uuid::Uuid::new_v4().to_string(),
14080 )
14081 .unwrap();
14082 assert_ne!(request2.nonce, request.nonce);
14083 macro_rules! release_second {
14084 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14085 activation
14086 .release_initial_input(
14087 &binding,
14088 $candidate,
14089 next,
14090 $epoch,
14091 &claim2,
14092 $request,
14093 &mut expected,
14094 $previous,
14095 )
14096 .await
14097 };
14098 }
14099 assert!(release_second!(&child, &request, epoch2, None).is_err());
14100 assert!(release_second!(&child, &request2, 9, None).is_err());
14101 let mut stale2 = child.clone();
14102 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14103 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14104 assert!(!activation
14105 .input_inbox
14106 .was_admitted(&child.id, &second.id)
14107 .await
14108 .unwrap());
14109 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14110 assert_eq!(
14111 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14112 release2
14113 );
14114 let mut wrong2 = request2.clone();
14115 wrong2.nonce = request.nonce.clone();
14116 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14117 assert!(activation
14118 .input_inbox
14119 .was_admitted(&child.id, &second.id)
14120 .await
14121 .unwrap());
14122 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14123 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14124 activation
14125 .input_inbox
14126 .mark_activation_eligible(
14127 &child.id,
14128 receipt.generation,
14129 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14130 )
14131 .await
14132 .unwrap();
14133 let now = chrono::Utc::now();
14134 let expired_claim = activation
14135 .input_inbox
14136 .claim_owned(
14137 &child.id,
14138 1,
14139 Some("release-host-run"),
14140 &SessionInboxLeaseRequest {
14141 consumer: activation.input_consumer.clone(),
14142 now,
14143 duration: chrono::Duration::seconds(1),
14144 },
14145 )
14146 .await
14147 .unwrap()
14148 .pop()
14149 .unwrap();
14150 let (prefix, delivery3) = activation
14151 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14152 .await
14153 .unwrap();
14154 let mut expired_run = next.clone();
14155 expired_run.execution_epoch = 11;
14156 expired_run.messages = prefix;
14157 expired_run.initial_session_messages = vec![delivery3.clone()];
14158 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14159 &expired_run,
14160 &delivery3,
14161 uuid::Uuid::new_v4().to_string(),
14162 )
14163 .unwrap();
14164 tokio::time::sleep(
14165 (expired_claim.lease.expires_at - chrono::Utc::now())
14166 .to_std()
14167 .unwrap_or_default(),
14168 )
14169 .await;
14170 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14171 assert!(activation
14172 .release_initial_input(
14173 &binding,
14174 &child,
14175 &expired_run,
14176 11,
14177 &expired_claim,
14178 &expired_request,
14179 &mut expected,
14180 None
14181 )
14182 .await
14183 .is_err());
14184 assert!(!activation
14185 .input_inbox
14186 .was_admitted(&child.id, &expired.id)
14187 .await
14188 .unwrap());
14189 assert_eq!(
14190 (
14191 activation.fence.clone(),
14192 activation.actor_deadline,
14193 activation.input_deadline
14194 ),
14195 deadlines
14196 );
14197 assert_eq!(
14198 link.sent.len(),
14199 1,
14200 "no dispatch from rejected release checks"
14201 );
14202 let cold = store.load_session(&child.id).await.unwrap().unwrap();
14203 for id in [&envelope.id, &second.id] {
14204 assert_eq!(
14205 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14206 1
14207 );
14208 assert!(cold.session_inbox_admission().unwrap().contains(id));
14209 }
14210 policy.deny_scoped_session_permission(
14212 &child.id,
14213 bamboo_tools::permission::PermissionType::ExecuteCommand,
14214 "blocked-action",
14215 );
14216 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14217 assert!(activation
14218 .input_inbox
14219 .was_admitted(&child.id, &second.id)
14220 .await
14221 .unwrap());
14222 activation
14223 .finish(ActorActivationFinish::Failed)
14224 .await
14225 .unwrap();
14226 activation
14228 .input_inbox
14229 .ack_owned(&child.id, &claim, chrono::Utc::now())
14230 .await
14231 .unwrap();
14232 assert!(activation
14233 .release_initial_input(
14234 &binding,
14235 &child,
14236 &run,
14237 9,
14238 &claim,
14239 &request,
14240 &mut expected,
14241 Some(&release)
14242 )
14243 .await
14244 .is_err());
14245 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14246 .await
14247 .unwrap()
14248 .load_session(&child.id)
14249 .await
14250 .unwrap()
14251 .unwrap();
14252 assert_eq!(
14253 cold.messages
14254 .iter()
14255 .filter(|m| m.id == envelope.id.as_str())
14256 .count(),
14257 1
14258 );
14259 }
14260
14261 #[tokio::test]
14262 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14263 for reject_checkpoint in [false, true] {
14264 let temp = tempfile::tempdir().unwrap();
14265 let store = Arc::new(
14266 bamboo_storage::SessionStoreV2::new(temp.path().into())
14267 .await
14268 .unwrap(),
14269 );
14270 let parent = Session::new("owned-input-parent", "model");
14271 store.save_session(&parent).await.unwrap();
14272 let mut child =
14273 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14274 store.save_session(&child).await.unwrap();
14275 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14276 let raw_storage: Arc<dyn Storage> = store.clone();
14277 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14278 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14279 store.clone(),
14280 bamboo_domain::SessionInboxLimits::default(),
14281 ));
14282 let binding = actor_binding(store.clone(), inbox, locked);
14283 let run_id = "owned-input-run";
14284 let _registration = binding
14285 .router
14286 .register_run(&child.id, run_id)
14287 .await
14288 .unwrap();
14289 assert!(PlainActorActivation::start(
14290 store.clone(),
14291 &child,
14292 &binding,
14293 Some(run_id),
14294 None,
14295 true,
14296 None,
14297 )
14298 .await
14299 .is_err());
14300 assert!(PlainActorActivation::start(
14301 store.clone(),
14302 &child,
14303 &binding,
14304 Some(run_id),
14305 Some(policy.clone()),
14306 true,
14307 None,
14308 )
14309 .await
14310 .is_err());
14311 assert_eq!(
14312 store
14313 .inspect_actor(&child.id)
14314 .await
14315 .unwrap()
14316 .actor
14317 .current_attempt,
14318 0
14319 );
14320 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14321 let activation = PlainActorActivation::start(
14322 store.clone(),
14323 &child,
14324 &binding,
14325 Some(run_id),
14326 Some(policy.clone()),
14327 true,
14328 None,
14329 )
14330 .await
14331 .unwrap();
14332 let envelope =
14333 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14334 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14335 activation
14336 .input_inbox
14337 .mark_activation_eligible(
14338 &child.id,
14339 receipt.generation,
14340 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14341 )
14342 .await
14343 .unwrap();
14344 let mut link = ConfirmationSequenceLink {
14345 frames: VecDeque::new(),
14346 sent: Vec::new(),
14347 };
14348 let policy_revision = policy.policy_revision();
14349 let run = RunSpec {
14350 assignment: "one plain task".into(),
14351 logical_session: None,
14352 project_id: None,
14353 reasoning_effort: None,
14354 permission_policy: Some(PermissionPolicyContext {
14355 revision: policy_revision,
14356 requested_mode: "default".into(),
14357 effective_mode: "default".into(),
14358 bypass_permissions: false,
14359 auto_approve_permissions: false,
14360 session_id: child.id.clone(),
14361 workspace_path: child.workspace.clone(),
14362 environment_lease: None,
14363 inherit_session_grants: false,
14364 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14365 }),
14366 messages: vec![],
14367 activation_run_id: Some(run_id.into()),
14368 execution_epoch: 1,
14369 initial_session_messages: vec![],
14370 secrets: Default::default(),
14371 };
14372 let epochs = AtomicU64::new(1);
14373 let (tx, mut rx) = mpsc::channel(1);
14374 let mut expected = Some(expected_default_permission_posture(policy_revision));
14375 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14376 else {
14377 unreachable!()
14378 };
14379 process_actor_event(
14380 event,
14381 true,
14382 &mut PermissionPostureHandshake::Awaiting,
14383 expected.as_ref(),
14384 Some(&binding),
14385 &mut child,
14386 &tx,
14387 &mut ActorEventDisplay::default(),
14388 )
14389 .await
14390 .unwrap();
14391 rx.recv().await.unwrap();
14392 let first_audit =
14393 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14394 .unwrap()
14395 .audit_revision;
14396 if reject_checkpoint {
14397 tx.send(AgentEvent::Complete {
14400 usage: Default::default(),
14401 })
14402 .await
14403 .unwrap();
14404 }
14405 let mut operation = activation.continue_input(
14406 &mut link,
14407 &binding,
14408 &mut child,
14409 run_id,
14410 Some((&run, &epochs)),
14411 &mut expected,
14412 "FIRST_PLAIN_REPLY".into(),
14413 &tx,
14414 );
14415 if reject_checkpoint {
14416 tokio::select! {
14417 result = &mut operation => panic!("publication barrier released early: {result:?}"),
14418 _ = async {
14419 tokio::time::timeout(Duration::from_secs(5), async {
14420 loop {
14421 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14422 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14423 tokio::task::yield_now().await;
14424 }
14425 }).await.unwrap();
14426 activation.finish(ActorActivationFinish::Failed).await.unwrap();
14427 rx.recv().await.unwrap();
14428 } => {}
14429 }
14430 }
14431 let result = operation.await;
14432 if reject_checkpoint {
14433 assert!(result
14434 .unwrap_err()
14435 .to_string()
14436 .contains("checkpoint unconfirmed"));
14437 assert!(
14438 link.sent.is_empty(),
14439 "zero worker/provider correction dispatch"
14440 );
14441 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14442 assert!(durable
14443 .messages
14444 .iter()
14445 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14446 assert!(!durable
14447 .messages
14448 .iter()
14449 .any(|m| m.id == envelope.id.as_str()));
14450 assert!(!activation
14451 .input_inbox
14452 .was_admitted(&child.id, &envelope.id)
14453 .await
14454 .unwrap());
14455 assert_eq!(
14456 activation
14457 .input_inbox
14458 .inspect(&child.id)
14459 .await
14460 .unwrap()
14461 .claimed,
14462 1
14463 );
14464 continue;
14465 }
14466 let (claim, epoch) = result.unwrap().unwrap();
14467 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
14469 assert_eq!(link.sent.len(), 1);
14470 let ParentFrame::Run(next) = &link.sent[0] else {
14471 panic!("correlated continuation Run")
14472 };
14473 assert_eq!(next.execution_epoch, 2);
14474 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14475 assert!(next
14476 .messages
14477 .iter()
14478 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14479 assert!(!next
14480 .messages
14481 .iter()
14482 .any(|m| m["id"] == envelope.id.as_str()));
14483 let delivery = &next.initial_session_messages[0];
14484 assert_eq!(delivery.envelope, envelope);
14485 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14486 assert!(durable
14487 .session_inbox_admission()
14488 .unwrap()
14489 .contains(&envelope.id));
14490 assert_eq!(
14491 durable
14492 .messages
14493 .iter()
14494 .filter(|m| m.id == envelope.id.as_str())
14495 .count(),
14496 1
14497 );
14498 assert!(
14499 !activation
14500 .input_inbox
14501 .was_admitted(&child.id, &envelope.id)
14502 .await
14503 .unwrap(),
14504 "checkpoint is not a worker admission receipt"
14505 );
14506 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14507 confirmation.canonical_claim_generation += 1;
14508 assert!(activation
14509 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14510 .await
14511 .is_err());
14512 assert!(!activation
14513 .input_inbox
14514 .was_admitted(&child.id, &envelope.id)
14515 .await
14516 .unwrap());
14517 confirmation.canonical_claim_generation = claim.claim.generation;
14518 activation
14519 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14520 .await
14521 .unwrap();
14522 assert!(activation
14523 .input_inbox
14524 .was_admitted(&child.id, &envelope.id)
14525 .await
14526 .unwrap());
14527 assert_eq!(
14528 expected.as_ref().unwrap().expected_audit_revision,
14529 Some(first_audit)
14530 );
14531 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14532 else {
14533 unreachable!()
14534 };
14535 process_actor_event(
14536 event,
14537 true,
14538 &mut PermissionPostureHandshake::Awaiting,
14539 expected.as_ref(),
14540 Some(&binding),
14541 &mut child,
14542 &tx,
14543 &mut ActorEventDisplay::default(),
14544 )
14545 .await
14546 .unwrap();
14547 rx.recv().await.unwrap();
14548 assert!(
14549 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14550 .unwrap()
14551 .audit_revision
14552 > first_audit
14553 );
14554 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14555 .await
14556 .unwrap();
14557 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14558 assert_eq!(
14559 cold.messages
14560 .iter()
14561 .filter(|m| m.id == envelope.id.as_str())
14562 .count(),
14563 1
14564 );
14565 assert!(cold
14566 .session_inbox_admission()
14567 .unwrap()
14568 .contains(&envelope.id));
14569 }
14570 }
14571
14572 fn admission_confirmation(
14573 session_id: &str,
14574 claim: &SessionInboxClaim,
14575 run_id: &str,
14576 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14577 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14578 target_session_id: session_id.to_string(),
14579 envelope_id: claim.envelope.id.to_string(),
14580 canonical_claim_generation: claim.generation,
14581 activation_run_id: run_id.to_string(),
14582 }
14583 }
14584
14585 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14586 ExpectedPermissionPosture {
14587 policy_revision,
14588 resolution: bamboo_domain::PermissionModeResolution {
14589 requested: bamboo_domain::SessionPermissionMode::Default,
14590 effective: bamboo_domain::PermissionMode::Default,
14591 },
14592 expected_audit_revision: None,
14593 executor_mapping: "test_actor:permission_mode=default".to_string(),
14594 }
14595 }
14596
14597 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14598 ChildFrame::Event {
14599 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14600 session_id: session_id.to_string(),
14601 policy_revision,
14602 requested_mode: "default".to_string(),
14603 effective_mode: "default".to_string(),
14604 executor_mapping: "test_actor:permission_mode=default".to_string(),
14605 })
14606 .expect("serialize permission posture event"),
14607 }
14608 }
14609
14610 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14611 ChildFrame::Event {
14612 event: serde_json::to_value(event).expect("serialize actor event"),
14613 }
14614 }
14615
14616 #[test]
14617 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14618 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14619 let mut batch = ActorEventBatch {
14620 logical_session: Some(LogicalSessionIdentity {
14621 creation: None,
14622 session_id: session.id.clone(),
14623 parent_session_id: session.parent_session_id.clone(),
14624 root_session_id: session.root_session_id.clone(),
14625 }),
14626 activation_id: Some("activation-7".into()),
14627 execution_epoch: 11,
14628 source_node_id: Some("node-a".into()),
14629 source_actor_id: Some("worker-a".into()),
14630 first_seq: 1,
14631 last_seq: 1,
14632 qos: bamboo_subagent::ActorEventQos::Ephemeral,
14633 events: vec![serde_json::json!({"type":"token","content":"ok"})],
14634 };
14635 validate_actor_event_batch(
14636 &batch,
14637 &session,
14638 "logical-parent",
14639 Some("activation-7"),
14640 11,
14641 "worker-a",
14642 None,
14643 )
14644 .unwrap();
14645
14646 let creation = bamboo_subagent::proto::ChildCreationIdentity {
14647 created_at: session.created_at,
14648 spawn_depth: session.spawn_depth,
14649 };
14650 assert!(validate_actor_event_batch(
14651 &batch,
14652 &session,
14653 "logical-parent",
14654 Some("activation-7"),
14655 11,
14656 "worker-a",
14657 Some(&creation)
14658 )
14659 .is_err());
14660 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14661 validate_actor_event_batch(
14662 &batch,
14663 &session,
14664 "logical-parent",
14665 Some("activation-7"),
14666 11,
14667 "worker-a",
14668 Some(&creation),
14669 )
14670 .unwrap();
14671 batch
14672 .logical_session
14673 .as_mut()
14674 .unwrap()
14675 .creation
14676 .as_mut()
14677 .unwrap()
14678 .created_at += chrono::Duration::nanoseconds(1);
14679 assert!(validate_actor_event_batch(
14680 &batch,
14681 &session,
14682 "logical-parent",
14683 Some("activation-7"),
14684 11,
14685 "worker-a",
14686 Some(&creation)
14687 )
14688 .is_err());
14689 batch.logical_session.as_mut().unwrap().creation = None;
14690
14691 batch.execution_epoch = 10;
14692 assert!(validate_actor_event_batch(
14693 &batch,
14694 &session,
14695 "logical-parent",
14696 Some("activation-7"),
14697 11,
14698 "worker-a",
14699 None,
14700 )
14701 .unwrap_err()
14702 .to_string()
14703 .contains("execution epoch"));
14704 batch.execution_epoch = 11;
14705 batch.activation_id = Some("replaced-activation".into());
14706 assert!(validate_actor_event_batch(
14707 &batch,
14708 &session,
14709 "logical-parent",
14710 Some("activation-7"),
14711 11,
14712 "worker-a",
14713 None,
14714 )
14715 .unwrap_err()
14716 .to_string()
14717 .contains("stale activation"));
14718 batch.activation_id = Some("activation-7".into());
14719 batch
14720 .logical_session
14721 .as_mut()
14722 .expect("logical identity")
14723 .session_id = "foreign-child".into();
14724 assert!(validate_actor_event_batch(
14725 &batch,
14726 &session,
14727 "logical-parent",
14728 Some("activation-7"),
14729 11,
14730 "worker-a",
14731 None,
14732 )
14733 .unwrap_err()
14734 .to_string()
14735 .contains("different logical session"));
14736 batch
14737 .logical_session
14738 .as_mut()
14739 .expect("logical identity")
14740 .session_id = session.id.clone();
14741 batch.source_actor_id = Some("worker-b".into());
14742 assert!(validate_actor_event_batch(
14743 &batch,
14744 &session,
14745 "logical-parent",
14746 Some("activation-7"),
14747 11,
14748 "worker-a",
14749 None,
14750 )
14751 .unwrap_err()
14752 .to_string()
14753 .contains("different physical actor"));
14754 }
14755
14756 #[tokio::test]
14757 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14758 use bamboo_domain::Storage;
14759
14760 let temp = tempfile::tempdir().unwrap();
14761 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14762 .await
14763 .unwrap();
14764 let parent = Session::new("event-parent", "model");
14765 let child = Session::new_child_of("event-child", &parent, "model", "task");
14766 store.save_session(&parent).await.unwrap();
14767 store.save_session(&child).await.unwrap();
14768 store.ensure_actor(&child.id).await.unwrap();
14769 let now = chrono::Utc::now();
14770 let claim = |run_id: &str| ActorActivationClaim {
14771 actor_id: child.id.clone(),
14772 run_id: run_id.into(),
14773 lease_owner: "event-owner".into(),
14774 lease_expires_at: now + chrono::Duration::minutes(2),
14775 inbox_generation: 0,
14776 placement_ref: None,
14777 now,
14778 };
14779 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14780 store.start_activation(&first.fence(), now).await.unwrap();
14781 let first_fence = first.fence();
14782 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14783 .await
14784 .unwrap();
14785 validate_directory_actor_event(&store, &first_fence, &child, None)
14786 .await
14787 .unwrap(); assert!(
14789 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14790 .await
14791 .is_err()
14792 );
14793 let mut forged = child.clone();
14794 forged.root_session_id = "foreign-root".into();
14795 assert!(
14796 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14797 .await
14798 .is_err()
14799 );
14800
14801 store
14802 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14803 .await
14804 .unwrap();
14805 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14806 store.start_activation(&second.fence(), now).await.unwrap();
14807 assert!(
14808 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14809 .await
14810 .is_err()
14811 );
14812 assert!(
14813 validate_directory_actor_event(&store, &first_fence, &child, None)
14814 .await
14815 .is_err()
14816 );
14817 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14818 .await
14819 .unwrap();
14820 }
14821
14822 fn completed_actor_frame() -> ChildFrame {
14823 ChildFrame::Terminal {
14824 status: TerminalStatus::Completed,
14825 result: Some("done".to_string()),
14826 error: None,
14827 final_event_watermark: None,
14828 transcript: Vec::new(),
14829 }
14830 }
14831
14832 async fn drive_permission_handshake_frames(
14833 session_id: &str,
14834 frames: impl IntoIterator<Item = ChildFrame>,
14835 expected: ExpectedPermissionPosture,
14836 ) -> (
14837 crate::runtime::runner::Result<Option<String>>,
14838 Session,
14839 Vec<AgentEvent>,
14840 Vec<ParentFrame>,
14841 ) {
14842 let mut link = ConfirmationSequenceLink {
14843 frames: frames.into_iter().collect(),
14844 sent: Vec::new(),
14845 };
14846 let (event_tx, mut event_rx) = mpsc::channel(128);
14847 let cancel = CancellationToken::new();
14848 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14849 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14850 let mut session = Session::new(session_id, "model");
14851 let result = drive(ActorDriveContext {
14852 expected_creation: None,
14853 client: &mut link,
14854 parent_session_id: "permission-parent",
14855 child_session_id: session_id,
14856 child_attempt: 0,
14857 approval_registry: None,
14858 approval_decider: None,
14859 approval_reviewer: None,
14860 escalation_bridge: None,
14861 event_tx: &event_tx,
14862 cancel_token: &cancel,
14863 live_rx: &mut live_rx,
14864 delivery_rx: &mut delivery_rx,
14865 logical_session: &mut session,
14866 expected_permission_posture: Some(expected),
14867 session_inbox_runtime: None,
14868 actor_directory_store: None,
14869 canonical_subagent_tool: None,
14870 ticket_service: None,
14871 activation_run_id: None,
14872 execution_epoch: 0,
14873 expected_source_actor_id: session_id,
14874 initial_inflight_claims: VecDeque::new(),
14875 plain_actor: false,
14876 remote_environment_lease: false,
14877 readonly_output: None,
14878 local_history_tools: None,
14879 local_history_read_only: false,
14880 strict_history_output: None,
14881 plain_input: None,
14882 canonical_activation: None,
14883 canonical_placement_ref: None,
14884 actor_event_observer: None,
14885 plain_run: None,
14886 first_frame_timeout: Some(Duration::from_secs(1)),
14887 })
14888 .await;
14889 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14890 (result, session, events, link.sent)
14891 }
14892
14893 #[tokio::test]
14894 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14895 let session_id = "permission-missing";
14896 let (result, session, events, _) = drive_permission_handshake_frames(
14897 session_id,
14898 [completed_actor_frame()],
14899 expected_default_permission_posture(7),
14900 )
14901 .await;
14902
14903 assert!(result
14904 .unwrap_err()
14905 .to_string()
14906 .contains("terminated before permission posture confirmation"));
14907 assert!(events.is_empty());
14908 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14909 }
14910
14911 #[tokio::test]
14912 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14913 struct TerminalProofProbe {
14914 frame: Option<ChildFrame>,
14915 accepted: bool,
14916 }
14917
14918 #[async_trait]
14919 impl bamboo_subagent::ChildLink for TerminalProofProbe {
14920 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14921 Ok(())
14922 }
14923
14924 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14925 Ok(self.frame.take())
14926 }
14927
14928 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14929 self.accepted = status == TerminalStatus::Error;
14930 }
14931
14932 fn has_pending_durable_terminal(&self) -> bool {
14933 self.accepted
14934 }
14935 }
14936
14937 for (status, error, expected_proof) in [
14938 (
14939 TerminalStatus::Error,
14940 Some("remote_environment_checkout_not_clean"),
14941 true,
14942 ),
14943 (TerminalStatus::Error, Some("untrusted worker error"), false),
14944 (
14945 TerminalStatus::Cancelled,
14946 Some("remote_environment_checkout_not_clean"),
14947 false,
14948 ),
14949 ] {
14950 let mut link = TerminalProofProbe {
14951 frame: Some(ChildFrame::Terminal {
14952 status,
14953 result: None,
14954 error: error.map(str::to_owned),
14955 final_event_watermark: None,
14956 transcript: Vec::new(),
14957 }),
14958 accepted: false,
14959 };
14960 let (event_tx, _event_rx) = mpsc::channel(8);
14961 let cancel = CancellationToken::new();
14962 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14963 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14964 let mut session = Session::new("remote-terminal-proof", "model");
14965 let result = drive(ActorDriveContext {
14966 client: &mut link,
14967 parent_session_id: "proof-parent",
14968 child_session_id: "remote-terminal-proof",
14969 child_attempt: 0,
14970 approval_registry: None,
14971 approval_decider: None,
14972 approval_reviewer: None,
14973 escalation_bridge: None,
14974 event_tx: &event_tx,
14975 cancel_token: &cancel,
14976 live_rx: &mut live_rx,
14977 delivery_rx: &mut delivery_rx,
14978 logical_session: &mut session,
14979 expected_permission_posture: Some(expected_default_permission_posture(7)),
14980 expected_creation: None,
14981 session_inbox_runtime: None,
14982 actor_directory_store: None,
14983 canonical_subagent_tool: None,
14984 ticket_service: None,
14985 activation_run_id: None,
14986 execution_epoch: 0,
14987 expected_source_actor_id: "remote-terminal-proof",
14988 initial_inflight_claims: VecDeque::new(),
14989 plain_actor: false,
14990 remote_environment_lease: true,
14991 readonly_output: None,
14992 local_history_tools: None,
14993 local_history_read_only: false,
14994 strict_history_output: None,
14995 plain_input: None,
14996 canonical_activation: None,
14997 canonical_placement_ref: None,
14998 actor_event_observer: None,
14999 plain_run: None,
15000 first_frame_timeout: Some(Duration::from_secs(1)),
15001 })
15002 .await;
15003 assert!(result.is_err());
15004 assert_eq!(
15005 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15006 expected_proof
15007 );
15008 }
15009 }
15010
15011 #[tokio::test(start_paused = true)]
15012 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
15013 struct CancelOutcomeLink {
15014 cancel_sent: bool,
15015 settle: bool,
15016 accepted: bool,
15017 }
15018
15019 #[async_trait]
15020 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
15021 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15022 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
15023 Ok(())
15024 }
15025
15026 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15027 if !self.cancel_sent || !self.settle {
15028 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15029 .await
15030 } else {
15031 self.cancel_sent = false;
15032 Ok(Some(ChildFrame::Terminal {
15033 status: TerminalStatus::Cancelled,
15034 result: None,
15035 error: None,
15036 final_event_watermark: None,
15037 transcript: Vec::new(),
15038 }))
15039 }
15040 }
15041
15042 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15043 self.accepted = status == TerminalStatus::Cancelled;
15044 }
15045
15046 fn has_pending_durable_terminal(&self) -> bool {
15047 self.accepted
15048 }
15049 }
15050
15051 let temp = tempfile::tempdir().unwrap();
15052 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15053 .await
15054 .unwrap();
15055 let fence = ActorActivationFence {
15056 schema_version: 2,
15057 actor_id: "cancelled-remote-child".into(),
15058 activation_id: "cancelled-remote-activation".into(),
15059 attempt: 1,
15060 run_id: "cancelled-remote-run".into(),
15061 lease_owner: "cancelled-remote-host".into(),
15062 lease_epoch: 1,
15063 };
15064 for settle in [true, false] {
15065 let mut link = CancelOutcomeLink {
15066 cancel_sent: false,
15067 settle,
15068 accepted: false,
15069 };
15070 let (event_tx, _event_rx) = mpsc::channel(8);
15071 let cancel = CancellationToken::new();
15072 cancel.cancel();
15073 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15074 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15075 let mut session = Session::new("cancelled-remote-child", "model");
15076 let result = drive(ActorDriveContext {
15077 expected_creation: None,
15078 client: &mut link,
15079 parent_session_id: "cancelled-remote-parent",
15080 child_session_id: "cancelled-remote-child",
15081 child_attempt: 1,
15082 approval_registry: None,
15083 approval_decider: None,
15084 approval_reviewer: None,
15085 escalation_bridge: None,
15086 event_tx: &event_tx,
15087 cancel_token: &cancel,
15088 live_rx: &mut live_rx,
15089 delivery_rx: &mut delivery_rx,
15090 logical_session: &mut session,
15091 expected_permission_posture: Some(expected_default_permission_posture(7)),
15092 session_inbox_runtime: None,
15093 actor_directory_store: Some(&store),
15094 canonical_subagent_tool: None,
15095 ticket_service: None,
15096 activation_run_id: Some("cancelled-remote-run"),
15097 execution_epoch: 0,
15098 expected_source_actor_id: "cancelled-remote-worker",
15099 initial_inflight_claims: VecDeque::new(),
15100 plain_actor: false,
15101 remote_environment_lease: true,
15102 readonly_output: None,
15103 local_history_tools: None,
15104 local_history_read_only: false,
15105 strict_history_output: None,
15106 plain_input: None,
15107 canonical_activation: Some(&fence),
15108 canonical_placement_ref: None,
15109 actor_event_observer: None,
15110 plain_run: None,
15111 first_frame_timeout: None,
15112 })
15113 .await;
15114 assert!(matches!(result, Err(AgentError::Cancelled)));
15115 assert_eq!(
15116 link.accepted, settle,
15117 "only a consumed exact terminal can release the remote fence"
15118 );
15119 assert_eq!(
15120 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15121 settle
15122 );
15123 }
15124 }
15125
15126 #[tokio::test]
15127 async fn actor_permission_handshake_rejects_malformed_agent_event() {
15128 let session_id = "permission-malformed";
15129 let (result, session, events, _) = drive_permission_handshake_frames(
15130 session_id,
15131 [ChildFrame::Event {
15132 event: serde_json::json!({"type": "token", "content": 42}),
15133 }],
15134 expected_default_permission_posture(7),
15135 )
15136 .await;
15137
15138 assert!(result
15139 .unwrap_err()
15140 .to_string()
15141 .contains("malformed AgentEvent"));
15142 assert!(events.is_empty());
15143 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15144
15145 let (result, _, events, _) = drive_permission_handshake_frames(
15146 session_id,
15147 [
15148 permission_posture_frame(session_id, 7),
15149 ChildFrame::Event {
15150 event: serde_json::json!({"type":"private-download-secret"}),
15151 },
15152 ],
15153 expected_default_permission_posture(7),
15154 )
15155 .await;
15156 assert!(!result
15157 .unwrap_err()
15158 .to_string()
15159 .contains("private-download-secret"));
15160 assert_eq!(
15161 events.len(),
15162 1,
15163 "only the confirmed posture may be forwarded"
15164 );
15165 }
15166
15167 #[tokio::test]
15168 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15169 let session_id = "permission-early-event";
15170 let early_events = [
15171 (
15172 "progress",
15173 AgentEvent::RunnerProgress {
15174 session_id: session_id.to_string(),
15175 round_count: 1,
15176 },
15177 ),
15178 (
15179 "token",
15180 AgentEvent::Token {
15181 content: "must-not-forward".to_string(),
15182 },
15183 ),
15184 (
15185 "tool",
15186 AgentEvent::ToolStart {
15187 tool_call_id: "early-tool".to_string(),
15188 tool_name: "Read".to_string(),
15189 arguments: serde_json::json!({"file_path": "README.md"}),
15190 },
15191 ),
15192 ];
15193 for (kind, event) in early_events {
15194 let (result, session, events, _) = drive_permission_handshake_frames(
15195 session_id,
15196 [
15197 actor_event_frame(event),
15198 permission_posture_frame(session_id, 7),
15199 completed_actor_frame(),
15200 ],
15201 expected_default_permission_posture(7),
15202 )
15203 .await;
15204
15205 assert!(
15206 result
15207 .unwrap_err()
15208 .to_string()
15209 .contains("execution event before permission posture confirmation"),
15210 "{kind} must fail closed before posture"
15211 );
15212 assert!(events.is_empty(), "{kind} must not be forwarded");
15213 assert!(
15214 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15215 "{kind} must not advance the permission audit"
15216 );
15217 }
15218 }
15219
15220 #[tokio::test]
15221 async fn actor_permission_handshake_rejects_approval_before_posture() {
15222 let session_id = "permission-early-approval";
15223 let (result, session, events, sent) = drive_permission_handshake_frames(
15224 session_id,
15225 [ChildFrame::ApprovalRequest {
15226 id: "approval-before-posture".to_string(),
15227 body: serde_json::json!({
15228 "tool_name": "Bash",
15229 "permission": "execute",
15230 "resource": "echo must-not-run"
15231 }),
15232 }],
15233 expected_default_permission_posture(7),
15234 )
15235 .await;
15236
15237 assert!(result
15238 .unwrap_err()
15239 .to_string()
15240 .contains("requested approval before permission posture confirmation"));
15241 assert!(events.is_empty());
15242 assert!(
15243 sent.is_empty(),
15244 "an unconfirmed actor must receive no approval reply"
15245 );
15246 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15247 }
15248
15249 #[tokio::test]
15250 async fn actor_permission_handshake_rejects_mismatched_posture() {
15251 let session_id = "permission-mismatch";
15252 let (result, session, events, _) = drive_permission_handshake_frames(
15253 session_id,
15254 [permission_posture_frame(session_id, 8)],
15255 expected_default_permission_posture(7),
15256 )
15257 .await;
15258
15259 assert!(result
15260 .unwrap_err()
15261 .to_string()
15262 .contains("does not match the host-dispatched policy"));
15263 assert!(events.is_empty());
15264 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15265 }
15266
15267 #[tokio::test]
15268 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15269 let session_id = "permission-hostile-mapping";
15270 for hostile_mapping in [
15271 "wrong_executor:permission_mode=default",
15272 "test_actor:permission_mode=default;credential=must-not-persist",
15273 ] {
15274 let frame = ChildFrame::Event {
15275 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15276 session_id: session_id.to_string(),
15277 policy_revision: 7,
15278 requested_mode: "default".to_string(),
15279 effective_mode: "default".to_string(),
15280 executor_mapping: hostile_mapping.to_string(),
15281 })
15282 .expect("serialize hostile posture fixture"),
15283 };
15284 let (result, session, events, _) = drive_permission_handshake_frames(
15285 session_id,
15286 [frame],
15287 expected_default_permission_posture(7),
15288 )
15289 .await;
15290
15291 let error = result.unwrap_err().to_string();
15292 assert!(error.contains("host-dispatched executor mapping"));
15293 assert!(
15294 !error.contains(hostile_mapping),
15295 "untrusted mapping must not be reflected in host errors"
15296 );
15297 assert!(events.is_empty());
15298 assert!(
15299 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15300 "untrusted mapping must not reach durable or in-memory audit state"
15301 );
15302 }
15303 }
15304
15305 #[tokio::test]
15306 async fn actor_permission_handshake_rejects_duplicate_posture() {
15307 let session_id = "permission-duplicate";
15308 let (result, session, events, _) = drive_permission_handshake_frames(
15309 session_id,
15310 [
15311 permission_posture_frame(session_id, 7),
15312 permission_posture_frame(session_id, 7),
15313 completed_actor_frame(),
15314 ],
15315 expected_default_permission_posture(7),
15316 )
15317 .await;
15318
15319 assert!(result
15320 .unwrap_err()
15321 .to_string()
15322 .contains("duplicate permission posture activation"));
15323 assert_eq!(
15324 events.len(),
15325 1,
15326 "only the confirmed posture may be forwarded"
15327 );
15328 assert!(matches!(
15329 events[0],
15330 AgentEvent::PermissionPostureActivated { .. }
15331 ));
15332 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15333 .expect("the first matching posture must be recorded");
15334 assert_eq!(audit.policy_revision, 7);
15335 }
15336
15337 #[tokio::test]
15338 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15339 let session_id = "permission-happy";
15340 let (result, session, events, _) = drive_permission_handshake_frames(
15341 session_id,
15342 [
15343 permission_posture_frame(session_id, 7),
15344 actor_event_frame(AgentEvent::RunnerProgress {
15345 session_id: session_id.to_string(),
15346 round_count: 1,
15347 }),
15348 actor_event_frame(AgentEvent::Token {
15349 content: "working".to_string(),
15350 }),
15351 actor_event_frame(AgentEvent::ToolStart {
15352 tool_call_id: "tool-1".to_string(),
15353 tool_name: "Read".to_string(),
15354 arguments: serde_json::json!({"file_path": "README.md"}),
15355 }),
15356 completed_actor_frame(),
15357 ],
15358 expected_default_permission_posture(7),
15359 )
15360 .await;
15361
15362 assert_eq!(result.unwrap().as_deref(), Some("done"));
15363 assert!(matches!(
15364 events.as_slice(),
15365 [
15366 AgentEvent::PermissionPostureActivated { .. },
15367 AgentEvent::RunnerProgress { .. },
15368 AgentEvent::Token { .. },
15369 AgentEvent::ToolStart { .. }
15370 ]
15371 ));
15372 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15373 .expect("matching posture must be recorded before execution events are accepted");
15374 assert_eq!(audit.policy_revision, 7);
15375 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15376 }
15377
15378 #[tokio::test]
15379 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15380 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15381 .into_iter()
15382 .enumerate()
15383 {
15384 let session_id = format!("actor-download-{index}");
15385 let args = serde_json::json!({
15386 "action":"download",
15387 "selector":"a[data-secret='private-selector']",
15388 "expected_epoch":17,
15389 "data_base64":"private-extra-bytes",
15390 "extra":{"url":"https://private.test/file"},
15391 });
15392 let original = args.clone();
15393 let read_args = serde_json::json!({"file_path":"README.md"});
15394 let (result, _session, events, _) = drive_permission_handshake_frames(
15395 &session_id,
15396 [
15397 permission_posture_frame(&session_id, 7),
15398 actor_event_frame(AgentEvent::ToolStart {
15399 tool_call_id: "download-call".to_string(),
15400 tool_name: tool_name.to_string(),
15401 arguments: args.clone(),
15402 }),
15403 actor_event_frame(AgentEvent::ToolStart {
15404 tool_call_id: "read-call".to_string(),
15405 tool_name: "Read".to_string(),
15406 arguments: read_args.clone(),
15407 }),
15408 completed_actor_frame(),
15409 ],
15410 expected_default_permission_posture(7),
15411 )
15412 .await;
15413 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15414 assert_eq!(args, original);
15415 let starts: Vec<_> = events
15416 .iter()
15417 .filter_map(|event| match event {
15418 AgentEvent::ToolStart {
15419 tool_name,
15420 arguments,
15421 ..
15422 } => Some((tool_name.as_str(), arguments)),
15423 _ => None,
15424 })
15425 .collect();
15426 assert_eq!(starts.len(), 2);
15427 assert_eq!(starts[0].0, "browser");
15428 assert_eq!(
15429 starts[0].1,
15430 &serde_json::json!({"action":"download","expected_epoch":17})
15431 );
15432 assert_eq!(starts[1], ("Read", &read_args));
15433 assert!(!format!("{events:?}").contains("private"));
15434 }
15435 }
15436
15437 #[tokio::test]
15438 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15439 let session_id = "actor-hook-display";
15440 let private = "private-selector-url-filename-response";
15441 let hook_name = format!("audit-{private}");
15442 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15443 hook_name: hook_name.clone(),
15444 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15445 phase: phase.into(),
15446 duration_ms,
15447 decision,
15448 };
15449 let decisions = [
15450 HookResult::Deny {
15451 reason: private.into(),
15452 },
15453 HookResult::InjectContext {
15454 text: private.into(),
15455 },
15456 HookResult::WithContext {
15457 result: Box::new(HookResult::WithContext {
15458 result: Box::new(HookResult::Deny {
15459 reason: private.into(),
15460 }),
15461 text: private.into(),
15462 }),
15463 text: private.into(),
15464 },
15465 HookResult::Suspend {
15466 reason: private.into(),
15467 },
15468 HookResult::Abort {
15469 reason: private.into(),
15470 },
15471 ];
15472 let originals = decisions.to_vec();
15473 let batch_events = [
15474 hook(2, "completed", decisions[1].clone()),
15475 hook(3, "completed", decisions[2].clone()),
15476 hook(4, "completed", decisions[3].clone()),
15477 ]
15478 .into_iter()
15479 .map(|event| serde_json::to_value(event).unwrap())
15480 .collect::<Vec<_>>();
15481 let batch = ActorEventBatch {
15482 logical_session: Some(LogicalSessionIdentity {
15483 creation: None,
15484 session_id: session_id.into(),
15485 parent_session_id: Some("permission-parent".into()),
15486 root_session_id: session_id.into(),
15487 }),
15488 activation_id: None,
15489 execution_epoch: 0,
15490 source_node_id: None,
15491 source_actor_id: Some(session_id.into()),
15492 first_seq: 1,
15493 last_seq: batch_events.len() as u64,
15494 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15495 events: batch_events,
15496 };
15497 let nested = AgentEvent::SubAgentEvent {
15498 parent_session_id: "parent".into(),
15499 child_session_id: "child".into(),
15500 event: Box::new(hook(5, "completed", decisions[4].clone())),
15501 };
15502 let raw = serde_json::to_string(&originals).unwrap();
15503 assert!(raw.contains(private));
15504
15505 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15506 session_id,
15507 [
15508 permission_posture_frame(session_id, 7),
15509 actor_event_frame(hook(1, private, decisions[0].clone())),
15510 ChildFrame::EventBatch { batch },
15511 actor_event_frame(nested),
15512 completed_actor_frame(),
15513 ],
15514 expected_default_permission_posture(7),
15515 )
15516 .await;
15517 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15518 let wire = serde_json::to_string(&forwarded).unwrap();
15519 assert!(
15520 !wire.contains(private),
15521 "private hook text reached SSE: {wire}"
15522 );
15523
15524 let hooks = forwarded
15525 .iter()
15526 .filter_map(|event| match event {
15527 AgentEvent::HookLifecycle {
15528 hook_name,
15529 point,
15530 phase,
15531 duration_ms,
15532 decision,
15533 } => Some((hook_name, point, phase, duration_ms, decision)),
15534 _ => None,
15535 })
15536 .collect::<Vec<_>>();
15537 assert_eq!(hooks.len(), 4);
15538 assert!(hooks.iter().all(|(name, point, _, _, _)| {
15539 name == &&"actor-hook-1".to_string()
15540 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15541 }));
15542 assert_eq!(hooks[0].2, "event");
15543 assert_eq!(hooks[1].2, "completed");
15544 assert_eq!(*hooks[0].3, 1);
15545 assert_eq!(*hooks[3].3, 4);
15546 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15547 assert!(
15548 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15549 );
15550 assert!(
15551 matches!(hooks[2].4, HookResult::WithContext { result, text }
15552 if text == "Hook context hidden" && matches!(result.as_ref(),
15553 HookResult::WithContext { result, text }
15554 if text == "Hook context hidden" && matches!(result.as_ref(),
15555 HookResult::Deny { reason } if reason == "Hook denied")))
15556 );
15557 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15558 assert!(forwarded.iter().any(|event| matches!(event,
15559 AgentEvent::SubAgentEvent { event, .. }
15560 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15561 hook_name, duration_ms: 5,
15562 decision: HookResult::Abort { reason }, ..
15563 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15564 )));
15565 }
15566
15567 #[test]
15568 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15569 let mut display = ActorEventDisplay::default();
15570 let mut deep = HookResult::Allow;
15571 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15572 deep = HookResult::WithContext {
15573 result: Box::new(deep),
15574 text: "private-download-bytes".into(),
15575 };
15576 }
15577 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15578 hook_name,
15579 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15580 phase: "completed".into(),
15581 duration_ms: 7,
15582 decision,
15583 };
15584 assert!(display.project(event("deep".into(), deep), 0).is_none());
15585 assert!(display.overflowed);
15586 assert!(display
15587 .project(
15588 event(
15589 "private-download-bytes".into(),
15590 HookResult::Deny {
15591 reason: "private-download-bytes".into(),
15592 },
15593 ),
15594 0,
15595 )
15596 .is_none());
15597
15598 let mut oversized = ActorEventDisplay::default();
15599 assert!(oversized
15600 .project(
15601 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15602 0,
15603 )
15604 .is_none());
15605 assert!(oversized.overflowed);
15606
15607 let mut reconnected = ActorEventDisplay::default();
15611 let private = "private-download-bytes";
15612 for _ in 0..2 {
15613 let projected = reconnected
15614 .project(event(private.into(), HookResult::Allow), 0)
15615 .unwrap();
15616 assert!(
15617 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15618 if hook_name == "actor-hook-1")
15619 );
15620 }
15621 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15622 for index in 1..MAX_DISPLAY_CALLS {
15623 assert!(reconnected
15624 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15625 .is_some());
15626 }
15627 assert!(reconnected
15628 .project(event("overflow-private".into(), HookResult::Allow), 0)
15629 .is_none());
15630 assert!(reconnected.overflowed);
15631 assert!(reconnected.hook_names.is_empty());
15632 }
15633
15634 #[test]
15635 fn actor_hook_display_preserves_nontext_decision_kinds() {
15636 for result in [
15637 HookResult::Continue,
15638 HookResult::Mutated,
15639 HookResult::Allow,
15640 HookResult::Ask,
15641 ] {
15642 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15643 }
15644 }
15645
15646 #[tokio::test]
15647 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15648 let session_id = "actor-malformed-hook";
15649 let private = "private-download-selector";
15650 let original = AgentEvent::HookLifecycle {
15651 hook_name: "audit".into(),
15652 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15653 phase: "completed".into(),
15654 duration_ms: 9,
15655 decision: HookResult::Deny {
15656 reason: private.into(),
15657 },
15658 };
15659 let mut invalid = serde_json::to_value(&original).unwrap();
15660 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15661 let nested = AgentEvent::SubAgentEvent {
15662 parent_session_id: "parent".into(),
15663 child_session_id: "child".into(),
15664 event: Box::new(original),
15665 };
15666 let mut invalid_nested = serde_json::to_value(nested).unwrap();
15667 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15668 let batch = ActorEventBatch {
15669 logical_session: Some(LogicalSessionIdentity {
15670 creation: None,
15671 session_id: session_id.into(),
15672 parent_session_id: Some("permission-parent".into()),
15673 root_session_id: session_id.into(),
15674 }),
15675 activation_id: None,
15676 execution_epoch: 0,
15677 source_node_id: None,
15678 source_actor_id: Some(session_id.into()),
15679 first_seq: 1,
15680 last_seq: 1,
15681 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15682 events: vec![invalid.clone()],
15683 };
15684 for frame in [
15685 ChildFrame::Event {
15686 event: invalid.clone(),
15687 },
15688 ChildFrame::EventBatch { batch },
15689 ChildFrame::Event {
15690 event: invalid_nested,
15691 },
15692 ] {
15693 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15694 session_id,
15695 [permission_posture_frame(session_id, 7), frame],
15696 expected_default_permission_posture(7),
15697 )
15698 .await;
15699 let error = outcome.unwrap_err().to_string();
15700 assert!(error.contains("malformed AgentEvent"), "{error}");
15701 assert!(!error.contains(private));
15702 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15703 }
15704
15705 let (event_tx, mut event_rx) = mpsc::channel(8);
15706 let mut session = Session::new("legacy-hook", "model");
15707 let mut handshake = PermissionPostureHandshake::NotRequired;
15708 let mut display = ActorEventDisplay::default();
15709 process_actor_event(
15710 invalid,
15711 false,
15712 &mut handshake,
15713 None,
15714 None,
15715 &mut session,
15716 &event_tx,
15717 &mut display,
15718 )
15719 .await
15720 .unwrap();
15721 assert!(display.overflowed);
15722 assert!(event_rx.try_recv().is_err());
15723 }
15724
15725 #[tokio::test]
15726 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15727 let session_id = "actor-download-batch";
15728 let private = "private-download-bytes-and-url";
15729 let events = vec![
15730 AgentEvent::ToolStart {
15731 tool_call_id: "download-call".into(),
15732 tool_name: "default::browser".into(),
15733 arguments: serde_json::json!({
15734 "action":"download", "selector": private, "expected_epoch": 17,
15735 }),
15736 },
15737 AgentEvent::ToolApprovalRequested {
15738 tool_call_id: "download-call".into(),
15739 tool_name: format!("{private}::browser"),
15740 parameters: serde_json::json!({
15741 "action":"download", "selector": private, "expected_epoch": 17,
15742 }),
15743 },
15744 AgentEvent::ToolStart {
15745 tool_call_id: "read-call".into(),
15746 tool_name: "Read".into(),
15747 arguments: serde_json::json!({"file_path":"README.md"}),
15748 },
15749 ];
15750 let batch = ActorEventBatch {
15751 logical_session: Some(LogicalSessionIdentity {
15752 creation: None,
15753 session_id: session_id.into(),
15754 parent_session_id: Some("permission-parent".into()),
15755 root_session_id: session_id.into(),
15756 }),
15757 activation_id: None,
15758 execution_epoch: 0,
15759 source_node_id: None,
15760 source_actor_id: Some(session_id.into()),
15761 first_seq: 1,
15762 last_seq: events.len() as u64,
15763 qos: bamboo_subagent::ActorEventQos::Durable,
15764 events: events
15765 .into_iter()
15766 .map(|event| serde_json::to_value(event).unwrap())
15767 .collect(),
15768 };
15769 let result = bamboo_agent_core::tools::ToolResult {
15770 success: true,
15771 result: format!("{{\"data_base64\":\"{private}\"}}"),
15772 display_preference: Some(private.into()),
15773 images: vec![bamboo_agent_core::tools::ToolResultImage {
15774 mime_type: "image/png".into(),
15775 data: private.into(),
15776 }],
15777 };
15778 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15779 session_id,
15780 [
15781 permission_posture_frame(session_id, 7),
15782 ChildFrame::EventBatch { batch },
15783 actor_event_frame(AgentEvent::ToolToken {
15784 tool_call_id: "download-call".into(),
15785 content: private.into(),
15786 }),
15787 actor_event_frame(AgentEvent::ToolToken {
15788 tool_call_id: "read-call".into(),
15789 content: "public live output".into(),
15790 }),
15791 actor_event_frame(AgentEvent::ToolComplete {
15792 tool_call_id: "download-call".into(),
15793 result,
15794 }),
15795 actor_event_frame(AgentEvent::ToolLifecycle {
15796 tool_call_id: "download-call".into(),
15797 tool_name: "browser".into(),
15798 phase: "finished".into(),
15799 elapsed_ms: Some(1),
15800 is_mutating: false,
15801 auto_approved: false,
15802 summary: Some(private.into()),
15803 error: Some(private.into()),
15804 }),
15805 actor_event_frame(AgentEvent::ToolError {
15806 tool_call_id: "download-call".into(),
15807 error: private.into(),
15808 }),
15809 actor_event_frame(AgentEvent::ToolComplete {
15810 tool_call_id: "read-call".into(),
15811 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15812 }),
15813 completed_actor_frame(),
15814 ],
15815 expected_default_permission_posture(7),
15816 )
15817 .await;
15818 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15819 let wire = serde_json::to_string(&forwarded).unwrap();
15820 assert!(
15821 !wire.contains(private),
15822 "private content reached actor SSE: {wire}"
15823 );
15824 assert!(wire.contains("public README text"));
15825 assert!(wire.contains("public live output"));
15826 assert!(forwarded.iter().any(|event| matches!(event,
15827 AgentEvent::ToolComplete { tool_call_id, result }
15828 if tool_call_id == "download-call"
15829 && result.result == "Browser tool result hidden"
15830 && result.images.is_empty() && result.display_preference.is_none()
15831 )));
15832 }
15833
15834 #[tokio::test]
15835 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15836 let private = "private-download-content";
15837 let nested = |event| AgentEvent::SubAgentEvent {
15838 parent_session_id: "parent".into(),
15839 child_session_id: "nested-child".into(),
15840 event: Box::new(event),
15841 };
15842 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15843 "actor-reorder",
15844 [
15845 permission_posture_frame("actor-reorder", 7),
15846 actor_event_frame(AgentEvent::ToolComplete {
15847 tool_call_id: "missing".into(),
15848 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15849 }),
15850 actor_event_frame(AgentEvent::ToolLifecycle {
15851 tool_call_id: "missing".into(),
15852 tool_name: "browser".into(),
15853 phase: private.into(),
15854 elapsed_ms: None,
15855 is_mutating: false,
15856 auto_approved: false,
15857 summary: Some(private.into()),
15858 error: Some(private.into()),
15859 }),
15860 actor_event_frame(AgentEvent::ToolApprovalRequested {
15861 tool_call_id: "approval-only".into(),
15862 tool_name: "browser".into(),
15863 parameters: serde_json::json!({"action":"download","selector":private}),
15864 }),
15865 actor_event_frame(AgentEvent::ToolToken {
15866 tool_call_id: "approval-only".into(),
15867 content: private.into(),
15868 }),
15869 actor_event_frame(AgentEvent::ToolStart {
15870 tool_call_id: "malformed-browser".into(),
15871 tool_name: format!("{private}::browser"),
15872 arguments: serde_json::json!({"selector":private}),
15873 }),
15874 actor_event_frame(AgentEvent::ToolComplete {
15875 tool_call_id: "malformed-browser".into(),
15876 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15877 }),
15878 actor_event_frame(AgentEvent::ToolStart {
15879 tool_call_id: "reused".into(),
15880 tool_name: "Read".into(),
15881 arguments: serde_json::json!({"file_path":"README.md"}),
15882 }),
15883 actor_event_frame(AgentEvent::ToolStart {
15884 tool_call_id: "reused".into(),
15885 tool_name: "browser".into(),
15886 arguments: serde_json::json!({"action":"download","selector":private}),
15887 }),
15888 actor_event_frame(AgentEvent::ToolToken {
15889 tool_call_id: "reused".into(),
15890 content: private.into(),
15891 }),
15892 actor_event_frame(nested(AgentEvent::ToolStart {
15893 tool_call_id: "nested".into(),
15894 tool_name: "browser".into(),
15895 arguments: serde_json::json!({"action":"download","selector":private}),
15896 })),
15897 actor_event_frame(nested(AgentEvent::ToolComplete {
15898 tool_call_id: "nested".into(),
15899 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15900 })),
15901 actor_event_frame(AgentEvent::ChildApprovalRequested {
15902 child_session_id: "nested-child".into(),
15903 request_id: "approval".into(),
15904 tool_name: format!("{private}::browser"),
15905 permission: private.into(),
15906 resource: private.into(),
15907 }),
15908 actor_event_frame(AgentEvent::ChildApprovalChanged {
15909 parent_session_id: "parent".into(),
15910 child_session_id: "nested-child".into(),
15911 child_attempt: 1,
15912 request_id: "approval".into(),
15913 version: 1,
15914 status: "pending".into(),
15915 reason: Some(private.into()),
15916 tool_name: "browser".into(),
15917 permission: private.into(),
15918 resource: private.into(),
15919 created_at: "2026-01-01T00:00:00Z".into(),
15920 resolved_at: None,
15921 }),
15922 completed_actor_frame(),
15923 ],
15924 expected_default_permission_posture(7),
15925 )
15926 .await;
15927 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15928 let wire = serde_json::to_string(&forwarded).unwrap();
15929 assert!(
15930 !wire.contains(private),
15931 "private content reached actor SSE: {wire}"
15932 );
15933 assert!(wire.contains("Tool result hidden"));
15934 assert!(wire.contains("Browser tool result hidden"));
15935 assert!(wire.contains("Browser interaction approval"));
15936 assert!(!forwarded
15937 .iter()
15938 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15939 }
15940
15941 #[test]
15942 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15943 let mut display = ActorEventDisplay::default();
15944 display
15945 .project(
15946 AgentEvent::ToolStart {
15947 tool_call_id: "private-call".into(),
15948 tool_name: "browser".into(),
15949 arguments: serde_json::json!({"action":"download","selector":"private"}),
15950 },
15951 0,
15952 )
15953 .unwrap();
15954 for index in 0..MAX_DISPLAY_CALLS {
15955 display
15956 .project(
15957 AgentEvent::ToolStart {
15958 tool_call_id: format!("ordinary-{index}"),
15959 tool_name: "Read".into(),
15960 arguments: serde_json::json!({"file_path":"README.md"}),
15961 },
15962 0,
15963 )
15964 .unwrap();
15965 }
15966 assert!(display.overflowed);
15967 let forwarded = display
15968 .project(
15969 AgentEvent::ToolComplete {
15970 tool_call_id: "private-call".into(),
15971 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15972 },
15973 0,
15974 )
15975 .unwrap();
15976 assert!(!serde_json::to_string(&forwarded)
15977 .unwrap()
15978 .contains("private-bytes"));
15979 }
15980
15981 #[test]
15982 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15983 let mut display = ActorEventDisplay::default();
15984 let nested = |index: usize| AgentEvent::SubAgentEvent {
15985 parent_session_id: "parent".into(),
15986 child_session_id: format!("child-{index}"),
15987 event: Box::new(AgentEvent::ToolStart {
15988 tool_call_id: format!("read-{index}"),
15989 tool_name: "Read".into(),
15990 arguments: serde_json::json!({"file_path":"README.md"}),
15991 }),
15992 };
15993 for index in 0..(MAX_DISPLAY_CALLS / 2) {
15994 assert!(display.project(nested(index), 0).is_some());
15995 }
15996 assert_eq!(
15997 display.identities.load(Ordering::Relaxed),
15998 MAX_DISPLAY_CALLS
15999 );
16000 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
16001 assert!(display.overflowed);
16002 assert!(display.children.is_empty());
16003
16004 let mut oversized = ActorEventDisplay::default();
16005 assert!(oversized
16006 .project(
16007 AgentEvent::SubAgentEvent {
16008 parent_session_id: "parent".into(),
16009 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16010 event: Box::new(AgentEvent::ToolComplete {
16011 tool_call_id: "call".into(),
16012 result: bamboo_agent_core::tools::ToolResult::text(
16013 true,
16014 "private-download-bytes",
16015 ),
16016 }),
16017 },
16018 0,
16019 )
16020 .is_none());
16021 assert!(oversized.overflowed);
16022 assert!(oversized
16023 .project(
16024 AgentEvent::ToolStart {
16025 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16026 tool_name: "Read".into(),
16027 arguments: serde_json::json!({"file_path":"README.md"}),
16028 },
16029 0,
16030 )
16031 .is_none());
16032 }
16033
16034 #[tokio::test]
16035 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16036 let session_id = "actor-gap";
16037 let batch = |seq, event: AgentEvent| {
16038 let event = serde_json::to_value(event).unwrap();
16039 ChildFrame::EventBatch {
16040 batch: ActorEventBatch {
16041 logical_session: Some(LogicalSessionIdentity {
16042 creation: None,
16043 session_id: session_id.into(),
16044 parent_session_id: Some("permission-parent".into()),
16045 root_session_id: session_id.into(),
16046 }),
16047 activation_id: None,
16048 execution_epoch: 0,
16049 source_node_id: None,
16050 source_actor_id: Some(session_id.into()),
16051 first_seq: seq,
16052 last_seq: seq,
16053 qos: bamboo_subagent::ActorEventQos::classify(&event),
16054 events: vec![event],
16055 },
16056 }
16057 };
16058 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16059 session_id,
16060 [
16061 permission_posture_frame(session_id, 7),
16062 batch(
16063 1,
16064 AgentEvent::ToolStart {
16065 tool_call_id: "reused-call".into(),
16066 tool_name: "Read".into(),
16067 arguments: serde_json::json!({"file_path":"README.md"}),
16068 },
16069 ),
16070 batch(
16073 3,
16074 AgentEvent::ToolComplete {
16075 tool_call_id: "reused-call".into(),
16076 result: bamboo_agent_core::tools::ToolResult::text(
16077 true,
16078 "private-download-bytes",
16079 ),
16080 },
16081 ),
16082 completed_actor_frame(),
16083 ],
16084 expected_default_permission_posture(7),
16085 )
16086 .await;
16087 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16088 let wire = serde_json::to_string(&forwarded).unwrap();
16089 assert!(!wire.contains("private-download-bytes"), "{wire}");
16090 assert!(wire.contains("Tool result hidden"));
16091 }
16092
16093 #[tokio::test]
16094 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16095 let (event_tx, mut event_rx) = mpsc::channel(8);
16096 let mut session = Session::new("legacy-malformed-private", "model");
16097 let mut handshake = PermissionPostureHandshake::NotRequired;
16098 let mut display = ActorEventDisplay::default();
16099 let read_start = serde_json::to_value(AgentEvent::ToolStart {
16100 tool_call_id: "reused-call".into(),
16101 tool_name: "Read".into(),
16102 arguments: serde_json::json!({"file_path":"README.md"}),
16103 })
16104 .unwrap();
16105 process_actor_event(
16106 read_start,
16107 false,
16108 &mut handshake,
16109 None,
16110 None,
16111 &mut session,
16112 &event_tx,
16113 &mut display,
16114 )
16115 .await
16116 .unwrap();
16117
16118 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16121 tool_call_id: "reused-call".into(),
16122 tool_name: "browser".into(),
16123 arguments: serde_json::json!({
16124 "action":"download", "selector":"private-download-selector"
16125 }),
16126 })
16127 .unwrap();
16128 malformed["tool_name"] = serde_json::json!(7);
16129 process_actor_event(
16130 malformed,
16131 false,
16132 &mut handshake,
16133 None,
16134 None,
16135 &mut session,
16136 &event_tx,
16137 &mut display,
16138 )
16139 .await
16140 .unwrap();
16141 process_actor_event(
16142 serde_json::to_value(AgentEvent::ToolComplete {
16143 tool_call_id: "reused-call".into(),
16144 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16145 })
16146 .unwrap(),
16147 false,
16148 &mut handshake,
16149 None,
16150 None,
16151 &mut session,
16152 &event_tx,
16153 &mut display,
16154 )
16155 .await
16156 .unwrap();
16157 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16158 assert_eq!(forwarded.len(), 2);
16159 let wire = serde_json::to_string(&forwarded).unwrap();
16160 assert!(!wire.contains("private-download"), "{wire}");
16161 assert!(wire.contains("Tool result hidden"));
16162 }
16163
16164 #[tokio::test]
16165 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16166 let temp = tempfile::tempdir().unwrap();
16167 let store = Arc::new(
16168 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16169 .await
16170 .unwrap(),
16171 );
16172 let storage: Arc<dyn Storage> = store.clone();
16173 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16174 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16175 store.clone(),
16176 bamboo_domain::SessionInboxLimits::default(),
16177 ));
16178 let session_id = "actor-confirmation-order";
16179 let run_id = "actor-run-current";
16180 let mut session = Session::new(session_id, "model");
16181 store.save_session(&session).await.unwrap();
16182 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16183 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16184 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16185 inbox.deliver(&envelope).await.unwrap();
16186 }
16187 inbox
16188 .mark_activation_eligible(
16189 session_id,
16190 2,
16191 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16192 )
16193 .await
16194 .unwrap();
16195 let router = SessionActivationRouter::new();
16196 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16197 let binding = SessionInboxRuntimeBinding {
16198 router,
16199 inbox: inbox.clone(),
16200 storage,
16201 persistence: locked,
16202 parent_question_lock: None,
16203 };
16204 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16205 .await
16206 .unwrap();
16207 assert_eq!(
16208 pairs
16209 .iter()
16210 .map(|(claim, _)| claim.envelope.id.as_str())
16211 .collect::<Vec<_>>(),
16212 vec!["actor-first", "actor-second"]
16213 );
16214 let seeded = store.load_session(session_id).await.unwrap().unwrap();
16215 assert_eq!(
16216 seeded
16217 .messages
16218 .iter()
16219 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16220 .map(|message| message.id.as_str())
16221 .collect::<Vec<_>>(),
16222 vec!["actor-first", "actor-second"],
16223 "host context must be durable before actor dispatch"
16224 );
16225 for (claim, _) in &pairs {
16226 assert_eq!(
16227 seeded
16228 .messages
16229 .iter()
16230 .filter(|message| bamboo_domain::is_matching_session_message(
16231 message,
16232 &claim.envelope
16233 ))
16234 .count(),
16235 1,
16236 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16237 claim.envelope.id
16238 );
16239 }
16240 assert!(
16241 seeded.session_inbox_admission().is_none_or(|cursor| {
16242 !cursor.contains(&pairs[0].0.envelope.id)
16243 && !cursor.contains(&pairs[1].0.envelope.id)
16244 }),
16245 "pre-dispatch transcript seeding must not forge worker confirmation"
16246 );
16247 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16248 let claims = pairs
16249 .into_iter()
16250 .map(|(claim, _)| claim)
16251 .collect::<VecDeque<_>>();
16252 let first = claims[0].clone();
16253 let second = claims[1].clone();
16254 let mut stale = admission_confirmation(session_id, &first, "stale-run");
16255 stale.canonical_claim_generation = second.generation;
16256 let mut link = ConfirmationSequenceLink {
16257 frames: VecDeque::from([
16258 ChildFrame::SessionMessageAdmitted {
16259 confirmation: stale,
16260 },
16261 ChildFrame::SessionMessageAdmitted {
16262 confirmation: admission_confirmation(session_id, &first, run_id),
16263 },
16264 ChildFrame::SessionMessageAdmitted {
16265 confirmation: admission_confirmation(session_id, &second, run_id),
16266 },
16267 ChildFrame::Terminal {
16268 status: TerminalStatus::Completed,
16269 result: Some("done".to_string()),
16270 error: None,
16271 final_event_watermark: None,
16272 transcript: Vec::new(),
16273 },
16274 ]),
16275 sent: Vec::new(),
16276 };
16277 let (event_tx, _event_rx) = mpsc::channel(8);
16278 let cancel = CancellationToken::new();
16279 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16280 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16281 let result = drive(ActorDriveContext {
16282 expected_creation: None,
16283 client: &mut link,
16284 parent_session_id: "parent",
16285 child_session_id: session_id,
16286 child_attempt: 0,
16287 approval_registry: None,
16288 approval_decider: None,
16289 approval_reviewer: None,
16290 escalation_bridge: None,
16291 event_tx: &event_tx,
16292 cancel_token: &cancel,
16293 live_rx: &mut live_rx,
16294 delivery_rx: &mut delivery_rx,
16295 logical_session: &mut session,
16296 expected_permission_posture: None,
16297 session_inbox_runtime: Some(&binding),
16298 actor_directory_store: None,
16299 canonical_subagent_tool: None,
16300 ticket_service: None,
16301 activation_run_id: Some(run_id),
16302 execution_epoch: 0,
16303 expected_source_actor_id: session_id,
16304 initial_inflight_claims: claims,
16305 plain_actor: false,
16306 remote_environment_lease: false,
16307 readonly_output: None,
16308 local_history_tools: None,
16309 local_history_read_only: false,
16310 strict_history_output: None,
16311 plain_input: None,
16312 canonical_activation: None,
16313 canonical_placement_ref: None,
16314 actor_event_observer: None,
16315 plain_run: None,
16316 first_frame_timeout: Some(Duration::from_secs(1)),
16317 })
16318 .await
16319 .unwrap();
16320 assert_eq!(result.as_deref(), Some("done"));
16321 assert_eq!(
16322 session
16323 .messages
16324 .iter()
16325 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16326 .map(|message| message.id.as_str())
16327 .collect::<Vec<_>>(),
16328 vec!["actor-first", "actor-second"]
16329 );
16330 let backlog = inbox.inspect(session_id).await.unwrap();
16331 assert_eq!(backlog.pending + backlog.claimed, 0);
16332 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16333 let cursor = confirmed
16334 .session_inbox_admission()
16335 .expect("exact worker confirmation must checkpoint the admission cursor");
16336 assert!(cursor.contains(&first.envelope.id));
16337 assert!(cursor.contains(&second.envelope.id));
16338 for claim in [&first, &second] {
16339 assert_eq!(
16340 confirmed
16341 .messages
16342 .iter()
16343 .filter(|message| bamboo_domain::is_matching_session_message(
16344 message,
16345 &claim.envelope
16346 ))
16347 .count(),
16348 1,
16349 "confirmation must retain one exact canonical marker for {}",
16350 claim.envelope.id
16351 );
16352 }
16353 assert!(inbox
16354 .was_admitted(session_id, &first.envelope.id)
16355 .await
16356 .unwrap());
16357 assert!(inbox
16358 .was_admitted(session_id, &second.envelope.id)
16359 .await
16360 .unwrap());
16361 owner_registration.begin_finalization().await;
16362 owner_registration.finish(2).await.unwrap();
16363 }
16364
16365 #[derive(Default)]
16366 struct RecordingCodexTokenAuthority {
16367 issued_for: std::sync::Mutex<Vec<String>>,
16368 revoked: std::sync::Mutex<Vec<String>>,
16369 }
16370
16371 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16372 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16373 self.issued_for
16374 .lock()
16375 .expect("issued fixture lock")
16376 .push(session_id.to_string());
16377 Ok(IssuedCodexRunToken {
16378 token_id: format!("id-{session_id}"),
16379 token: format!("bcx1_secret-{session_id}"),
16380 })
16381 }
16382
16383 fn revoke(&self, token_id: &str) {
16384 self.revoked
16385 .lock()
16386 .expect("revoked fixture lock")
16387 .push(token_id.to_string());
16388 }
16389 }
16390
16391 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16392 let bamboo_mode = auth_mode == Some("bamboo")
16393 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16394 ExecutorSpec::Codex {
16395 binary: None,
16396 model: None,
16397 mode: None,
16398 sandbox: None,
16399 inherit_user_config,
16400 auth_mode: auth_mode.map(str::to_string),
16401 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16402 wire_api: Some("responses".to_string()),
16403 provider_key_ref: None,
16404 forward_env: None,
16405 approval_policy: None,
16406 network_access: None,
16407 allow_danger_bypass: None,
16408 permission_profile: None,
16409 workspace_owned: None,
16410 }
16411 }
16412
16413 fn permission_resolution(
16414 requested: bamboo_domain::SessionPermissionMode,
16415 effective: bamboo_domain::PermissionMode,
16416 ) -> bamboo_domain::PermissionModeResolution {
16417 bamboo_domain::PermissionModeResolution {
16418 requested,
16419 effective,
16420 }
16421 }
16422
16423 #[test]
16424 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16425 use bamboo_domain::{PermissionMode, SessionPermissionMode};
16426
16427 let default =
16428 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16429 assert_eq!(
16430 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16431 .unwrap()
16432 .as_deref(),
16433 Some("bamboo_runtime:default")
16434 );
16435 assert_eq!(
16436 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16437 None,
16438 "transport-only Echo must not claim the typed permission contract"
16439 );
16440 assert_eq!(
16441 expected_permission_executor_mapping(
16442 &ExecutorSpec::CliAdapter {
16443 command: "must-not-appear-in-contract".to_string(),
16444 args: vec!["credential-like-argument".to_string()],
16445 },
16446 default,
16447 false,
16448 )
16449 .unwrap(),
16450 None,
16451 "unimplemented CliAdapter must not leak command data into a contract"
16452 );
16453
16454 let claude = ExecutorSpec::ClaudeCode {
16455 binary: None,
16456 model: None,
16457 permission_mode: Some("default".to_string()),
16458 inherit_user_config: None,
16459 forward_env: None,
16460 };
16461 for (resolution, mapping) in [
16462 (
16463 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16464 "claude_code:permission_mode=plan",
16465 ),
16466 (
16467 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16468 "claude_code:permission_mode=bypassPermissions",
16469 ),
16470 (
16471 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16472 "claude_code:permission_mode=acceptEdits",
16473 ),
16474 (
16475 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16476 "claude_code:permission_mode=dontAsk",
16477 ),
16478 (
16479 permission_resolution(
16480 SessionPermissionMode::Bypass,
16481 PermissionMode::BypassPermissions,
16482 ),
16483 "claude_code:permission_mode=default",
16484 ),
16485 ] {
16486 assert_eq!(
16487 expected_permission_executor_mapping(&claude, resolution, false)
16488 .unwrap()
16489 .as_deref(),
16490 Some(mapping)
16491 );
16492 }
16493 assert_eq!(
16494 expected_permission_executor_mapping(&claude, default, true)
16495 .unwrap()
16496 .as_deref(),
16497 Some("claude_code:blocked_explicit_deny")
16498 );
16499
16500 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16501 if let ExecutorSpec::Codex {
16502 approval_policy, ..
16503 } = &mut codex_exec
16504 {
16505 *approval_policy = Some("on-failure".to_string());
16506 }
16507 assert_eq!(
16508 expected_permission_executor_mapping(&codex_exec, default, false)
16509 .unwrap()
16510 .as_deref(),
16511 Some("codex_exec:approval_policy=on-failure")
16512 );
16513 assert_eq!(
16514 expected_permission_executor_mapping(
16515 &codex_exec,
16516 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16517 false,
16518 )
16519 .unwrap()
16520 .as_deref(),
16521 Some("codex_exec:approval_policy=never")
16522 );
16523 assert_eq!(
16524 expected_permission_executor_mapping(&codex_exec, default, true)
16525 .unwrap()
16526 .as_deref(),
16527 Some("codex_exec:blocked_explicit_deny")
16528 );
16529
16530 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16531 if let ExecutorSpec::Codex {
16532 mode,
16533 approval_policy,
16534 ..
16535 } = &mut codex_app_server
16536 {
16537 *mode = Some("app_server".to_string());
16538 *approval_policy = Some("on-request".to_string());
16539 }
16540 assert_eq!(
16541 expected_permission_executor_mapping(&codex_app_server, default, false)
16542 .unwrap()
16543 .as_deref(),
16544 Some("codex_app_server:approvalPolicy=on-request")
16545 );
16546 assert_eq!(
16547 expected_permission_executor_mapping(
16548 &codex_app_server,
16549 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16550 false,
16551 )
16552 .unwrap()
16553 .as_deref(),
16554 Some("codex_app_server:approvalPolicy=never")
16555 );
16556 assert_eq!(
16557 expected_permission_executor_mapping(&codex_app_server, default, true)
16558 .unwrap()
16559 .as_deref(),
16560 Some("codex_app_server:blocked_explicit_deny")
16561 );
16562 }
16563
16564 #[test]
16565 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16566 let project = tempfile::tempdir().unwrap();
16567 let managed = project.path().join(".bamboo/worktree/child-571");
16568 std::fs::create_dir_all(&managed).unwrap();
16569 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16570 let marker = project
16571 .path()
16572 .join(".bamboo/worktree/.bamboo-owned/child-571");
16573 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16574 std::fs::write(&marker, "bamboo/child-571").unwrap();
16575 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16576 let nested = managed.join("nested/path");
16577 std::fs::create_dir_all(&nested).unwrap();
16578 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16579
16580 let arbitrary = tempfile::tempdir().unwrap();
16581 assert!(!workspace_is_bamboo_owned(
16582 arbitrary.path().to_str().unwrap()
16583 ));
16584 }
16585
16586 #[test]
16587 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16588 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16589 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16590
16591 let (secrets, guard) = build_codex_run_secrets(
16592 &codex_executor(Some("bamboo"), None),
16593 Some(authority_dyn),
16594 "child-570",
16595 )
16596 .unwrap();
16597
16598 let token = secrets
16599 .codex_provider_token
16600 .as_ref()
16601 .expect("bamboo mode mints a token");
16602 assert_eq!(token.expose(), "bcx1_secret-child-570");
16603 assert!(!format!("{token:?}").contains("secret-child-570"));
16604 assert_eq!(
16605 authority.issued_for.lock().unwrap().as_slice(),
16606 ["child-570"]
16607 );
16608 assert!(authority.revoked.lock().unwrap().is_empty());
16609
16610 drop(guard);
16611 assert_eq!(
16612 authority.revoked.lock().unwrap().as_slice(),
16613 ["id-child-570"]
16614 );
16615 }
16616
16617 #[test]
16618 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16619 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16620 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16621 let (secrets, guard) = build_codex_run_secrets(
16622 &codex_executor(Some("custom"), None),
16623 Some(authority_dyn),
16624 "child-custom",
16625 )
16626 .unwrap();
16627 assert!(secrets.codex_provider_token.is_none());
16628 assert!(guard.is_none());
16629 assert!(authority.issued_for.lock().unwrap().is_empty());
16630
16631 let error = build_codex_run_secrets(
16632 &codex_executor(Some("bamboo"), None),
16633 None,
16634 "child-no-authority",
16635 )
16636 .err()
16637 .expect("bamboo mode without an authority must fail closed");
16638 assert!(error.to_string().contains("per-run token authority"));
16639 }
16640
16641 #[test]
16642 fn codex_provisioning_never_leaks_the_session_provider_credential() {
16643 let credentials = vec![ScopedCredential {
16644 provider: "openai".to_string(),
16645 api_key: "upstream-secret-must-not-cross".to_string(),
16646 base_url: None,
16647 provider_type: Some("openai".to_string()),
16648 credential_ref: Some("provider.openai.api_key".to_string()),
16649 }];
16650
16651 for (mode, label) in [
16652 (Some("inherit"), "inherit"),
16653 (Some("api_key"), "api_key"),
16654 (Some("bamboo"), "bamboo"),
16655 (None, "default-bamboo"),
16656 ] {
16657 let runner = ActorChildRunner::new(
16658 format!("codex-{label}-test"),
16659 PathBuf::from("/bin/false"),
16660 Vec::new(),
16661 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16662 codex_executor(mode, None),
16663 credentials.clone(),
16664 "openai".to_string(),
16665 1,
16666 );
16667 let mut session = Session::new(format!("child-{label}"), "model");
16668 session.add_message(bamboo_agent_core::Message::user("test"));
16669 let spec = runner.build_spec(
16670 &session,
16671 &crate::runtime::execution::SpawnJob {
16672 parent_session_id: "parent".to_string(),
16673 child_session_id: format!("child-{label}"),
16674 model: "gpt-5.4".to_string(),
16675 disabled_tools: None,
16676 },
16677 );
16678 assert!(
16679 spec.secrets.provider_credentials.is_empty(),
16680 "{label} Codex must not receive the session provider key"
16681 );
16682 }
16683 }
16684
16685 #[test]
16686 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16687 let credentials = vec![
16688 ScopedCredential {
16689 provider: "openai".to_string(),
16690 api_key: "selected-openai-secret".to_string(),
16691 base_url: None,
16692 provider_type: Some("openai".to_string()),
16693 credential_ref: Some("provider.openai.api_key".to_string()),
16694 },
16695 ScopedCredential {
16696 provider: "other".to_string(),
16697 api_key: "unrelated-secret".to_string(),
16698 base_url: None,
16699 provider_type: Some("openai".to_string()),
16700 credential_ref: Some("provider.other.api_key".to_string()),
16701 },
16702 ];
16703 let runner = ActorChildRunner::new(
16704 "echo-test".to_string(),
16705 PathBuf::from("/bin/false"),
16706 Vec::new(),
16707 std::env::temp_dir().join("bamboo-echo-provider-570"),
16708 ExecutorSpec::Echo,
16709 credentials,
16710 "openai".to_string(),
16711 1,
16712 );
16713 let spec = runner.build_spec(
16714 &Session::new("child-echo", "model"),
16715 &crate::runtime::execution::SpawnJob {
16716 parent_session_id: "parent".to_string(),
16717 child_session_id: "child-echo".to_string(),
16718 model: "gpt-5.4".to_string(),
16719 disabled_tools: None,
16720 },
16721 );
16722
16723 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16724 assert_eq!(
16725 spec.secrets.provider_credentials[0].api_key,
16726 "selected-openai-secret"
16727 );
16728 }
16729
16730 #[tokio::test]
16731 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16732 fn provider_instance(
16733 provider_type: &str,
16734 api_key: &str,
16735 ) -> bamboo_config::ProviderInstanceConfig {
16736 bamboo_config::ProviderInstanceConfig {
16737 provider_type: provider_type.to_string(),
16738 label: None,
16739 api_key: api_key.to_string(),
16740 api_key_encrypted: None,
16741 credential_ref: None,
16742 base_url: None,
16743 model: None,
16744 fast_model: None,
16745 vision_model: None,
16746 reasoning_effort: None,
16747 responses_only_models: Vec::new(),
16748 request_overrides: None,
16749 enabled: true,
16750 extra: Default::default(),
16751 }
16752 }
16753
16754 let mut initial = bamboo_llm::Config::default();
16755 initial.provider_instances.insert(
16756 "old-provider".to_string(),
16757 provider_instance("openai", "old-key"),
16758 );
16759 initial.default_provider_instance = Some("old-provider".to_string());
16760 let live = Arc::new(tokio::sync::RwLock::new(initial));
16761
16762 let runner = ActorChildRunner::new(
16763 "echo-live-config-test".to_string(),
16764 PathBuf::from("/bin/false"),
16765 Vec::new(),
16766 std::env::temp_dir().join("bamboo-echo-live-config"),
16767 ExecutorSpec::Echo,
16768 Vec::new(),
16769 "stale-provider".to_string(),
16770 1,
16771 )
16772 .with_live_provider_config(live.clone());
16773 let job = crate::runtime::execution::SpawnJob {
16774 parent_session_id: "parent".to_string(),
16775 child_session_id: "child".to_string(),
16776 model: "model".to_string(),
16777 disabled_tools: None,
16778 };
16779
16780 let first = runner
16781 .build_live_spec(&Session::new("child", "model"), &job)
16782 .await;
16783 assert_eq!(
16784 first.model.as_ref().map(|model| model.provider.as_str()),
16785 Some("old-provider")
16786 );
16787 assert_eq!(first.secrets.provider_credentials.len(), 1);
16788 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16789
16790 let mut reloaded = bamboo_llm::Config::default();
16791 reloaded.provider_instances.insert(
16792 "new-provider".to_string(),
16793 provider_instance("openai", "new-key"),
16794 );
16795 reloaded.default_provider_instance = Some("new-provider".to_string());
16796 *live.write().await = reloaded;
16797
16798 let second = runner
16799 .build_live_spec(&Session::new("child", "model"), &job)
16800 .await;
16801 assert_eq!(
16802 second.model.as_ref().map(|model| model.provider.as_str()),
16803 Some("new-provider")
16804 );
16805 assert_eq!(second.secrets.provider_credentials.len(), 1);
16806 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16807 }
16808
16809 #[test]
16810 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16811 let mut executor = codex_executor(Some("custom"), None);
16812 if let ExecutorSpec::Codex {
16813 base_url,
16814 provider_key_ref,
16815 ..
16816 } = &mut executor
16817 {
16818 *base_url = Some("https://provider.example/v1".to_string());
16819 *provider_key_ref = Some("provider.custom.api_key".to_string());
16820 }
16821 let credentials = vec![
16822 ScopedCredential {
16823 provider: "openai".to_string(),
16824 api_key: "session-provider-secret".to_string(),
16825 base_url: None,
16826 provider_type: Some("openai".to_string()),
16827 credential_ref: Some("provider.openai.api_key".to_string()),
16828 },
16829 ScopedCredential {
16830 provider: "custom".to_string(),
16831 api_key: "selected-secret".to_string(),
16832 base_url: None,
16833 provider_type: Some("openai".to_string()),
16834 credential_ref: Some("provider.custom.api_key".to_string()),
16835 },
16836 ScopedCredential {
16837 provider: "other".to_string(),
16838 api_key: "unrelated-secret".to_string(),
16839 base_url: None,
16840 provider_type: Some("openai".to_string()),
16841 credential_ref: Some("provider.other.api_key".to_string()),
16842 },
16843 ];
16844 let runner = ActorChildRunner::new(
16845 "codex-test".to_string(),
16846 PathBuf::from("/bin/false"),
16847 Vec::new(),
16848 std::env::temp_dir().join("bamboo-codex-570"),
16849 executor,
16850 credentials,
16851 "openai".to_string(),
16852 1,
16853 );
16854 let mut session = Session::new("child-custom", "model");
16855 session.add_message(bamboo_agent_core::Message::user("test"));
16856 let spec = runner.build_spec(
16857 &session,
16858 &crate::runtime::execution::SpawnJob {
16859 parent_session_id: "parent".to_string(),
16860 child_session_id: "child-custom".to_string(),
16861 model: "gpt-5.4".to_string(),
16862 disabled_tools: None,
16863 },
16864 );
16865
16866 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16867 assert_eq!(
16868 spec.secrets.provider_credentials[0]
16869 .credential_ref
16870 .as_deref(),
16871 Some("provider.custom.api_key")
16872 );
16873 assert_eq!(
16874 spec.secrets.provider_credentials[0].api_key,
16875 "selected-secret"
16876 );
16877 }
16878
16879 fn spec_with(
16880 role: &str,
16881 provider: &str,
16882 model: &str,
16883 workspace: Option<&str>,
16884 disabled: Option<Vec<&str>>,
16885 ) -> ProvisionSpec {
16886 let mut spec = ProvisionSpec::new(
16887 ChildIdentity {
16888 child_id: "c".into(),
16889 parent_id: None,
16890 project_key: None,
16891 role: role.into(),
16892 depth: 0,
16893 },
16894 ExecutorSpec::Echo,
16895 "/tmp/fab".into(),
16896 );
16897 spec.workspace = workspace.map(|w| w.to_string());
16898 spec.model = Some(ModelRefSpec {
16899 provider: provider.into(),
16900 model: model.into(),
16901 });
16902 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16903 spec
16904 }
16905
16906 #[test]
16907 fn fingerprint_matches_interchangeable_children() {
16908 let a = spec_with(
16911 "explorer",
16912 "p",
16913 "m",
16914 Some("/ws"),
16915 Some(vec!["Bash", "Edit"]),
16916 );
16917 let mut b = spec_with(
16918 "explorer",
16919 "p",
16920 "m",
16921 Some("/ws"),
16922 Some(vec!["Edit", "Bash"]),
16923 );
16924 b.identity.child_id = "other".into();
16925 assert_eq!(
16926 ActorChildRunner::fingerprint(&a),
16927 ActorChildRunner::fingerprint(&b)
16928 );
16929 }
16930
16931 #[test]
16932 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16933 let mut session =
16934 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16935 session.root_session_id = "logical-root-681".to_string();
16936 let job = SpawnJob {
16937 parent_session_id: "logical-parent-681".to_string(),
16938 child_session_id: "logical-child-681".to_string(),
16939 model: "model".to_string(),
16940 disabled_tools: None,
16941 };
16942 let expected = LogicalSessionIdentity {
16943 creation: None,
16944 session_id: "logical-child-681".to_string(),
16945 parent_session_id: Some("logical-parent-681".to_string()),
16946 root_session_id: "logical-root-681".to_string(),
16947 };
16948
16949 let placements_and_transport_ids = [
16950 (Placement::Local, "local-mailbox-first"),
16951 (
16952 Placement::Remote {
16953 endpoint: "wss://remote.example/actor".to_string(),
16954 },
16955 "remote-process-44",
16956 ),
16957 (
16958 Placement::Schedulable {
16959 pool: "gpu-pool".to_string(),
16960 },
16961 "scheduled-mailbox-9",
16962 ),
16963 (Placement::Local, "warm-mailbox-reused-77"),
16965 ];
16966 for (placement, transport_id) in placements_and_transport_ids {
16967 let mut provision = spec_with("worker", "provider", "model", None, None);
16968 provision.placement = placement;
16969 provision.identity.child_id = transport_id.to_string();
16970 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16971 assert_ne!(
16972 provision.identity.child_id, expected.session_id,
16973 "test fixture must prove transport identity is independent"
16974 );
16975 }
16976 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16977 original.executor = ExecutorSpec::BambooRuntime;
16978 original.identity.depth = session.spawn_depth;
16979 original.capabilities.required_child_context = true;
16980 original.capabilities.child_creation_identity = true;
16981 original.capabilities.enforce_permissions = true;
16982 original.capabilities.native_tool_ceiling_required = true;
16983 original.capabilities.native_tool_ceiling =
16984 Some(bamboo_subagent::proto::NativeToolCeiling {
16985 version: 1,
16986 child_session_id: session.id.clone(),
16987 parent_session_id: job.parent_session_id.clone(),
16988 root_session_id: session.root_session_id.clone(),
16989 created_at: session.created_at,
16990 spawn_depth: session.spawn_depth,
16991 project_id: None,
16992 tools: vec![],
16993 });
16994 original.validate().unwrap();
16995 let first = ActorChildRunner::fresh_required_worker_spec(&original);
16996 let second = ActorChildRunner::fresh_required_worker_spec(&original);
16997 assert_ne!(first.identity.child_id, second.identity.child_id);
16998 assert_ne!(first.identity.child_id, original.identity.child_id);
16999 for physical in [first, second] {
17000 physical.validate().unwrap();
17001 let mut actual = serde_json::to_value(&physical).unwrap();
17002 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
17003 assert_eq!(actual, serde_json::to_value(&original).unwrap());
17004 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17005 }
17006 }
17007
17008 #[test]
17009 fn fingerprint_separates_distinct_runtimes() {
17010 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
17011 let base_fp = ActorChildRunner::fingerprint(&base);
17012 assert_ne!(
17014 base_fp,
17015 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
17016 );
17017 assert_ne!(
17018 base_fp,
17019 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
17020 );
17021 assert_ne!(
17022 base_fp,
17023 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
17024 );
17025 assert_ne!(
17026 base_fp,
17027 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17028 );
17029 assert_ne!(
17030 base_fp,
17031 ActorChildRunner::fingerprint(&spec_with(
17032 "explorer",
17033 "p",
17034 "m",
17035 Some("/ws"),
17036 Some(vec!["Bash"])
17037 ))
17038 );
17039 }
17040
17041 #[test]
17042 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17043 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17044 before.secrets.provider_credentials.push(ScopedCredential {
17045 provider: "provider".to_string(),
17046 api_key: "old-secret-key".to_string(),
17047 base_url: Some("https://provider.example/v1".to_string()),
17048 provider_type: Some("openai".to_string()),
17049 credential_ref: None,
17050 });
17051 let mut after = before.clone();
17052 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17053
17054 let before_fingerprint = ActorChildRunner::fingerprint(&before);
17055 let after_fingerprint = ActorChildRunner::fingerprint(&after);
17056 assert_ne!(before_fingerprint, after_fingerprint);
17057 assert!(!before_fingerprint.contains("old-secret-key"));
17058 assert!(!after_fingerprint.contains("new-secret-key"));
17059 }
17060
17061 #[test]
17062 fn fingerprint_splits_on_baked_capabilities() {
17063 let base_fp =
17068 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17069
17070 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17071 creation.capabilities.child_creation_identity = true;
17072 let typed = ActorChildRunner::fingerprint(&creation);
17073 assert_ne!(
17074 base_fp, typed,
17075 "legacy worker pool cannot satisfy birth-required Run"
17076 );
17077 creation.identity.child_id = "different-logical-child".into();
17078 assert_eq!(
17079 typed,
17080 ActorChildRunner::fingerprint(&creation),
17081 "birth-required mode retains cross-Child pool reuse"
17082 );
17083
17084 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17085 depth.identity.depth = 2;
17086 assert_ne!(
17087 base_fp,
17088 ActorChildRunner::fingerprint(&depth),
17089 "depth must split"
17090 );
17091
17092 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17093 nested.capabilities.nested_spawn = true;
17094 assert_ne!(
17095 base_fp,
17096 ActorChildRunner::fingerprint(&nested),
17097 "nested_spawn must split"
17098 );
17099
17100 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17101 bypass.capabilities.bypass = true;
17102 assert_ne!(
17103 base_fp,
17104 ActorChildRunner::fingerprint(&bypass),
17105 "bypass must split"
17106 );
17107
17108 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17109 auto.capabilities.auto_approve_permissions = true;
17110 assert_ne!(
17111 base_fp,
17112 ActorChildRunner::fingerprint(&auto),
17113 "auto_approve_permissions must split"
17114 );
17115
17116 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17117 global_auto.capabilities.permission_requested_mode = "default".to_string();
17118 global_auto.capabilities.permission_effective_mode = "auto".to_string();
17119 global_auto.capabilities.auto_approve_permissions = true;
17120 let mut explicit_auto = global_auto.clone();
17121 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17122 assert_ne!(
17123 ActorChildRunner::fingerprint(&global_auto),
17124 ActorChildRunner::fingerprint(&explicit_auto),
17125 "permission_requested_mode must split global and explicit Auto"
17126 );
17127
17128 let mut plan_overlay = explicit_auto.clone();
17129 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17130 assert_ne!(
17131 ActorChildRunner::fingerprint(&explicit_auto),
17132 ActorChildRunner::fingerprint(&plan_overlay),
17133 "permission_effective_mode must split Plan overlay from Auto"
17134 );
17135
17136 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17137 enforce.capabilities.enforce_permissions = true;
17138 assert_ne!(
17139 base_fp,
17140 ActorChildRunner::fingerprint(&enforce),
17141 "enforce_permissions must split"
17142 );
17143
17144 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17145 cap.capabilities.max_spawn_depth = Some(8);
17146 assert_ne!(
17147 base_fp,
17148 ActorChildRunner::fingerprint(&cap),
17149 "max_spawn_depth must split"
17150 );
17151
17152 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17156 nha.capabilities.no_human_approver = true;
17157 assert_ne!(
17158 base_fp,
17159 ActorChildRunner::fingerprint(&nha),
17160 "no_human_approver must split"
17161 );
17162
17163 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17166 read_only.capabilities.read_only = true;
17167 assert_ne!(
17168 base_fp,
17169 ActorChildRunner::fingerprint(&read_only),
17170 "read_only must split"
17171 );
17172
17173 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17175 gro.capabilities.guardian_read_only = true;
17176 assert_ne!(
17177 base_fp,
17178 ActorChildRunner::fingerprint(&gro),
17179 "guardian_read_only must split"
17180 );
17181 }
17182
17183 #[test]
17184 fn fingerprint_splits_codex_exec_and_app_server_workers() {
17185 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17186 exec.executor = codex_executor(Some("inherit"), None);
17187 let mut app_server = exec.clone();
17188 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17189 *mode = Some("app_server".to_string());
17190 }
17191 assert_ne!(
17192 ActorChildRunner::fingerprint(&exec),
17193 ActorChildRunner::fingerprint(&app_server)
17194 );
17195 }
17196
17197 struct StaticDecider(bool);
17198
17199 #[async_trait]
17200 impl ChildApprovalDecider for StaticDecider {
17201 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17202 self.0
17203 }
17204 }
17205
17206 struct RecordingReviewer {
17207 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17208 }
17209
17210 #[async_trait]
17211 impl ChildApprovalReviewer for RecordingReviewer {
17212 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17213 let _ = self
17214 .reviewed
17215 .send((parent.to_string(), child.to_string(), request.clone()));
17216 true
17217 }
17218 }
17219
17220 struct SilentLink;
17225 #[async_trait]
17226 impl bamboo_subagent::ChildLink for SilentLink {
17227 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17228 Ok(())
17229 }
17230 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17231 std::future::pending().await
17232 }
17233 }
17234
17235 struct InstantTerminalLink {
17237 done: bool,
17238 }
17239
17240 struct ApprovalRoundTripLink {
17241 step: u8,
17242 approval_reply: Option<(String, bool)>,
17243 }
17244
17245 #[async_trait]
17246 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17247 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17248 if let ParentFrame::ApprovalReply { id, approved } = frame {
17249 self.approval_reply = Some((id, approved));
17250 self.step = 2;
17251 }
17252 Ok(())
17253 }
17254
17255 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17256 match self.step {
17257 0 => {
17258 self.step = 1;
17259 Ok(Some(ChildFrame::ApprovalRequest {
17260 id: "approval-1".into(),
17261 body: serde_json::json!({
17262 "tool_name": "Bash",
17263 "permission": "execute",
17264 "resource": "rm -rf target",
17265 "permission_request": {"reason_code": "hard_dangerous"}
17266 }),
17267 }))
17268 }
17269 1 => std::future::pending().await,
17270 2 => {
17271 self.step = 3;
17272 Ok(Some(ChildFrame::Terminal {
17273 status: TerminalStatus::Completed,
17274 result: Some("done".into()),
17275 error: None,
17276 final_event_watermark: None,
17277 transcript: vec![],
17278 }))
17279 }
17280 _ => std::future::pending().await,
17281 }
17282 }
17283 }
17284
17285 #[tokio::test]
17286 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17287 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17288 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17289 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17290 reviewed: review_tx,
17291 });
17292 let cancel = CancellationToken::new();
17293 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17294 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17295 let mut logical_session = Session::new("child-reviewer", "model");
17296 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17297 let mut link = ApprovalRoundTripLink {
17298 step: 0,
17299 approval_reply: None,
17300 };
17301
17302 let result = tokio::time::timeout(
17303 Duration::from_secs(1),
17304 drive(ActorDriveContext {
17305 expected_creation: None,
17306 client: &mut link,
17307 parent_session_id: "parent-reviewer",
17308 child_session_id: "child-reviewer",
17309 child_attempt: 0,
17310 approval_registry: None,
17311 approval_decider: None,
17312 approval_reviewer: Some(&reviewer),
17313 escalation_bridge: None,
17314 event_tx: &event_tx,
17315 cancel_token: &cancel,
17316 live_rx: &mut live_rx,
17317 delivery_rx: &mut delivery_rx,
17318 logical_session: &mut logical_session,
17319 expected_permission_posture: None,
17320 session_inbox_runtime: None,
17321 actor_directory_store: None,
17322 canonical_subagent_tool: None,
17323 ticket_service: None,
17324 activation_run_id: None,
17325 execution_epoch: 0,
17326 expected_source_actor_id: "child-reviewer",
17327 initial_inflight_claims: VecDeque::new(),
17328 plain_actor: false,
17329 remote_environment_lease: false,
17330 readonly_output: None,
17331 local_history_tools: None,
17332 local_history_read_only: false,
17333 strict_history_output: None,
17334 plain_input: None,
17335 canonical_activation: None,
17336 canonical_placement_ref: None,
17337 actor_event_observer: None,
17338 plain_run: None,
17339 first_frame_timeout: None,
17340 }),
17341 )
17342 .await
17343 .expect("worker must receive the reviewer verdict before terminating");
17344
17345 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17346 assert_eq!(
17347 link.approval_reply,
17348 Some(("approval-1".to_string(), true)),
17349 "reviewer verdict must traverse the live route back to the worker"
17350 );
17351 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17352 .await
17353 .expect("reviewer should be invoked off-loop")
17354 .expect("review channel should remain open");
17355 assert_eq!(parent, "parent-reviewer");
17356 assert_eq!(child, "child-reviewer");
17357 assert_eq!(
17358 body.pointer("/permission_request/reason_code")
17359 .and_then(serde_json::Value::as_str),
17360 Some("hard_dangerous")
17361 );
17362 assert!(
17363 event_rx.try_recv().is_err(),
17364 "must not emit a human-review event"
17365 );
17366 drop(live_guard);
17367 }
17368
17369 #[tokio::test]
17370 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17371 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17372 let cancel = CancellationToken::new();
17373 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17374 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17375 let mut logical_session = Session::new("child-no-reviewer", "model");
17376 let mut link = ApprovalRoundTripLink {
17377 step: 0,
17378 approval_reply: None,
17379 };
17380
17381 let result = tokio::time::timeout(
17382 Duration::from_secs(1),
17383 drive(ActorDriveContext {
17384 expected_creation: None,
17385 client: &mut link,
17386 parent_session_id: "parent-no-reviewer",
17387 child_session_id: "child-no-reviewer",
17388 child_attempt: 0,
17389 approval_registry: None,
17390 approval_decider: None,
17391 approval_reviewer: None,
17392 escalation_bridge: None,
17393 event_tx: &event_tx,
17394 cancel_token: &cancel,
17395 live_rx: &mut live_rx,
17396 delivery_rx: &mut delivery_rx,
17397 logical_session: &mut logical_session,
17398 expected_permission_posture: None,
17399 session_inbox_runtime: None,
17400 actor_directory_store: None,
17401 canonical_subagent_tool: None,
17402 ticket_service: None,
17403 activation_run_id: None,
17404 execution_epoch: 0,
17405 expected_source_actor_id: "child-no-reviewer",
17406 initial_inflight_claims: VecDeque::new(),
17407 plain_actor: false,
17408 remote_environment_lease: false,
17409 readonly_output: None,
17410 local_history_tools: None,
17411 local_history_read_only: false,
17412 strict_history_output: None,
17413 plain_input: None,
17414 canonical_activation: None,
17415 canonical_placement_ref: None,
17416 actor_event_observer: None,
17417 plain_run: None,
17418 first_frame_timeout: None,
17419 }),
17420 )
17421 .await
17422 .expect("fail-closed reply must unblock the child immediately");
17423
17424 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17425 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17426 assert!(
17427 event_rx.try_recv().is_err(),
17428 "missing parent review must not surface a manual approval event"
17429 );
17430 }
17431 #[async_trait]
17432 impl bamboo_subagent::ChildLink for InstantTerminalLink {
17433 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17434 Ok(())
17435 }
17436 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17437 if self.done {
17438 std::future::pending().await
17439 } else {
17440 self.done = true;
17441 Ok(Some(ChildFrame::Terminal {
17442 status: TerminalStatus::Completed,
17443 result: Some("done".into()),
17444 error: None,
17445 final_event_watermark: None,
17446 transcript: vec![],
17447 }))
17448 }
17449 }
17450 }
17451
17452 #[tokio::test]
17453 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17454 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17455 let cancel = CancellationToken::new();
17456 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17457 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17458 let mut logical_session = Session::new("child-x", "model");
17459 let mut link = SilentLink;
17460 let r = drive(ActorDriveContext {
17461 expected_creation: None,
17462 client: &mut link,
17463 parent_session_id: "parent-x",
17464 child_session_id: "child-x",
17465 child_attempt: 0,
17466 approval_registry: None,
17467 approval_decider: None,
17468 approval_reviewer: None,
17469 escalation_bridge: None,
17470 event_tx: &event_tx,
17471 cancel_token: &cancel,
17472 live_rx: &mut live_rx,
17473 delivery_rx: &mut delivery_rx,
17474 logical_session: &mut logical_session,
17475 expected_permission_posture: None,
17476 session_inbox_runtime: None,
17477 actor_directory_store: None,
17478 canonical_subagent_tool: None,
17479 ticket_service: None,
17480 activation_run_id: None,
17481 execution_epoch: 0,
17482 expected_source_actor_id: "child-x",
17483 initial_inflight_claims: VecDeque::new(),
17484 plain_actor: false,
17485 remote_environment_lease: false,
17486 readonly_output: None,
17487 local_history_tools: None,
17488 local_history_read_only: false,
17489 strict_history_output: None,
17490 plain_input: None,
17491 canonical_activation: None,
17492 canonical_placement_ref: None,
17493 actor_event_observer: None,
17494 plain_run: None,
17495 first_frame_timeout: Some(Duration::from_millis(100)),
17496 })
17497 .await;
17498 assert!(
17499 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17500 "a silent worker must trip the first-frame watchdog, got {r:?}"
17501 );
17502 }
17503
17504 #[tokio::test]
17505 async fn drive_does_not_trip_when_a_frame_arrives() {
17506 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17507 let cancel = CancellationToken::new();
17508 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17509 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17510 let mut logical_session = Session::new("child-y", "model");
17511 let mut link = InstantTerminalLink { done: false };
17512 let r = drive(ActorDriveContext {
17515 expected_creation: None,
17516 client: &mut link,
17517 parent_session_id: "parent-y",
17518 child_session_id: "child-y",
17519 child_attempt: 0,
17520 approval_registry: None,
17521 approval_decider: None,
17522 approval_reviewer: None,
17523 escalation_bridge: None,
17524 event_tx: &event_tx,
17525 cancel_token: &cancel,
17526 live_rx: &mut live_rx,
17527 delivery_rx: &mut delivery_rx,
17528 logical_session: &mut logical_session,
17529 expected_permission_posture: None,
17530 session_inbox_runtime: None,
17531 actor_directory_store: None,
17532 canonical_subagent_tool: None,
17533 ticket_service: None,
17534 activation_run_id: None,
17535 execution_epoch: 0,
17536 expected_source_actor_id: "child-y",
17537 initial_inflight_claims: VecDeque::new(),
17538 plain_actor: false,
17539 remote_environment_lease: false,
17540 readonly_output: None,
17541 local_history_tools: None,
17542 local_history_read_only: false,
17543 strict_history_output: None,
17544 plain_input: None,
17545 canonical_activation: None,
17546 canonical_placement_ref: None,
17547 actor_event_observer: None,
17548 plain_run: None,
17549 first_frame_timeout: Some(Duration::from_millis(50)),
17550 })
17551 .await;
17552 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17553 }
17554
17555 #[tokio::test]
17556 async fn child_approval_fails_closed_without_decider() {
17557 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17559 assert!(!decide_child_approval(None, "child-1", &body).await);
17560 }
17561
17562 #[tokio::test]
17563 async fn child_approval_honors_wired_decider() {
17564 let body =
17565 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17566 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17567 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17568 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17569 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17570 }
17571
17572 use crate::runtime::execution::SpawnJob;
17575 use bamboo_agent_core::Session;
17576
17577 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17580 ActorChildRunner::new(
17581 "test-actor".into(),
17582 PathBuf::from("/bin/false"),
17583 vec![],
17584 std::env::temp_dir().join("bamboo-test-fab-193"),
17585 ExecutorSpec::Echo,
17586 vec![],
17587 "anthropic".into(),
17588 4,
17589 )
17590 .with_remote_placements(placements)
17591 }
17592
17593 fn session_of_role(role: &str, assignment: &str) -> Session {
17596 let mut s = Session::new("child-1", "test-model");
17597 s.metadata
17598 .insert("subagent_type".to_string(), role.to_string());
17599 s.add_message(bamboo_agent_core::Message::user(assignment));
17600 s
17601 }
17602
17603 fn job_for(child: &str) -> SpawnJob {
17604 SpawnJob {
17605 parent_session_id: "parent-1".into(),
17606 child_session_id: child.into(),
17607 model: String::new(),
17608 disabled_tools: None,
17609 }
17610 }
17611
17612 #[tokio::test]
17613 async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17614 let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17615 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17616 let mut remote = HashMap::new();
17617 remote.insert(
17618 "remote-role".into(),
17619 ResolvedRemotePlacement {
17620 endpoint: "wss://worker.example:8443".into(),
17621 token: None,
17622 ca_cert_file: None,
17623 host_label: None,
17624 broker_peer: None,
17625 requirements: None,
17626 },
17627 );
17628 let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17629 let root = Session::new("provision-root", "model");
17630 for role in ["local-role", "remote-role"] {
17631 let mut child =
17632 Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17633 child.metadata.insert("subagent_type".into(), role.into());
17634 let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17635 assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17636 assert!(!first.capabilities.nested_spawn);
17637 assert_eq!(
17638 matches!(first.placement, Placement::Remote { .. }),
17639 role == "remote-role"
17640 );
17641 live.write().await.subagents_mut().max_spawn_depth = Some(7);
17642 let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17643 assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17644 assert!(second.capabilities.nested_spawn);
17645 assert_ne!(
17646 ActorChildRunner::fingerprint(&first),
17647 ActorChildRunner::fingerprint(&second)
17648 );
17649 assert_eq!(second.identity.depth, 1);
17650 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17651 }
17652 let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17653 let spec = fallback.build_spec(&root, &job_for(&root.id));
17654 assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17655 assert!(!spec.capabilities.nested_spawn);
17656 }
17657
17658 struct RecordingChildSessionPort {
17659 parent: Session,
17660 saved: std::sync::Mutex<Option<Session>>,
17661 }
17662
17663 impl RecordingChildSessionPort {
17664 fn saved_child(&self) -> Session {
17665 self.saved
17666 .lock()
17667 .expect("saved-child fixture lock")
17668 .clone()
17669 .expect("create_child_action must save the child")
17670 }
17671 }
17672
17673 #[async_trait]
17674 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17675 async fn load_root_session(
17676 &self,
17677 root_id: &str,
17678 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17679 assert_eq!(root_id, self.parent.id);
17680 Ok(self.parent.clone())
17681 }
17682
17683 async fn load_child_for_parent(
17684 &self,
17685 _parent_id: &str,
17686 _child_id: &str,
17687 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17688 unreachable!("create_child_action does not reload the child")
17689 }
17690
17691 async fn save_child_session(
17692 &self,
17693 child: &mut Session,
17694 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17695 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17696 Ok(())
17697 }
17698
17699 async fn save_child_session_authoritative_flags(
17700 &self,
17701 _child: &mut Session,
17702 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17703 unreachable!("new-child creation uses the ordinary save")
17704 }
17705
17706 async fn is_child_running(&self, _child_id: &str) -> bool {
17707 false
17708 }
17709
17710 async fn list_children(
17711 &self,
17712 _parent_id: &str,
17713 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17714 Vec::new()
17715 }
17716
17717 async fn enqueue_child_run(
17718 &self,
17719 _parent: &Session,
17720 _child: &Session,
17721 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17722 unreachable!("fixture creates the child with auto_run=false")
17723 }
17724
17725 async fn cancel_child_run_and_wait(
17726 &self,
17727 _child_id: &str,
17728 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17729 unreachable!("create_child_action does not cancel")
17730 }
17731
17732 async fn delete_child_session(
17733 &self,
17734 _parent_id: &str,
17735 _child_id: &str,
17736 ) -> Result<
17737 crate::session_app::child_session::DeleteChildResult,
17738 crate::session_app::child_session::ChildSessionError,
17739 > {
17740 unreachable!("create_child_action does not delete")
17741 }
17742
17743 async fn get_child_runner_info(
17744 &self,
17745 _child_id: &str,
17746 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17747 None
17748 }
17749
17750 async fn register_parent_wait_for_child(
17751 &self,
17752 _parent_session_id: &str,
17753 _child_session_id: &str,
17754 _tool_call_id: Option<&str>,
17755 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17756 unreachable!("create_child_action does not register a wait")
17757 }
17758
17759 async fn register_parent_wait_for_children(
17760 &self,
17761 _parent_session_id: &str,
17762 _child_session_ids: &[String],
17763 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17764 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17765 unreachable!("create_child_action does not register a wait")
17766 }
17767
17768 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17769 Vec::new()
17770 }
17771
17772 async fn find_resident_child(
17773 &self,
17774 _root_session_id: &str,
17775 _resident_name: &str,
17776 ) -> Option<String> {
17777 None
17778 }
17779
17780 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17781 }
17782
17783 #[test]
17784 fn build_spec_sets_remote_placement_for_matching_role() {
17785 let mut placements = HashMap::new();
17786 placements.insert(
17787 "explorer".to_string(),
17788 ResolvedRemotePlacement {
17789 endpoint: "wss://gpu-host:8443".into(),
17790 token: Some("T-secret".into()),
17791 ca_cert_file: None,
17792 host_label: None,
17793 broker_peer: None,
17794 requirements: None,
17795 },
17796 );
17797 let runner = bogus_runner(placements);
17798
17799 let s = session_of_role("explorer", "do the thing");
17801 let spec = runner.build_spec(&s, &job_for("child-1"));
17802 match &spec.placement {
17803 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17804 other => panic!("expected Remote, got {other:?}"),
17805 }
17806 assert!(spec.secrets.worker_auth_token.is_none());
17807 }
17808
17809 #[test]
17810 fn build_spec_leaves_local_for_unmatched_role() {
17811 let mut placements = HashMap::new();
17812 placements.insert(
17813 "explorer".to_string(),
17814 ResolvedRemotePlacement {
17815 endpoint: "wss://gpu-host:8443".into(),
17816 token: Some("T".into()),
17817 ca_cert_file: None,
17818 host_label: None,
17819 broker_peer: None,
17820 requirements: None,
17821 },
17822 );
17823 let runner = bogus_runner(placements);
17824
17825 let s = session_of_role("writer", "do the thing");
17827 let spec = runner.build_spec(&s, &job_for("child-1"));
17828 assert_eq!(spec.placement, Placement::Local);
17829 assert!(spec.secrets.worker_auth_token.is_none());
17830 }
17831
17832 #[test]
17833 fn build_spec_local_when_no_placements() {
17834 let runner = bogus_runner(HashMap::new());
17835 let s = session_of_role("explorer", "do the thing");
17836 let spec = runner.build_spec(&s, &job_for("child-1"));
17837 assert_eq!(spec.placement, Placement::Local);
17838 assert!(spec.secrets.worker_auth_token.is_none());
17839 }
17840
17841 #[test]
17842 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17843 for mode in [
17844 bamboo_domain::SessionPermissionMode::Auto,
17845 bamboo_domain::SessionPermissionMode::Bypass,
17846 ] {
17847 let runner = bogus_runner(HashMap::new());
17848 let mut child = session_of_role("planner", "inspect and plan");
17849 let runtime = child
17850 .agent_runtime_state
17851 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17852 runtime.set_permission_mode(mode);
17853 runtime.read_only = true;
17854
17855 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17856
17857 assert!(spec.capabilities.read_only);
17858 assert!(spec.capabilities.read_only_enforced());
17859 assert!(
17860 spec.capabilities.guardian_read_only,
17861 "the compatibility alias must protect planners on older workers"
17862 );
17863 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17864 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17865 assert!(
17866 !spec.capabilities.bypass,
17867 "read-only must win over requested {mode:?}"
17868 );
17869 assert_eq!(
17870 spec.capabilities.auto_approve_permissions,
17871 mode == bamboo_domain::SessionPermissionMode::Auto,
17872 "Auto may suppress prompts but must not widen Plan authorization"
17873 );
17874 let disabled = spec
17875 .disabled_tools
17876 .as_ref()
17877 .expect("read-only state must provision a host-owned denylist");
17878 for tool_name in [
17879 "Edit",
17880 "Write",
17881 "Plan",
17882 "SubAgent",
17883 "session_control",
17884 "request_permissions",
17885 ] {
17886 assert!(
17887 disabled.iter().any(|name| name == tool_name),
17888 "read-only provisioning must disable {tool_name}"
17889 );
17890 }
17891 }
17892
17893 let runner = bogus_runner(HashMap::new());
17894 let cosmetic_planner = runner.build_spec(
17895 &session_of_role("planner", "label only"),
17896 &job_for("cosmetic"),
17897 );
17898 assert!(
17899 !cosmetic_planner.capabilities.read_only_enforced(),
17900 "a cosmetic role label must not change authority"
17901 );
17902 assert!(cosmetic_planner.disabled_tools.is_none());
17903
17904 let cosmetic_guardian = runner.build_spec(
17905 &session_of_role("guardian", "label only"),
17906 &job_for("cosmetic-guardian"),
17907 );
17908 assert!(
17909 !cosmetic_guardian.capabilities.read_only_enforced(),
17910 "a Guardian label without typed state or the legacy denylist is cosmetic"
17911 );
17912
17913 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17914 let mut legacy_job = job_for("legacy-guardian");
17915 legacy_job.disabled_tools = Some(vec![
17916 "Edit".to_string(),
17917 "Write".to_string(),
17918 "SubAgent".to_string(),
17919 "WebFetch".to_string(),
17920 ]);
17921 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17922 assert!(
17923 legacy_spec.capabilities.read_only_enforced(),
17924 "an in-flight pre-upgrade Guardian keeps its read-only authority"
17925 );
17926
17927 let mut differently_named = session_of_role("explorer", "typed authority");
17928 differently_named
17929 .agent_runtime_state
17930 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17931 .read_only = true;
17932 let typed = runner.build_spec(&differently_named, &job_for("typed"));
17933 assert!(
17934 typed.capabilities.read_only_enforced(),
17935 "typed state must enforce read-only independently of role"
17936 );
17937 }
17938
17939 #[test]
17940 fn typed_read_only_forces_native_external_executor_profiles() {
17941 let mut planner = session_of_role("planner", "inspect and plan");
17942 planner
17943 .agent_runtime_state
17944 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17945 .read_only = true;
17946
17947 let claude_runner = ActorChildRunner::new(
17948 "test-actor".into(),
17949 PathBuf::from("/bin/false"),
17950 vec![],
17951 std::env::temp_dir().join("bamboo-test-plan-claude"),
17952 ExecutorSpec::ClaudeCode {
17953 binary: None,
17954 model: None,
17955 permission_mode: Some("bypassPermissions".to_string()),
17956 inherit_user_config: Some(true),
17957 forward_env: None,
17958 },
17959 vec![],
17960 "anthropic".into(),
17961 4,
17962 );
17963 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17964 let disabled = claude_spec
17965 .disabled_tools
17966 .as_ref()
17967 .expect("typed read-only child must receive a host-owned denylist");
17968 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17969 assert!(
17970 disabled.iter().any(|name| name == tool),
17971 "typed read-only child must not advertise {tool}"
17972 );
17973 }
17974 assert!(matches!(
17975 claude_spec.executor,
17976 ExecutorSpec::ClaudeCode {
17977 permission_mode: Some(ref mode),
17978 inherit_user_config: Some(false),
17979 ..
17980 } if mode == "plan"
17981 ));
17982 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17983
17984 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17985 if let ExecutorSpec::Codex {
17986 sandbox,
17987 approval_policy,
17988 network_access,
17989 allow_danger_bypass,
17990 permission_profile,
17991 ..
17992 } = &mut codex_exec
17993 {
17994 *sandbox = Some("danger-full-access".to_string());
17995 *approval_policy = Some("on-failure".to_string());
17996 *network_access = Some(true);
17997 *allow_danger_bypass = Some(true);
17998 *permission_profile = Some("full-access".to_string());
17999 }
18000 let codex_runner = ActorChildRunner::new(
18001 "test-actor".into(),
18002 PathBuf::from("/bin/false"),
18003 vec![],
18004 std::env::temp_dir().join("bamboo-test-plan-codex"),
18005 codex_exec,
18006 vec![],
18007 "openai".into(),
18008 4,
18009 );
18010 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
18011 assert_eq!(
18012 expected_permission_executor_mapping(
18013 &codex_spec.executor,
18014 codex_spec.capabilities.permission_resolution().unwrap(),
18015 false,
18016 )
18017 .unwrap()
18018 .as_deref(),
18019 Some("codex_exec:approval_policy=never")
18020 );
18021 assert!(matches!(
18022 codex_spec.executor,
18023 ExecutorSpec::Codex {
18024 sandbox: Some(ref sandbox),
18025 inherit_user_config: Some(false),
18026 approval_policy: Some(ref approval_policy),
18027 network_access: Some(false),
18028 allow_danger_bypass: Some(false),
18029 permission_profile: Some(ref profile),
18030 ..
18031 } if sandbox == "read-only"
18032 && approval_policy == "never"
18033 && profile == "read-only"
18034 ));
18035
18036 let mut app_server = codex_executor(Some("inherit"), Some(true));
18037 if let ExecutorSpec::Codex {
18038 mode,
18039 sandbox,
18040 approval_policy,
18041 network_access,
18042 allow_danger_bypass,
18043 permission_profile,
18044 ..
18045 } = &mut app_server
18046 {
18047 *mode = Some("app_server".to_string());
18048 *sandbox = Some("danger-full-access".to_string());
18049 *approval_policy = Some("on-request".to_string());
18050 *network_access = Some(true);
18051 *allow_danger_bypass = Some(true);
18052 *permission_profile = Some("full-access".to_string());
18053 }
18054 let app_server_runner = ActorChildRunner::new(
18055 "test-actor".into(),
18056 PathBuf::from("/bin/false"),
18057 vec![],
18058 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18059 app_server,
18060 vec![],
18061 "openai".into(),
18062 4,
18063 );
18064 let app_server_spec =
18065 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18066 assert_eq!(
18067 expected_permission_executor_mapping(
18068 &app_server_spec.executor,
18069 app_server_spec
18070 .capabilities
18071 .permission_resolution()
18072 .unwrap(),
18073 false,
18074 )
18075 .unwrap()
18076 .as_deref(),
18077 Some("codex_app_server:approvalPolicy=never")
18078 );
18079 assert!(matches!(
18080 app_server_spec.executor,
18081 ExecutorSpec::Codex {
18082 mode: Some(ref mode),
18083 sandbox: Some(ref sandbox),
18084 inherit_user_config: Some(false),
18085 approval_policy: Some(ref approval_policy),
18086 network_access: Some(false),
18087 allow_danger_bypass: Some(false),
18088 permission_profile: Some(ref profile),
18089 ..
18090 } if mode == "app_server"
18091 && sandbox == "read-only"
18092 && approval_policy == "on-request"
18093 && profile == "read-only"
18094 ));
18095 }
18096
18097 #[test]
18098 fn typed_read_only_rejects_both_codex_executor_modes() {
18099 let mut planner = session_of_role("planner", "inspect and plan");
18100 planner
18101 .agent_runtime_state
18102 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18103 .read_only = true;
18104
18105 for mode in ["exec", "app_server"] {
18106 let mut executor = codex_executor(Some("inherit"), Some(true));
18107 if let ExecutorSpec::Codex {
18108 mode: configured_mode,
18109 ..
18110 } = &mut executor
18111 {
18112 *configured_mode = Some(mode.to_string());
18113 }
18114 let runner = ActorChildRunner::new(
18115 "test-actor".into(),
18116 PathBuf::from("/bin/false"),
18117 vec![],
18118 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18119 executor,
18120 vec![],
18121 "openai".into(),
18122 4,
18123 );
18124 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18125 let error = ensure_read_only_activation_is_enforceable(&spec)
18126 .expect_err("Codex command execution must fail closed for read-only activations");
18127 let message = error.to_string();
18128 assert!(message.contains("Codex executor"), "{message}");
18129 assert!(message.contains("command execution"), "{message}");
18130 }
18131 }
18132
18133 #[tokio::test]
18134 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18135 let mut planner = session_of_role("planner", "inspect and plan");
18136 planner
18137 .agent_runtime_state
18138 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18139 .read_only = true;
18140
18141 let runner = ActorChildRunner::new(
18142 "test-actor".into(),
18143 PathBuf::from("/bin/false"),
18144 vec![],
18145 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18146 codex_executor(Some("inherit"), Some(true)),
18147 vec![],
18148 "openai".into(),
18149 4,
18150 );
18151 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18152 let error = runner
18153 .execute_external_child(
18154 &mut planner,
18155 &job_for("planner-codex-no-spawn"),
18156 event_tx,
18157 CancellationToken::new(),
18158 )
18159 .await
18160 .expect_err("typed read-only Codex must fail before invoking /bin/false");
18161 let message = error.to_string();
18162 assert!(message.contains("Codex executor"), "{message}");
18163 assert!(message.contains("command execution"), "{message}");
18164 }
18165
18166 #[test]
18167 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18168 let runner = bogus_runner(HashMap::new());
18169 let ordinary = session_of_role("worker", "ordinary work");
18170 for placement in [
18171 Placement::Local,
18172 Placement::Remote {
18173 endpoint: "wss://fixture.invalid/actor".into(),
18174 },
18175 Placement::Schedulable {
18176 pool: "fixture-pool".into(),
18177 },
18178 ] {
18179 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18180 spec.placement = placement;
18181 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18182 for marker in [
18183 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18184 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18185 ] {
18186 let mut ticket = ordinary.clone();
18187 ticket.metadata.insert(marker.into(), "fixture".into());
18188 assert_eq!(
18189 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18190 matches!(spec.placement, Placement::Local)
18191 );
18192 }
18193 }
18194 }
18195
18196 #[tokio::test]
18197 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18198 for marker in [
18199 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18200 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18201 ] {
18202 for schedulable in [false, true] {
18203 let mut session = session_of_role("worker", "Ticket work");
18204 session.metadata.insert(marker.into(), "fixture".into());
18205 let before = serde_json::to_value(&session).unwrap();
18206 let remote = if schedulable {
18207 HashMap::new()
18208 } else {
18209 HashMap::from([(
18210 "worker".into(),
18211 ResolvedRemotePlacement {
18212 endpoint: "wss://fixture.invalid/actor".into(),
18213 ..Default::default()
18214 },
18215 )])
18216 };
18217 let sched = if schedulable {
18218 HashMap::from([(
18219 "worker".into(),
18220 sched_placement("fixture-pool", "wss://fixture.invalid"),
18221 )])
18222 } else {
18223 HashMap::new()
18224 };
18225 let runner = bogus_sched_runner(remote, sched);
18226 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18227 let error = runner
18228 .execute_external_child(
18229 &mut session,
18230 &job_for("ticket-no-remote"),
18231 events,
18232 CancellationToken::new(),
18233 )
18234 .await
18235 .unwrap_err();
18236 assert!(
18237 error
18238 .to_string()
18239 .contains("Ticket execution requires local placement"),
18240 "{error}"
18241 );
18242 assert_eq!(serde_json::to_value(&session).unwrap(), before);
18243 assert!(observations.try_recv().is_err());
18244 }
18245 }
18246 }
18247
18248 #[test]
18249 fn typed_read_only_rejects_resident_actor_placements() {
18250 let runner = bogus_runner(HashMap::new());
18251 let mut planner = session_of_role("planner", "inspect and plan");
18252 planner
18253 .agent_runtime_state
18254 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18255 .read_only = true;
18256
18257 for placement in [
18258 Placement::Remote {
18259 endpoint: "wss://resident.example/actor".to_string(),
18260 },
18261 Placement::Schedulable {
18262 pool: "resident-pool".to_string(),
18263 },
18264 ] {
18265 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18266 spec.placement = placement;
18267
18268 let error = ensure_read_only_activation_is_enforceable(&spec)
18269 .expect_err("resident placement must fail closed for read-only activation");
18270 let message = error.to_string();
18271 assert!(message.contains("requires local placement"), "{message}");
18272 assert!(message.contains("read-only tool boundary"), "{message}");
18273 }
18274
18275 let local = runner.build_spec(&planner, &job_for("read-only-local"));
18276 ensure_read_only_activation_is_enforceable(&local)
18277 .expect("a locally provisioned read-only worker is enforceable");
18278 }
18279
18280 #[tokio::test]
18281 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18282 for (label, mode) in [
18286 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18287 ("auto", bamboo_domain::SessionPermissionMode::Auto),
18288 ] {
18289 let runner = bogus_runner(HashMap::new());
18290 let mut parent = Session::new(format!("parent-{label}"), "test-model");
18291 parent
18292 .agent_runtime_state
18293 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18294 .set_permission_mode(mode);
18295 let workspace = tempfile::tempdir().expect("workspace fixture");
18296 let port = RecordingChildSessionPort {
18297 parent: parent.clone(),
18298 saved: Default::default(),
18299 };
18300 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18301 crate::session_app::child_session::create_child_action(
18302 &port,
18303 crate::session_app::child_session::CreateChildInput {
18304 parent_session: parent,
18305 child_id: child_id.clone(),
18306 title: format!("{label} child"),
18307 responsibility: "Run ordinary commands".to_string(),
18308 assignment_prompt: "run an ordinary command".to_string(),
18309 subagent_type: "explorer".to_string(),
18310 workspace: workspace.path().to_string_lossy().into_owned(),
18311 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18312 model_override: None,
18313 model_ref_override: None,
18314 runtime_metadata: HashMap::new(),
18315 read_only: false,
18316 auto_run: false,
18317 reasoning_effort: None,
18318 lifecycle: None,
18319 resident_name: None,
18320 resident_context: None,
18321 disabled_tools: None,
18322 context_fork: None,
18323 },
18324 )
18325 .await
18326 .expect("create inherited-permission child");
18327 let child = port.saved_child();
18328
18329 assert_eq!(
18330 child
18331 .agent_runtime_state
18332 .as_ref()
18333 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18334 Some(mode),
18335 "create_child_action must inherit {label} from the parent"
18336 );
18337
18338 let spec = runner.build_spec(&child, &job_for(&child_id));
18339
18340 assert_eq!(
18341 spec.capabilities.bypass,
18342 mode == bamboo_domain::SessionPermissionMode::Bypass
18343 );
18344 assert_eq!(
18345 spec.capabilities.auto_approve_permissions,
18346 mode == bamboo_domain::SessionPermissionMode::Auto
18347 );
18348 assert!(
18349 spec.capabilities.enforce_permissions,
18350 "policy evaluation must remain active under {label}"
18351 );
18352 }
18353 }
18354
18355 #[tokio::test]
18356 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18357 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18358 let workspace = tempfile::tempdir().expect("workspace fixture");
18359
18360 for (role, lifecycle, resident_name) in [
18361 ("explorer", None, None),
18362 ("resident", Some("resident"), Some("stable-reviewer")),
18363 ("guardian", None, None),
18364 ] {
18365 let mut parent = Session::new(format!("parent-{role}"), "test-model");
18366 parent.set_project_id_meta(project_id.to_string());
18367 let port = RecordingChildSessionPort {
18368 parent: parent.clone(),
18369 saved: Default::default(),
18370 };
18371 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18372 crate::session_app::child_session::create_child_action(
18373 &port,
18374 crate::session_app::child_session::CreateChildInput {
18375 parent_session: parent,
18376 child_id: child_id.clone(),
18377 title: format!("{role} child"),
18378 responsibility: "Review the assigned work".to_string(),
18379 assignment_prompt: "inspect the change".to_string(),
18380 subagent_type: role.to_string(),
18381 workspace: workspace.path().to_string_lossy().into_owned(),
18382 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18383 model_override: None,
18384 model_ref_override: None,
18385 runtime_metadata: HashMap::new(),
18386 read_only: false,
18387 auto_run: false,
18388 reasoning_effort: None,
18389 lifecycle: lifecycle.map(str::to_string),
18390 resident_name: resident_name.map(str::to_string),
18391 resident_context: None,
18392 disabled_tools: None,
18393 context_fork: None,
18394 },
18395 )
18396 .await
18397 .expect("create Project-inheriting child");
18398 let child = port.saved_child();
18399 assert_eq!(
18400 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18401 Some(project_id.clone()),
18402 "{role} child must inherit its parent's Project"
18403 );
18404
18405 assert_eq!(
18406 project_id_for_actor_run(&child).expect("valid actor Project identity"),
18407 Some(project_id.clone()),
18408 "{role} actor RunSpec must preserve inherited Project identity"
18409 );
18410 }
18411 }
18412
18413 #[test]
18414 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18415 assert_eq!(placement_metadata(&Placement::Local, None), None);
18417
18418 let r = placement_metadata(
18420 &Placement::Remote {
18421 endpoint: "wss://10.0.0.5:8443/stream".into(),
18422 },
18423 None,
18424 )
18425 .unwrap();
18426 assert!(r.contains(r#""kind":"remote""#), "{r}");
18427 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18428
18429 let labeled = placement_metadata(
18431 &Placement::Remote {
18432 endpoint: "ws://169.254.230.101:8899".into(),
18433 },
18434 Some("mini"),
18435 )
18436 .unwrap();
18437 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18438
18439 let s = placement_metadata(
18441 &Placement::Schedulable {
18442 pool: "explorers".into(),
18443 },
18444 Some("mini"),
18445 )
18446 .unwrap();
18447 assert!(s.contains(r#""kind":"remote""#), "{s}");
18448 assert!(s.contains(r#""host":"mini""#), "{s}");
18449
18450 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18452 assert_eq!(p.kind, "remote");
18453 assert_eq!(p.host, "mini");
18454 }
18455
18456 #[tokio::test]
18459 async fn execute_external_child_rejects_direct_remote_worker() {
18460 let mut placements = HashMap::new();
18461 placements.insert(
18462 "explorer".to_string(),
18463 ResolvedRemotePlacement {
18464 endpoint: "ws://127.0.0.1:1".into(),
18465 token: Some("remote-test-token".into()),
18466 ca_cert_file: None,
18467 host_label: Some("mini-e2e".into()),
18468 broker_peer: None,
18469 requirements: None,
18470 },
18471 );
18472 let runner = bogus_runner(placements);
18473 let mut session = session_of_role("explorer", "hello remote");
18474 let job = job_for("child-1");
18475 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18476 let error = runner
18477 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18478 .await
18479 .err()
18480 .expect("direct remote transport must fail closed");
18481 assert!(error
18482 .to_string()
18483 .contains("remote_broker_activation_unavailable"));
18484 assert!(session
18485 .messages
18486 .iter()
18487 .all(|message| message.role != Role::Assistant));
18488 }
18489
18490 fn bogus_sched_runner(
18496 remote: HashMap<String, ResolvedRemotePlacement>,
18497 sched: HashMap<String, ResolvedSchedulablePlacement>,
18498 ) -> ActorChildRunner {
18499 ActorChildRunner::new(
18500 "test-actor".into(),
18501 PathBuf::from("/bin/false"),
18502 vec![],
18503 std::env::temp_dir().join("bamboo-test-fab-181"),
18504 ExecutorSpec::Echo,
18505 vec![],
18506 "anthropic".into(),
18507 4,
18508 )
18509 .with_remote_placements(remote)
18510 .with_schedulable_placements(sched)
18511 }
18512
18513 fn sched_placement(
18514 pool: &str,
18515 _registry_url: impl Into<String>,
18516 ) -> ResolvedSchedulablePlacement {
18517 ResolvedSchedulablePlacement {
18518 pool: pool.into(),
18519 host_label: None,
18520 requirements: None,
18521 ..Default::default()
18522 }
18523 }
18524
18525 #[test]
18526 fn build_spec_sets_schedulable_placement_for_matching_role() {
18527 let mut sched = HashMap::new();
18528 sched.insert(
18529 "explorer".to_string(),
18530 sched_placement("gpu-pool", "unused"),
18531 );
18532 let runner = bogus_sched_runner(HashMap::new(), sched);
18533
18534 let s = session_of_role("explorer", "do the thing");
18535 let spec = runner.build_spec(&s, &job_for("child-1"));
18536 match &spec.placement {
18537 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18538 other => panic!("expected Schedulable, got {other:?}"),
18539 }
18540 assert!(spec.secrets.worker_auth_token.is_none());
18542 }
18543
18544 #[test]
18545 fn build_spec_remote_wins_when_role_in_both_maps() {
18546 let mut remote = HashMap::new();
18549 remote.insert(
18550 "explorer".to_string(),
18551 ResolvedRemotePlacement {
18552 endpoint: "wss://fixed-host:8443".into(),
18553 token: Some("T-remote".into()),
18554 ca_cert_file: None,
18555 host_label: None,
18556 broker_peer: None,
18557 requirements: None,
18558 },
18559 );
18560 let mut sched = HashMap::new();
18561 sched.insert(
18562 "explorer".to_string(),
18563 sched_placement("gpu-pool", "https://control-plane:9562"),
18564 );
18565 let runner = bogus_sched_runner(remote, sched);
18566
18567 let s = session_of_role("explorer", "do the thing");
18568 let spec = runner.build_spec(&s, &job_for("child-1"));
18569 match &spec.placement {
18570 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18571 other => panic!("expected Remote (precedence), got {other:?}"),
18572 }
18573 assert!(spec.secrets.worker_auth_token.is_none());
18574 }
18575
18576 #[test]
18577 fn build_spec_local_for_unmatched_schedulable_role() {
18578 let mut sched = HashMap::new();
18579 sched.insert(
18580 "explorer".to_string(),
18581 sched_placement("gpu-pool", "https://control-plane:9562"),
18582 );
18583 let runner = bogus_sched_runner(HashMap::new(), sched);
18584 let s = session_of_role("writer", "do the thing");
18585 let spec = runner.build_spec(&s, &job_for("child-1"));
18586 assert_eq!(spec.placement, Placement::Local);
18587 assert!(spec.secrets.worker_auth_token.is_none());
18588 }
18589
18590 #[test]
18595 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18596 let mut remote = HashMap::new();
18598 remote.insert(
18599 "explorer".to_string(),
18600 ResolvedRemotePlacement {
18601 endpoint: "ws://169.254.230.101:8899".into(),
18602 token: None,
18603 ca_cert_file: None,
18604 host_label: Some("mini".into()),
18605 broker_peer: None,
18606 requirements: None,
18607 },
18608 );
18609 let runner = bogus_runner(remote);
18610 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18611 let stamp = runner
18612 .placement_stamp_for(&spec)
18613 .expect("remote child is stamped");
18614 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18615 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18616
18617 let mut remote_nolabel = HashMap::new();
18619 remote_nolabel.insert(
18620 "explorer".to_string(),
18621 ResolvedRemotePlacement {
18622 endpoint: "ws://169.254.230.101:8899".into(),
18623 token: None,
18624 ca_cert_file: None,
18625 host_label: None,
18626 broker_peer: None,
18627 requirements: None,
18628 },
18629 );
18630 let r2 = bogus_runner(remote_nolabel);
18631 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18632 assert!(r2
18633 .placement_stamp_for(&spec2)
18634 .unwrap()
18635 .contains(r#""host":"169.254.230.101""#));
18636
18637 let mut sched = HashMap::new();
18639 sched.insert(
18640 "mac-mini-monitor".to_string(),
18641 ResolvedSchedulablePlacement {
18642 pool: "mac-mini-monitor".into(),
18643 host_label: Some("mini".into()),
18644 requirements: None,
18645 ..Default::default()
18646 },
18647 );
18648 let sr = bogus_sched_runner(HashMap::new(), sched);
18649 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18650 let stamp3 = sr
18651 .placement_stamp_for(&spec3)
18652 .expect("scheduled child is stamped");
18653 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18654 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18655
18656 let local = bogus_runner(HashMap::new());
18658 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18659 assert_eq!(local.placement_stamp_for(&spec4), None);
18660 }
18661
18662 #[tokio::test]
18663 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18664 let mut sched = HashMap::new();
18665 sched.insert(
18666 "explorer".to_string(),
18667 sched_placement("gpu-pool", "unused"),
18668 );
18669 let runner = bogus_sched_runner(HashMap::new(), sched);
18670 let mut session = session_of_role("explorer", "do the thing");
18671 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18672 let error = runner
18673 .execute_external_child(
18674 &mut session,
18675 &job_for("child-1"),
18676 event_tx,
18677 CancellationToken::new(),
18678 )
18679 .await
18680 .expect_err("legacy bus presence cannot grant placement");
18681 assert!(error
18682 .to_string()
18683 .contains("remote_broker_activation_unavailable"));
18684 }
18685}