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 registration.abandon().await;
12927 assert!(
12928 !binding.router.owns_run(&child.id, "first-run").await,
12929 "{case}: abandoned first run must release its owner before retry"
12930 );
12931 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12932 &child.id,
12933 if case == "oversize" {
12934 "x".repeat(8193)
12935 } else {
12936 "NEW_CORRECTION".into()
12937 },
12938 );
12939 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12940 first
12941 .input_inbox
12942 .mark_activation_eligible(
12943 &child.id,
12944 receipt.generation,
12945 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12946 )
12947 .await
12948 .unwrap();
12949 let before = store.load_session(&child.id).await.unwrap().unwrap();
12950 let expected_messages = serde_json::to_value(&before.messages).unwrap();
12951 child = before.clone();
12952 if case == "prefix-refusal" {
12953 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12954 }
12955 if case == "changed-birth" {
12956 child.created_at += chrono::Duration::milliseconds(1);
12957 }
12958 let _retry_registration = binding
12959 .router
12960 .register_run(&child.id, "retry-run")
12961 .await
12962 .unwrap();
12963 let result = PlainActorActivation::start_and_prepare(
12964 store.clone(),
12965 &mut child,
12966 &binding,
12967 Some("retry-run"),
12968 Some(policy.clone()),
12969 case != "readonly-refusal",
12970 None,
12971 )
12972 .await;
12973 if case != "accepted" {
12974 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12975 let actual = store.load_session(&before.id).await.unwrap().unwrap();
12976 assert_eq!(
12977 serde_json::to_value(&actual.messages).unwrap(),
12978 expected_messages,
12979 "{case}: failed preparation preserves Main history"
12980 );
12981 assert!(
12982 !first
12983 .input_inbox
12984 .was_admitted(&before.id, &envelope.id)
12985 .await
12986 .unwrap(),
12987 "{case}: no ACK"
12988 );
12989 if case == "readonly-refusal" {
12990 let entry = store.inspect_actor(&before.id).await.unwrap();
12991 assert_eq!(entry.actor.current_attempt, 1);
12992 assert_eq!(
12993 entry.activation.unwrap().activation_id,
12994 first.fence.activation_id
12995 );
12996 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
12997 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
12998 }
12999 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
13000 assert_eq!(
13001 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
13002 1
13003 );
13004 assert_eq!(
13005 store
13006 .inspect_actor(&before.id)
13007 .await
13008 .unwrap()
13009 .activation
13010 .unwrap()
13011 .status,
13012 bamboo_domain::ActorActivationStatus::Failed
13013 );
13014 }
13015 if case == "oversize" {
13016 let reopened = Arc::new(
13017 bamboo_storage::SessionStoreV2::new(temp.path().into())
13018 .await
13019 .unwrap(),
13020 );
13021 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13022 reopened,
13023 bamboo_domain::SessionInboxLimits::default(),
13024 );
13025 let now = chrono::Utc::now();
13026 let leases = cold_inbox
13027 .inspect_owned_leases(&before.id, 2, now)
13028 .await
13029 .unwrap();
13030 assert_eq!(leases.len(), 1);
13031 assert_eq!(leases[0].failure_count, 1);
13032 assert_eq!(
13033 leases[0].last_error_code.as_deref(),
13034 Some("unsupported_actor_input")
13035 );
13036 let retry_after = leases[0].retry_after.unwrap();
13037 assert!(retry_after > now);
13038 let readiness = cold_inbox
13039 .inspect_wake_readiness(&before.id, now)
13040 .await
13041 .unwrap();
13042 assert!(
13043 readiness.ready.is_none(),
13044 "failed correction must not hot-loop"
13045 );
13046 assert_eq!(readiness.next_due_at, Some(retry_after));
13047 }
13048 continue;
13049 }
13050 let (retry, prepared) = result.unwrap();
13051 let (prefix, mut deliveries) = prepared.unwrap();
13052 assert_eq!(deliveries.len(), 1);
13053 let delivery = deliveries.pop().unwrap();
13054 assert_eq!(
13055 prefix,
13056 serde_json::to_value(&before.messages)
13057 .unwrap()
13058 .as_array()
13059 .unwrap()
13060 .clone()
13061 );
13062 assert_eq!(delivery.envelope, envelope);
13063 assert_eq!(delivery.activation_run_id, "retry-run");
13064 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13065 assert!(store
13066 .validate_fence(&first.fence, chrono::Utc::now())
13067 .await
13068 .is_err());
13069 let running = store.inspect_actor(&child.id).await.unwrap();
13070 assert_eq!(running.actor.current_attempt, 2);
13071 assert_eq!(
13072 running.activation.as_ref().unwrap().status,
13073 bamboo_domain::ActorActivationStatus::Running
13074 );
13075 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13076 let claim = retry.initial_inputs.first().unwrap();
13077 assert!(child
13078 .session_inbox_admission()
13079 .unwrap()
13080 .contains(&envelope.id));
13081 assert!(
13082 !retry
13083 .input_inbox
13084 .was_admitted(&child.id, &envelope.id)
13085 .await
13086 .unwrap(),
13087 "checkpoint is not startup confirmation"
13088 );
13089 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13090 confirmation.activation_run_id = "first-run".into();
13091 assert!(retry
13092 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13093 .await
13094 .is_err());
13095 assert!(!retry
13096 .input_inbox
13097 .was_admitted(&child.id, &envelope.id)
13098 .await
13099 .unwrap());
13100 confirmation.activation_run_id = "retry-run".into();
13101 retry
13102 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13103 .await
13104 .unwrap();
13105 let (tx, mut rx) = mpsc::channel(1);
13106 retry
13107 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13108 .await
13109 .unwrap();
13110 rx.recv().await.unwrap();
13111 retry
13112 .finish(ActorActivationFinish::Succeeded)
13113 .await
13114 .unwrap();
13115 let reopened = Arc::new(
13116 bamboo_storage::SessionStoreV2::new(temp.path().into())
13117 .await
13118 .unwrap(),
13119 );
13120 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13121 assert_eq!(cold.created_at, before.created_at);
13122 assert_eq!(cold.messages.len(), before.messages.len() + 2);
13123 assert_eq!(
13124 cold.messages[before.messages.len()].id,
13125 envelope.id.as_str()
13126 );
13127 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13128 assert_eq!(
13129 cold.messages
13130 .iter()
13131 .filter(|m| m.id == envelope.id.as_str())
13132 .count(),
13133 1
13134 );
13135 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13136 reopened,
13137 bamboo_domain::SessionInboxLimits::default(),
13138 );
13139 assert!(cold_inbox
13140 .was_admitted(&cold.id, &envelope.id)
13141 .await
13142 .unwrap());
13143 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13144 }
13145 }
13146
13147 #[tokio::test]
13148 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13149 for case in [
13150 "accepted",
13151 "actor-live",
13152 "claim-live",
13153 "legacy",
13154 "ack",
13155 "readonly",
13156 "prefix",
13157 "duplicate",
13158 "missing-marker",
13159 "mixed",
13160 "project",
13161 "birth",
13162 ] {
13163 let temp = tempfile::tempdir().unwrap();
13164 let store = Arc::new(
13165 bamboo_storage::SessionStoreV2::new(temp.path().into())
13166 .await
13167 .unwrap(),
13168 );
13169 let parent = Session::new("recovery-parent", "model");
13170 store.save_session(&parent).await.unwrap();
13171 let mut child =
13172 Session::new_child_of("recovery-child", &parent, "model", "original task");
13173 child.add_message(bamboo_agent_core::Message::user("original task"));
13174 store.save_session(&child).await.unwrap();
13175 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13176 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13177 let inbox = bamboo_storage::FileSessionInbox::new(
13178 store.clone(),
13179 bamboo_domain::SessionInboxLimits::default(),
13180 );
13181 let envelope =
13183 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13184 let receipt = inbox.deliver(&envelope).await.unwrap();
13185 inbox
13186 .mark_activation_eligible(
13187 &child.id,
13188 receipt.generation,
13189 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13190 )
13191 .await
13192 .unwrap();
13193 let now = chrono::Utc::now();
13194 let cutoff = now + chrono::Duration::seconds(10);
13195 let consumer = SessionInboxConsumerId::new();
13196 let old = store
13197 .claim_activation(&ActorActivationClaim {
13198 actor_id: child.id.clone(),
13199 run_id: "old-run".into(),
13200 lease_owner: consumer.as_str().into(),
13201 lease_expires_at: if case == "actor-live" {
13202 now + chrono::Duration::minutes(5)
13203 } else {
13204 cutoff
13205 },
13206 inbox_generation: 0,
13207 placement_ref: if case == "legacy" {
13208 None
13209 } else {
13210 Some(bamboo_domain::ActorPlacementRef {
13211 class: bamboo_domain::ActorPlacementClass::Local,
13212 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13213 slot_epoch: None,
13214 })
13215 },
13216 now,
13217 })
13218 .await
13219 .unwrap();
13220 store
13221 .start_activation(&old.fence(), chrono::Utc::now())
13222 .await
13223 .unwrap();
13224 let claim_now = chrono::Utc::now();
13225 let claim = inbox
13226 .claim_owned(
13227 &child.id,
13228 1,
13229 Some("old-run"),
13230 &SessionInboxLeaseRequest {
13231 consumer,
13232 now: claim_now,
13233 duration: if case == "claim-live" {
13234 chrono::Duration::minutes(5)
13235 } else {
13236 cutoff - claim_now
13237 },
13238 },
13239 )
13240 .await
13241 .unwrap()
13242 .pop()
13243 .unwrap();
13244 child = inbox
13245 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13246 fence: old.fence(),
13247 expected_created_at: child.created_at,
13248 expected_conversation_generation: child.conversation_generation_observation(),
13249 claim: claim.clone(),
13250 expected_messages: child.messages.clone(),
13251 expected_provider_transcript: child.provider_transcript.clone(),
13252 expected_admission: None,
13253 })
13254 .await
13255 .unwrap()
13256 .session;
13257 store
13259 .validate_fence(&old.fence(), chrono::Utc::now())
13260 .await
13261 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13262 let setup_leases = inbox
13263 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13264 .await
13265 .unwrap();
13266 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13267 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13268 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13269 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13270 let setup_at = chrono::Utc::now();
13271 assert!(
13272 setup_at < old.lease_expires_at,
13273 "{case}: setup Actor expired"
13274 );
13275 assert!(
13276 setup_at < claim.lease.expires_at,
13277 "{case}: setup claim expired"
13278 );
13279 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13280 if case == "ack" {
13281 inbox
13282 .ack_owned(&child.id, &claim, chrono::Utc::now())
13283 .await
13284 .unwrap();
13285 }
13286 if case == "mixed" {
13287 inbox
13288 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13289 &child.id,
13290 "different input",
13291 ))
13292 .await
13293 .unwrap();
13294 }
13295 let directory = store
13296 .bamboo_home_dir()
13297 .join(store.resolve_rel_path(&child.id).await.unwrap());
13298 let main = std::fs::read(directory.join("session.json")).unwrap();
13299 let canonical = child.clone();
13300 match case {
13301 "prefix" => child.messages[0].content.push_str(" stale"),
13302 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13303 "missing-marker" => {
13304 child
13305 .messages
13306 .last_mut()
13307 .unwrap()
13308 .metadata
13309 .as_mut()
13310 .unwrap()
13311 .as_object_mut()
13312 .unwrap()
13313 .remove("_bamboo_owned_input_checkpoint");
13314 }
13315 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13316 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13317 _ => {}
13318 }
13319 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13322 tokio::time::sleep(delay + Duration::from_millis(10)).await;
13323 let recovery_at = chrono::Utc::now();
13324 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13325 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13326 let original = before_recovery.activation.unwrap();
13327 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13328 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13329 assert_eq!(
13330 store
13331 .validate_fence(&old.fence(), recovery_at)
13332 .await
13333 .is_ok(),
13334 case == "actor-live",
13335 "{case}: Actor expiry control"
13336 );
13337 let recovery_leases = inbox
13338 .inspect_owned_leases(&child.id, 2, recovery_at)
13339 .await
13340 .unwrap();
13341 if case == "ack" {
13342 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13343 } else {
13344 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13345 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13346 assert_eq!(
13347 recovery_leases[0].expires_at, claim.lease.expires_at,
13348 "{case}"
13349 );
13350 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13351 }
13352 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13353 let second = Arc::new(
13354 bamboo_storage::SessionStoreV2::new(temp.path().into())
13355 .await
13356 .unwrap(),
13357 );
13358 let binding = actor_binding(
13359 second.clone(),
13360 Arc::new(bamboo_storage::FileSessionInbox::new(
13361 second.clone(),
13362 bamboo_domain::SessionInboxLimits::default(),
13363 )),
13364 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13365 );
13366 let _owner = binding
13367 .router
13368 .register_run(&child.id, "replacement-run")
13369 .await
13370 .unwrap();
13371 let prepared = PlainActorActivation::start_and_prepare(
13372 second.clone(),
13373 &mut child,
13374 &binding,
13375 Some("replacement-run"),
13376 Some(policy.clone()),
13377 case != "readonly",
13378 Some("probed-new-worker"),
13379 )
13380 .await;
13381 assert_eq!(
13382 std::fs::read(directory.join("session.json")).unwrap(),
13383 main,
13384 "{case}: Already or refusal never rewrites Main"
13385 );
13386 if case != "accepted" {
13387 assert!(prepared.is_err(), "{case}");
13388 assert_eq!(
13389 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13390 case == "ack"
13391 );
13392 continue;
13393 }
13394 let (replacement, delivery) = prepared.unwrap();
13395 assert!(replacement.recovering_pre_ack);
13396 let (messages, mut deliveries) = delivery.unwrap();
13397 assert_eq!(deliveries.len(), 1);
13398 let delivery = deliveries.pop().unwrap();
13399 let new_claim = replacement.initial_inputs.first().unwrap();
13400 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13401 assert_eq!(new_claim.claim.generation, claim.claim.generation);
13402 assert_eq!(
13403 new_claim.claim.activation_policy,
13404 claim.claim.activation_policy
13405 );
13406 assert!(new_claim.lease.epoch > claim.lease.epoch);
13407 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13408 assert_eq!(
13409 replacement.inbox_generation, 1,
13410 "old Actor generation was zero"
13411 );
13412 assert_eq!(
13413 second
13414 .inspect_actor(&child.id)
13415 .await
13416 .unwrap()
13417 .actor
13418 .current_attempt,
13419 2
13420 );
13421 assert!(second
13422 .validate_fence(&old.fence(), chrono::Utc::now())
13423 .await
13424 .is_err());
13425 assert!(inbox
13426 .ack_owned(&child.id, &claim, chrono::Utc::now())
13427 .await
13428 .is_err());
13429 let authority = second.inspect_actor(&child.id).await.unwrap();
13433 let leases = inbox
13434 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13435 .await
13436 .unwrap();
13437 let delayed = inbox
13438 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13439 fence: old.fence(),
13440 expected_created_at: child.created_at,
13441 expected_conversation_generation: child.conversation_generation_observation(),
13442 claim: claim.clone(),
13443 expected_messages: child.messages.clone(),
13444 expected_provider_transcript: child.provider_transcript.clone(),
13445 expected_admission: child.session_inbox_admission().cloned(),
13446 })
13447 .await;
13448 assert!(matches!(
13449 delayed,
13450 Err(bamboo_storage::ActorInputCheckpointError::Actor(
13451 bamboo_domain::ActorDirectoryError::StaleFence
13452 ))
13453 ));
13454 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13455 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13456 assert_eq!(
13457 inbox
13458 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13459 .await
13460 .unwrap(),
13461 leases
13462 );
13463 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13464 assert_eq!(
13465 serde_json::to_value(&child.messages).unwrap(),
13466 serde_json::to_value(&canonical.messages).unwrap()
13467 );
13468 let mut worker: Vec<bamboo_agent_core::Message> = messages
13469 .iter()
13470 .cloned()
13471 .map(|v| serde_json::from_value(v).unwrap())
13472 .collect();
13473 let target = worker.last_mut().unwrap();
13474 assert!(target
13475 .metadata
13476 .as_ref()
13477 .unwrap()
13478 .get("_bamboo_owned_input_checkpoint")
13479 .is_none());
13480 assert!(bamboo_domain::is_matching_session_message(
13481 target, &envelope
13482 ));
13483 let mut wanted = canonical.messages.last().unwrap().clone();
13484 wanted
13485 .metadata
13486 .as_mut()
13487 .unwrap()
13488 .as_object_mut()
13489 .unwrap()
13490 .remove("_bamboo_owned_input_checkpoint");
13491 assert_eq!(
13492 serde_json::to_value(target).unwrap(),
13493 serde_json::to_value(wanted).unwrap()
13494 );
13495 let run = RunSpec {
13496 assignment: "original task".into(),
13497 logical_session: Some(LogicalSessionIdentity {
13498 session_id: child.id.clone(),
13499 parent_session_id: child.parent_session_id.clone(),
13500 root_session_id: child.root_session_id.clone(),
13501 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13502 created_at: child.created_at,
13503 spawn_depth: child.spawn_depth,
13504 }),
13505 }),
13506 project_id: None,
13507 reasoning_effort: None,
13508 permission_policy: Some(PermissionPolicyContext {
13509 revision: policy.policy_revision(),
13510 requested_mode: "default".into(),
13511 effective_mode: "default".into(),
13512 bypass_permissions: false,
13513 auto_approve_permissions: false,
13514 session_id: child.id.clone(),
13515 workspace_path: child.workspace.clone(),
13516 environment_lease: None,
13517 inherit_session_grants: false,
13518 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13519 }),
13520 messages,
13521 activation_run_id: Some("replacement-run".into()),
13522 execution_epoch: 2,
13523 initial_session_messages: vec![delivery.clone()],
13524 secrets: Default::default(),
13525 };
13526 let policy_revision = policy.policy_revision();
13527 let mut expected = Some(expected_default_permission_posture(policy_revision));
13528 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13529 else {
13530 unreachable!()
13531 };
13532 let (events, mut rx) = mpsc::channel(1);
13533 process_actor_event(
13534 event,
13535 true,
13536 &mut PermissionPostureHandshake::Awaiting,
13537 expected.as_ref(),
13538 Some(&binding),
13539 &mut child,
13540 &events,
13541 &mut ActorEventDisplay::default(),
13542 )
13543 .await
13544 .unwrap();
13545 rx.recv().await.unwrap();
13546 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13547 &run,
13548 &delivery,
13549 uuid::Uuid::new_v4().to_string(),
13550 )
13551 .unwrap();
13552 replacement
13553 .release_initial_input(
13554 &binding,
13555 &child,
13556 &run,
13557 2,
13558 new_claim,
13559 &request,
13560 &mut expected,
13561 None,
13562 )
13563 .await
13564 .unwrap();
13565 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13566 replacement
13567 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13568 .await
13569 .unwrap();
13570 rx.recv().await.unwrap();
13571 replacement
13572 .finish(ActorActivationFinish::Succeeded)
13573 .await
13574 .unwrap();
13575 let cold = second.load_session(&child.id).await.unwrap().unwrap();
13576 assert_eq!(cold.created_at, canonical.created_at);
13577 assert_eq!(
13578 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13579 serde_json::to_value(&canonical.messages).unwrap()
13580 );
13581 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13582 assert_eq!(
13583 cold.messages
13584 .iter()
13585 .filter(|m| m.id == envelope.id.as_str())
13586 .count(),
13587 1
13588 );
13589 }
13590 }
13591
13592 #[cfg(unix)]
13593 #[tokio::test]
13594 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13595 use bamboo_subagent::provision::*;
13596 use std::os::unix::fs::PermissionsExt;
13597 let temp = tempfile::tempdir().unwrap();
13598 let worker = temp.path().join("mixed-worker");
13599 let spawned = temp.path().join("spawned");
13600 let run = temp.path().join("run");
13601 let probes = temp.path().join("probes");
13602 let fabric = temp.path().join("fabric");
13603 let mut report = WorkerCapabilityReport::current();
13604 report
13605 .capabilities
13606 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13607 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13608 let report = serde_json::to_string(&report).unwrap();
13609 std::fs::write(&worker, format!(
13610 "#!/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"
13611 )).unwrap();
13612 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13613 let args = vec![
13614 spawned.to_str().unwrap().into(),
13615 run.to_str().unwrap().into(),
13616 probes.to_str().unwrap().into(),
13617 ];
13618 for capability in [
13620 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13621 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13622 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13623 TYPED_READ_ONLY_WORKER_CAPABILITY,
13624 ] {
13625 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13626 .await
13627 .unwrap();
13628 }
13629 let runner = ActorChildRunner::new(
13630 "mixed".into(),
13631 worker,
13632 args,
13633 fabric.clone(),
13634 ExecutorSpec::BambooRuntime,
13635 vec![],
13636 "openai".into(),
13637 1,
13638 );
13639 let error = runner
13641 .require_initial_input_release_worker()
13642 .await
13643 .unwrap_err();
13644 assert!(error
13645 .to_string()
13646 .contains("owned initial release worker capability unconfirmed"));
13647 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13648 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13649 assert!(runner.pool.lock().await.is_empty());
13650 }
13651
13652 #[tokio::test]
13653 async fn ordinary_glob_only_observes_own_initialization_signals() {
13654 let temp = tempfile::tempdir().unwrap();
13655 let home = temp.path().canonicalize().unwrap();
13656 let store = bamboo_storage::SessionStoreV2::new(home.clone())
13657 .await
13658 .unwrap();
13659 let parent = Session::new("glob-standard-parent", "model");
13660 store.save_session(&parent).await.unwrap();
13661 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13662 store.save_session(&child).await.unwrap();
13663 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13664 let unrelated = directory.parent().unwrap().join("unrelated");
13667 std::fs::create_dir(&unrelated).unwrap();
13668 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13669 require_unowned_glob(&store, &child).await.unwrap();
13670 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13671 let path = directory.join(name);
13672 std::fs::write(&path, b"invalid prior signal").unwrap();
13673 assert!(require_unowned_glob(&store, &child).await.is_err());
13674 std::fs::remove_file(&path).unwrap();
13675 }
13676 #[cfg(unix)]
13677 {
13678 let marker = directory.join("actor-authority.initialized.json");
13679 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13680 assert!(require_unowned_glob(&store, &child).await.is_err());
13681 }
13682 }
13683
13684 #[test]
13685 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13686 let call = bamboo_domain::ToolCall {
13687 id: "glob".into(),
13688 tool_type: "function".into(),
13689 function: bamboo_domain::FunctionCall {
13690 name: "Glob".into(),
13691 arguments: r#"{"pattern":"marker.txt"}"#.into(),
13692 },
13693 };
13694 let messages = vec![
13695 bamboo_agent_core::Message::assistant("", Some(vec![call])),
13696 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13697 bamboo_agent_core::Message::assistant("done", None),
13698 ];
13699 let start = serde_json::to_value(AgentEvent::ToolStart {
13700 tool_call_id: "glob".into(),
13701 tool_name: "Glob".into(),
13702 arguments: serde_json::json!({"pattern":"marker.txt"}),
13703 })
13704 .unwrap();
13705 let complete = serde_json::to_value(AgentEvent::ToolComplete {
13706 tool_call_id: "glob".into(),
13707 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13708 })
13709 .unwrap();
13710 let tail =
13711 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13712 messages: messages
13713 .iter()
13714 .map(serde_json::to_value)
13715 .collect::<Result<_, _>>()
13716 .unwrap(),
13717 })
13718 .unwrap();
13719 let mut ordered = ReadOnlyActorCollector::default();
13720 assert!(ordered.event(&start).unwrap());
13721 assert!(ordered.event(&complete).unwrap());
13722 assert!(!ordered.event(&tail).unwrap());
13723 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13724 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13725 assert!(ReadOnlyActorCollector::default()
13726 .finish(Some("done"))
13727 .is_err());
13728 let mut unfinished = ReadOnlyActorCollector::default();
13729 unfinished.event(&start).unwrap();
13730 unfinished.event(&complete).unwrap();
13731 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13732 let mut closed = tail.clone();
13733 closed["grant"] = true.into();
13734 assert!(
13735 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13736 .is_err()
13737 );
13738 let mut oversized = tail.clone();
13739 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13740 assert!(
13741 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13742 .unwrap()
13743 .validate()
13744 .is_err()
13745 );
13746 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13747 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13748 let mut bad = start.clone();
13749 bad["tool_name"] = "Bash".into();
13750 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13751 let mut duplicate = ReadOnlyActorCollector::default();
13752 duplicate.event(&start).unwrap();
13753 assert!(duplicate.event(&start).is_err());
13754 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13755 content: "hidden".into(),
13756 })
13757 .unwrap();
13758 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13759 let mut mismatched = ReadOnlyActorCollector::default();
13760 mismatched.event(&start).unwrap();
13761 let mut bad = complete.clone();
13762 bad["tool_call_id"] = "foreign".into();
13763 assert!(mismatched.event(&bad).is_err());
13764 mismatched.event(&complete).unwrap();
13765 let mut changed = tail.clone();
13766 changed["messages"][1]["content"] = "fabricated".into();
13767 assert!(mismatched.event(&changed).is_err());
13768 }
13769
13770 #[cfg(unix)]
13771 #[tokio::test]
13772 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13773 let temp = tempfile::tempdir().unwrap();
13774 let store = Arc::new(
13775 bamboo_storage::SessionStoreV2::new(temp.path().into())
13776 .await
13777 .unwrap(),
13778 );
13779 let parent = Session::new("release-host-parent", "model");
13780 store.save_session(&parent).await.unwrap();
13781 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13782 store.save_session(&child).await.unwrap();
13783 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13784 store.clone(),
13785 bamboo_domain::SessionInboxLimits::default(),
13786 ));
13787 let binding = actor_binding(
13788 store.clone(),
13789 inbox,
13790 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13791 );
13792 let _owner = binding
13793 .router
13794 .register_run(&child.id, "release-host-run")
13795 .await
13796 .unwrap();
13797 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13798 let revision = policy.policy_revision();
13799 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13800 let activation = PlainActorActivation::start(
13801 store.clone(),
13802 &child,
13803 &binding,
13804 Some("release-host-run"),
13805 Some(policy.clone()),
13806 true,
13807 Some("probed-physical-worker"),
13808 )
13809 .await
13810 .unwrap();
13811 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13812 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13813 activation
13814 .input_inbox
13815 .mark_activation_eligible(
13816 &child.id,
13817 receipt.generation,
13818 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13819 )
13820 .await
13821 .unwrap();
13822 let claim = activation
13823 .claim_input(&binding, &child, "release-host-run")
13824 .await
13825 .unwrap()
13826 .unwrap();
13827 let (prefix, delivery) = activation
13828 .checkpoint_input(&mut child, "release-host-run", &claim)
13829 .await
13830 .unwrap();
13831 let mut expected = Some(expected_default_permission_posture(revision));
13832 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13833 unreachable!()
13834 };
13835 let (events, mut events_rx) = mpsc::channel(1);
13836 process_actor_event(
13837 event,
13838 true,
13839 &mut PermissionPostureHandshake::Awaiting,
13840 expected.as_ref(),
13841 Some(&binding),
13842 &mut child,
13843 &events,
13844 &mut ActorEventDisplay::default(),
13845 )
13846 .await
13847 .unwrap();
13848 events_rx.recv().await.unwrap();
13849 let run = RunSpec {
13850 assignment: "assignment".into(),
13851 logical_session: Some(LogicalSessionIdentity {
13852 session_id: child.id.clone(),
13853 parent_session_id: child.parent_session_id.clone(),
13854 root_session_id: child.root_session_id.clone(),
13855 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13856 created_at: child.created_at,
13857 spawn_depth: child.spawn_depth,
13858 }),
13859 }),
13860 project_id: None,
13861 reasoning_effort: None,
13862 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13863 revision,
13864 requested_mode: "default".into(),
13865 effective_mode: "default".into(),
13866 bypass_permissions: false,
13867 auto_approve_permissions: false,
13868 session_id: child.id.clone(),
13869 workspace_path: child.workspace.clone(),
13870 environment_lease: None,
13871 inherit_session_grants: false,
13872 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13873 }),
13874 messages: prefix,
13875 activation_run_id: Some("release-host-run".into()),
13876 execution_epoch: 9,
13877 initial_session_messages: vec![delivery.clone()],
13878 secrets: Default::default(),
13879 };
13880 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13881 &run,
13882 &delivery,
13883 uuid::Uuid::new_v4().to_string(),
13884 )
13885 .unwrap();
13886 let mut stale = child.clone();
13887 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13888 assert!(activation
13889 .release_initial_input(
13890 &binding,
13891 &stale,
13892 &run,
13893 9,
13894 &claim,
13895 &request,
13896 &mut expected,
13897 None
13898 )
13899 .await
13900 .is_err());
13901 assert!(!activation
13902 .input_inbox
13903 .was_admitted(&child.id, &envelope.id)
13904 .await
13905 .unwrap());
13906 use std::os::unix::fs::PermissionsExt;
13909 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13910 impl Drop for RestorePermissions {
13911 fn drop(&mut self) {
13912 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13913 }
13914 }
13915 let admitted = store
13916 .bamboo_home_dir()
13917 .join(store.resolve_rel_path(&child.id).await.unwrap())
13918 .join("inbox/admitted");
13919 std::fs::create_dir_all(&admitted).unwrap();
13920 let restore = RestorePermissions(
13921 admitted.clone(),
13922 std::fs::metadata(&admitted).unwrap().permissions(),
13923 );
13924 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13925 let verified = activation
13926 .input_inbox
13927 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13928 fence: activation.fence.clone(),
13929 expected_created_at: child.created_at,
13930 expected_conversation_generation: child.conversation_generation_observation(),
13931 claim: claim.clone(),
13932 expected_messages: child.messages.clone(),
13933 expected_provider_transcript: child.provider_transcript.clone(),
13934 expected_admission: child.session_inbox_admission().cloned(),
13935 })
13936 .await
13937 .unwrap();
13938 assert_eq!(
13939 verified.status,
13940 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13941 );
13942 let error = activation
13943 .release_initial_input(
13944 &binding,
13945 &child,
13946 &run,
13947 9,
13948 &claim,
13949 &request,
13950 &mut expected,
13951 None,
13952 )
13953 .await
13954 .unwrap_err();
13955 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13956 drop(restore);
13957 assert!(
13958 error.to_string().contains("ACK unresolved"),
13959 "actual release error (<=512B): {}",
13960 error.to_string().chars().take(128).collect::<String>()
13961 );
13962 let release = activation
13963 .release_initial_input(
13964 &binding,
13965 &child,
13966 &run,
13967 9,
13968 &claim,
13969 &request,
13970 &mut expected,
13971 None,
13972 )
13973 .await
13974 .unwrap();
13975 assert!(activation
13976 .input_inbox
13977 .was_admitted(&child.id, &envelope.id)
13978 .await
13979 .unwrap());
13980 assert_eq!(
13981 release.expires_at,
13982 activation
13983 .actor_deadline
13984 .min(claim.lease.expires_at)
13985 .min(activation.input_deadline)
13986 );
13987 assert_eq!(
13988 activation
13989 .release_initial_input(
13990 &binding,
13991 &child,
13992 &run,
13993 9,
13994 &claim,
13995 &request,
13996 &mut expected,
13997 Some(&release)
13998 )
13999 .await
14000 .unwrap(),
14001 release
14002 );
14003 let mut wrong = request.clone();
14004 wrong.nonce = uuid::Uuid::new_v4().to_string();
14005 assert!(activation
14006 .release_initial_input(
14007 &binding,
14008 &child,
14009 &run,
14010 9,
14011 &claim,
14012 &wrong,
14013 &mut expected,
14014 Some(&release)
14015 )
14016 .await
14017 .is_err());
14018 assert!(activation
14019 .release_initial_input(
14020 &binding,
14021 &child,
14022 &run,
14023 10,
14024 &claim,
14025 &request,
14026 &mut expected,
14027 Some(&release)
14028 )
14029 .await
14030 .is_err());
14031 let deadlines = (
14033 activation.fence.clone(),
14034 activation.actor_deadline,
14035 activation.input_deadline,
14036 );
14037 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14038 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14039 activation
14040 .input_inbox
14041 .mark_activation_eligible(
14042 &child.id,
14043 receipt.generation,
14044 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14045 )
14046 .await
14047 .unwrap();
14048 let mut link = ConfirmationSequenceLink {
14049 frames: VecDeque::new(),
14050 sent: Vec::new(),
14051 };
14052 let epochs = AtomicU64::new(9);
14053 let (claim2, epoch2) = activation
14054 .continue_input(
14055 &mut link,
14056 &binding,
14057 &mut child,
14058 "release-host-run",
14059 Some((&run, &epochs)),
14060 &mut expected,
14061 "SECOND_REPLY".into(),
14062 &events,
14063 )
14064 .await
14065 .unwrap()
14066 .unwrap();
14067 events_rx.recv().await.unwrap();
14068 assert_eq!(epoch2, 10);
14069 let ParentFrame::Run(next) = &link.sent[0] else {
14070 panic!("second correlated Run")
14071 };
14072 assert_eq!(next.logical_session, run.logical_session);
14073 assert_eq!(next.activation_run_id, run.activation_run_id);
14074 assert_eq!(next.initial_session_messages.len(), 1);
14075 assert!(next
14076 .messages
14077 .iter()
14078 .any(|m| m["id"] == envelope.id.as_str()));
14079 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14080 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14081 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14082 next,
14083 &next.initial_session_messages[0],
14084 uuid::Uuid::new_v4().to_string(),
14085 )
14086 .unwrap();
14087 assert_ne!(request2.nonce, request.nonce);
14088 macro_rules! release_second {
14089 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14090 activation
14091 .release_initial_input(
14092 &binding,
14093 $candidate,
14094 next,
14095 $epoch,
14096 &claim2,
14097 $request,
14098 &mut expected,
14099 $previous,
14100 )
14101 .await
14102 };
14103 }
14104 assert!(release_second!(&child, &request, epoch2, None).is_err());
14105 assert!(release_second!(&child, &request2, 9, None).is_err());
14106 let mut stale2 = child.clone();
14107 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14108 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14109 assert!(!activation
14110 .input_inbox
14111 .was_admitted(&child.id, &second.id)
14112 .await
14113 .unwrap());
14114 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14115 assert_eq!(
14116 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14117 release2
14118 );
14119 let mut wrong2 = request2.clone();
14120 wrong2.nonce = request.nonce.clone();
14121 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14122 assert!(activation
14123 .input_inbox
14124 .was_admitted(&child.id, &second.id)
14125 .await
14126 .unwrap());
14127 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14128 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14129 activation
14130 .input_inbox
14131 .mark_activation_eligible(
14132 &child.id,
14133 receipt.generation,
14134 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14135 )
14136 .await
14137 .unwrap();
14138 let now = chrono::Utc::now();
14139 let expired_claim = activation
14140 .input_inbox
14141 .claim_owned(
14142 &child.id,
14143 1,
14144 Some("release-host-run"),
14145 &SessionInboxLeaseRequest {
14146 consumer: activation.input_consumer.clone(),
14147 now,
14148 duration: chrono::Duration::seconds(1),
14149 },
14150 )
14151 .await
14152 .unwrap()
14153 .pop()
14154 .unwrap();
14155 let (prefix, delivery3) = activation
14156 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14157 .await
14158 .unwrap();
14159 let mut expired_run = next.clone();
14160 expired_run.execution_epoch = 11;
14161 expired_run.messages = prefix;
14162 expired_run.initial_session_messages = vec![delivery3.clone()];
14163 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14164 &expired_run,
14165 &delivery3,
14166 uuid::Uuid::new_v4().to_string(),
14167 )
14168 .unwrap();
14169 tokio::time::sleep(
14170 (expired_claim.lease.expires_at - chrono::Utc::now())
14171 .to_std()
14172 .unwrap_or_default(),
14173 )
14174 .await;
14175 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14176 assert!(activation
14177 .release_initial_input(
14178 &binding,
14179 &child,
14180 &expired_run,
14181 11,
14182 &expired_claim,
14183 &expired_request,
14184 &mut expected,
14185 None
14186 )
14187 .await
14188 .is_err());
14189 assert!(!activation
14190 .input_inbox
14191 .was_admitted(&child.id, &expired.id)
14192 .await
14193 .unwrap());
14194 assert_eq!(
14195 (
14196 activation.fence.clone(),
14197 activation.actor_deadline,
14198 activation.input_deadline
14199 ),
14200 deadlines
14201 );
14202 assert_eq!(
14203 link.sent.len(),
14204 1,
14205 "no dispatch from rejected release checks"
14206 );
14207 let cold = store.load_session(&child.id).await.unwrap().unwrap();
14208 for id in [&envelope.id, &second.id] {
14209 assert_eq!(
14210 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14211 1
14212 );
14213 assert!(cold.session_inbox_admission().unwrap().contains(id));
14214 }
14215 policy.deny_scoped_session_permission(
14217 &child.id,
14218 bamboo_tools::permission::PermissionType::ExecuteCommand,
14219 "blocked-action",
14220 );
14221 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14222 assert!(activation
14223 .input_inbox
14224 .was_admitted(&child.id, &second.id)
14225 .await
14226 .unwrap());
14227 activation
14228 .finish(ActorActivationFinish::Failed)
14229 .await
14230 .unwrap();
14231 activation
14233 .input_inbox
14234 .ack_owned(&child.id, &claim, chrono::Utc::now())
14235 .await
14236 .unwrap();
14237 assert!(activation
14238 .release_initial_input(
14239 &binding,
14240 &child,
14241 &run,
14242 9,
14243 &claim,
14244 &request,
14245 &mut expected,
14246 Some(&release)
14247 )
14248 .await
14249 .is_err());
14250 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14251 .await
14252 .unwrap()
14253 .load_session(&child.id)
14254 .await
14255 .unwrap()
14256 .unwrap();
14257 assert_eq!(
14258 cold.messages
14259 .iter()
14260 .filter(|m| m.id == envelope.id.as_str())
14261 .count(),
14262 1
14263 );
14264 }
14265
14266 #[tokio::test]
14267 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14268 for reject_checkpoint in [false, true] {
14269 let temp = tempfile::tempdir().unwrap();
14270 let store = Arc::new(
14271 bamboo_storage::SessionStoreV2::new(temp.path().into())
14272 .await
14273 .unwrap(),
14274 );
14275 let parent = Session::new("owned-input-parent", "model");
14276 store.save_session(&parent).await.unwrap();
14277 let mut child =
14278 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14279 store.save_session(&child).await.unwrap();
14280 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14281 let raw_storage: Arc<dyn Storage> = store.clone();
14282 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14283 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14284 store.clone(),
14285 bamboo_domain::SessionInboxLimits::default(),
14286 ));
14287 let binding = actor_binding(store.clone(), inbox, locked);
14288 let run_id = "owned-input-run";
14289 let _registration = binding
14290 .router
14291 .register_run(&child.id, run_id)
14292 .await
14293 .unwrap();
14294 assert!(PlainActorActivation::start(
14295 store.clone(),
14296 &child,
14297 &binding,
14298 Some(run_id),
14299 None,
14300 true,
14301 None,
14302 )
14303 .await
14304 .is_err());
14305 assert!(PlainActorActivation::start(
14306 store.clone(),
14307 &child,
14308 &binding,
14309 Some(run_id),
14310 Some(policy.clone()),
14311 true,
14312 None,
14313 )
14314 .await
14315 .is_err());
14316 assert_eq!(
14317 store
14318 .inspect_actor(&child.id)
14319 .await
14320 .unwrap()
14321 .actor
14322 .current_attempt,
14323 0
14324 );
14325 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14326 let activation = PlainActorActivation::start(
14327 store.clone(),
14328 &child,
14329 &binding,
14330 Some(run_id),
14331 Some(policy.clone()),
14332 true,
14333 None,
14334 )
14335 .await
14336 .unwrap();
14337 let envelope =
14338 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14339 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14340 activation
14341 .input_inbox
14342 .mark_activation_eligible(
14343 &child.id,
14344 receipt.generation,
14345 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14346 )
14347 .await
14348 .unwrap();
14349 let mut link = ConfirmationSequenceLink {
14350 frames: VecDeque::new(),
14351 sent: Vec::new(),
14352 };
14353 let policy_revision = policy.policy_revision();
14354 let run = RunSpec {
14355 assignment: "one plain task".into(),
14356 logical_session: None,
14357 project_id: None,
14358 reasoning_effort: None,
14359 permission_policy: Some(PermissionPolicyContext {
14360 revision: policy_revision,
14361 requested_mode: "default".into(),
14362 effective_mode: "default".into(),
14363 bypass_permissions: false,
14364 auto_approve_permissions: false,
14365 session_id: child.id.clone(),
14366 workspace_path: child.workspace.clone(),
14367 environment_lease: None,
14368 inherit_session_grants: false,
14369 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14370 }),
14371 messages: vec![],
14372 activation_run_id: Some(run_id.into()),
14373 execution_epoch: 1,
14374 initial_session_messages: vec![],
14375 secrets: Default::default(),
14376 };
14377 let epochs = AtomicU64::new(1);
14378 let (tx, mut rx) = mpsc::channel(1);
14379 let mut expected = Some(expected_default_permission_posture(policy_revision));
14380 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14381 else {
14382 unreachable!()
14383 };
14384 process_actor_event(
14385 event,
14386 true,
14387 &mut PermissionPostureHandshake::Awaiting,
14388 expected.as_ref(),
14389 Some(&binding),
14390 &mut child,
14391 &tx,
14392 &mut ActorEventDisplay::default(),
14393 )
14394 .await
14395 .unwrap();
14396 rx.recv().await.unwrap();
14397 let first_audit =
14398 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14399 .unwrap()
14400 .audit_revision;
14401 if reject_checkpoint {
14402 tx.send(AgentEvent::Complete {
14405 usage: Default::default(),
14406 })
14407 .await
14408 .unwrap();
14409 }
14410 let mut operation = activation.continue_input(
14411 &mut link,
14412 &binding,
14413 &mut child,
14414 run_id,
14415 Some((&run, &epochs)),
14416 &mut expected,
14417 "FIRST_PLAIN_REPLY".into(),
14418 &tx,
14419 );
14420 if reject_checkpoint {
14421 tokio::select! {
14422 result = &mut operation => panic!("publication barrier released early: {result:?}"),
14423 _ = async {
14424 tokio::time::timeout(Duration::from_secs(5), async {
14425 loop {
14426 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14427 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14428 tokio::task::yield_now().await;
14429 }
14430 }).await.unwrap();
14431 activation.finish(ActorActivationFinish::Failed).await.unwrap();
14432 rx.recv().await.unwrap();
14433 } => {}
14434 }
14435 }
14436 let result = operation.await;
14437 if reject_checkpoint {
14438 assert!(result
14439 .unwrap_err()
14440 .to_string()
14441 .contains("checkpoint unconfirmed"));
14442 assert!(
14443 link.sent.is_empty(),
14444 "zero worker/provider correction dispatch"
14445 );
14446 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14447 assert!(durable
14448 .messages
14449 .iter()
14450 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14451 assert!(!durable
14452 .messages
14453 .iter()
14454 .any(|m| m.id == envelope.id.as_str()));
14455 assert!(!activation
14456 .input_inbox
14457 .was_admitted(&child.id, &envelope.id)
14458 .await
14459 .unwrap());
14460 assert_eq!(
14461 activation
14462 .input_inbox
14463 .inspect(&child.id)
14464 .await
14465 .unwrap()
14466 .claimed,
14467 1
14468 );
14469 continue;
14470 }
14471 let (claim, epoch) = result.unwrap().unwrap();
14472 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
14474 assert_eq!(link.sent.len(), 1);
14475 let ParentFrame::Run(next) = &link.sent[0] else {
14476 panic!("correlated continuation Run")
14477 };
14478 assert_eq!(next.execution_epoch, 2);
14479 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14480 assert!(next
14481 .messages
14482 .iter()
14483 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14484 assert!(!next
14485 .messages
14486 .iter()
14487 .any(|m| m["id"] == envelope.id.as_str()));
14488 let delivery = &next.initial_session_messages[0];
14489 assert_eq!(delivery.envelope, envelope);
14490 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14491 assert!(durable
14492 .session_inbox_admission()
14493 .unwrap()
14494 .contains(&envelope.id));
14495 assert_eq!(
14496 durable
14497 .messages
14498 .iter()
14499 .filter(|m| m.id == envelope.id.as_str())
14500 .count(),
14501 1
14502 );
14503 assert!(
14504 !activation
14505 .input_inbox
14506 .was_admitted(&child.id, &envelope.id)
14507 .await
14508 .unwrap(),
14509 "checkpoint is not a worker admission receipt"
14510 );
14511 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14512 confirmation.canonical_claim_generation += 1;
14513 assert!(activation
14514 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14515 .await
14516 .is_err());
14517 assert!(!activation
14518 .input_inbox
14519 .was_admitted(&child.id, &envelope.id)
14520 .await
14521 .unwrap());
14522 confirmation.canonical_claim_generation = claim.claim.generation;
14523 activation
14524 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14525 .await
14526 .unwrap();
14527 assert!(activation
14528 .input_inbox
14529 .was_admitted(&child.id, &envelope.id)
14530 .await
14531 .unwrap());
14532 assert_eq!(
14533 expected.as_ref().unwrap().expected_audit_revision,
14534 Some(first_audit)
14535 );
14536 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14537 else {
14538 unreachable!()
14539 };
14540 process_actor_event(
14541 event,
14542 true,
14543 &mut PermissionPostureHandshake::Awaiting,
14544 expected.as_ref(),
14545 Some(&binding),
14546 &mut child,
14547 &tx,
14548 &mut ActorEventDisplay::default(),
14549 )
14550 .await
14551 .unwrap();
14552 rx.recv().await.unwrap();
14553 assert!(
14554 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14555 .unwrap()
14556 .audit_revision
14557 > first_audit
14558 );
14559 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14560 .await
14561 .unwrap();
14562 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14563 assert_eq!(
14564 cold.messages
14565 .iter()
14566 .filter(|m| m.id == envelope.id.as_str())
14567 .count(),
14568 1
14569 );
14570 assert!(cold
14571 .session_inbox_admission()
14572 .unwrap()
14573 .contains(&envelope.id));
14574 }
14575 }
14576
14577 fn admission_confirmation(
14578 session_id: &str,
14579 claim: &SessionInboxClaim,
14580 run_id: &str,
14581 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14582 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14583 target_session_id: session_id.to_string(),
14584 envelope_id: claim.envelope.id.to_string(),
14585 canonical_claim_generation: claim.generation,
14586 activation_run_id: run_id.to_string(),
14587 }
14588 }
14589
14590 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14591 ExpectedPermissionPosture {
14592 policy_revision,
14593 resolution: bamboo_domain::PermissionModeResolution {
14594 requested: bamboo_domain::SessionPermissionMode::Default,
14595 effective: bamboo_domain::PermissionMode::Default,
14596 },
14597 expected_audit_revision: None,
14598 executor_mapping: "test_actor:permission_mode=default".to_string(),
14599 }
14600 }
14601
14602 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14603 ChildFrame::Event {
14604 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14605 session_id: session_id.to_string(),
14606 policy_revision,
14607 requested_mode: "default".to_string(),
14608 effective_mode: "default".to_string(),
14609 executor_mapping: "test_actor:permission_mode=default".to_string(),
14610 })
14611 .expect("serialize permission posture event"),
14612 }
14613 }
14614
14615 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14616 ChildFrame::Event {
14617 event: serde_json::to_value(event).expect("serialize actor event"),
14618 }
14619 }
14620
14621 #[test]
14622 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14623 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14624 let mut batch = ActorEventBatch {
14625 logical_session: Some(LogicalSessionIdentity {
14626 creation: None,
14627 session_id: session.id.clone(),
14628 parent_session_id: session.parent_session_id.clone(),
14629 root_session_id: session.root_session_id.clone(),
14630 }),
14631 activation_id: Some("activation-7".into()),
14632 execution_epoch: 11,
14633 source_node_id: Some("node-a".into()),
14634 source_actor_id: Some("worker-a".into()),
14635 first_seq: 1,
14636 last_seq: 1,
14637 qos: bamboo_subagent::ActorEventQos::Ephemeral,
14638 events: vec![serde_json::json!({"type":"token","content":"ok"})],
14639 };
14640 validate_actor_event_batch(
14641 &batch,
14642 &session,
14643 "logical-parent",
14644 Some("activation-7"),
14645 11,
14646 "worker-a",
14647 None,
14648 )
14649 .unwrap();
14650
14651 let creation = bamboo_subagent::proto::ChildCreationIdentity {
14652 created_at: session.created_at,
14653 spawn_depth: session.spawn_depth,
14654 };
14655 assert!(validate_actor_event_batch(
14656 &batch,
14657 &session,
14658 "logical-parent",
14659 Some("activation-7"),
14660 11,
14661 "worker-a",
14662 Some(&creation)
14663 )
14664 .is_err());
14665 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14666 validate_actor_event_batch(
14667 &batch,
14668 &session,
14669 "logical-parent",
14670 Some("activation-7"),
14671 11,
14672 "worker-a",
14673 Some(&creation),
14674 )
14675 .unwrap();
14676 batch
14677 .logical_session
14678 .as_mut()
14679 .unwrap()
14680 .creation
14681 .as_mut()
14682 .unwrap()
14683 .created_at += chrono::Duration::nanoseconds(1);
14684 assert!(validate_actor_event_batch(
14685 &batch,
14686 &session,
14687 "logical-parent",
14688 Some("activation-7"),
14689 11,
14690 "worker-a",
14691 Some(&creation)
14692 )
14693 .is_err());
14694 batch.logical_session.as_mut().unwrap().creation = None;
14695
14696 batch.execution_epoch = 10;
14697 assert!(validate_actor_event_batch(
14698 &batch,
14699 &session,
14700 "logical-parent",
14701 Some("activation-7"),
14702 11,
14703 "worker-a",
14704 None,
14705 )
14706 .unwrap_err()
14707 .to_string()
14708 .contains("execution epoch"));
14709 batch.execution_epoch = 11;
14710 batch.activation_id = Some("replaced-activation".into());
14711 assert!(validate_actor_event_batch(
14712 &batch,
14713 &session,
14714 "logical-parent",
14715 Some("activation-7"),
14716 11,
14717 "worker-a",
14718 None,
14719 )
14720 .unwrap_err()
14721 .to_string()
14722 .contains("stale activation"));
14723 batch.activation_id = Some("activation-7".into());
14724 batch
14725 .logical_session
14726 .as_mut()
14727 .expect("logical identity")
14728 .session_id = "foreign-child".into();
14729 assert!(validate_actor_event_batch(
14730 &batch,
14731 &session,
14732 "logical-parent",
14733 Some("activation-7"),
14734 11,
14735 "worker-a",
14736 None,
14737 )
14738 .unwrap_err()
14739 .to_string()
14740 .contains("different logical session"));
14741 batch
14742 .logical_session
14743 .as_mut()
14744 .expect("logical identity")
14745 .session_id = session.id.clone();
14746 batch.source_actor_id = Some("worker-b".into());
14747 assert!(validate_actor_event_batch(
14748 &batch,
14749 &session,
14750 "logical-parent",
14751 Some("activation-7"),
14752 11,
14753 "worker-a",
14754 None,
14755 )
14756 .unwrap_err()
14757 .to_string()
14758 .contains("different physical actor"));
14759 }
14760
14761 #[tokio::test]
14762 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14763 use bamboo_domain::Storage;
14764
14765 let temp = tempfile::tempdir().unwrap();
14766 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14767 .await
14768 .unwrap();
14769 let parent = Session::new("event-parent", "model");
14770 let child = Session::new_child_of("event-child", &parent, "model", "task");
14771 store.save_session(&parent).await.unwrap();
14772 store.save_session(&child).await.unwrap();
14773 store.ensure_actor(&child.id).await.unwrap();
14774 let now = chrono::Utc::now();
14775 let claim = |run_id: &str| ActorActivationClaim {
14776 actor_id: child.id.clone(),
14777 run_id: run_id.into(),
14778 lease_owner: "event-owner".into(),
14779 lease_expires_at: now + chrono::Duration::minutes(2),
14780 inbox_generation: 0,
14781 placement_ref: None,
14782 now,
14783 };
14784 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14785 store.start_activation(&first.fence(), now).await.unwrap();
14786 let first_fence = first.fence();
14787 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14788 .await
14789 .unwrap();
14790 validate_directory_actor_event(&store, &first_fence, &child, None)
14791 .await
14792 .unwrap(); assert!(
14794 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14795 .await
14796 .is_err()
14797 );
14798 let mut forged = child.clone();
14799 forged.root_session_id = "foreign-root".into();
14800 assert!(
14801 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14802 .await
14803 .is_err()
14804 );
14805
14806 store
14807 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14808 .await
14809 .unwrap();
14810 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14811 store.start_activation(&second.fence(), now).await.unwrap();
14812 assert!(
14813 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14814 .await
14815 .is_err()
14816 );
14817 assert!(
14818 validate_directory_actor_event(&store, &first_fence, &child, None)
14819 .await
14820 .is_err()
14821 );
14822 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14823 .await
14824 .unwrap();
14825 }
14826
14827 fn completed_actor_frame() -> ChildFrame {
14828 ChildFrame::Terminal {
14829 status: TerminalStatus::Completed,
14830 result: Some("done".to_string()),
14831 error: None,
14832 final_event_watermark: None,
14833 transcript: Vec::new(),
14834 }
14835 }
14836
14837 async fn drive_permission_handshake_frames(
14838 session_id: &str,
14839 frames: impl IntoIterator<Item = ChildFrame>,
14840 expected: ExpectedPermissionPosture,
14841 ) -> (
14842 crate::runtime::runner::Result<Option<String>>,
14843 Session,
14844 Vec<AgentEvent>,
14845 Vec<ParentFrame>,
14846 ) {
14847 let mut link = ConfirmationSequenceLink {
14848 frames: frames.into_iter().collect(),
14849 sent: Vec::new(),
14850 };
14851 let (event_tx, mut event_rx) = mpsc::channel(128);
14852 let cancel = CancellationToken::new();
14853 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14854 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14855 let mut session = Session::new(session_id, "model");
14856 let result = drive(ActorDriveContext {
14857 expected_creation: None,
14858 client: &mut link,
14859 parent_session_id: "permission-parent",
14860 child_session_id: session_id,
14861 child_attempt: 0,
14862 approval_registry: None,
14863 approval_decider: None,
14864 approval_reviewer: None,
14865 escalation_bridge: None,
14866 event_tx: &event_tx,
14867 cancel_token: &cancel,
14868 live_rx: &mut live_rx,
14869 delivery_rx: &mut delivery_rx,
14870 logical_session: &mut session,
14871 expected_permission_posture: Some(expected),
14872 session_inbox_runtime: None,
14873 actor_directory_store: None,
14874 canonical_subagent_tool: None,
14875 ticket_service: None,
14876 activation_run_id: None,
14877 execution_epoch: 0,
14878 expected_source_actor_id: session_id,
14879 initial_inflight_claims: VecDeque::new(),
14880 plain_actor: false,
14881 remote_environment_lease: false,
14882 readonly_output: None,
14883 local_history_tools: None,
14884 local_history_read_only: false,
14885 strict_history_output: None,
14886 plain_input: None,
14887 canonical_activation: None,
14888 canonical_placement_ref: None,
14889 actor_event_observer: None,
14890 plain_run: None,
14891 first_frame_timeout: Some(Duration::from_secs(1)),
14892 })
14893 .await;
14894 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14895 (result, session, events, link.sent)
14896 }
14897
14898 #[tokio::test]
14899 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14900 let session_id = "permission-missing";
14901 let (result, session, events, _) = drive_permission_handshake_frames(
14902 session_id,
14903 [completed_actor_frame()],
14904 expected_default_permission_posture(7),
14905 )
14906 .await;
14907
14908 assert!(result
14909 .unwrap_err()
14910 .to_string()
14911 .contains("terminated before permission posture confirmation"));
14912 assert!(events.is_empty());
14913 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14914 }
14915
14916 #[tokio::test]
14917 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14918 struct TerminalProofProbe {
14919 frame: Option<ChildFrame>,
14920 accepted: bool,
14921 }
14922
14923 #[async_trait]
14924 impl bamboo_subagent::ChildLink for TerminalProofProbe {
14925 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14926 Ok(())
14927 }
14928
14929 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14930 Ok(self.frame.take())
14931 }
14932
14933 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14934 self.accepted = status == TerminalStatus::Error;
14935 }
14936
14937 fn has_pending_durable_terminal(&self) -> bool {
14938 self.accepted
14939 }
14940 }
14941
14942 for (status, error, expected_proof) in [
14943 (
14944 TerminalStatus::Error,
14945 Some("remote_environment_checkout_not_clean"),
14946 true,
14947 ),
14948 (TerminalStatus::Error, Some("untrusted worker error"), false),
14949 (
14950 TerminalStatus::Cancelled,
14951 Some("remote_environment_checkout_not_clean"),
14952 false,
14953 ),
14954 ] {
14955 let mut link = TerminalProofProbe {
14956 frame: Some(ChildFrame::Terminal {
14957 status,
14958 result: None,
14959 error: error.map(str::to_owned),
14960 final_event_watermark: None,
14961 transcript: Vec::new(),
14962 }),
14963 accepted: false,
14964 };
14965 let (event_tx, _event_rx) = mpsc::channel(8);
14966 let cancel = CancellationToken::new();
14967 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14968 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14969 let mut session = Session::new("remote-terminal-proof", "model");
14970 let result = drive(ActorDriveContext {
14971 client: &mut link,
14972 parent_session_id: "proof-parent",
14973 child_session_id: "remote-terminal-proof",
14974 child_attempt: 0,
14975 approval_registry: None,
14976 approval_decider: None,
14977 approval_reviewer: None,
14978 escalation_bridge: None,
14979 event_tx: &event_tx,
14980 cancel_token: &cancel,
14981 live_rx: &mut live_rx,
14982 delivery_rx: &mut delivery_rx,
14983 logical_session: &mut session,
14984 expected_permission_posture: Some(expected_default_permission_posture(7)),
14985 expected_creation: None,
14986 session_inbox_runtime: None,
14987 actor_directory_store: None,
14988 canonical_subagent_tool: None,
14989 ticket_service: None,
14990 activation_run_id: None,
14991 execution_epoch: 0,
14992 expected_source_actor_id: "remote-terminal-proof",
14993 initial_inflight_claims: VecDeque::new(),
14994 plain_actor: false,
14995 remote_environment_lease: true,
14996 readonly_output: None,
14997 local_history_tools: None,
14998 local_history_read_only: false,
14999 strict_history_output: None,
15000 plain_input: None,
15001 canonical_activation: None,
15002 canonical_placement_ref: None,
15003 actor_event_observer: None,
15004 plain_run: None,
15005 first_frame_timeout: Some(Duration::from_secs(1)),
15006 })
15007 .await;
15008 assert!(result.is_err());
15009 assert_eq!(
15010 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15011 expected_proof
15012 );
15013 }
15014 }
15015
15016 #[tokio::test(start_paused = true)]
15017 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
15018 struct CancelOutcomeLink {
15019 cancel_sent: bool,
15020 settle: bool,
15021 accepted: bool,
15022 }
15023
15024 #[async_trait]
15025 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
15026 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15027 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
15028 Ok(())
15029 }
15030
15031 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15032 if !self.cancel_sent || !self.settle {
15033 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15034 .await
15035 } else {
15036 self.cancel_sent = false;
15037 Ok(Some(ChildFrame::Terminal {
15038 status: TerminalStatus::Cancelled,
15039 result: None,
15040 error: None,
15041 final_event_watermark: None,
15042 transcript: Vec::new(),
15043 }))
15044 }
15045 }
15046
15047 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15048 self.accepted = status == TerminalStatus::Cancelled;
15049 }
15050
15051 fn has_pending_durable_terminal(&self) -> bool {
15052 self.accepted
15053 }
15054 }
15055
15056 let temp = tempfile::tempdir().unwrap();
15057 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15058 .await
15059 .unwrap();
15060 let fence = ActorActivationFence {
15061 schema_version: 2,
15062 actor_id: "cancelled-remote-child".into(),
15063 activation_id: "cancelled-remote-activation".into(),
15064 attempt: 1,
15065 run_id: "cancelled-remote-run".into(),
15066 lease_owner: "cancelled-remote-host".into(),
15067 lease_epoch: 1,
15068 };
15069 for settle in [true, false] {
15070 let mut link = CancelOutcomeLink {
15071 cancel_sent: false,
15072 settle,
15073 accepted: false,
15074 };
15075 let (event_tx, _event_rx) = mpsc::channel(8);
15076 let cancel = CancellationToken::new();
15077 cancel.cancel();
15078 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15079 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15080 let mut session = Session::new("cancelled-remote-child", "model");
15081 let result = drive(ActorDriveContext {
15082 expected_creation: None,
15083 client: &mut link,
15084 parent_session_id: "cancelled-remote-parent",
15085 child_session_id: "cancelled-remote-child",
15086 child_attempt: 1,
15087 approval_registry: None,
15088 approval_decider: None,
15089 approval_reviewer: None,
15090 escalation_bridge: None,
15091 event_tx: &event_tx,
15092 cancel_token: &cancel,
15093 live_rx: &mut live_rx,
15094 delivery_rx: &mut delivery_rx,
15095 logical_session: &mut session,
15096 expected_permission_posture: Some(expected_default_permission_posture(7)),
15097 session_inbox_runtime: None,
15098 actor_directory_store: Some(&store),
15099 canonical_subagent_tool: None,
15100 ticket_service: None,
15101 activation_run_id: Some("cancelled-remote-run"),
15102 execution_epoch: 0,
15103 expected_source_actor_id: "cancelled-remote-worker",
15104 initial_inflight_claims: VecDeque::new(),
15105 plain_actor: false,
15106 remote_environment_lease: true,
15107 readonly_output: None,
15108 local_history_tools: None,
15109 local_history_read_only: false,
15110 strict_history_output: None,
15111 plain_input: None,
15112 canonical_activation: Some(&fence),
15113 canonical_placement_ref: None,
15114 actor_event_observer: None,
15115 plain_run: None,
15116 first_frame_timeout: None,
15117 })
15118 .await;
15119 assert!(matches!(result, Err(AgentError::Cancelled)));
15120 assert_eq!(
15121 link.accepted, settle,
15122 "only a consumed exact terminal can release the remote fence"
15123 );
15124 assert_eq!(
15125 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15126 settle
15127 );
15128 }
15129 }
15130
15131 #[tokio::test]
15132 async fn actor_permission_handshake_rejects_malformed_agent_event() {
15133 let session_id = "permission-malformed";
15134 let (result, session, events, _) = drive_permission_handshake_frames(
15135 session_id,
15136 [ChildFrame::Event {
15137 event: serde_json::json!({"type": "token", "content": 42}),
15138 }],
15139 expected_default_permission_posture(7),
15140 )
15141 .await;
15142
15143 assert!(result
15144 .unwrap_err()
15145 .to_string()
15146 .contains("malformed AgentEvent"));
15147 assert!(events.is_empty());
15148 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15149
15150 let (result, _, events, _) = drive_permission_handshake_frames(
15151 session_id,
15152 [
15153 permission_posture_frame(session_id, 7),
15154 ChildFrame::Event {
15155 event: serde_json::json!({"type":"private-download-secret"}),
15156 },
15157 ],
15158 expected_default_permission_posture(7),
15159 )
15160 .await;
15161 assert!(!result
15162 .unwrap_err()
15163 .to_string()
15164 .contains("private-download-secret"));
15165 assert_eq!(
15166 events.len(),
15167 1,
15168 "only the confirmed posture may be forwarded"
15169 );
15170 }
15171
15172 #[tokio::test]
15173 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15174 let session_id = "permission-early-event";
15175 let early_events = [
15176 (
15177 "progress",
15178 AgentEvent::RunnerProgress {
15179 session_id: session_id.to_string(),
15180 round_count: 1,
15181 },
15182 ),
15183 (
15184 "token",
15185 AgentEvent::Token {
15186 content: "must-not-forward".to_string(),
15187 },
15188 ),
15189 (
15190 "tool",
15191 AgentEvent::ToolStart {
15192 tool_call_id: "early-tool".to_string(),
15193 tool_name: "Read".to_string(),
15194 arguments: serde_json::json!({"file_path": "README.md"}),
15195 },
15196 ),
15197 ];
15198 for (kind, event) in early_events {
15199 let (result, session, events, _) = drive_permission_handshake_frames(
15200 session_id,
15201 [
15202 actor_event_frame(event),
15203 permission_posture_frame(session_id, 7),
15204 completed_actor_frame(),
15205 ],
15206 expected_default_permission_posture(7),
15207 )
15208 .await;
15209
15210 assert!(
15211 result
15212 .unwrap_err()
15213 .to_string()
15214 .contains("execution event before permission posture confirmation"),
15215 "{kind} must fail closed before posture"
15216 );
15217 assert!(events.is_empty(), "{kind} must not be forwarded");
15218 assert!(
15219 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15220 "{kind} must not advance the permission audit"
15221 );
15222 }
15223 }
15224
15225 #[tokio::test]
15226 async fn actor_permission_handshake_rejects_approval_before_posture() {
15227 let session_id = "permission-early-approval";
15228 let (result, session, events, sent) = drive_permission_handshake_frames(
15229 session_id,
15230 [ChildFrame::ApprovalRequest {
15231 id: "approval-before-posture".to_string(),
15232 body: serde_json::json!({
15233 "tool_name": "Bash",
15234 "permission": "execute",
15235 "resource": "echo must-not-run"
15236 }),
15237 }],
15238 expected_default_permission_posture(7),
15239 )
15240 .await;
15241
15242 assert!(result
15243 .unwrap_err()
15244 .to_string()
15245 .contains("requested approval before permission posture confirmation"));
15246 assert!(events.is_empty());
15247 assert!(
15248 sent.is_empty(),
15249 "an unconfirmed actor must receive no approval reply"
15250 );
15251 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15252 }
15253
15254 #[tokio::test]
15255 async fn actor_permission_handshake_rejects_mismatched_posture() {
15256 let session_id = "permission-mismatch";
15257 let (result, session, events, _) = drive_permission_handshake_frames(
15258 session_id,
15259 [permission_posture_frame(session_id, 8)],
15260 expected_default_permission_posture(7),
15261 )
15262 .await;
15263
15264 assert!(result
15265 .unwrap_err()
15266 .to_string()
15267 .contains("does not match the host-dispatched policy"));
15268 assert!(events.is_empty());
15269 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15270 }
15271
15272 #[tokio::test]
15273 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15274 let session_id = "permission-hostile-mapping";
15275 for hostile_mapping in [
15276 "wrong_executor:permission_mode=default",
15277 "test_actor:permission_mode=default;credential=must-not-persist",
15278 ] {
15279 let frame = ChildFrame::Event {
15280 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15281 session_id: session_id.to_string(),
15282 policy_revision: 7,
15283 requested_mode: "default".to_string(),
15284 effective_mode: "default".to_string(),
15285 executor_mapping: hostile_mapping.to_string(),
15286 })
15287 .expect("serialize hostile posture fixture"),
15288 };
15289 let (result, session, events, _) = drive_permission_handshake_frames(
15290 session_id,
15291 [frame],
15292 expected_default_permission_posture(7),
15293 )
15294 .await;
15295
15296 let error = result.unwrap_err().to_string();
15297 assert!(error.contains("host-dispatched executor mapping"));
15298 assert!(
15299 !error.contains(hostile_mapping),
15300 "untrusted mapping must not be reflected in host errors"
15301 );
15302 assert!(events.is_empty());
15303 assert!(
15304 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15305 "untrusted mapping must not reach durable or in-memory audit state"
15306 );
15307 }
15308 }
15309
15310 #[tokio::test]
15311 async fn actor_permission_handshake_rejects_duplicate_posture() {
15312 let session_id = "permission-duplicate";
15313 let (result, session, events, _) = drive_permission_handshake_frames(
15314 session_id,
15315 [
15316 permission_posture_frame(session_id, 7),
15317 permission_posture_frame(session_id, 7),
15318 completed_actor_frame(),
15319 ],
15320 expected_default_permission_posture(7),
15321 )
15322 .await;
15323
15324 assert!(result
15325 .unwrap_err()
15326 .to_string()
15327 .contains("duplicate permission posture activation"));
15328 assert_eq!(
15329 events.len(),
15330 1,
15331 "only the confirmed posture may be forwarded"
15332 );
15333 assert!(matches!(
15334 events[0],
15335 AgentEvent::PermissionPostureActivated { .. }
15336 ));
15337 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15338 .expect("the first matching posture must be recorded");
15339 assert_eq!(audit.policy_revision, 7);
15340 }
15341
15342 #[tokio::test]
15343 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15344 let session_id = "permission-happy";
15345 let (result, session, events, _) = drive_permission_handshake_frames(
15346 session_id,
15347 [
15348 permission_posture_frame(session_id, 7),
15349 actor_event_frame(AgentEvent::RunnerProgress {
15350 session_id: session_id.to_string(),
15351 round_count: 1,
15352 }),
15353 actor_event_frame(AgentEvent::Token {
15354 content: "working".to_string(),
15355 }),
15356 actor_event_frame(AgentEvent::ToolStart {
15357 tool_call_id: "tool-1".to_string(),
15358 tool_name: "Read".to_string(),
15359 arguments: serde_json::json!({"file_path": "README.md"}),
15360 }),
15361 completed_actor_frame(),
15362 ],
15363 expected_default_permission_posture(7),
15364 )
15365 .await;
15366
15367 assert_eq!(result.unwrap().as_deref(), Some("done"));
15368 assert!(matches!(
15369 events.as_slice(),
15370 [
15371 AgentEvent::PermissionPostureActivated { .. },
15372 AgentEvent::RunnerProgress { .. },
15373 AgentEvent::Token { .. },
15374 AgentEvent::ToolStart { .. }
15375 ]
15376 ));
15377 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15378 .expect("matching posture must be recorded before execution events are accepted");
15379 assert_eq!(audit.policy_revision, 7);
15380 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15381 }
15382
15383 #[tokio::test]
15384 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15385 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15386 .into_iter()
15387 .enumerate()
15388 {
15389 let session_id = format!("actor-download-{index}");
15390 let args = serde_json::json!({
15391 "action":"download",
15392 "selector":"a[data-secret='private-selector']",
15393 "expected_epoch":17,
15394 "data_base64":"private-extra-bytes",
15395 "extra":{"url":"https://private.test/file"},
15396 });
15397 let original = args.clone();
15398 let read_args = serde_json::json!({"file_path":"README.md"});
15399 let (result, _session, events, _) = drive_permission_handshake_frames(
15400 &session_id,
15401 [
15402 permission_posture_frame(&session_id, 7),
15403 actor_event_frame(AgentEvent::ToolStart {
15404 tool_call_id: "download-call".to_string(),
15405 tool_name: tool_name.to_string(),
15406 arguments: args.clone(),
15407 }),
15408 actor_event_frame(AgentEvent::ToolStart {
15409 tool_call_id: "read-call".to_string(),
15410 tool_name: "Read".to_string(),
15411 arguments: read_args.clone(),
15412 }),
15413 completed_actor_frame(),
15414 ],
15415 expected_default_permission_posture(7),
15416 )
15417 .await;
15418 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15419 assert_eq!(args, original);
15420 let starts: Vec<_> = events
15421 .iter()
15422 .filter_map(|event| match event {
15423 AgentEvent::ToolStart {
15424 tool_name,
15425 arguments,
15426 ..
15427 } => Some((tool_name.as_str(), arguments)),
15428 _ => None,
15429 })
15430 .collect();
15431 assert_eq!(starts.len(), 2);
15432 assert_eq!(starts[0].0, "browser");
15433 assert_eq!(
15434 starts[0].1,
15435 &serde_json::json!({"action":"download","expected_epoch":17})
15436 );
15437 assert_eq!(starts[1], ("Read", &read_args));
15438 assert!(!format!("{events:?}").contains("private"));
15439 }
15440 }
15441
15442 #[tokio::test]
15443 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15444 let session_id = "actor-hook-display";
15445 let private = "private-selector-url-filename-response";
15446 let hook_name = format!("audit-{private}");
15447 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15448 hook_name: hook_name.clone(),
15449 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15450 phase: phase.into(),
15451 duration_ms,
15452 decision,
15453 };
15454 let decisions = [
15455 HookResult::Deny {
15456 reason: private.into(),
15457 },
15458 HookResult::InjectContext {
15459 text: private.into(),
15460 },
15461 HookResult::WithContext {
15462 result: Box::new(HookResult::WithContext {
15463 result: Box::new(HookResult::Deny {
15464 reason: private.into(),
15465 }),
15466 text: private.into(),
15467 }),
15468 text: private.into(),
15469 },
15470 HookResult::Suspend {
15471 reason: private.into(),
15472 },
15473 HookResult::Abort {
15474 reason: private.into(),
15475 },
15476 ];
15477 let originals = decisions.to_vec();
15478 let batch_events = [
15479 hook(2, "completed", decisions[1].clone()),
15480 hook(3, "completed", decisions[2].clone()),
15481 hook(4, "completed", decisions[3].clone()),
15482 ]
15483 .into_iter()
15484 .map(|event| serde_json::to_value(event).unwrap())
15485 .collect::<Vec<_>>();
15486 let batch = ActorEventBatch {
15487 logical_session: Some(LogicalSessionIdentity {
15488 creation: None,
15489 session_id: session_id.into(),
15490 parent_session_id: Some("permission-parent".into()),
15491 root_session_id: session_id.into(),
15492 }),
15493 activation_id: None,
15494 execution_epoch: 0,
15495 source_node_id: None,
15496 source_actor_id: Some(session_id.into()),
15497 first_seq: 1,
15498 last_seq: batch_events.len() as u64,
15499 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15500 events: batch_events,
15501 };
15502 let nested = AgentEvent::SubAgentEvent {
15503 parent_session_id: "parent".into(),
15504 child_session_id: "child".into(),
15505 event: Box::new(hook(5, "completed", decisions[4].clone())),
15506 };
15507 let raw = serde_json::to_string(&originals).unwrap();
15508 assert!(raw.contains(private));
15509
15510 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15511 session_id,
15512 [
15513 permission_posture_frame(session_id, 7),
15514 actor_event_frame(hook(1, private, decisions[0].clone())),
15515 ChildFrame::EventBatch { batch },
15516 actor_event_frame(nested),
15517 completed_actor_frame(),
15518 ],
15519 expected_default_permission_posture(7),
15520 )
15521 .await;
15522 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15523 let wire = serde_json::to_string(&forwarded).unwrap();
15524 assert!(
15525 !wire.contains(private),
15526 "private hook text reached SSE: {wire}"
15527 );
15528
15529 let hooks = forwarded
15530 .iter()
15531 .filter_map(|event| match event {
15532 AgentEvent::HookLifecycle {
15533 hook_name,
15534 point,
15535 phase,
15536 duration_ms,
15537 decision,
15538 } => Some((hook_name, point, phase, duration_ms, decision)),
15539 _ => None,
15540 })
15541 .collect::<Vec<_>>();
15542 assert_eq!(hooks.len(), 4);
15543 assert!(hooks.iter().all(|(name, point, _, _, _)| {
15544 name == &&"actor-hook-1".to_string()
15545 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15546 }));
15547 assert_eq!(hooks[0].2, "event");
15548 assert_eq!(hooks[1].2, "completed");
15549 assert_eq!(*hooks[0].3, 1);
15550 assert_eq!(*hooks[3].3, 4);
15551 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15552 assert!(
15553 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15554 );
15555 assert!(
15556 matches!(hooks[2].4, HookResult::WithContext { result, text }
15557 if text == "Hook context hidden" && matches!(result.as_ref(),
15558 HookResult::WithContext { result, text }
15559 if text == "Hook context hidden" && matches!(result.as_ref(),
15560 HookResult::Deny { reason } if reason == "Hook denied")))
15561 );
15562 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15563 assert!(forwarded.iter().any(|event| matches!(event,
15564 AgentEvent::SubAgentEvent { event, .. }
15565 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15566 hook_name, duration_ms: 5,
15567 decision: HookResult::Abort { reason }, ..
15568 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15569 )));
15570 }
15571
15572 #[test]
15573 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15574 let mut display = ActorEventDisplay::default();
15575 let mut deep = HookResult::Allow;
15576 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15577 deep = HookResult::WithContext {
15578 result: Box::new(deep),
15579 text: "private-download-bytes".into(),
15580 };
15581 }
15582 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15583 hook_name,
15584 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15585 phase: "completed".into(),
15586 duration_ms: 7,
15587 decision,
15588 };
15589 assert!(display.project(event("deep".into(), deep), 0).is_none());
15590 assert!(display.overflowed);
15591 assert!(display
15592 .project(
15593 event(
15594 "private-download-bytes".into(),
15595 HookResult::Deny {
15596 reason: "private-download-bytes".into(),
15597 },
15598 ),
15599 0,
15600 )
15601 .is_none());
15602
15603 let mut oversized = ActorEventDisplay::default();
15604 assert!(oversized
15605 .project(
15606 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15607 0,
15608 )
15609 .is_none());
15610 assert!(oversized.overflowed);
15611
15612 let mut reconnected = ActorEventDisplay::default();
15616 let private = "private-download-bytes";
15617 for _ in 0..2 {
15618 let projected = reconnected
15619 .project(event(private.into(), HookResult::Allow), 0)
15620 .unwrap();
15621 assert!(
15622 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15623 if hook_name == "actor-hook-1")
15624 );
15625 }
15626 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15627 for index in 1..MAX_DISPLAY_CALLS {
15628 assert!(reconnected
15629 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15630 .is_some());
15631 }
15632 assert!(reconnected
15633 .project(event("overflow-private".into(), HookResult::Allow), 0)
15634 .is_none());
15635 assert!(reconnected.overflowed);
15636 assert!(reconnected.hook_names.is_empty());
15637 }
15638
15639 #[test]
15640 fn actor_hook_display_preserves_nontext_decision_kinds() {
15641 for result in [
15642 HookResult::Continue,
15643 HookResult::Mutated,
15644 HookResult::Allow,
15645 HookResult::Ask,
15646 ] {
15647 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15648 }
15649 }
15650
15651 #[tokio::test]
15652 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15653 let session_id = "actor-malformed-hook";
15654 let private = "private-download-selector";
15655 let original = AgentEvent::HookLifecycle {
15656 hook_name: "audit".into(),
15657 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15658 phase: "completed".into(),
15659 duration_ms: 9,
15660 decision: HookResult::Deny {
15661 reason: private.into(),
15662 },
15663 };
15664 let mut invalid = serde_json::to_value(&original).unwrap();
15665 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15666 let nested = AgentEvent::SubAgentEvent {
15667 parent_session_id: "parent".into(),
15668 child_session_id: "child".into(),
15669 event: Box::new(original),
15670 };
15671 let mut invalid_nested = serde_json::to_value(nested).unwrap();
15672 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15673 let batch = ActorEventBatch {
15674 logical_session: Some(LogicalSessionIdentity {
15675 creation: None,
15676 session_id: session_id.into(),
15677 parent_session_id: Some("permission-parent".into()),
15678 root_session_id: session_id.into(),
15679 }),
15680 activation_id: None,
15681 execution_epoch: 0,
15682 source_node_id: None,
15683 source_actor_id: Some(session_id.into()),
15684 first_seq: 1,
15685 last_seq: 1,
15686 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15687 events: vec![invalid.clone()],
15688 };
15689 for frame in [
15690 ChildFrame::Event {
15691 event: invalid.clone(),
15692 },
15693 ChildFrame::EventBatch { batch },
15694 ChildFrame::Event {
15695 event: invalid_nested,
15696 },
15697 ] {
15698 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15699 session_id,
15700 [permission_posture_frame(session_id, 7), frame],
15701 expected_default_permission_posture(7),
15702 )
15703 .await;
15704 let error = outcome.unwrap_err().to_string();
15705 assert!(error.contains("malformed AgentEvent"), "{error}");
15706 assert!(!error.contains(private));
15707 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15708 }
15709
15710 let (event_tx, mut event_rx) = mpsc::channel(8);
15711 let mut session = Session::new("legacy-hook", "model");
15712 let mut handshake = PermissionPostureHandshake::NotRequired;
15713 let mut display = ActorEventDisplay::default();
15714 process_actor_event(
15715 invalid,
15716 false,
15717 &mut handshake,
15718 None,
15719 None,
15720 &mut session,
15721 &event_tx,
15722 &mut display,
15723 )
15724 .await
15725 .unwrap();
15726 assert!(display.overflowed);
15727 assert!(event_rx.try_recv().is_err());
15728 }
15729
15730 #[tokio::test]
15731 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15732 let session_id = "actor-download-batch";
15733 let private = "private-download-bytes-and-url";
15734 let events = vec![
15735 AgentEvent::ToolStart {
15736 tool_call_id: "download-call".into(),
15737 tool_name: "default::browser".into(),
15738 arguments: serde_json::json!({
15739 "action":"download", "selector": private, "expected_epoch": 17,
15740 }),
15741 },
15742 AgentEvent::ToolApprovalRequested {
15743 tool_call_id: "download-call".into(),
15744 tool_name: format!("{private}::browser"),
15745 parameters: serde_json::json!({
15746 "action":"download", "selector": private, "expected_epoch": 17,
15747 }),
15748 },
15749 AgentEvent::ToolStart {
15750 tool_call_id: "read-call".into(),
15751 tool_name: "Read".into(),
15752 arguments: serde_json::json!({"file_path":"README.md"}),
15753 },
15754 ];
15755 let batch = ActorEventBatch {
15756 logical_session: Some(LogicalSessionIdentity {
15757 creation: None,
15758 session_id: session_id.into(),
15759 parent_session_id: Some("permission-parent".into()),
15760 root_session_id: session_id.into(),
15761 }),
15762 activation_id: None,
15763 execution_epoch: 0,
15764 source_node_id: None,
15765 source_actor_id: Some(session_id.into()),
15766 first_seq: 1,
15767 last_seq: events.len() as u64,
15768 qos: bamboo_subagent::ActorEventQos::Durable,
15769 events: events
15770 .into_iter()
15771 .map(|event| serde_json::to_value(event).unwrap())
15772 .collect(),
15773 };
15774 let result = bamboo_agent_core::tools::ToolResult {
15775 success: true,
15776 result: format!("{{\"data_base64\":\"{private}\"}}"),
15777 display_preference: Some(private.into()),
15778 images: vec![bamboo_agent_core::tools::ToolResultImage {
15779 mime_type: "image/png".into(),
15780 data: private.into(),
15781 }],
15782 };
15783 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15784 session_id,
15785 [
15786 permission_posture_frame(session_id, 7),
15787 ChildFrame::EventBatch { batch },
15788 actor_event_frame(AgentEvent::ToolToken {
15789 tool_call_id: "download-call".into(),
15790 content: private.into(),
15791 }),
15792 actor_event_frame(AgentEvent::ToolToken {
15793 tool_call_id: "read-call".into(),
15794 content: "public live output".into(),
15795 }),
15796 actor_event_frame(AgentEvent::ToolComplete {
15797 tool_call_id: "download-call".into(),
15798 result,
15799 }),
15800 actor_event_frame(AgentEvent::ToolLifecycle {
15801 tool_call_id: "download-call".into(),
15802 tool_name: "browser".into(),
15803 phase: "finished".into(),
15804 elapsed_ms: Some(1),
15805 is_mutating: false,
15806 auto_approved: false,
15807 summary: Some(private.into()),
15808 error: Some(private.into()),
15809 }),
15810 actor_event_frame(AgentEvent::ToolError {
15811 tool_call_id: "download-call".into(),
15812 error: private.into(),
15813 }),
15814 actor_event_frame(AgentEvent::ToolComplete {
15815 tool_call_id: "read-call".into(),
15816 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15817 }),
15818 completed_actor_frame(),
15819 ],
15820 expected_default_permission_posture(7),
15821 )
15822 .await;
15823 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15824 let wire = serde_json::to_string(&forwarded).unwrap();
15825 assert!(
15826 !wire.contains(private),
15827 "private content reached actor SSE: {wire}"
15828 );
15829 assert!(wire.contains("public README text"));
15830 assert!(wire.contains("public live output"));
15831 assert!(forwarded.iter().any(|event| matches!(event,
15832 AgentEvent::ToolComplete { tool_call_id, result }
15833 if tool_call_id == "download-call"
15834 && result.result == "Browser tool result hidden"
15835 && result.images.is_empty() && result.display_preference.is_none()
15836 )));
15837 }
15838
15839 #[tokio::test]
15840 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15841 let private = "private-download-content";
15842 let nested = |event| AgentEvent::SubAgentEvent {
15843 parent_session_id: "parent".into(),
15844 child_session_id: "nested-child".into(),
15845 event: Box::new(event),
15846 };
15847 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15848 "actor-reorder",
15849 [
15850 permission_posture_frame("actor-reorder", 7),
15851 actor_event_frame(AgentEvent::ToolComplete {
15852 tool_call_id: "missing".into(),
15853 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15854 }),
15855 actor_event_frame(AgentEvent::ToolLifecycle {
15856 tool_call_id: "missing".into(),
15857 tool_name: "browser".into(),
15858 phase: private.into(),
15859 elapsed_ms: None,
15860 is_mutating: false,
15861 auto_approved: false,
15862 summary: Some(private.into()),
15863 error: Some(private.into()),
15864 }),
15865 actor_event_frame(AgentEvent::ToolApprovalRequested {
15866 tool_call_id: "approval-only".into(),
15867 tool_name: "browser".into(),
15868 parameters: serde_json::json!({"action":"download","selector":private}),
15869 }),
15870 actor_event_frame(AgentEvent::ToolToken {
15871 tool_call_id: "approval-only".into(),
15872 content: private.into(),
15873 }),
15874 actor_event_frame(AgentEvent::ToolStart {
15875 tool_call_id: "malformed-browser".into(),
15876 tool_name: format!("{private}::browser"),
15877 arguments: serde_json::json!({"selector":private}),
15878 }),
15879 actor_event_frame(AgentEvent::ToolComplete {
15880 tool_call_id: "malformed-browser".into(),
15881 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15882 }),
15883 actor_event_frame(AgentEvent::ToolStart {
15884 tool_call_id: "reused".into(),
15885 tool_name: "Read".into(),
15886 arguments: serde_json::json!({"file_path":"README.md"}),
15887 }),
15888 actor_event_frame(AgentEvent::ToolStart {
15889 tool_call_id: "reused".into(),
15890 tool_name: "browser".into(),
15891 arguments: serde_json::json!({"action":"download","selector":private}),
15892 }),
15893 actor_event_frame(AgentEvent::ToolToken {
15894 tool_call_id: "reused".into(),
15895 content: private.into(),
15896 }),
15897 actor_event_frame(nested(AgentEvent::ToolStart {
15898 tool_call_id: "nested".into(),
15899 tool_name: "browser".into(),
15900 arguments: serde_json::json!({"action":"download","selector":private}),
15901 })),
15902 actor_event_frame(nested(AgentEvent::ToolComplete {
15903 tool_call_id: "nested".into(),
15904 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15905 })),
15906 actor_event_frame(AgentEvent::ChildApprovalRequested {
15907 child_session_id: "nested-child".into(),
15908 request_id: "approval".into(),
15909 tool_name: format!("{private}::browser"),
15910 permission: private.into(),
15911 resource: private.into(),
15912 }),
15913 actor_event_frame(AgentEvent::ChildApprovalChanged {
15914 parent_session_id: "parent".into(),
15915 child_session_id: "nested-child".into(),
15916 child_attempt: 1,
15917 request_id: "approval".into(),
15918 version: 1,
15919 status: "pending".into(),
15920 reason: Some(private.into()),
15921 tool_name: "browser".into(),
15922 permission: private.into(),
15923 resource: private.into(),
15924 created_at: "2026-01-01T00:00:00Z".into(),
15925 resolved_at: None,
15926 }),
15927 completed_actor_frame(),
15928 ],
15929 expected_default_permission_posture(7),
15930 )
15931 .await;
15932 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15933 let wire = serde_json::to_string(&forwarded).unwrap();
15934 assert!(
15935 !wire.contains(private),
15936 "private content reached actor SSE: {wire}"
15937 );
15938 assert!(wire.contains("Tool result hidden"));
15939 assert!(wire.contains("Browser tool result hidden"));
15940 assert!(wire.contains("Browser interaction approval"));
15941 assert!(!forwarded
15942 .iter()
15943 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15944 }
15945
15946 #[test]
15947 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15948 let mut display = ActorEventDisplay::default();
15949 display
15950 .project(
15951 AgentEvent::ToolStart {
15952 tool_call_id: "private-call".into(),
15953 tool_name: "browser".into(),
15954 arguments: serde_json::json!({"action":"download","selector":"private"}),
15955 },
15956 0,
15957 )
15958 .unwrap();
15959 for index in 0..MAX_DISPLAY_CALLS {
15960 display
15961 .project(
15962 AgentEvent::ToolStart {
15963 tool_call_id: format!("ordinary-{index}"),
15964 tool_name: "Read".into(),
15965 arguments: serde_json::json!({"file_path":"README.md"}),
15966 },
15967 0,
15968 )
15969 .unwrap();
15970 }
15971 assert!(display.overflowed);
15972 let forwarded = display
15973 .project(
15974 AgentEvent::ToolComplete {
15975 tool_call_id: "private-call".into(),
15976 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15977 },
15978 0,
15979 )
15980 .unwrap();
15981 assert!(!serde_json::to_string(&forwarded)
15982 .unwrap()
15983 .contains("private-bytes"));
15984 }
15985
15986 #[test]
15987 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
15988 let mut display = ActorEventDisplay::default();
15989 let nested = |index: usize| AgentEvent::SubAgentEvent {
15990 parent_session_id: "parent".into(),
15991 child_session_id: format!("child-{index}"),
15992 event: Box::new(AgentEvent::ToolStart {
15993 tool_call_id: format!("read-{index}"),
15994 tool_name: "Read".into(),
15995 arguments: serde_json::json!({"file_path":"README.md"}),
15996 }),
15997 };
15998 for index in 0..(MAX_DISPLAY_CALLS / 2) {
15999 assert!(display.project(nested(index), 0).is_some());
16000 }
16001 assert_eq!(
16002 display.identities.load(Ordering::Relaxed),
16003 MAX_DISPLAY_CALLS
16004 );
16005 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
16006 assert!(display.overflowed);
16007 assert!(display.children.is_empty());
16008
16009 let mut oversized = ActorEventDisplay::default();
16010 assert!(oversized
16011 .project(
16012 AgentEvent::SubAgentEvent {
16013 parent_session_id: "parent".into(),
16014 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16015 event: Box::new(AgentEvent::ToolComplete {
16016 tool_call_id: "call".into(),
16017 result: bamboo_agent_core::tools::ToolResult::text(
16018 true,
16019 "private-download-bytes",
16020 ),
16021 }),
16022 },
16023 0,
16024 )
16025 .is_none());
16026 assert!(oversized.overflowed);
16027 assert!(oversized
16028 .project(
16029 AgentEvent::ToolStart {
16030 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16031 tool_name: "Read".into(),
16032 arguments: serde_json::json!({"file_path":"README.md"}),
16033 },
16034 0,
16035 )
16036 .is_none());
16037 }
16038
16039 #[tokio::test]
16040 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16041 let session_id = "actor-gap";
16042 let batch = |seq, event: AgentEvent| {
16043 let event = serde_json::to_value(event).unwrap();
16044 ChildFrame::EventBatch {
16045 batch: ActorEventBatch {
16046 logical_session: Some(LogicalSessionIdentity {
16047 creation: None,
16048 session_id: session_id.into(),
16049 parent_session_id: Some("permission-parent".into()),
16050 root_session_id: session_id.into(),
16051 }),
16052 activation_id: None,
16053 execution_epoch: 0,
16054 source_node_id: None,
16055 source_actor_id: Some(session_id.into()),
16056 first_seq: seq,
16057 last_seq: seq,
16058 qos: bamboo_subagent::ActorEventQos::classify(&event),
16059 events: vec![event],
16060 },
16061 }
16062 };
16063 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16064 session_id,
16065 [
16066 permission_posture_frame(session_id, 7),
16067 batch(
16068 1,
16069 AgentEvent::ToolStart {
16070 tool_call_id: "reused-call".into(),
16071 tool_name: "Read".into(),
16072 arguments: serde_json::json!({"file_path":"README.md"}),
16073 },
16074 ),
16075 batch(
16078 3,
16079 AgentEvent::ToolComplete {
16080 tool_call_id: "reused-call".into(),
16081 result: bamboo_agent_core::tools::ToolResult::text(
16082 true,
16083 "private-download-bytes",
16084 ),
16085 },
16086 ),
16087 completed_actor_frame(),
16088 ],
16089 expected_default_permission_posture(7),
16090 )
16091 .await;
16092 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16093 let wire = serde_json::to_string(&forwarded).unwrap();
16094 assert!(!wire.contains("private-download-bytes"), "{wire}");
16095 assert!(wire.contains("Tool result hidden"));
16096 }
16097
16098 #[tokio::test]
16099 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16100 let (event_tx, mut event_rx) = mpsc::channel(8);
16101 let mut session = Session::new("legacy-malformed-private", "model");
16102 let mut handshake = PermissionPostureHandshake::NotRequired;
16103 let mut display = ActorEventDisplay::default();
16104 let read_start = serde_json::to_value(AgentEvent::ToolStart {
16105 tool_call_id: "reused-call".into(),
16106 tool_name: "Read".into(),
16107 arguments: serde_json::json!({"file_path":"README.md"}),
16108 })
16109 .unwrap();
16110 process_actor_event(
16111 read_start,
16112 false,
16113 &mut handshake,
16114 None,
16115 None,
16116 &mut session,
16117 &event_tx,
16118 &mut display,
16119 )
16120 .await
16121 .unwrap();
16122
16123 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16126 tool_call_id: "reused-call".into(),
16127 tool_name: "browser".into(),
16128 arguments: serde_json::json!({
16129 "action":"download", "selector":"private-download-selector"
16130 }),
16131 })
16132 .unwrap();
16133 malformed["tool_name"] = serde_json::json!(7);
16134 process_actor_event(
16135 malformed,
16136 false,
16137 &mut handshake,
16138 None,
16139 None,
16140 &mut session,
16141 &event_tx,
16142 &mut display,
16143 )
16144 .await
16145 .unwrap();
16146 process_actor_event(
16147 serde_json::to_value(AgentEvent::ToolComplete {
16148 tool_call_id: "reused-call".into(),
16149 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16150 })
16151 .unwrap(),
16152 false,
16153 &mut handshake,
16154 None,
16155 None,
16156 &mut session,
16157 &event_tx,
16158 &mut display,
16159 )
16160 .await
16161 .unwrap();
16162 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16163 assert_eq!(forwarded.len(), 2);
16164 let wire = serde_json::to_string(&forwarded).unwrap();
16165 assert!(!wire.contains("private-download"), "{wire}");
16166 assert!(wire.contains("Tool result hidden"));
16167 }
16168
16169 #[tokio::test]
16170 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16171 let temp = tempfile::tempdir().unwrap();
16172 let store = Arc::new(
16173 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16174 .await
16175 .unwrap(),
16176 );
16177 let storage: Arc<dyn Storage> = store.clone();
16178 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16179 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16180 store.clone(),
16181 bamboo_domain::SessionInboxLimits::default(),
16182 ));
16183 let session_id = "actor-confirmation-order";
16184 let run_id = "actor-run-current";
16185 let mut session = Session::new(session_id, "model");
16186 store.save_session(&session).await.unwrap();
16187 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16188 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16189 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16190 inbox.deliver(&envelope).await.unwrap();
16191 }
16192 inbox
16193 .mark_activation_eligible(
16194 session_id,
16195 2,
16196 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16197 )
16198 .await
16199 .unwrap();
16200 let router = SessionActivationRouter::new();
16201 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16202 let binding = SessionInboxRuntimeBinding {
16203 router,
16204 inbox: inbox.clone(),
16205 storage,
16206 persistence: locked,
16207 parent_question_lock: None,
16208 };
16209 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16210 .await
16211 .unwrap();
16212 assert_eq!(
16213 pairs
16214 .iter()
16215 .map(|(claim, _)| claim.envelope.id.as_str())
16216 .collect::<Vec<_>>(),
16217 vec!["actor-first", "actor-second"]
16218 );
16219 let seeded = store.load_session(session_id).await.unwrap().unwrap();
16220 assert_eq!(
16221 seeded
16222 .messages
16223 .iter()
16224 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16225 .map(|message| message.id.as_str())
16226 .collect::<Vec<_>>(),
16227 vec!["actor-first", "actor-second"],
16228 "host context must be durable before actor dispatch"
16229 );
16230 for (claim, _) in &pairs {
16231 assert_eq!(
16232 seeded
16233 .messages
16234 .iter()
16235 .filter(|message| bamboo_domain::is_matching_session_message(
16236 message,
16237 &claim.envelope
16238 ))
16239 .count(),
16240 1,
16241 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16242 claim.envelope.id
16243 );
16244 }
16245 assert!(
16246 seeded.session_inbox_admission().is_none_or(|cursor| {
16247 !cursor.contains(&pairs[0].0.envelope.id)
16248 && !cursor.contains(&pairs[1].0.envelope.id)
16249 }),
16250 "pre-dispatch transcript seeding must not forge worker confirmation"
16251 );
16252 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16253 let claims = pairs
16254 .into_iter()
16255 .map(|(claim, _)| claim)
16256 .collect::<VecDeque<_>>();
16257 let first = claims[0].clone();
16258 let second = claims[1].clone();
16259 let mut stale = admission_confirmation(session_id, &first, "stale-run");
16260 stale.canonical_claim_generation = second.generation;
16261 let mut link = ConfirmationSequenceLink {
16262 frames: VecDeque::from([
16263 ChildFrame::SessionMessageAdmitted {
16264 confirmation: stale,
16265 },
16266 ChildFrame::SessionMessageAdmitted {
16267 confirmation: admission_confirmation(session_id, &first, run_id),
16268 },
16269 ChildFrame::SessionMessageAdmitted {
16270 confirmation: admission_confirmation(session_id, &second, run_id),
16271 },
16272 ChildFrame::Terminal {
16273 status: TerminalStatus::Completed,
16274 result: Some("done".to_string()),
16275 error: None,
16276 final_event_watermark: None,
16277 transcript: Vec::new(),
16278 },
16279 ]),
16280 sent: Vec::new(),
16281 };
16282 let (event_tx, _event_rx) = mpsc::channel(8);
16283 let cancel = CancellationToken::new();
16284 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16285 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16286 let result = drive(ActorDriveContext {
16287 expected_creation: None,
16288 client: &mut link,
16289 parent_session_id: "parent",
16290 child_session_id: session_id,
16291 child_attempt: 0,
16292 approval_registry: None,
16293 approval_decider: None,
16294 approval_reviewer: None,
16295 escalation_bridge: None,
16296 event_tx: &event_tx,
16297 cancel_token: &cancel,
16298 live_rx: &mut live_rx,
16299 delivery_rx: &mut delivery_rx,
16300 logical_session: &mut session,
16301 expected_permission_posture: None,
16302 session_inbox_runtime: Some(&binding),
16303 actor_directory_store: None,
16304 canonical_subagent_tool: None,
16305 ticket_service: None,
16306 activation_run_id: Some(run_id),
16307 execution_epoch: 0,
16308 expected_source_actor_id: session_id,
16309 initial_inflight_claims: claims,
16310 plain_actor: false,
16311 remote_environment_lease: false,
16312 readonly_output: None,
16313 local_history_tools: None,
16314 local_history_read_only: false,
16315 strict_history_output: None,
16316 plain_input: None,
16317 canonical_activation: None,
16318 canonical_placement_ref: None,
16319 actor_event_observer: None,
16320 plain_run: None,
16321 first_frame_timeout: Some(Duration::from_secs(1)),
16322 })
16323 .await
16324 .unwrap();
16325 assert_eq!(result.as_deref(), Some("done"));
16326 assert_eq!(
16327 session
16328 .messages
16329 .iter()
16330 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16331 .map(|message| message.id.as_str())
16332 .collect::<Vec<_>>(),
16333 vec!["actor-first", "actor-second"]
16334 );
16335 let backlog = inbox.inspect(session_id).await.unwrap();
16336 assert_eq!(backlog.pending + backlog.claimed, 0);
16337 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16338 let cursor = confirmed
16339 .session_inbox_admission()
16340 .expect("exact worker confirmation must checkpoint the admission cursor");
16341 assert!(cursor.contains(&first.envelope.id));
16342 assert!(cursor.contains(&second.envelope.id));
16343 for claim in [&first, &second] {
16344 assert_eq!(
16345 confirmed
16346 .messages
16347 .iter()
16348 .filter(|message| bamboo_domain::is_matching_session_message(
16349 message,
16350 &claim.envelope
16351 ))
16352 .count(),
16353 1,
16354 "confirmation must retain one exact canonical marker for {}",
16355 claim.envelope.id
16356 );
16357 }
16358 assert!(inbox
16359 .was_admitted(session_id, &first.envelope.id)
16360 .await
16361 .unwrap());
16362 assert!(inbox
16363 .was_admitted(session_id, &second.envelope.id)
16364 .await
16365 .unwrap());
16366 owner_registration.begin_finalization().await;
16367 owner_registration.finish(2).await.unwrap();
16368 }
16369
16370 #[derive(Default)]
16371 struct RecordingCodexTokenAuthority {
16372 issued_for: std::sync::Mutex<Vec<String>>,
16373 revoked: std::sync::Mutex<Vec<String>>,
16374 }
16375
16376 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16377 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16378 self.issued_for
16379 .lock()
16380 .expect("issued fixture lock")
16381 .push(session_id.to_string());
16382 Ok(IssuedCodexRunToken {
16383 token_id: format!("id-{session_id}"),
16384 token: format!("bcx1_secret-{session_id}"),
16385 })
16386 }
16387
16388 fn revoke(&self, token_id: &str) {
16389 self.revoked
16390 .lock()
16391 .expect("revoked fixture lock")
16392 .push(token_id.to_string());
16393 }
16394 }
16395
16396 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16397 let bamboo_mode = auth_mode == Some("bamboo")
16398 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16399 ExecutorSpec::Codex {
16400 binary: None,
16401 model: None,
16402 mode: None,
16403 sandbox: None,
16404 inherit_user_config,
16405 auth_mode: auth_mode.map(str::to_string),
16406 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16407 wire_api: Some("responses".to_string()),
16408 provider_key_ref: None,
16409 forward_env: None,
16410 approval_policy: None,
16411 network_access: None,
16412 allow_danger_bypass: None,
16413 permission_profile: None,
16414 workspace_owned: None,
16415 }
16416 }
16417
16418 fn permission_resolution(
16419 requested: bamboo_domain::SessionPermissionMode,
16420 effective: bamboo_domain::PermissionMode,
16421 ) -> bamboo_domain::PermissionModeResolution {
16422 bamboo_domain::PermissionModeResolution {
16423 requested,
16424 effective,
16425 }
16426 }
16427
16428 #[test]
16429 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16430 use bamboo_domain::{PermissionMode, SessionPermissionMode};
16431
16432 let default =
16433 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16434 assert_eq!(
16435 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16436 .unwrap()
16437 .as_deref(),
16438 Some("bamboo_runtime:default")
16439 );
16440 assert_eq!(
16441 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16442 None,
16443 "transport-only Echo must not claim the typed permission contract"
16444 );
16445 assert_eq!(
16446 expected_permission_executor_mapping(
16447 &ExecutorSpec::CliAdapter {
16448 command: "must-not-appear-in-contract".to_string(),
16449 args: vec!["credential-like-argument".to_string()],
16450 },
16451 default,
16452 false,
16453 )
16454 .unwrap(),
16455 None,
16456 "unimplemented CliAdapter must not leak command data into a contract"
16457 );
16458
16459 let claude = ExecutorSpec::ClaudeCode {
16460 binary: None,
16461 model: None,
16462 permission_mode: Some("default".to_string()),
16463 inherit_user_config: None,
16464 forward_env: None,
16465 };
16466 for (resolution, mapping) in [
16467 (
16468 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16469 "claude_code:permission_mode=plan",
16470 ),
16471 (
16472 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16473 "claude_code:permission_mode=bypassPermissions",
16474 ),
16475 (
16476 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16477 "claude_code:permission_mode=acceptEdits",
16478 ),
16479 (
16480 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16481 "claude_code:permission_mode=dontAsk",
16482 ),
16483 (
16484 permission_resolution(
16485 SessionPermissionMode::Bypass,
16486 PermissionMode::BypassPermissions,
16487 ),
16488 "claude_code:permission_mode=default",
16489 ),
16490 ] {
16491 assert_eq!(
16492 expected_permission_executor_mapping(&claude, resolution, false)
16493 .unwrap()
16494 .as_deref(),
16495 Some(mapping)
16496 );
16497 }
16498 assert_eq!(
16499 expected_permission_executor_mapping(&claude, default, true)
16500 .unwrap()
16501 .as_deref(),
16502 Some("claude_code:blocked_explicit_deny")
16503 );
16504
16505 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16506 if let ExecutorSpec::Codex {
16507 approval_policy, ..
16508 } = &mut codex_exec
16509 {
16510 *approval_policy = Some("on-failure".to_string());
16511 }
16512 assert_eq!(
16513 expected_permission_executor_mapping(&codex_exec, default, false)
16514 .unwrap()
16515 .as_deref(),
16516 Some("codex_exec:approval_policy=on-failure")
16517 );
16518 assert_eq!(
16519 expected_permission_executor_mapping(
16520 &codex_exec,
16521 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16522 false,
16523 )
16524 .unwrap()
16525 .as_deref(),
16526 Some("codex_exec:approval_policy=never")
16527 );
16528 assert_eq!(
16529 expected_permission_executor_mapping(&codex_exec, default, true)
16530 .unwrap()
16531 .as_deref(),
16532 Some("codex_exec:blocked_explicit_deny")
16533 );
16534
16535 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16536 if let ExecutorSpec::Codex {
16537 mode,
16538 approval_policy,
16539 ..
16540 } = &mut codex_app_server
16541 {
16542 *mode = Some("app_server".to_string());
16543 *approval_policy = Some("on-request".to_string());
16544 }
16545 assert_eq!(
16546 expected_permission_executor_mapping(&codex_app_server, default, false)
16547 .unwrap()
16548 .as_deref(),
16549 Some("codex_app_server:approvalPolicy=on-request")
16550 );
16551 assert_eq!(
16552 expected_permission_executor_mapping(
16553 &codex_app_server,
16554 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16555 false,
16556 )
16557 .unwrap()
16558 .as_deref(),
16559 Some("codex_app_server:approvalPolicy=never")
16560 );
16561 assert_eq!(
16562 expected_permission_executor_mapping(&codex_app_server, default, true)
16563 .unwrap()
16564 .as_deref(),
16565 Some("codex_app_server:blocked_explicit_deny")
16566 );
16567 }
16568
16569 #[test]
16570 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16571 let project = tempfile::tempdir().unwrap();
16572 let managed = project.path().join(".bamboo/worktree/child-571");
16573 std::fs::create_dir_all(&managed).unwrap();
16574 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16575 let marker = project
16576 .path()
16577 .join(".bamboo/worktree/.bamboo-owned/child-571");
16578 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16579 std::fs::write(&marker, "bamboo/child-571").unwrap();
16580 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16581 let nested = managed.join("nested/path");
16582 std::fs::create_dir_all(&nested).unwrap();
16583 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16584
16585 let arbitrary = tempfile::tempdir().unwrap();
16586 assert!(!workspace_is_bamboo_owned(
16587 arbitrary.path().to_str().unwrap()
16588 ));
16589 }
16590
16591 #[test]
16592 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16593 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16594 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16595
16596 let (secrets, guard) = build_codex_run_secrets(
16597 &codex_executor(Some("bamboo"), None),
16598 Some(authority_dyn),
16599 "child-570",
16600 )
16601 .unwrap();
16602
16603 let token = secrets
16604 .codex_provider_token
16605 .as_ref()
16606 .expect("bamboo mode mints a token");
16607 assert_eq!(token.expose(), "bcx1_secret-child-570");
16608 assert!(!format!("{token:?}").contains("secret-child-570"));
16609 assert_eq!(
16610 authority.issued_for.lock().unwrap().as_slice(),
16611 ["child-570"]
16612 );
16613 assert!(authority.revoked.lock().unwrap().is_empty());
16614
16615 drop(guard);
16616 assert_eq!(
16617 authority.revoked.lock().unwrap().as_slice(),
16618 ["id-child-570"]
16619 );
16620 }
16621
16622 #[test]
16623 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16624 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16625 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16626 let (secrets, guard) = build_codex_run_secrets(
16627 &codex_executor(Some("custom"), None),
16628 Some(authority_dyn),
16629 "child-custom",
16630 )
16631 .unwrap();
16632 assert!(secrets.codex_provider_token.is_none());
16633 assert!(guard.is_none());
16634 assert!(authority.issued_for.lock().unwrap().is_empty());
16635
16636 let error = build_codex_run_secrets(
16637 &codex_executor(Some("bamboo"), None),
16638 None,
16639 "child-no-authority",
16640 )
16641 .err()
16642 .expect("bamboo mode without an authority must fail closed");
16643 assert!(error.to_string().contains("per-run token authority"));
16644 }
16645
16646 #[test]
16647 fn codex_provisioning_never_leaks_the_session_provider_credential() {
16648 let credentials = vec![ScopedCredential {
16649 provider: "openai".to_string(),
16650 api_key: "upstream-secret-must-not-cross".to_string(),
16651 base_url: None,
16652 provider_type: Some("openai".to_string()),
16653 credential_ref: Some("provider.openai.api_key".to_string()),
16654 }];
16655
16656 for (mode, label) in [
16657 (Some("inherit"), "inherit"),
16658 (Some("api_key"), "api_key"),
16659 (Some("bamboo"), "bamboo"),
16660 (None, "default-bamboo"),
16661 ] {
16662 let runner = ActorChildRunner::new(
16663 format!("codex-{label}-test"),
16664 PathBuf::from("/bin/false"),
16665 Vec::new(),
16666 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16667 codex_executor(mode, None),
16668 credentials.clone(),
16669 "openai".to_string(),
16670 1,
16671 );
16672 let mut session = Session::new(format!("child-{label}"), "model");
16673 session.add_message(bamboo_agent_core::Message::user("test"));
16674 let spec = runner.build_spec(
16675 &session,
16676 &crate::runtime::execution::SpawnJob {
16677 parent_session_id: "parent".to_string(),
16678 child_session_id: format!("child-{label}"),
16679 model: "gpt-5.4".to_string(),
16680 disabled_tools: None,
16681 },
16682 );
16683 assert!(
16684 spec.secrets.provider_credentials.is_empty(),
16685 "{label} Codex must not receive the session provider key"
16686 );
16687 }
16688 }
16689
16690 #[test]
16691 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16692 let credentials = vec![
16693 ScopedCredential {
16694 provider: "openai".to_string(),
16695 api_key: "selected-openai-secret".to_string(),
16696 base_url: None,
16697 provider_type: Some("openai".to_string()),
16698 credential_ref: Some("provider.openai.api_key".to_string()),
16699 },
16700 ScopedCredential {
16701 provider: "other".to_string(),
16702 api_key: "unrelated-secret".to_string(),
16703 base_url: None,
16704 provider_type: Some("openai".to_string()),
16705 credential_ref: Some("provider.other.api_key".to_string()),
16706 },
16707 ];
16708 let runner = ActorChildRunner::new(
16709 "echo-test".to_string(),
16710 PathBuf::from("/bin/false"),
16711 Vec::new(),
16712 std::env::temp_dir().join("bamboo-echo-provider-570"),
16713 ExecutorSpec::Echo,
16714 credentials,
16715 "openai".to_string(),
16716 1,
16717 );
16718 let spec = runner.build_spec(
16719 &Session::new("child-echo", "model"),
16720 &crate::runtime::execution::SpawnJob {
16721 parent_session_id: "parent".to_string(),
16722 child_session_id: "child-echo".to_string(),
16723 model: "gpt-5.4".to_string(),
16724 disabled_tools: None,
16725 },
16726 );
16727
16728 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16729 assert_eq!(
16730 spec.secrets.provider_credentials[0].api_key,
16731 "selected-openai-secret"
16732 );
16733 }
16734
16735 #[tokio::test]
16736 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16737 fn provider_instance(
16738 provider_type: &str,
16739 api_key: &str,
16740 ) -> bamboo_config::ProviderInstanceConfig {
16741 bamboo_config::ProviderInstanceConfig {
16742 provider_type: provider_type.to_string(),
16743 label: None,
16744 api_key: api_key.to_string(),
16745 api_key_encrypted: None,
16746 credential_ref: None,
16747 base_url: None,
16748 model: None,
16749 fast_model: None,
16750 vision_model: None,
16751 reasoning_effort: None,
16752 responses_only_models: Vec::new(),
16753 request_overrides: None,
16754 enabled: true,
16755 extra: Default::default(),
16756 }
16757 }
16758
16759 let mut initial = bamboo_llm::Config::default();
16760 initial.provider_instances.insert(
16761 "old-provider".to_string(),
16762 provider_instance("openai", "old-key"),
16763 );
16764 initial.default_provider_instance = Some("old-provider".to_string());
16765 let live = Arc::new(tokio::sync::RwLock::new(initial));
16766
16767 let runner = ActorChildRunner::new(
16768 "echo-live-config-test".to_string(),
16769 PathBuf::from("/bin/false"),
16770 Vec::new(),
16771 std::env::temp_dir().join("bamboo-echo-live-config"),
16772 ExecutorSpec::Echo,
16773 Vec::new(),
16774 "stale-provider".to_string(),
16775 1,
16776 )
16777 .with_live_provider_config(live.clone());
16778 let job = crate::runtime::execution::SpawnJob {
16779 parent_session_id: "parent".to_string(),
16780 child_session_id: "child".to_string(),
16781 model: "model".to_string(),
16782 disabled_tools: None,
16783 };
16784
16785 let first = runner
16786 .build_live_spec(&Session::new("child", "model"), &job)
16787 .await;
16788 assert_eq!(
16789 first.model.as_ref().map(|model| model.provider.as_str()),
16790 Some("old-provider")
16791 );
16792 assert_eq!(first.secrets.provider_credentials.len(), 1);
16793 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16794
16795 let mut reloaded = bamboo_llm::Config::default();
16796 reloaded.provider_instances.insert(
16797 "new-provider".to_string(),
16798 provider_instance("openai", "new-key"),
16799 );
16800 reloaded.default_provider_instance = Some("new-provider".to_string());
16801 *live.write().await = reloaded;
16802
16803 let second = runner
16804 .build_live_spec(&Session::new("child", "model"), &job)
16805 .await;
16806 assert_eq!(
16807 second.model.as_ref().map(|model| model.provider.as_str()),
16808 Some("new-provider")
16809 );
16810 assert_eq!(second.secrets.provider_credentials.len(), 1);
16811 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16812 }
16813
16814 #[test]
16815 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16816 let mut executor = codex_executor(Some("custom"), None);
16817 if let ExecutorSpec::Codex {
16818 base_url,
16819 provider_key_ref,
16820 ..
16821 } = &mut executor
16822 {
16823 *base_url = Some("https://provider.example/v1".to_string());
16824 *provider_key_ref = Some("provider.custom.api_key".to_string());
16825 }
16826 let credentials = vec![
16827 ScopedCredential {
16828 provider: "openai".to_string(),
16829 api_key: "session-provider-secret".to_string(),
16830 base_url: None,
16831 provider_type: Some("openai".to_string()),
16832 credential_ref: Some("provider.openai.api_key".to_string()),
16833 },
16834 ScopedCredential {
16835 provider: "custom".to_string(),
16836 api_key: "selected-secret".to_string(),
16837 base_url: None,
16838 provider_type: Some("openai".to_string()),
16839 credential_ref: Some("provider.custom.api_key".to_string()),
16840 },
16841 ScopedCredential {
16842 provider: "other".to_string(),
16843 api_key: "unrelated-secret".to_string(),
16844 base_url: None,
16845 provider_type: Some("openai".to_string()),
16846 credential_ref: Some("provider.other.api_key".to_string()),
16847 },
16848 ];
16849 let runner = ActorChildRunner::new(
16850 "codex-test".to_string(),
16851 PathBuf::from("/bin/false"),
16852 Vec::new(),
16853 std::env::temp_dir().join("bamboo-codex-570"),
16854 executor,
16855 credentials,
16856 "openai".to_string(),
16857 1,
16858 );
16859 let mut session = Session::new("child-custom", "model");
16860 session.add_message(bamboo_agent_core::Message::user("test"));
16861 let spec = runner.build_spec(
16862 &session,
16863 &crate::runtime::execution::SpawnJob {
16864 parent_session_id: "parent".to_string(),
16865 child_session_id: "child-custom".to_string(),
16866 model: "gpt-5.4".to_string(),
16867 disabled_tools: None,
16868 },
16869 );
16870
16871 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16872 assert_eq!(
16873 spec.secrets.provider_credentials[0]
16874 .credential_ref
16875 .as_deref(),
16876 Some("provider.custom.api_key")
16877 );
16878 assert_eq!(
16879 spec.secrets.provider_credentials[0].api_key,
16880 "selected-secret"
16881 );
16882 }
16883
16884 fn spec_with(
16885 role: &str,
16886 provider: &str,
16887 model: &str,
16888 workspace: Option<&str>,
16889 disabled: Option<Vec<&str>>,
16890 ) -> ProvisionSpec {
16891 let mut spec = ProvisionSpec::new(
16892 ChildIdentity {
16893 child_id: "c".into(),
16894 parent_id: None,
16895 project_key: None,
16896 role: role.into(),
16897 depth: 0,
16898 },
16899 ExecutorSpec::Echo,
16900 "/tmp/fab".into(),
16901 );
16902 spec.workspace = workspace.map(|w| w.to_string());
16903 spec.model = Some(ModelRefSpec {
16904 provider: provider.into(),
16905 model: model.into(),
16906 });
16907 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16908 spec
16909 }
16910
16911 #[test]
16912 fn fingerprint_matches_interchangeable_children() {
16913 let a = spec_with(
16916 "explorer",
16917 "p",
16918 "m",
16919 Some("/ws"),
16920 Some(vec!["Bash", "Edit"]),
16921 );
16922 let mut b = spec_with(
16923 "explorer",
16924 "p",
16925 "m",
16926 Some("/ws"),
16927 Some(vec!["Edit", "Bash"]),
16928 );
16929 b.identity.child_id = "other".into();
16930 assert_eq!(
16931 ActorChildRunner::fingerprint(&a),
16932 ActorChildRunner::fingerprint(&b)
16933 );
16934 }
16935
16936 #[test]
16937 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16938 let mut session =
16939 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16940 session.root_session_id = "logical-root-681".to_string();
16941 let job = SpawnJob {
16942 parent_session_id: "logical-parent-681".to_string(),
16943 child_session_id: "logical-child-681".to_string(),
16944 model: "model".to_string(),
16945 disabled_tools: None,
16946 };
16947 let expected = LogicalSessionIdentity {
16948 creation: None,
16949 session_id: "logical-child-681".to_string(),
16950 parent_session_id: Some("logical-parent-681".to_string()),
16951 root_session_id: "logical-root-681".to_string(),
16952 };
16953
16954 let placements_and_transport_ids = [
16955 (Placement::Local, "local-mailbox-first"),
16956 (
16957 Placement::Remote {
16958 endpoint: "wss://remote.example/actor".to_string(),
16959 },
16960 "remote-process-44",
16961 ),
16962 (
16963 Placement::Schedulable {
16964 pool: "gpu-pool".to_string(),
16965 },
16966 "scheduled-mailbox-9",
16967 ),
16968 (Placement::Local, "warm-mailbox-reused-77"),
16970 ];
16971 for (placement, transport_id) in placements_and_transport_ids {
16972 let mut provision = spec_with("worker", "provider", "model", None, None);
16973 provision.placement = placement;
16974 provision.identity.child_id = transport_id.to_string();
16975 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16976 assert_ne!(
16977 provision.identity.child_id, expected.session_id,
16978 "test fixture must prove transport identity is independent"
16979 );
16980 }
16981 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16982 original.executor = ExecutorSpec::BambooRuntime;
16983 original.identity.depth = session.spawn_depth;
16984 original.capabilities.required_child_context = true;
16985 original.capabilities.child_creation_identity = true;
16986 original.capabilities.enforce_permissions = true;
16987 original.capabilities.native_tool_ceiling_required = true;
16988 original.capabilities.native_tool_ceiling =
16989 Some(bamboo_subagent::proto::NativeToolCeiling {
16990 version: 1,
16991 child_session_id: session.id.clone(),
16992 parent_session_id: job.parent_session_id.clone(),
16993 root_session_id: session.root_session_id.clone(),
16994 created_at: session.created_at,
16995 spawn_depth: session.spawn_depth,
16996 project_id: None,
16997 tools: vec![],
16998 });
16999 original.validate().unwrap();
17000 let first = ActorChildRunner::fresh_required_worker_spec(&original);
17001 let second = ActorChildRunner::fresh_required_worker_spec(&original);
17002 assert_ne!(first.identity.child_id, second.identity.child_id);
17003 assert_ne!(first.identity.child_id, original.identity.child_id);
17004 for physical in [first, second] {
17005 physical.validate().unwrap();
17006 let mut actual = serde_json::to_value(&physical).unwrap();
17007 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
17008 assert_eq!(actual, serde_json::to_value(&original).unwrap());
17009 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17010 }
17011 }
17012
17013 #[test]
17014 fn fingerprint_separates_distinct_runtimes() {
17015 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
17016 let base_fp = ActorChildRunner::fingerprint(&base);
17017 assert_ne!(
17019 base_fp,
17020 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
17021 );
17022 assert_ne!(
17023 base_fp,
17024 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
17025 );
17026 assert_ne!(
17027 base_fp,
17028 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
17029 );
17030 assert_ne!(
17031 base_fp,
17032 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17033 );
17034 assert_ne!(
17035 base_fp,
17036 ActorChildRunner::fingerprint(&spec_with(
17037 "explorer",
17038 "p",
17039 "m",
17040 Some("/ws"),
17041 Some(vec!["Bash"])
17042 ))
17043 );
17044 }
17045
17046 #[test]
17047 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17048 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17049 before.secrets.provider_credentials.push(ScopedCredential {
17050 provider: "provider".to_string(),
17051 api_key: "old-secret-key".to_string(),
17052 base_url: Some("https://provider.example/v1".to_string()),
17053 provider_type: Some("openai".to_string()),
17054 credential_ref: None,
17055 });
17056 let mut after = before.clone();
17057 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17058
17059 let before_fingerprint = ActorChildRunner::fingerprint(&before);
17060 let after_fingerprint = ActorChildRunner::fingerprint(&after);
17061 assert_ne!(before_fingerprint, after_fingerprint);
17062 assert!(!before_fingerprint.contains("old-secret-key"));
17063 assert!(!after_fingerprint.contains("new-secret-key"));
17064 }
17065
17066 #[test]
17067 fn fingerprint_splits_on_baked_capabilities() {
17068 let base_fp =
17073 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17074
17075 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17076 creation.capabilities.child_creation_identity = true;
17077 let typed = ActorChildRunner::fingerprint(&creation);
17078 assert_ne!(
17079 base_fp, typed,
17080 "legacy worker pool cannot satisfy birth-required Run"
17081 );
17082 creation.identity.child_id = "different-logical-child".into();
17083 assert_eq!(
17084 typed,
17085 ActorChildRunner::fingerprint(&creation),
17086 "birth-required mode retains cross-Child pool reuse"
17087 );
17088
17089 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17090 depth.identity.depth = 2;
17091 assert_ne!(
17092 base_fp,
17093 ActorChildRunner::fingerprint(&depth),
17094 "depth must split"
17095 );
17096
17097 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17098 nested.capabilities.nested_spawn = true;
17099 assert_ne!(
17100 base_fp,
17101 ActorChildRunner::fingerprint(&nested),
17102 "nested_spawn must split"
17103 );
17104
17105 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17106 bypass.capabilities.bypass = true;
17107 assert_ne!(
17108 base_fp,
17109 ActorChildRunner::fingerprint(&bypass),
17110 "bypass must split"
17111 );
17112
17113 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17114 auto.capabilities.auto_approve_permissions = true;
17115 assert_ne!(
17116 base_fp,
17117 ActorChildRunner::fingerprint(&auto),
17118 "auto_approve_permissions must split"
17119 );
17120
17121 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17122 global_auto.capabilities.permission_requested_mode = "default".to_string();
17123 global_auto.capabilities.permission_effective_mode = "auto".to_string();
17124 global_auto.capabilities.auto_approve_permissions = true;
17125 let mut explicit_auto = global_auto.clone();
17126 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17127 assert_ne!(
17128 ActorChildRunner::fingerprint(&global_auto),
17129 ActorChildRunner::fingerprint(&explicit_auto),
17130 "permission_requested_mode must split global and explicit Auto"
17131 );
17132
17133 let mut plan_overlay = explicit_auto.clone();
17134 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17135 assert_ne!(
17136 ActorChildRunner::fingerprint(&explicit_auto),
17137 ActorChildRunner::fingerprint(&plan_overlay),
17138 "permission_effective_mode must split Plan overlay from Auto"
17139 );
17140
17141 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17142 enforce.capabilities.enforce_permissions = true;
17143 assert_ne!(
17144 base_fp,
17145 ActorChildRunner::fingerprint(&enforce),
17146 "enforce_permissions must split"
17147 );
17148
17149 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17150 cap.capabilities.max_spawn_depth = Some(8);
17151 assert_ne!(
17152 base_fp,
17153 ActorChildRunner::fingerprint(&cap),
17154 "max_spawn_depth must split"
17155 );
17156
17157 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17161 nha.capabilities.no_human_approver = true;
17162 assert_ne!(
17163 base_fp,
17164 ActorChildRunner::fingerprint(&nha),
17165 "no_human_approver must split"
17166 );
17167
17168 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17171 read_only.capabilities.read_only = true;
17172 assert_ne!(
17173 base_fp,
17174 ActorChildRunner::fingerprint(&read_only),
17175 "read_only must split"
17176 );
17177
17178 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17180 gro.capabilities.guardian_read_only = true;
17181 assert_ne!(
17182 base_fp,
17183 ActorChildRunner::fingerprint(&gro),
17184 "guardian_read_only must split"
17185 );
17186 }
17187
17188 #[test]
17189 fn fingerprint_splits_codex_exec_and_app_server_workers() {
17190 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17191 exec.executor = codex_executor(Some("inherit"), None);
17192 let mut app_server = exec.clone();
17193 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17194 *mode = Some("app_server".to_string());
17195 }
17196 assert_ne!(
17197 ActorChildRunner::fingerprint(&exec),
17198 ActorChildRunner::fingerprint(&app_server)
17199 );
17200 }
17201
17202 struct StaticDecider(bool);
17203
17204 #[async_trait]
17205 impl ChildApprovalDecider for StaticDecider {
17206 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17207 self.0
17208 }
17209 }
17210
17211 struct RecordingReviewer {
17212 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17213 }
17214
17215 #[async_trait]
17216 impl ChildApprovalReviewer for RecordingReviewer {
17217 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17218 let _ = self
17219 .reviewed
17220 .send((parent.to_string(), child.to_string(), request.clone()));
17221 true
17222 }
17223 }
17224
17225 struct SilentLink;
17230 #[async_trait]
17231 impl bamboo_subagent::ChildLink for SilentLink {
17232 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17233 Ok(())
17234 }
17235 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17236 std::future::pending().await
17237 }
17238 }
17239
17240 struct InstantTerminalLink {
17242 done: bool,
17243 }
17244
17245 struct ApprovalRoundTripLink {
17246 step: u8,
17247 approval_reply: Option<(String, bool)>,
17248 }
17249
17250 #[async_trait]
17251 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17252 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17253 if let ParentFrame::ApprovalReply { id, approved } = frame {
17254 self.approval_reply = Some((id, approved));
17255 self.step = 2;
17256 }
17257 Ok(())
17258 }
17259
17260 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17261 match self.step {
17262 0 => {
17263 self.step = 1;
17264 Ok(Some(ChildFrame::ApprovalRequest {
17265 id: "approval-1".into(),
17266 body: serde_json::json!({
17267 "tool_name": "Bash",
17268 "permission": "execute",
17269 "resource": "rm -rf target",
17270 "permission_request": {"reason_code": "hard_dangerous"}
17271 }),
17272 }))
17273 }
17274 1 => std::future::pending().await,
17275 2 => {
17276 self.step = 3;
17277 Ok(Some(ChildFrame::Terminal {
17278 status: TerminalStatus::Completed,
17279 result: Some("done".into()),
17280 error: None,
17281 final_event_watermark: None,
17282 transcript: vec![],
17283 }))
17284 }
17285 _ => std::future::pending().await,
17286 }
17287 }
17288 }
17289
17290 #[tokio::test]
17291 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17292 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17293 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17294 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17295 reviewed: review_tx,
17296 });
17297 let cancel = CancellationToken::new();
17298 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17299 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17300 let mut logical_session = Session::new("child-reviewer", "model");
17301 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17302 let mut link = ApprovalRoundTripLink {
17303 step: 0,
17304 approval_reply: None,
17305 };
17306
17307 let result = tokio::time::timeout(
17308 Duration::from_secs(1),
17309 drive(ActorDriveContext {
17310 expected_creation: None,
17311 client: &mut link,
17312 parent_session_id: "parent-reviewer",
17313 child_session_id: "child-reviewer",
17314 child_attempt: 0,
17315 approval_registry: None,
17316 approval_decider: None,
17317 approval_reviewer: Some(&reviewer),
17318 escalation_bridge: None,
17319 event_tx: &event_tx,
17320 cancel_token: &cancel,
17321 live_rx: &mut live_rx,
17322 delivery_rx: &mut delivery_rx,
17323 logical_session: &mut logical_session,
17324 expected_permission_posture: None,
17325 session_inbox_runtime: None,
17326 actor_directory_store: None,
17327 canonical_subagent_tool: None,
17328 ticket_service: None,
17329 activation_run_id: None,
17330 execution_epoch: 0,
17331 expected_source_actor_id: "child-reviewer",
17332 initial_inflight_claims: VecDeque::new(),
17333 plain_actor: false,
17334 remote_environment_lease: false,
17335 readonly_output: None,
17336 local_history_tools: None,
17337 local_history_read_only: false,
17338 strict_history_output: None,
17339 plain_input: None,
17340 canonical_activation: None,
17341 canonical_placement_ref: None,
17342 actor_event_observer: None,
17343 plain_run: None,
17344 first_frame_timeout: None,
17345 }),
17346 )
17347 .await
17348 .expect("worker must receive the reviewer verdict before terminating");
17349
17350 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17351 assert_eq!(
17352 link.approval_reply,
17353 Some(("approval-1".to_string(), true)),
17354 "reviewer verdict must traverse the live route back to the worker"
17355 );
17356 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17357 .await
17358 .expect("reviewer should be invoked off-loop")
17359 .expect("review channel should remain open");
17360 assert_eq!(parent, "parent-reviewer");
17361 assert_eq!(child, "child-reviewer");
17362 assert_eq!(
17363 body.pointer("/permission_request/reason_code")
17364 .and_then(serde_json::Value::as_str),
17365 Some("hard_dangerous")
17366 );
17367 assert!(
17368 event_rx.try_recv().is_err(),
17369 "must not emit a human-review event"
17370 );
17371 drop(live_guard);
17372 }
17373
17374 #[tokio::test]
17375 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17376 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17377 let cancel = CancellationToken::new();
17378 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17379 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17380 let mut logical_session = Session::new("child-no-reviewer", "model");
17381 let mut link = ApprovalRoundTripLink {
17382 step: 0,
17383 approval_reply: None,
17384 };
17385
17386 let result = tokio::time::timeout(
17387 Duration::from_secs(1),
17388 drive(ActorDriveContext {
17389 expected_creation: None,
17390 client: &mut link,
17391 parent_session_id: "parent-no-reviewer",
17392 child_session_id: "child-no-reviewer",
17393 child_attempt: 0,
17394 approval_registry: None,
17395 approval_decider: None,
17396 approval_reviewer: None,
17397 escalation_bridge: None,
17398 event_tx: &event_tx,
17399 cancel_token: &cancel,
17400 live_rx: &mut live_rx,
17401 delivery_rx: &mut delivery_rx,
17402 logical_session: &mut logical_session,
17403 expected_permission_posture: None,
17404 session_inbox_runtime: None,
17405 actor_directory_store: None,
17406 canonical_subagent_tool: None,
17407 ticket_service: None,
17408 activation_run_id: None,
17409 execution_epoch: 0,
17410 expected_source_actor_id: "child-no-reviewer",
17411 initial_inflight_claims: VecDeque::new(),
17412 plain_actor: false,
17413 remote_environment_lease: false,
17414 readonly_output: None,
17415 local_history_tools: None,
17416 local_history_read_only: false,
17417 strict_history_output: None,
17418 plain_input: None,
17419 canonical_activation: None,
17420 canonical_placement_ref: None,
17421 actor_event_observer: None,
17422 plain_run: None,
17423 first_frame_timeout: None,
17424 }),
17425 )
17426 .await
17427 .expect("fail-closed reply must unblock the child immediately");
17428
17429 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17430 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17431 assert!(
17432 event_rx.try_recv().is_err(),
17433 "missing parent review must not surface a manual approval event"
17434 );
17435 }
17436 #[async_trait]
17437 impl bamboo_subagent::ChildLink for InstantTerminalLink {
17438 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17439 Ok(())
17440 }
17441 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17442 if self.done {
17443 std::future::pending().await
17444 } else {
17445 self.done = true;
17446 Ok(Some(ChildFrame::Terminal {
17447 status: TerminalStatus::Completed,
17448 result: Some("done".into()),
17449 error: None,
17450 final_event_watermark: None,
17451 transcript: vec![],
17452 }))
17453 }
17454 }
17455 }
17456
17457 #[tokio::test]
17458 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17459 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17460 let cancel = CancellationToken::new();
17461 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17462 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17463 let mut logical_session = Session::new("child-x", "model");
17464 let mut link = SilentLink;
17465 let r = drive(ActorDriveContext {
17466 expected_creation: None,
17467 client: &mut link,
17468 parent_session_id: "parent-x",
17469 child_session_id: "child-x",
17470 child_attempt: 0,
17471 approval_registry: None,
17472 approval_decider: None,
17473 approval_reviewer: None,
17474 escalation_bridge: None,
17475 event_tx: &event_tx,
17476 cancel_token: &cancel,
17477 live_rx: &mut live_rx,
17478 delivery_rx: &mut delivery_rx,
17479 logical_session: &mut logical_session,
17480 expected_permission_posture: None,
17481 session_inbox_runtime: None,
17482 actor_directory_store: None,
17483 canonical_subagent_tool: None,
17484 ticket_service: None,
17485 activation_run_id: None,
17486 execution_epoch: 0,
17487 expected_source_actor_id: "child-x",
17488 initial_inflight_claims: VecDeque::new(),
17489 plain_actor: false,
17490 remote_environment_lease: false,
17491 readonly_output: None,
17492 local_history_tools: None,
17493 local_history_read_only: false,
17494 strict_history_output: None,
17495 plain_input: None,
17496 canonical_activation: None,
17497 canonical_placement_ref: None,
17498 actor_event_observer: None,
17499 plain_run: None,
17500 first_frame_timeout: Some(Duration::from_millis(100)),
17501 })
17502 .await;
17503 assert!(
17504 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17505 "a silent worker must trip the first-frame watchdog, got {r:?}"
17506 );
17507 }
17508
17509 #[tokio::test]
17510 async fn drive_does_not_trip_when_a_frame_arrives() {
17511 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17512 let cancel = CancellationToken::new();
17513 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17514 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17515 let mut logical_session = Session::new("child-y", "model");
17516 let mut link = InstantTerminalLink { done: false };
17517 let r = drive(ActorDriveContext {
17520 expected_creation: None,
17521 client: &mut link,
17522 parent_session_id: "parent-y",
17523 child_session_id: "child-y",
17524 child_attempt: 0,
17525 approval_registry: None,
17526 approval_decider: None,
17527 approval_reviewer: None,
17528 escalation_bridge: None,
17529 event_tx: &event_tx,
17530 cancel_token: &cancel,
17531 live_rx: &mut live_rx,
17532 delivery_rx: &mut delivery_rx,
17533 logical_session: &mut logical_session,
17534 expected_permission_posture: None,
17535 session_inbox_runtime: None,
17536 actor_directory_store: None,
17537 canonical_subagent_tool: None,
17538 ticket_service: None,
17539 activation_run_id: None,
17540 execution_epoch: 0,
17541 expected_source_actor_id: "child-y",
17542 initial_inflight_claims: VecDeque::new(),
17543 plain_actor: false,
17544 remote_environment_lease: false,
17545 readonly_output: None,
17546 local_history_tools: None,
17547 local_history_read_only: false,
17548 strict_history_output: None,
17549 plain_input: None,
17550 canonical_activation: None,
17551 canonical_placement_ref: None,
17552 actor_event_observer: None,
17553 plain_run: None,
17554 first_frame_timeout: Some(Duration::from_millis(50)),
17555 })
17556 .await;
17557 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17558 }
17559
17560 #[tokio::test]
17561 async fn child_approval_fails_closed_without_decider() {
17562 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17564 assert!(!decide_child_approval(None, "child-1", &body).await);
17565 }
17566
17567 #[tokio::test]
17568 async fn child_approval_honors_wired_decider() {
17569 let body =
17570 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17571 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17572 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17573 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17574 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17575 }
17576
17577 use crate::runtime::execution::SpawnJob;
17580 use bamboo_agent_core::Session;
17581
17582 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17585 ActorChildRunner::new(
17586 "test-actor".into(),
17587 PathBuf::from("/bin/false"),
17588 vec![],
17589 std::env::temp_dir().join("bamboo-test-fab-193"),
17590 ExecutorSpec::Echo,
17591 vec![],
17592 "anthropic".into(),
17593 4,
17594 )
17595 .with_remote_placements(placements)
17596 }
17597
17598 fn session_of_role(role: &str, assignment: &str) -> Session {
17601 let mut s = Session::new("child-1", "test-model");
17602 s.metadata
17603 .insert("subagent_type".to_string(), role.to_string());
17604 s.add_message(bamboo_agent_core::Message::user(assignment));
17605 s
17606 }
17607
17608 fn job_for(child: &str) -> SpawnJob {
17609 SpawnJob {
17610 parent_session_id: "parent-1".into(),
17611 child_session_id: child.into(),
17612 model: String::new(),
17613 disabled_tools: None,
17614 }
17615 }
17616
17617 #[tokio::test]
17618 async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17619 let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17620 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17621 let mut remote = HashMap::new();
17622 remote.insert(
17623 "remote-role".into(),
17624 ResolvedRemotePlacement {
17625 endpoint: "wss://worker.example:8443".into(),
17626 token: None,
17627 ca_cert_file: None,
17628 host_label: None,
17629 broker_peer: None,
17630 requirements: None,
17631 },
17632 );
17633 let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17634 let root = Session::new("provision-root", "model");
17635 for role in ["local-role", "remote-role"] {
17636 let mut child =
17637 Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17638 child.metadata.insert("subagent_type".into(), role.into());
17639 let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17640 assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17641 assert!(!first.capabilities.nested_spawn);
17642 assert_eq!(
17643 matches!(first.placement, Placement::Remote { .. }),
17644 role == "remote-role"
17645 );
17646 live.write().await.subagents_mut().max_spawn_depth = Some(7);
17647 let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17648 assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17649 assert!(second.capabilities.nested_spawn);
17650 assert_ne!(
17651 ActorChildRunner::fingerprint(&first),
17652 ActorChildRunner::fingerprint(&second)
17653 );
17654 assert_eq!(second.identity.depth, 1);
17655 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17656 }
17657 let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17658 let spec = fallback.build_spec(&root, &job_for(&root.id));
17659 assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17660 assert!(!spec.capabilities.nested_spawn);
17661 }
17662
17663 struct RecordingChildSessionPort {
17664 parent: Session,
17665 saved: std::sync::Mutex<Option<Session>>,
17666 }
17667
17668 impl RecordingChildSessionPort {
17669 fn saved_child(&self) -> Session {
17670 self.saved
17671 .lock()
17672 .expect("saved-child fixture lock")
17673 .clone()
17674 .expect("create_child_action must save the child")
17675 }
17676 }
17677
17678 #[async_trait]
17679 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17680 async fn load_root_session(
17681 &self,
17682 root_id: &str,
17683 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17684 assert_eq!(root_id, self.parent.id);
17685 Ok(self.parent.clone())
17686 }
17687
17688 async fn load_child_for_parent(
17689 &self,
17690 _parent_id: &str,
17691 _child_id: &str,
17692 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17693 unreachable!("create_child_action does not reload the child")
17694 }
17695
17696 async fn save_child_session(
17697 &self,
17698 child: &mut Session,
17699 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17700 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17701 Ok(())
17702 }
17703
17704 async fn save_child_session_authoritative_flags(
17705 &self,
17706 _child: &mut Session,
17707 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17708 unreachable!("new-child creation uses the ordinary save")
17709 }
17710
17711 async fn is_child_running(&self, _child_id: &str) -> bool {
17712 false
17713 }
17714
17715 async fn list_children(
17716 &self,
17717 _parent_id: &str,
17718 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17719 Vec::new()
17720 }
17721
17722 async fn enqueue_child_run(
17723 &self,
17724 _parent: &Session,
17725 _child: &Session,
17726 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17727 unreachable!("fixture creates the child with auto_run=false")
17728 }
17729
17730 async fn cancel_child_run_and_wait(
17731 &self,
17732 _child_id: &str,
17733 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17734 unreachable!("create_child_action does not cancel")
17735 }
17736
17737 async fn delete_child_session(
17738 &self,
17739 _parent_id: &str,
17740 _child_id: &str,
17741 ) -> Result<
17742 crate::session_app::child_session::DeleteChildResult,
17743 crate::session_app::child_session::ChildSessionError,
17744 > {
17745 unreachable!("create_child_action does not delete")
17746 }
17747
17748 async fn get_child_runner_info(
17749 &self,
17750 _child_id: &str,
17751 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17752 None
17753 }
17754
17755 async fn register_parent_wait_for_child(
17756 &self,
17757 _parent_session_id: &str,
17758 _child_session_id: &str,
17759 _tool_call_id: Option<&str>,
17760 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17761 unreachable!("create_child_action does not register a wait")
17762 }
17763
17764 async fn register_parent_wait_for_children(
17765 &self,
17766 _parent_session_id: &str,
17767 _child_session_ids: &[String],
17768 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17769 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17770 unreachable!("create_child_action does not register a wait")
17771 }
17772
17773 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17774 Vec::new()
17775 }
17776
17777 async fn find_resident_child(
17778 &self,
17779 _root_session_id: &str,
17780 _resident_name: &str,
17781 ) -> Option<String> {
17782 None
17783 }
17784
17785 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17786 }
17787
17788 #[test]
17789 fn build_spec_sets_remote_placement_for_matching_role() {
17790 let mut placements = HashMap::new();
17791 placements.insert(
17792 "explorer".to_string(),
17793 ResolvedRemotePlacement {
17794 endpoint: "wss://gpu-host:8443".into(),
17795 token: Some("T-secret".into()),
17796 ca_cert_file: None,
17797 host_label: None,
17798 broker_peer: None,
17799 requirements: None,
17800 },
17801 );
17802 let runner = bogus_runner(placements);
17803
17804 let s = session_of_role("explorer", "do the thing");
17806 let spec = runner.build_spec(&s, &job_for("child-1"));
17807 match &spec.placement {
17808 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17809 other => panic!("expected Remote, got {other:?}"),
17810 }
17811 assert!(spec.secrets.worker_auth_token.is_none());
17812 }
17813
17814 #[test]
17815 fn build_spec_leaves_local_for_unmatched_role() {
17816 let mut placements = HashMap::new();
17817 placements.insert(
17818 "explorer".to_string(),
17819 ResolvedRemotePlacement {
17820 endpoint: "wss://gpu-host:8443".into(),
17821 token: Some("T".into()),
17822 ca_cert_file: None,
17823 host_label: None,
17824 broker_peer: None,
17825 requirements: None,
17826 },
17827 );
17828 let runner = bogus_runner(placements);
17829
17830 let s = session_of_role("writer", "do the thing");
17832 let spec = runner.build_spec(&s, &job_for("child-1"));
17833 assert_eq!(spec.placement, Placement::Local);
17834 assert!(spec.secrets.worker_auth_token.is_none());
17835 }
17836
17837 #[test]
17838 fn build_spec_local_when_no_placements() {
17839 let runner = bogus_runner(HashMap::new());
17840 let s = session_of_role("explorer", "do the thing");
17841 let spec = runner.build_spec(&s, &job_for("child-1"));
17842 assert_eq!(spec.placement, Placement::Local);
17843 assert!(spec.secrets.worker_auth_token.is_none());
17844 }
17845
17846 #[test]
17847 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17848 for mode in [
17849 bamboo_domain::SessionPermissionMode::Auto,
17850 bamboo_domain::SessionPermissionMode::Bypass,
17851 ] {
17852 let runner = bogus_runner(HashMap::new());
17853 let mut child = session_of_role("planner", "inspect and plan");
17854 let runtime = child
17855 .agent_runtime_state
17856 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17857 runtime.set_permission_mode(mode);
17858 runtime.read_only = true;
17859
17860 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17861
17862 assert!(spec.capabilities.read_only);
17863 assert!(spec.capabilities.read_only_enforced());
17864 assert!(
17865 spec.capabilities.guardian_read_only,
17866 "the compatibility alias must protect planners on older workers"
17867 );
17868 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17869 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17870 assert!(
17871 !spec.capabilities.bypass,
17872 "read-only must win over requested {mode:?}"
17873 );
17874 assert_eq!(
17875 spec.capabilities.auto_approve_permissions,
17876 mode == bamboo_domain::SessionPermissionMode::Auto,
17877 "Auto may suppress prompts but must not widen Plan authorization"
17878 );
17879 let disabled = spec
17880 .disabled_tools
17881 .as_ref()
17882 .expect("read-only state must provision a host-owned denylist");
17883 for tool_name in [
17884 "Edit",
17885 "Write",
17886 "Plan",
17887 "SubAgent",
17888 "session_control",
17889 "request_permissions",
17890 ] {
17891 assert!(
17892 disabled.iter().any(|name| name == tool_name),
17893 "read-only provisioning must disable {tool_name}"
17894 );
17895 }
17896 }
17897
17898 let runner = bogus_runner(HashMap::new());
17899 let cosmetic_planner = runner.build_spec(
17900 &session_of_role("planner", "label only"),
17901 &job_for("cosmetic"),
17902 );
17903 assert!(
17904 !cosmetic_planner.capabilities.read_only_enforced(),
17905 "a cosmetic role label must not change authority"
17906 );
17907 assert!(cosmetic_planner.disabled_tools.is_none());
17908
17909 let cosmetic_guardian = runner.build_spec(
17910 &session_of_role("guardian", "label only"),
17911 &job_for("cosmetic-guardian"),
17912 );
17913 assert!(
17914 !cosmetic_guardian.capabilities.read_only_enforced(),
17915 "a Guardian label without typed state or the legacy denylist is cosmetic"
17916 );
17917
17918 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17919 let mut legacy_job = job_for("legacy-guardian");
17920 legacy_job.disabled_tools = Some(vec![
17921 "Edit".to_string(),
17922 "Write".to_string(),
17923 "SubAgent".to_string(),
17924 "WebFetch".to_string(),
17925 ]);
17926 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17927 assert!(
17928 legacy_spec.capabilities.read_only_enforced(),
17929 "an in-flight pre-upgrade Guardian keeps its read-only authority"
17930 );
17931
17932 let mut differently_named = session_of_role("explorer", "typed authority");
17933 differently_named
17934 .agent_runtime_state
17935 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17936 .read_only = true;
17937 let typed = runner.build_spec(&differently_named, &job_for("typed"));
17938 assert!(
17939 typed.capabilities.read_only_enforced(),
17940 "typed state must enforce read-only independently of role"
17941 );
17942 }
17943
17944 #[test]
17945 fn typed_read_only_forces_native_external_executor_profiles() {
17946 let mut planner = session_of_role("planner", "inspect and plan");
17947 planner
17948 .agent_runtime_state
17949 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17950 .read_only = true;
17951
17952 let claude_runner = ActorChildRunner::new(
17953 "test-actor".into(),
17954 PathBuf::from("/bin/false"),
17955 vec![],
17956 std::env::temp_dir().join("bamboo-test-plan-claude"),
17957 ExecutorSpec::ClaudeCode {
17958 binary: None,
17959 model: None,
17960 permission_mode: Some("bypassPermissions".to_string()),
17961 inherit_user_config: Some(true),
17962 forward_env: None,
17963 },
17964 vec![],
17965 "anthropic".into(),
17966 4,
17967 );
17968 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17969 let disabled = claude_spec
17970 .disabled_tools
17971 .as_ref()
17972 .expect("typed read-only child must receive a host-owned denylist");
17973 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17974 assert!(
17975 disabled.iter().any(|name| name == tool),
17976 "typed read-only child must not advertise {tool}"
17977 );
17978 }
17979 assert!(matches!(
17980 claude_spec.executor,
17981 ExecutorSpec::ClaudeCode {
17982 permission_mode: Some(ref mode),
17983 inherit_user_config: Some(false),
17984 ..
17985 } if mode == "plan"
17986 ));
17987 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
17988
17989 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
17990 if let ExecutorSpec::Codex {
17991 sandbox,
17992 approval_policy,
17993 network_access,
17994 allow_danger_bypass,
17995 permission_profile,
17996 ..
17997 } = &mut codex_exec
17998 {
17999 *sandbox = Some("danger-full-access".to_string());
18000 *approval_policy = Some("on-failure".to_string());
18001 *network_access = Some(true);
18002 *allow_danger_bypass = Some(true);
18003 *permission_profile = Some("full-access".to_string());
18004 }
18005 let codex_runner = ActorChildRunner::new(
18006 "test-actor".into(),
18007 PathBuf::from("/bin/false"),
18008 vec![],
18009 std::env::temp_dir().join("bamboo-test-plan-codex"),
18010 codex_exec,
18011 vec![],
18012 "openai".into(),
18013 4,
18014 );
18015 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
18016 assert_eq!(
18017 expected_permission_executor_mapping(
18018 &codex_spec.executor,
18019 codex_spec.capabilities.permission_resolution().unwrap(),
18020 false,
18021 )
18022 .unwrap()
18023 .as_deref(),
18024 Some("codex_exec:approval_policy=never")
18025 );
18026 assert!(matches!(
18027 codex_spec.executor,
18028 ExecutorSpec::Codex {
18029 sandbox: Some(ref sandbox),
18030 inherit_user_config: Some(false),
18031 approval_policy: Some(ref approval_policy),
18032 network_access: Some(false),
18033 allow_danger_bypass: Some(false),
18034 permission_profile: Some(ref profile),
18035 ..
18036 } if sandbox == "read-only"
18037 && approval_policy == "never"
18038 && profile == "read-only"
18039 ));
18040
18041 let mut app_server = codex_executor(Some("inherit"), Some(true));
18042 if let ExecutorSpec::Codex {
18043 mode,
18044 sandbox,
18045 approval_policy,
18046 network_access,
18047 allow_danger_bypass,
18048 permission_profile,
18049 ..
18050 } = &mut app_server
18051 {
18052 *mode = Some("app_server".to_string());
18053 *sandbox = Some("danger-full-access".to_string());
18054 *approval_policy = Some("on-request".to_string());
18055 *network_access = Some(true);
18056 *allow_danger_bypass = Some(true);
18057 *permission_profile = Some("full-access".to_string());
18058 }
18059 let app_server_runner = ActorChildRunner::new(
18060 "test-actor".into(),
18061 PathBuf::from("/bin/false"),
18062 vec![],
18063 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18064 app_server,
18065 vec![],
18066 "openai".into(),
18067 4,
18068 );
18069 let app_server_spec =
18070 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18071 assert_eq!(
18072 expected_permission_executor_mapping(
18073 &app_server_spec.executor,
18074 app_server_spec
18075 .capabilities
18076 .permission_resolution()
18077 .unwrap(),
18078 false,
18079 )
18080 .unwrap()
18081 .as_deref(),
18082 Some("codex_app_server:approvalPolicy=never")
18083 );
18084 assert!(matches!(
18085 app_server_spec.executor,
18086 ExecutorSpec::Codex {
18087 mode: Some(ref mode),
18088 sandbox: Some(ref sandbox),
18089 inherit_user_config: Some(false),
18090 approval_policy: Some(ref approval_policy),
18091 network_access: Some(false),
18092 allow_danger_bypass: Some(false),
18093 permission_profile: Some(ref profile),
18094 ..
18095 } if mode == "app_server"
18096 && sandbox == "read-only"
18097 && approval_policy == "on-request"
18098 && profile == "read-only"
18099 ));
18100 }
18101
18102 #[test]
18103 fn typed_read_only_rejects_both_codex_executor_modes() {
18104 let mut planner = session_of_role("planner", "inspect and plan");
18105 planner
18106 .agent_runtime_state
18107 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18108 .read_only = true;
18109
18110 for mode in ["exec", "app_server"] {
18111 let mut executor = codex_executor(Some("inherit"), Some(true));
18112 if let ExecutorSpec::Codex {
18113 mode: configured_mode,
18114 ..
18115 } = &mut executor
18116 {
18117 *configured_mode = Some(mode.to_string());
18118 }
18119 let runner = ActorChildRunner::new(
18120 "test-actor".into(),
18121 PathBuf::from("/bin/false"),
18122 vec![],
18123 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18124 executor,
18125 vec![],
18126 "openai".into(),
18127 4,
18128 );
18129 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18130 let error = ensure_read_only_activation_is_enforceable(&spec)
18131 .expect_err("Codex command execution must fail closed for read-only activations");
18132 let message = error.to_string();
18133 assert!(message.contains("Codex executor"), "{message}");
18134 assert!(message.contains("command execution"), "{message}");
18135 }
18136 }
18137
18138 #[tokio::test]
18139 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18140 let mut planner = session_of_role("planner", "inspect and plan");
18141 planner
18142 .agent_runtime_state
18143 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18144 .read_only = true;
18145
18146 let runner = ActorChildRunner::new(
18147 "test-actor".into(),
18148 PathBuf::from("/bin/false"),
18149 vec![],
18150 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18151 codex_executor(Some("inherit"), Some(true)),
18152 vec![],
18153 "openai".into(),
18154 4,
18155 );
18156 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18157 let error = runner
18158 .execute_external_child(
18159 &mut planner,
18160 &job_for("planner-codex-no-spawn"),
18161 event_tx,
18162 CancellationToken::new(),
18163 )
18164 .await
18165 .expect_err("typed read-only Codex must fail before invoking /bin/false");
18166 let message = error.to_string();
18167 assert!(message.contains("Codex executor"), "{message}");
18168 assert!(message.contains("command execution"), "{message}");
18169 }
18170
18171 #[test]
18172 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18173 let runner = bogus_runner(HashMap::new());
18174 let ordinary = session_of_role("worker", "ordinary work");
18175 for placement in [
18176 Placement::Local,
18177 Placement::Remote {
18178 endpoint: "wss://fixture.invalid/actor".into(),
18179 },
18180 Placement::Schedulable {
18181 pool: "fixture-pool".into(),
18182 },
18183 ] {
18184 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18185 spec.placement = placement;
18186 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18187 for marker in [
18188 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18189 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18190 ] {
18191 let mut ticket = ordinary.clone();
18192 ticket.metadata.insert(marker.into(), "fixture".into());
18193 assert_eq!(
18194 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18195 matches!(spec.placement, Placement::Local)
18196 );
18197 }
18198 }
18199 }
18200
18201 #[tokio::test]
18202 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18203 for marker in [
18204 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18205 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18206 ] {
18207 for schedulable in [false, true] {
18208 let mut session = session_of_role("worker", "Ticket work");
18209 session.metadata.insert(marker.into(), "fixture".into());
18210 let before = serde_json::to_value(&session).unwrap();
18211 let remote = if schedulable {
18212 HashMap::new()
18213 } else {
18214 HashMap::from([(
18215 "worker".into(),
18216 ResolvedRemotePlacement {
18217 endpoint: "wss://fixture.invalid/actor".into(),
18218 ..Default::default()
18219 },
18220 )])
18221 };
18222 let sched = if schedulable {
18223 HashMap::from([(
18224 "worker".into(),
18225 sched_placement("fixture-pool", "wss://fixture.invalid"),
18226 )])
18227 } else {
18228 HashMap::new()
18229 };
18230 let runner = bogus_sched_runner(remote, sched);
18231 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18232 let error = runner
18233 .execute_external_child(
18234 &mut session,
18235 &job_for("ticket-no-remote"),
18236 events,
18237 CancellationToken::new(),
18238 )
18239 .await
18240 .unwrap_err();
18241 assert!(
18242 error
18243 .to_string()
18244 .contains("Ticket execution requires local placement"),
18245 "{error}"
18246 );
18247 assert_eq!(serde_json::to_value(&session).unwrap(), before);
18248 assert!(observations.try_recv().is_err());
18249 }
18250 }
18251 }
18252
18253 #[test]
18254 fn typed_read_only_rejects_resident_actor_placements() {
18255 let runner = bogus_runner(HashMap::new());
18256 let mut planner = session_of_role("planner", "inspect and plan");
18257 planner
18258 .agent_runtime_state
18259 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18260 .read_only = true;
18261
18262 for placement in [
18263 Placement::Remote {
18264 endpoint: "wss://resident.example/actor".to_string(),
18265 },
18266 Placement::Schedulable {
18267 pool: "resident-pool".to_string(),
18268 },
18269 ] {
18270 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18271 spec.placement = placement;
18272
18273 let error = ensure_read_only_activation_is_enforceable(&spec)
18274 .expect_err("resident placement must fail closed for read-only activation");
18275 let message = error.to_string();
18276 assert!(message.contains("requires local placement"), "{message}");
18277 assert!(message.contains("read-only tool boundary"), "{message}");
18278 }
18279
18280 let local = runner.build_spec(&planner, &job_for("read-only-local"));
18281 ensure_read_only_activation_is_enforceable(&local)
18282 .expect("a locally provisioned read-only worker is enforceable");
18283 }
18284
18285 #[tokio::test]
18286 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18287 for (label, mode) in [
18291 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18292 ("auto", bamboo_domain::SessionPermissionMode::Auto),
18293 ] {
18294 let runner = bogus_runner(HashMap::new());
18295 let mut parent = Session::new(format!("parent-{label}"), "test-model");
18296 parent
18297 .agent_runtime_state
18298 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18299 .set_permission_mode(mode);
18300 let workspace = tempfile::tempdir().expect("workspace fixture");
18301 let port = RecordingChildSessionPort {
18302 parent: parent.clone(),
18303 saved: Default::default(),
18304 };
18305 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18306 crate::session_app::child_session::create_child_action(
18307 &port,
18308 crate::session_app::child_session::CreateChildInput {
18309 parent_session: parent,
18310 child_id: child_id.clone(),
18311 title: format!("{label} child"),
18312 responsibility: "Run ordinary commands".to_string(),
18313 assignment_prompt: "run an ordinary command".to_string(),
18314 subagent_type: "explorer".to_string(),
18315 workspace: workspace.path().to_string_lossy().into_owned(),
18316 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18317 model_override: None,
18318 model_ref_override: None,
18319 runtime_metadata: HashMap::new(),
18320 read_only: false,
18321 auto_run: false,
18322 reasoning_effort: None,
18323 lifecycle: None,
18324 resident_name: None,
18325 resident_context: None,
18326 disabled_tools: None,
18327 context_fork: None,
18328 },
18329 )
18330 .await
18331 .expect("create inherited-permission child");
18332 let child = port.saved_child();
18333
18334 assert_eq!(
18335 child
18336 .agent_runtime_state
18337 .as_ref()
18338 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18339 Some(mode),
18340 "create_child_action must inherit {label} from the parent"
18341 );
18342
18343 let spec = runner.build_spec(&child, &job_for(&child_id));
18344
18345 assert_eq!(
18346 spec.capabilities.bypass,
18347 mode == bamboo_domain::SessionPermissionMode::Bypass
18348 );
18349 assert_eq!(
18350 spec.capabilities.auto_approve_permissions,
18351 mode == bamboo_domain::SessionPermissionMode::Auto
18352 );
18353 assert!(
18354 spec.capabilities.enforce_permissions,
18355 "policy evaluation must remain active under {label}"
18356 );
18357 }
18358 }
18359
18360 #[tokio::test]
18361 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18362 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18363 let workspace = tempfile::tempdir().expect("workspace fixture");
18364
18365 for (role, lifecycle, resident_name) in [
18366 ("explorer", None, None),
18367 ("resident", Some("resident"), Some("stable-reviewer")),
18368 ("guardian", None, None),
18369 ] {
18370 let mut parent = Session::new(format!("parent-{role}"), "test-model");
18371 parent.set_project_id_meta(project_id.to_string());
18372 let port = RecordingChildSessionPort {
18373 parent: parent.clone(),
18374 saved: Default::default(),
18375 };
18376 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18377 crate::session_app::child_session::create_child_action(
18378 &port,
18379 crate::session_app::child_session::CreateChildInput {
18380 parent_session: parent,
18381 child_id: child_id.clone(),
18382 title: format!("{role} child"),
18383 responsibility: "Review the assigned work".to_string(),
18384 assignment_prompt: "inspect the change".to_string(),
18385 subagent_type: role.to_string(),
18386 workspace: workspace.path().to_string_lossy().into_owned(),
18387 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18388 model_override: None,
18389 model_ref_override: None,
18390 runtime_metadata: HashMap::new(),
18391 read_only: false,
18392 auto_run: false,
18393 reasoning_effort: None,
18394 lifecycle: lifecycle.map(str::to_string),
18395 resident_name: resident_name.map(str::to_string),
18396 resident_context: None,
18397 disabled_tools: None,
18398 context_fork: None,
18399 },
18400 )
18401 .await
18402 .expect("create Project-inheriting child");
18403 let child = port.saved_child();
18404 assert_eq!(
18405 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18406 Some(project_id.clone()),
18407 "{role} child must inherit its parent's Project"
18408 );
18409
18410 assert_eq!(
18411 project_id_for_actor_run(&child).expect("valid actor Project identity"),
18412 Some(project_id.clone()),
18413 "{role} actor RunSpec must preserve inherited Project identity"
18414 );
18415 }
18416 }
18417
18418 #[test]
18419 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18420 assert_eq!(placement_metadata(&Placement::Local, None), None);
18422
18423 let r = placement_metadata(
18425 &Placement::Remote {
18426 endpoint: "wss://10.0.0.5:8443/stream".into(),
18427 },
18428 None,
18429 )
18430 .unwrap();
18431 assert!(r.contains(r#""kind":"remote""#), "{r}");
18432 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18433
18434 let labeled = placement_metadata(
18436 &Placement::Remote {
18437 endpoint: "ws://169.254.230.101:8899".into(),
18438 },
18439 Some("mini"),
18440 )
18441 .unwrap();
18442 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18443
18444 let s = placement_metadata(
18446 &Placement::Schedulable {
18447 pool: "explorers".into(),
18448 },
18449 Some("mini"),
18450 )
18451 .unwrap();
18452 assert!(s.contains(r#""kind":"remote""#), "{s}");
18453 assert!(s.contains(r#""host":"mini""#), "{s}");
18454
18455 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18457 assert_eq!(p.kind, "remote");
18458 assert_eq!(p.host, "mini");
18459 }
18460
18461 #[tokio::test]
18464 async fn execute_external_child_rejects_direct_remote_worker() {
18465 let mut placements = HashMap::new();
18466 placements.insert(
18467 "explorer".to_string(),
18468 ResolvedRemotePlacement {
18469 endpoint: "ws://127.0.0.1:1".into(),
18470 token: Some("remote-test-token".into()),
18471 ca_cert_file: None,
18472 host_label: Some("mini-e2e".into()),
18473 broker_peer: None,
18474 requirements: None,
18475 },
18476 );
18477 let runner = bogus_runner(placements);
18478 let mut session = session_of_role("explorer", "hello remote");
18479 let job = job_for("child-1");
18480 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18481 let error = runner
18482 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18483 .await
18484 .err()
18485 .expect("direct remote transport must fail closed");
18486 assert!(error
18487 .to_string()
18488 .contains("remote_broker_activation_unavailable"));
18489 assert!(session
18490 .messages
18491 .iter()
18492 .all(|message| message.role != Role::Assistant));
18493 }
18494
18495 fn bogus_sched_runner(
18501 remote: HashMap<String, ResolvedRemotePlacement>,
18502 sched: HashMap<String, ResolvedSchedulablePlacement>,
18503 ) -> ActorChildRunner {
18504 ActorChildRunner::new(
18505 "test-actor".into(),
18506 PathBuf::from("/bin/false"),
18507 vec![],
18508 std::env::temp_dir().join("bamboo-test-fab-181"),
18509 ExecutorSpec::Echo,
18510 vec![],
18511 "anthropic".into(),
18512 4,
18513 )
18514 .with_remote_placements(remote)
18515 .with_schedulable_placements(sched)
18516 }
18517
18518 fn sched_placement(
18519 pool: &str,
18520 _registry_url: impl Into<String>,
18521 ) -> ResolvedSchedulablePlacement {
18522 ResolvedSchedulablePlacement {
18523 pool: pool.into(),
18524 host_label: None,
18525 requirements: None,
18526 ..Default::default()
18527 }
18528 }
18529
18530 #[test]
18531 fn build_spec_sets_schedulable_placement_for_matching_role() {
18532 let mut sched = HashMap::new();
18533 sched.insert(
18534 "explorer".to_string(),
18535 sched_placement("gpu-pool", "unused"),
18536 );
18537 let runner = bogus_sched_runner(HashMap::new(), sched);
18538
18539 let s = session_of_role("explorer", "do the thing");
18540 let spec = runner.build_spec(&s, &job_for("child-1"));
18541 match &spec.placement {
18542 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18543 other => panic!("expected Schedulable, got {other:?}"),
18544 }
18545 assert!(spec.secrets.worker_auth_token.is_none());
18547 }
18548
18549 #[test]
18550 fn build_spec_remote_wins_when_role_in_both_maps() {
18551 let mut remote = HashMap::new();
18554 remote.insert(
18555 "explorer".to_string(),
18556 ResolvedRemotePlacement {
18557 endpoint: "wss://fixed-host:8443".into(),
18558 token: Some("T-remote".into()),
18559 ca_cert_file: None,
18560 host_label: None,
18561 broker_peer: None,
18562 requirements: None,
18563 },
18564 );
18565 let mut sched = HashMap::new();
18566 sched.insert(
18567 "explorer".to_string(),
18568 sched_placement("gpu-pool", "https://control-plane:9562"),
18569 );
18570 let runner = bogus_sched_runner(remote, sched);
18571
18572 let s = session_of_role("explorer", "do the thing");
18573 let spec = runner.build_spec(&s, &job_for("child-1"));
18574 match &spec.placement {
18575 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18576 other => panic!("expected Remote (precedence), got {other:?}"),
18577 }
18578 assert!(spec.secrets.worker_auth_token.is_none());
18579 }
18580
18581 #[test]
18582 fn build_spec_local_for_unmatched_schedulable_role() {
18583 let mut sched = HashMap::new();
18584 sched.insert(
18585 "explorer".to_string(),
18586 sched_placement("gpu-pool", "https://control-plane:9562"),
18587 );
18588 let runner = bogus_sched_runner(HashMap::new(), sched);
18589 let s = session_of_role("writer", "do the thing");
18590 let spec = runner.build_spec(&s, &job_for("child-1"));
18591 assert_eq!(spec.placement, Placement::Local);
18592 assert!(spec.secrets.worker_auth_token.is_none());
18593 }
18594
18595 #[test]
18600 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18601 let mut remote = HashMap::new();
18603 remote.insert(
18604 "explorer".to_string(),
18605 ResolvedRemotePlacement {
18606 endpoint: "ws://169.254.230.101:8899".into(),
18607 token: None,
18608 ca_cert_file: None,
18609 host_label: Some("mini".into()),
18610 broker_peer: None,
18611 requirements: None,
18612 },
18613 );
18614 let runner = bogus_runner(remote);
18615 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18616 let stamp = runner
18617 .placement_stamp_for(&spec)
18618 .expect("remote child is stamped");
18619 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18620 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18621
18622 let mut remote_nolabel = HashMap::new();
18624 remote_nolabel.insert(
18625 "explorer".to_string(),
18626 ResolvedRemotePlacement {
18627 endpoint: "ws://169.254.230.101:8899".into(),
18628 token: None,
18629 ca_cert_file: None,
18630 host_label: None,
18631 broker_peer: None,
18632 requirements: None,
18633 },
18634 );
18635 let r2 = bogus_runner(remote_nolabel);
18636 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18637 assert!(r2
18638 .placement_stamp_for(&spec2)
18639 .unwrap()
18640 .contains(r#""host":"169.254.230.101""#));
18641
18642 let mut sched = HashMap::new();
18644 sched.insert(
18645 "mac-mini-monitor".to_string(),
18646 ResolvedSchedulablePlacement {
18647 pool: "mac-mini-monitor".into(),
18648 host_label: Some("mini".into()),
18649 requirements: None,
18650 ..Default::default()
18651 },
18652 );
18653 let sr = bogus_sched_runner(HashMap::new(), sched);
18654 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18655 let stamp3 = sr
18656 .placement_stamp_for(&spec3)
18657 .expect("scheduled child is stamped");
18658 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18659 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18660
18661 let local = bogus_runner(HashMap::new());
18663 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18664 assert_eq!(local.placement_stamp_for(&spec4), None);
18665 }
18666
18667 #[tokio::test]
18668 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18669 let mut sched = HashMap::new();
18670 sched.insert(
18671 "explorer".to_string(),
18672 sched_placement("gpu-pool", "unused"),
18673 );
18674 let runner = bogus_sched_runner(HashMap::new(), sched);
18675 let mut session = session_of_role("explorer", "do the thing");
18676 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18677 let error = runner
18678 .execute_external_child(
18679 &mut session,
18680 &job_for("child-1"),
18681 event_tx,
18682 CancellationToken::new(),
18683 )
18684 .await
18685 .expect_err("legacy bus presence cannot grant placement");
18686 assert!(error
18687 .to_string()
18688 .contains("remote_broker_activation_unavailable"));
18689 }
18690}