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_observed(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_observed(&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_observed(&self.fence, chrono::Utc::now(), outcome)
5429 .await
5430 .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5431 let router = self.completion_router.lock().recover_poison().take();
5434 if let (Some(observer), Some(router)) = (observer, router) {
5435 match self.store.inspect_actor(&self.fence.actor_id).await {
5439 Ok(entry) => match router.into_host_completion(&entry, &committed) {
5440 Ok(event) => observer.publish(event),
5441 Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5442 "Actor completion notification superseded"),
5443 },
5444 Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5445 "Actor completion committed; notification snapshot unavailable"),
5446 }
5447 }
5448 Ok(())
5449 }
5450}
5451fn local_tool_history_unsupported() -> AgentError {
5452 AgentError::LLM("local_tool_history_unsupported".into())
5453}
5454
5455#[derive(Clone, Copy)]
5456enum LocalToolTerminal<'a> {
5457 Completed(Option<&'a str>),
5458 Cancelled,
5461}
5462
5463impl LocalToolTerminal<'_> {
5464 fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5465 match self {
5466 Self::Completed(_) => !cancel.is_cancelled(),
5467 Self::Cancelled => cancel.is_cancelled(),
5468 }
5469 }
5470}
5471
5472#[derive(Default)]
5473struct LocalToolCollector {
5474 messages: Option<Vec<bamboo_agent_core::Message>>,
5475 starts: HashMap<String, (String, serde_json::Value)>,
5476 outcomes: HashMap<String, (bool, String)>,
5477 ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5480}
5481
5482impl LocalToolCollector {
5483 fn ticket_event(
5484 &self,
5485 value: &serde_json::Value,
5486 committed: &HashMap<String, serde_json::Value>,
5487 ) -> Result<bool, AgentError> {
5488 if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5489 return Ok(true);
5490 }
5491 match serde_json::from_value::<AgentEvent>(value.clone())
5492 .map_err(|_| local_tool_history_unsupported())?
5493 {
5494 AgentEvent::ToolStart { tool_name, .. }
5495 if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5496 {
5497 Err(local_tool_history_unsupported())
5498 }
5499 AgentEvent::ToolComplete {
5500 tool_call_id,
5501 result,
5502 } if result.success => {
5503 let Some((name, args)) = self.starts.get(&tool_call_id) else {
5504 return Err(local_tool_history_unsupported());
5505 };
5506 let valid = if name == "Task" {
5507 committed.get(&tool_call_id) == Some(args)
5508 } else {
5509 committed.get(&tool_call_id).is_some_and(|proof| {
5510 proof["file_tool"] == *name
5511 && proof["arguments"] == *args
5512 && proof["result"] == result.result
5513 })
5514 };
5515 if !valid {
5516 return Err(AgentError::LLM(
5517 "LocalPlan success has no exact Host receipt".into(),
5518 ));
5519 }
5520 Ok(true)
5521 }
5522 AgentEvent::TaskListUpdated { .. } => Ok(false),
5524 _ => Ok(true),
5525 }
5526 }
5527
5528 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5529 use bamboo_subagent::proto::LocalToolMessages;
5530 if self.messages.is_some() {
5531 return Err(local_tool_history_unsupported());
5532 }
5533 if value["type"] == LocalToolMessages::TYPE {
5534 let observation: LocalToolMessages = serde_json::from_value(value.clone())
5535 .map_err(|_| local_tool_history_unsupported())?;
5536 self.messages = Some(
5537 observation
5538 .validate()
5539 .map_err(|_| local_tool_history_unsupported())?,
5540 );
5541 return Ok(false);
5542 }
5543 let event: AgentEvent =
5544 serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5545 match event {
5546 AgentEvent::ToolStart {
5547 tool_call_id,
5548 tool_name,
5549 arguments,
5550 } => {
5551 if !arguments.is_object()
5552 || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5553 || tool_call_id.is_empty()
5554 || tool_call_id.len() > 128
5555 || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5556 || self.outcomes.contains_key(&tool_call_id)
5557 || self
5558 .starts
5559 .insert(tool_call_id, (tool_name, arguments))
5560 .is_some()
5561 {
5562 return Err(local_tool_history_unsupported());
5563 }
5564 }
5565 AgentEvent::ToolComplete {
5566 tool_call_id,
5567 result,
5568 } => {
5569 if !result.images.is_empty() {
5570 return Err(local_tool_history_unsupported());
5571 }
5572 self.outcome(tool_call_id, result.success, result.result)?;
5573 }
5574 AgentEvent::ToolError {
5575 tool_call_id,
5576 error,
5577 } => {
5578 self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5579 }
5580 AgentEvent::ToolToken { tool_call_id, .. } => {
5581 if !self.starts.contains_key(&tool_call_id)
5582 || self.outcomes.contains_key(&tool_call_id)
5583 {
5584 return Err(local_tool_history_unsupported());
5585 }
5586 }
5587 AgentEvent::ToolLifecycle {
5588 tool_call_id,
5589 tool_name,
5590 is_mutating,
5591 auto_approved,
5592 phase,
5593 ..
5594 } => {
5595 if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5596 || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5597 || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5598 || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5599 {
5600 return Err(local_tool_history_unsupported());
5601 }
5602 }
5603 AgentEvent::ReasoningToken { .. } => {
5604 }
5608 AgentEvent::ContextSummarized { .. }
5609 | AgentEvent::ContextArchived { .. }
5610 | AgentEvent::ContextCompressionStatus { .. }
5611 | AgentEvent::NeedClarification { .. }
5612 | AgentEvent::SubAgentStarted { .. }
5613 | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5614 AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5616 return Ok(false)
5617 }
5618 _ => {}
5619 }
5620 Ok(true)
5621 }
5622
5623 fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5624 if id.is_empty()
5625 || id.len() > 128
5626 || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5627 || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5628 || self.outcomes.insert(id, (success, text)).is_some()
5629 {
5630 return Err(local_tool_history_unsupported());
5631 }
5632 Ok(())
5633 }
5634
5635 fn suffix(
5636 self,
5637 host: &Session,
5638 tools: &[String],
5639 read_only: bool,
5640 terminal: Option<&str>,
5641 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5642 self.suffix_for_terminal(
5643 host,
5644 tools,
5645 read_only,
5646 LocalToolTerminal::Completed(terminal),
5647 )
5648 }
5649
5650 fn suffix_for_terminal(
5651 self,
5652 host: &Session,
5653 tools: &[String],
5654 read_only: bool,
5655 terminal: LocalToolTerminal<'_>,
5656 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5657 use bamboo_subagent::proto::LocalToolMessages;
5658 let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5659 if cancelled
5660 && (!read_only
5661 || tools.is_empty()
5662 || tools
5663 .iter()
5664 .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5665 || self.ticket_yield.is_some())
5666 {
5667 return Err(local_tool_history_unsupported());
5668 }
5669 let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5670 let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5671 let prefix: Vec<_> = host
5672 .messages
5673 .iter()
5674 .filter(|m| m.role != Role::System)
5675 .collect();
5676 if !host.provider_transcript.groups().is_empty()
5677 || candidate.len() < prefix.len()
5678 || (!cancelled && candidate.len() == prefix.len())
5679 || serde_json::to_value(&candidate[..prefix.len()])
5680 .map_err(|_| local_tool_history_unsupported())?
5681 != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5682 {
5683 return Err(local_tool_history_unsupported());
5684 }
5685 let mut ids = std::collections::HashSet::new();
5686 let mut calls = std::collections::HashSet::new();
5687 for message in &host.messages {
5688 if !ids.insert(message.id.clone()) {
5689 return Err(local_tool_history_unsupported());
5690 }
5691 for call in message.tool_calls.iter().flatten() {
5692 calls.insert(call.id.clone());
5693 }
5694 if let Some(id) = &message.tool_call_id {
5695 calls.insert(id.clone());
5696 }
5697 }
5698 let suffix: Vec<_> = candidate[prefix.len()..]
5699 .iter()
5700 .map(|message| (**message).clone())
5701 .collect();
5702 let mut pending = HashMap::new();
5703 let mut used = std::collections::HashSet::new();
5704 for message in &suffix {
5705 if !ids.insert(message.id.clone()) || message.never_compress {
5706 return Err(local_tool_history_unsupported());
5707 }
5708 match message.role {
5709 Role::Assistant => {
5710 if !pending.is_empty()
5711 || message.metadata.is_some()
5712 || message.tool_call_id.is_some()
5713 || message.tool_success.is_some()
5714 {
5715 return Err(local_tool_history_unsupported());
5716 }
5717 if let Some(batch) = &message.tool_calls {
5718 if batch.is_empty() {
5719 return Err(local_tool_history_unsupported());
5720 }
5721 for call in batch {
5722 let arguments =
5723 serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5724 .ok()
5725 .filter(|arguments| arguments.is_object())
5726 .ok_or_else(local_tool_history_unsupported)?;
5727 if call.id.is_empty()
5728 || call.id.len() > 128
5729 || call.tool_type != "function"
5730 || !calls.insert(call.id.clone())
5731 || calls.len() > LocalToolMessages::MAX_MESSAGES
5732 || !matches!(
5733 call.function.name.as_str(),
5734 "Read" | "Glob" | "Write" | "Task"
5735 )
5736 || (cancelled
5737 && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5738 || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5739 name != &call.function.name || actual != &arguments
5740 })
5741 {
5742 return Err(local_tool_history_unsupported());
5743 }
5744 pending.insert(call.id.clone(), call.function.name.clone());
5745 }
5746 } else if message.content.trim().is_empty() {
5747 return Err(local_tool_history_unsupported());
5748 }
5749 }
5750 Role::Tool => {
5751 let id = message
5752 .tool_call_id
5753 .as_ref()
5754 .ok_or_else(local_tool_history_unsupported)?;
5755 let name = pending
5756 .remove(id)
5757 .ok_or_else(local_tool_history_unsupported)?;
5758 let success = message
5759 .tool_success
5760 .ok_or_else(local_tool_history_unsupported)?;
5761 if message.tool_calls.is_some()
5762 || message.phase.is_some()
5763 || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5764 || !used.insert(id.clone())
5765 || used.len() > LocalToolMessages::MAX_PAIRS
5766 {
5767 return Err(local_tool_history_unsupported());
5768 }
5769 let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5770 if denied_write {
5771 if success
5772 || !(message.content.contains("native_tool_ceiling_denied")
5773 || message
5774 .content
5775 .contains("not callable at the current conversation position"))
5776 {
5777 return Err(local_tool_history_unsupported());
5778 }
5779 } else if !tools.contains(&name)
5780 || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5781 {
5782 return Err(local_tool_history_unsupported());
5783 }
5784 if let Some(metadata) = &message.metadata {
5785 let object = metadata
5786 .as_object()
5787 .ok_or_else(local_tool_history_unsupported)?;
5788 if object.len() != 5
5789 || metadata["elapsed_ms"].as_u64().is_none()
5790 || metadata["tool_name"].as_str() != Some(name.as_str())
5791 || metadata["is_mutating"].as_bool()
5792 != Some(matches!(name.as_str(), "Write" | "Task"))
5793 || metadata["auto_approved"].as_bool()
5794 != Some(!matches!(name.as_str(), "Write" | "Task"))
5795 || metadata["success"].as_bool() != Some(success)
5796 {
5797 return Err(local_tool_history_unsupported());
5798 }
5799 }
5800 }
5801 _ => return Err(local_tool_history_unsupported()),
5802 }
5803 }
5804 let last = suffix.last();
5805 let completed_text = match terminal {
5806 LocalToolTerminal::Completed(text) => text,
5807 LocalToolTerminal::Cancelled => None,
5808 };
5809 let question_terminal =
5810 self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5811 last.is_some_and(|last| last.role == Role::Tool
5812 && last.tool_call_id.as_ref() == Some(id)
5813 && last.tool_success == Some(true)
5814 && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5815 && completed_text.is_none_or(str::is_empty)
5816 && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5817 == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5818 });
5819 let report_terminal = last.is_some_and(|last| {
5820 last.role == Role::Assistant
5821 && last.tool_calls.is_none()
5822 && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5823 && completed_text == Some(last.content.as_str())
5824 });
5825 if !pending.is_empty()
5826 || used.len() != self.outcomes.len()
5827 || self.starts.keys().any(|id| !used.contains(id))
5828 || !(cancelled || report_terminal || question_terminal)
5829 {
5830 return Err(local_tool_history_unsupported());
5831 }
5832 Ok(suffix)
5833 }
5834}
5835
5836async fn validate_local_tool_checkpoint(
5837 session: &Session,
5838 binding: &SessionInboxRuntimeBinding,
5839 run_id: &str,
5840 terminal: LocalToolTerminal<'_>,
5841 cancel: &CancellationToken,
5842) -> Result<(), AgentError> {
5843 if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5844 return Err(local_tool_history_unsupported());
5845 }
5846 if matches!(terminal, LocalToolTerminal::Cancelled) {
5847 let backlog = binding
5848 .inbox
5849 .inspect(&session.id)
5850 .await
5851 .map_err(|_| local_tool_history_unsupported())?;
5852 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5853 return Err(local_tool_history_unsupported());
5856 }
5857 }
5858 Ok(())
5859}
5860
5861async fn commit_local_tool_history(
5862 collector: LocalToolCollector,
5863 session: &mut Session,
5864 binding: &SessionInboxRuntimeBinding,
5865 run_id: &str,
5866 selection: (&[String], bool),
5867 terminal: LocalToolTerminal<'_>,
5868 cancel: &CancellationToken,
5869) -> Result<(), AgentError> {
5870 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5871 let latest = binding
5872 .storage
5873 .load_session(&session.id)
5874 .await
5875 .map_err(|_| local_tool_history_unsupported())?
5876 .ok_or_else(local_tool_history_unsupported)?;
5877 if latest.id != session.id
5878 || latest.kind != session.kind
5879 || latest.created_at != session.created_at
5880 || latest.root_session_id != session.root_session_id
5881 || latest.parent_session_id != session.parent_session_id
5882 || latest.spawn_depth != session.spawn_depth
5883 || latest.project_id_meta() != session.project_id_meta()
5884 || session.check_conversation_generation(&latest).is_err()
5885 || serde_json::to_value(&latest.provider_transcript)
5886 .map_err(|_| local_tool_history_unsupported())?
5887 != serde_json::to_value(&session.provider_transcript)
5888 .map_err(|_| local_tool_history_unsupported())?
5889 {
5890 return Err(local_tool_history_unsupported());
5891 }
5892 let suffix = match terminal {
5893 LocalToolTerminal::Completed(text) => {
5894 collector.suffix(&latest, selection.0, selection.1, text)?
5895 }
5896 LocalToolTerminal::Cancelled => {
5897 collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5898 }
5899 };
5900 let before = session.clone();
5901 let mut staged = session.clone();
5902 staged.messages = latest.messages.clone();
5903 staged.messages.extend(suffix.iter().cloned());
5904 staged.updated_at = chrono::Utc::now();
5905 let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5906 Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5907 } else {
5908 None
5909 };
5910 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5911 binding
5912 .persistence
5913 .checkpoint_runtime_session(&mut staged)
5914 .await
5915 .map_err(|_| local_tool_history_unsupported())?;
5916 let saved = binding
5917 .storage
5918 .load_session(&session.id)
5919 .await
5920 .map_err(|_| local_tool_history_unsupported())?
5921 .ok_or_else(local_tool_history_unsupported)?;
5922 if session.check_conversation_generation(&saved).is_err()
5923 || serde_json::to_value(&saved.provider_transcript)
5924 .map_err(|_| local_tool_history_unsupported())?
5925 != serde_json::to_value(&latest.provider_transcript)
5926 .map_err(|_| local_tool_history_unsupported())?
5927 {
5928 return Err(local_tool_history_unsupported());
5929 }
5930 let start = match suffix.first() {
5931 Some(first) => saved
5932 .messages
5933 .iter()
5934 .position(|message| message.id == first.id)
5935 .ok_or_else(local_tool_history_unsupported)?,
5936 None => latest.messages.len(),
5937 };
5938 if cancelled_messages.as_ref().is_some_and(|expected| {
5939 serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5940 }) {
5941 return Err(local_tool_history_unsupported());
5944 }
5945 if saved.messages.len() < latest.messages.len()
5946 || serde_json::to_value(&saved.messages[..latest.messages.len()])
5947 .map_err(|_| local_tool_history_unsupported())?
5948 != serde_json::to_value(&latest.messages)
5949 .map_err(|_| local_tool_history_unsupported())?
5950 || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5951 .map_err(|_| local_tool_history_unsupported())?
5952 != serde_json::to_value(Some(&suffix[..]))
5953 .map_err(|_| local_tool_history_unsupported())?
5954 {
5955 return Err(local_tool_history_unsupported());
5956 }
5957 let expected = latest.messages.iter().chain(&suffix);
5960 for message in expected {
5961 let matches: Vec<_> = saved
5962 .messages
5963 .iter()
5964 .filter(|current| current.id == message.id)
5965 .collect();
5966 if matches.len() != 1
5967 || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5968 != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5969 {
5970 *session = before;
5971 return Err(local_tool_history_unsupported());
5972 }
5973 }
5974 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5975 staged.messages = saved.messages;
5976 *session = staged;
5977 Ok(())
5978}
5979
5980#[derive(Default)]
5981struct ReadOnlyActorCollector {
5982 start: Option<(String, serde_json::Value)>,
5983 result: Option<String>,
5984 messages: Option<Vec<bamboo_agent_core::Message>>,
5985}
5986impl ReadOnlyActorCollector {
5987 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5988 if self.messages.is_some() {
5989 return Err(plain_actor_unsupported());
5990 }
5991 if value["type"] == "owned_readonly_transcript" {
5992 let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5993 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5994 tail.validate().map_err(|_| plain_actor_unsupported())?;
5995 let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5996 let typed: Vec<bamboo_agent_core::Message> = messages
5997 .iter()
5998 .map(|raw| {
5999 let message: bamboo_agent_core::Message =
6000 serde_json::from_value(raw.clone())
6001 .map_err(|_| plain_actor_unsupported())?;
6002 if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
6003 != *raw
6004 {
6005 return Err(plain_actor_unsupported());
6006 }
6007 Ok(message)
6008 })
6009 .collect::<Result<_, _>>()?;
6010 let (id, args) = self
6011 .start
6012 .as_ref()
6013 .ok_or_else(|| plain_actor_unsupported())?;
6014 let call = typed[0]
6015 .tool_calls
6016 .as_ref()
6017 .and_then(|calls| {
6018 if calls.len() == 1 {
6019 calls.first()
6020 } else {
6021 None
6022 }
6023 })
6024 .ok_or_else(|| plain_actor_unsupported())?;
6025 if call.id != *id
6026 || call.function.name != "Glob"
6027 || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
6028 .map_err(|_| plain_actor_unsupported())?
6029 != *args
6030 || typed[1].tool_call_id.as_ref() != Some(id)
6031 || typed[1].tool_success != Some(true)
6032 || self.result.as_ref() != Some(&typed[1].content)
6033 {
6034 return Err(plain_actor_unsupported());
6035 }
6036 self.messages = Some(typed);
6037 return Ok(false);
6038 }
6039 let event: AgentEvent =
6040 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6041 match event {
6042 AgentEvent::ToolStart {
6043 tool_call_id,
6044 tool_name,
6045 arguments,
6046 } => {
6047 if tool_name != "Glob"
6048 || tool_call_id.trim().is_empty()
6049 || self.start.is_some()
6050 || !arguments.is_object()
6051 {
6052 return Err(plain_actor_unsupported());
6053 }
6054 self.start = Some((tool_call_id, arguments));
6055 Ok(true)
6056 }
6057 AgentEvent::ToolComplete {
6058 tool_call_id,
6059 result,
6060 } => {
6061 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6062 || self.result.is_some()
6063 || !result.success
6064 || !result.images.is_empty()
6065 {
6066 return Err(plain_actor_unsupported());
6067 }
6068 self.result = Some(result.result);
6069 Ok(true)
6070 }
6071 AgentEvent::ToolToken { tool_call_id, .. } => {
6072 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6073 {
6074 return Err(plain_actor_unsupported());
6075 }
6076 Ok(true)
6077 }
6078 AgentEvent::ToolLifecycle {
6079 tool_call_id,
6080 tool_name,
6081 is_mutating,
6082 phase,
6083 error,
6084 ..
6085 } => {
6086 if tool_name != "Glob"
6087 || is_mutating
6088 || !matches!(phase.as_str(), "begin" | "finished")
6089 || error.is_some()
6090 || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6091 {
6092 return Err(plain_actor_unsupported());
6093 }
6094 Ok(true)
6095 }
6096 _ => plain_actor_event(value),
6097 }
6098 }
6099 fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6100 let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6101 if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6102 return Err(plain_actor_unsupported());
6103 }
6104 Ok(messages)
6105 }
6106}
6107
6108fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6109 let event: AgentEvent =
6110 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6111 match event {
6112 AgentEvent::ReasoningToken { .. }
6113 | AgentEvent::ToolToken { .. }
6114 | AgentEvent::ToolStart { .. }
6115 | AgentEvent::ToolComplete { .. }
6116 | AgentEvent::ToolError { .. }
6117 | AgentEvent::ToolLifecycle { .. }
6118 | AgentEvent::ContextSummarized { .. }
6119 | AgentEvent::ContextArchived { .. }
6120 | AgentEvent::ContextCompressionStatus { .. }
6121 | AgentEvent::NeedClarification { .. }
6122 | AgentEvent::SubAgentStarted { .. }
6123 | AgentEvent::SubAgentEvent { .. }
6124 | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6125 AgentEvent::MessageAppended { .. }
6128 | AgentEvent::SessionHistoryCommitted { .. }
6129 | AgentEvent::VisibleMessageStart { .. }
6130 | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6131 event if event.is_durable_change() => Ok(false),
6132 _ => Ok(true),
6133 }
6134}
6135
6136fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6142 let value = match placement {
6145 Placement::Local => return None,
6146 Placement::Remote { endpoint } => serde_json::json!({
6147 "kind": "remote",
6148 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6149 }),
6150 Placement::Schedulable { pool } => serde_json::json!({
6151 "kind": "remote",
6152 "host": host_label.unwrap_or(pool),
6153 }),
6154 };
6155 serde_json::to_string(&value).ok()
6156}
6157
6158fn host_of_endpoint(endpoint: &str) -> String {
6160 endpoint
6161 .trim()
6162 .trim_start_matches("wss://")
6163 .trim_start_matches("ws://")
6164 .split(['/', ':'])
6165 .next()
6166 .unwrap_or(endpoint)
6167 .to_string()
6168}
6169
6170async fn reconcile_already_admitted_claim(
6171 binding: &SessionInboxRuntimeBinding,
6172 session: &mut Session,
6173 claim: &SessionInboxClaim,
6174) -> crate::runtime::runner::Result<()> {
6175 let latest = binding
6176 .storage
6177 .load_session(&session.id)
6178 .await
6179 .map_err(|error| {
6180 AgentError::LLM(format!(
6181 "load canonical SessionInbox checkpoint for {}: {error}",
6182 session.id
6183 ))
6184 })?
6185 .ok_or_else(|| {
6186 AgentError::LLM(format!(
6187 "canonical SessionInbox target disappeared: {}",
6188 session.id
6189 ))
6190 })?;
6191 session
6192 .check_conversation_generation(&latest)
6193 .map_err(|error| {
6194 AgentError::LLM(format!(
6195 "canonical SessionInbox conversation changed: {error}"
6196 ))
6197 })?;
6198 let Some(message) = latest
6199 .messages
6200 .iter()
6201 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6202 .cloned()
6203 else {
6204 return Err(AgentError::LLM(format!(
6205 "canonical admitted receipt for {} exists without transcript message {}",
6206 session.id, claim.envelope.id
6207 )));
6208 };
6209 if let Some(existing) = session
6210 .messages
6211 .iter_mut()
6212 .find(|existing| existing.id == message.id)
6213 {
6214 *existing = message;
6215 } else {
6216 session.add_message(message);
6217 }
6218 bamboo_domain::merge_session_inbox_admission(session, &latest);
6219 binding
6220 .inbox
6221 .ack(&session.id, claim)
6222 .await
6223 .map_err(|error| {
6224 AgentError::LLM(format!(
6225 "ack recovered canonical SessionInbox claim {}: {error}",
6226 claim.envelope.id
6227 ))
6228 })
6229}
6230
6231async fn checkpoint_and_ack_canonical_claim(
6235 binding: &SessionInboxRuntimeBinding,
6236 session: &mut Session,
6237 claim: &SessionInboxClaim,
6238 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6239) -> crate::runtime::runner::Result<()> {
6240 if claim.envelope.target_session_id != session.id {
6241 return Err(AgentError::LLM(format!(
6242 "canonical SessionInbox claim target {} does not match active logical session {}",
6243 claim.envelope.target_session_id, session.id
6244 )));
6245 }
6246 if binding
6247 .inbox
6248 .was_admitted(&session.id, &claim.envelope.id)
6249 .await
6250 .map_err(|error| {
6251 AgentError::LLM(format!(
6252 "inspect canonical admitted receipt {}: {error}",
6253 claim.envelope.id
6254 ))
6255 })?
6256 {
6257 return reconcile_already_admitted_claim(binding, session, claim).await;
6258 }
6259
6260 if let Some((store, fence)) = fenced {
6261 let committed = store
6262 .confirm_actor_claim_context(
6263 concrete_file_inbox(binding)?,
6264 bamboo_storage::ActorClaimContextSeed {
6265 fence: fence.clone(),
6266 expected_created_at: session.created_at,
6267 expected_conversation_generation: session.conversation_generation_observation(),
6268 expected_messages: session.messages.clone(),
6269 expected_provider_transcript: session.provider_transcript.clone(),
6270 expected_admission: session.session_inbox_admission().cloned(),
6271 claims: vec![claim.clone()],
6272 },
6273 )
6274 .await;
6275 let committed = match committed {
6276 Ok(committed) => committed,
6277 Err(error) => {
6278 adopt_latest_actor_context(store, session).await;
6282 return Err(AgentError::LLM(format!(
6283 "confirm fenced Actor claim context unconfirmed: {error}"
6284 )));
6285 }
6286 };
6287 bamboo_domain::merge_session_inbox_admission(session, &committed);
6288 session.messages = committed.messages;
6289 session.provider_transcript = committed.provider_transcript;
6290 session.updated_at = committed.updated_at;
6291 if !session
6292 .messages
6293 .iter()
6294 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6295 || !session
6296 .session_inbox_admission()
6297 .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6298 {
6299 return Err(AgentError::LLM(
6300 "fenced Actor claim confirmation lost durable proof".into(),
6301 ));
6302 }
6303 return binding
6304 .inbox
6305 .ack(&session.id, claim)
6306 .await
6307 .map_err(|error| {
6308 AgentError::LLM(format!(
6309 "ack fenced Actor claim {} after confirmation: {error}",
6310 claim.envelope.id
6311 ))
6312 });
6313 }
6314
6315 let transcript_has_id = session
6316 .messages
6317 .iter()
6318 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6319 if session
6320 .messages
6321 .iter()
6322 .any(|message| message.id == claim.envelope.id.as_str())
6323 && !transcript_has_id
6324 {
6325 return Err(AgentError::LLM(format!(
6326 "canonical SessionInbox id {} collides with a non-matching transcript message",
6327 claim.envelope.id
6328 )));
6329 }
6330 let cursor_has_id = session
6331 .session_inbox_admission()
6332 .is_some_and(|state| state.contains(&claim.envelope.id));
6333 if cursor_has_id && !transcript_has_id {
6334 return Err(AgentError::LLM(format!(
6335 "canonical SessionInbox cursor exists without transcript message {}",
6336 claim.envelope.id
6337 )));
6338 }
6339 let before = session.clone();
6340 if !transcript_has_id {
6341 let message = claim.envelope.to_provider_message().map_err(|error| {
6342 AgentError::LLM(format!(
6343 "translate canonical SessionInbox envelope {}: {error}",
6344 claim.envelope.id
6345 ))
6346 })?;
6347 session.add_message(message);
6348 }
6349 session
6350 .session_inbox_admission_mut()
6351 .record(claim.envelope.id.clone(), claim.generation);
6352 session.updated_at = chrono::Utc::now();
6353
6354 if let Err(error) = binding
6355 .persistence
6356 .checkpoint_runtime_session(session)
6357 .await
6358 {
6359 *session = before;
6360 return Err(AgentError::LLM(format!(
6361 "checkpoint canonical SessionInbox claim {}: {error}",
6362 claim.envelope.id
6363 )));
6364 }
6365 if !session
6366 .messages
6367 .iter()
6368 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6369 {
6370 *session = before;
6371 return Err(AgentError::LLM(format!(
6372 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6373 claim.envelope.id
6374 )));
6375 }
6376 binding
6377 .inbox
6378 .ack(&session.id, claim)
6379 .await
6380 .map_err(|error| {
6381 AgentError::LLM(format!(
6382 "ack canonical SessionInbox claim {} after checkpoint: {error}",
6383 claim.envelope.id
6384 ))
6385 })
6386}
6387
6388fn concrete_file_inbox(
6389 binding: &SessionInboxRuntimeBinding,
6390) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6391 binding
6392 .inbox
6393 .as_any()
6394 .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6395 .cloned()
6396 .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6397}
6398
6399async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6400 if let Ok(Some(latest)) = store.load_session(&session.id).await {
6401 if latest.created_at == session.created_at
6402 && session.check_conversation_generation(&latest).is_ok()
6403 {
6404 bamboo_domain::merge_session_inbox_admission(session, &latest);
6405 session.messages = latest.messages;
6406 session.provider_transcript = latest.provider_transcript;
6407 session.updated_at = latest.updated_at;
6408 }
6409 }
6410}
6411
6412async fn checkpoint_claim_context_before_dispatch(
6423 binding: &SessionInboxRuntimeBinding,
6424 session: &mut Session,
6425 claims: &[SessionInboxClaim],
6426 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6427) -> crate::runtime::runner::Result<()> {
6428 if claims.is_empty() {
6429 return Ok(());
6430 }
6431 let before = session.clone();
6432 let mut additions = Vec::new();
6433 for claim in claims {
6434 if claim.envelope.target_session_id != session.id {
6435 return Err(AgentError::LLM(format!(
6436 "canonical SessionInbox claim target {} does not match actor session {}",
6437 claim.envelope.target_session_id, session.id
6438 )));
6439 }
6440 let matching = session
6441 .messages
6442 .iter()
6443 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6444 if session
6445 .messages
6446 .iter()
6447 .any(|message| message.id == claim.envelope.id.as_str())
6448 && !matching
6449 {
6450 return Err(AgentError::LLM(format!(
6451 "canonical SessionInbox id {} collides before actor dispatch",
6452 claim.envelope.id
6453 )));
6454 }
6455 if session
6456 .session_inbox_admission()
6457 .is_some_and(|state| state.contains(&claim.envelope.id))
6458 && !matching
6459 {
6460 return Err(AgentError::LLM(format!(
6461 "canonical SessionInbox cursor exists without transcript proof for {}",
6462 claim.envelope.id
6463 )));
6464 }
6465 if !matching {
6466 let message = claim.envelope.to_provider_message().map_err(|error| {
6467 AgentError::LLM(format!(
6468 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6469 claim.envelope.id
6470 ))
6471 })?;
6472 additions.push(message);
6473 }
6474 }
6475 let fenced_seed = fenced.is_some();
6476 if let Some((store, fence)) = fenced {
6477 let committed = store
6478 .seed_actor_claim_context(
6479 concrete_file_inbox(binding)?,
6480 bamboo_storage::ActorClaimContextSeed {
6481 fence: fence.clone(),
6482 expected_created_at: session.created_at,
6483 expected_conversation_generation: session.conversation_generation_observation(),
6484 expected_messages: session.messages.clone(),
6485 expected_provider_transcript: session.provider_transcript.clone(),
6486 expected_admission: session.session_inbox_admission().cloned(),
6487 claims: claims.to_vec(),
6488 },
6489 )
6490 .await;
6491 let committed = match committed {
6492 Ok(committed) => committed,
6493 Err(error) => {
6494 adopt_latest_actor_context(store, session).await;
6498 return Err(AgentError::LLM(format!(
6499 "seed fenced Actor claim context unconfirmed: {error}"
6500 )));
6501 }
6502 };
6503 bamboo_domain::merge_session_inbox_admission(session, &committed);
6504 session.messages = committed.messages;
6505 session.provider_transcript = committed.provider_transcript;
6506 session.updated_at = committed.updated_at;
6507 } else {
6508 for message in additions {
6509 session.add_message(message);
6510 }
6511 session.updated_at = chrono::Utc::now();
6512 if let Err(error) = binding
6513 .persistence
6514 .checkpoint_runtime_session(session)
6515 .await
6516 {
6517 *session = before;
6518 return Err(AgentError::LLM(format!(
6519 "checkpoint canonical SessionInbox actor context: {error}"
6520 )));
6521 }
6522 }
6523 for claim in claims {
6524 if !session
6525 .messages
6526 .iter()
6527 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6528 {
6529 if !fenced_seed {
6530 *session = before;
6531 }
6532 return Err(AgentError::LLM(format!(
6533 "actor context checkpoint lost typed transcript proof for {}",
6534 claim.envelope.id
6535 )));
6536 }
6537 }
6538 Ok(())
6539}
6540
6541async fn claim_canonical_deliveries(
6542 binding: &SessionInboxRuntimeBinding,
6543 session: &mut Session,
6544 activation_run_id: &str,
6545 limit: usize,
6546 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6547) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6548 let claims = binding
6549 .inbox
6550 .claim_for_turn(&session.id, limit, Some(activation_run_id))
6551 .await
6552 .map_err(|error| {
6553 AgentError::LLM(format!(
6554 "claim canonical SessionInbox for active actor {}: {error}",
6555 session.id
6556 ))
6557 })?;
6558 if claims.is_empty() {
6559 return Ok(Vec::new());
6560 }
6561 let mut unconfirmed = Vec::with_capacity(claims.len());
6562 for claim in claims {
6563 if binding
6564 .inbox
6565 .was_admitted(&session.id, &claim.envelope.id)
6566 .await
6567 .map_err(|error| {
6568 AgentError::LLM(format!(
6569 "inspect canonical SessionInbox claim {}: {error}",
6570 claim.envelope.id
6571 ))
6572 })?
6573 {
6574 reconcile_already_admitted_claim(binding, session, &claim).await?;
6575 continue;
6576 }
6577 unconfirmed.push(claim);
6578 }
6579 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6580
6581 let mut deliveries = Vec::with_capacity(unconfirmed.len());
6582 for claim in unconfirmed {
6583 if session
6588 .session_inbox_admission()
6589 .is_some_and(|state| state.contains(&claim.envelope.id))
6590 {
6591 binding
6592 .inbox
6593 .ack(&session.id, &claim)
6594 .await
6595 .map_err(|error| {
6596 AgentError::LLM(format!(
6597 "finish confirmed canonical SessionInbox ack {}: {error}",
6598 claim.envelope.id
6599 ))
6600 })?;
6601 continue;
6602 }
6603 let delivery = SessionMessageDelivery {
6604 target_session_id: session.id.clone(),
6605 envelope: claim.envelope.clone(),
6606 canonical_claim_generation: claim.generation,
6607 activation_run_id: activation_run_id.to_string(),
6608 activation_policy: claim.activation_policy,
6609 };
6610 deliveries.push((claim, delivery));
6611 }
6612 Ok(deliveries)
6613}
6614
6615async fn forward_next_canonical_claim(
6616 client: &mut dyn bamboo_subagent::ChildLink,
6617 binding: &SessionInboxRuntimeBinding,
6618 session: &mut Session,
6619 activation_run_id: &str,
6620 inflight: &mut VecDeque<SessionInboxClaim>,
6621 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6622) -> crate::runtime::runner::Result<()> {
6623 if !inflight.is_empty() {
6624 return Ok(());
6625 }
6626 let Some((claim, delivery)) =
6627 claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6628 .await?
6629 .pop()
6630 else {
6631 return Ok(());
6632 };
6633 client
6634 .send(ParentFrame::SessionMessage { delivery })
6635 .await
6636 .map_err(|error| {
6637 AgentError::LLM(format!(
6638 "forward canonical SessionInbox claim {} to active actor: {error}",
6639 claim.envelope.id
6640 ))
6641 })?;
6642 inflight.push_back(claim);
6643 Ok(())
6644}
6645
6646struct ActorDriveContext<'a> {
6648 client: &'a mut dyn bamboo_subagent::ChildLink,
6649 parent_session_id: &'a str,
6650 child_session_id: &'a str,
6651 child_attempt: u32,
6652 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6653 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6654 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6655 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6656 event_tx: &'a mpsc::Sender<AgentEvent>,
6657 cancel_token: &'a CancellationToken,
6658 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6659 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6660 logical_session: &'a mut Session,
6661 expected_permission_posture: Option<ExpectedPermissionPosture>,
6662 expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6663 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6664 actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6665 canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6666 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6667 activation_run_id: Option<&'a str>,
6668 execution_epoch: u64,
6669 expected_source_actor_id: &'a str,
6670 initial_inflight_claims: VecDeque<SessionInboxClaim>,
6671 plain_actor: bool,
6672 remote_environment_lease: bool,
6673 readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6674 local_history_tools: Option<&'a [String]>,
6675 local_history_read_only: bool,
6676 strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6677 plain_input: Option<&'a PlainActorActivation>,
6678 canonical_activation: Option<&'a ActorActivationFence>,
6679 canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6680 actor_event_observer: Option<&'a dyn ActorEventObserver>,
6681 plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6682 first_frame_timeout: Option<Duration>,
6683}
6684
6685#[derive(Debug, Clone, PartialEq, Eq)]
6686struct ExpectedPermissionPosture {
6687 policy_revision: u64,
6688 resolution: bamboo_domain::PermissionModeResolution,
6689 expected_audit_revision: Option<u64>,
6690 executor_mapping: String,
6691}
6692
6693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6694enum PermissionPostureHandshake {
6695 NotRequired,
6697 Awaiting,
6700 Confirmed,
6702}
6703
6704impl PermissionPostureHandshake {
6705 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6706 if expected.is_some() {
6707 Self::Awaiting
6708 } else {
6709 Self::NotRequired
6710 }
6711 }
6712
6713 fn is_awaiting(self) -> bool {
6714 self == Self::Awaiting
6715 }
6716
6717 fn posture_was_confirmed(self) -> bool {
6718 self == Self::Confirmed
6719 }
6720}
6721
6722fn permission_posture_seed_from_event(
6723 session: &Session,
6724 event: &AgentEvent,
6725) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6726 let AgentEvent::PermissionPostureActivated {
6727 session_id,
6728 policy_revision,
6729 requested_mode,
6730 effective_mode,
6731 executor_mapping,
6732 } = event
6733 else {
6734 return Ok(None);
6735 };
6736 if session_id != &session.id {
6737 return Err("permission posture event targets a different logical session".to_string());
6738 }
6739 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6740 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6741 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6742 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6743 let resolution = bamboo_domain::PermissionModeResolution {
6744 requested,
6745 effective,
6746 };
6747 if !resolution.is_consistent() {
6748 return Err("permission posture event has an inconsistent mode pair".to_string());
6749 }
6750 let current_requested = session
6751 .agent_runtime_state
6752 .as_ref()
6753 .map(|state| state.effective_permission_mode())
6754 .unwrap_or_default();
6755 if current_requested != requested {
6756 return Err("permission posture event is stale for the host typed mode".to_string());
6757 }
6758 let mapping_chars = executor_mapping.chars().count();
6759 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6760 return Err("permission posture event has an invalid executor mapping".to_string());
6761 }
6762 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6763 *policy_revision,
6764 resolution,
6765 executor_mapping,
6766 )))
6767}
6768
6769fn actor_event_logical_identity(
6770 session: &Session,
6771 parent_session_id: &str,
6772 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6773) -> LogicalSessionIdentity {
6774 LogicalSessionIdentity {
6775 creation: creation.cloned(),
6776 session_id: session.id.clone(),
6777 parent_session_id: session
6778 .parent_session_id
6779 .clone()
6780 .or_else(|| Some(parent_session_id.to_string())),
6781 root_session_id: if session.root_session_id.trim().is_empty() {
6782 parent_session_id.to_string()
6783 } else {
6784 session.root_session_id.clone()
6785 },
6786 }
6787}
6788
6789fn validate_strict_history_terminal(
6790 watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6791 session: &Session,
6792 parent_session_id: &str,
6793 activation_run_id: Option<&str>,
6794 execution_epoch: u64,
6795 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6796 consumed_seq: u64,
6797) -> Result<(), AgentError> {
6798 let identity = actor_event_logical_identity(session, parent_session_id, creation);
6799 if watermark
6800 .zip(activation_run_id)
6801 .is_some_and(|(watermark, run_id)| {
6802 watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6803 })
6804 {
6805 Ok(())
6806 } else {
6807 Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6808 }
6809}
6810
6811fn validate_actor_event_batch(
6812 batch: &ActorEventBatch,
6813 logical_session: &Session,
6814 parent_session_id: &str,
6815 activation_run_id: Option<&str>,
6816 execution_epoch: u64,
6817 expected_source_actor_id: &str,
6818 expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6819) -> Result<(), AgentError> {
6820 batch
6821 .validate()
6822 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6823 let identity = batch.logical_session.as_ref().ok_or_else(|| {
6824 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6825 })?;
6826 let expected_identity =
6831 actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6832 if identity != &expected_identity {
6833 return Err(AgentError::LLM(
6834 "actor event batch targets a different logical session".to_string(),
6835 ));
6836 }
6837 if batch.activation_id.as_deref() != activation_run_id {
6838 return Err(AgentError::LLM(
6839 "actor event batch belongs to a stale activation".to_string(),
6840 ));
6841 }
6842 if batch.execution_epoch != execution_epoch {
6843 return Err(AgentError::LLM(
6844 "actor event batch belongs to a stale execution epoch".to_string(),
6845 ));
6846 }
6847 if batch
6848 .source_actor_id
6849 .as_deref()
6850 .is_some_and(|source| source != expected_source_actor_id)
6851 {
6852 return Err(AgentError::LLM(
6853 "actor event batch declares a different physical actor".to_string(),
6854 ));
6855 }
6856 Ok(())
6857}
6858
6859async fn validate_directory_actor_event(
6863 store: &bamboo_storage::SessionStoreV2,
6864 fence: &ActorActivationFence,
6865 session: &Session,
6866 run_id: Option<&str>,
6867) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6868 store
6869 .validate_fence(fence, chrono::Utc::now())
6870 .await
6871 .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6872 let entry = store
6873 .inspect_actor(&fence.actor_id)
6874 .await
6875 .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6876 if !entry.actor.matches_session(session)
6877 || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6878 || !entry.activation.as_ref().is_some_and(|current| {
6879 current.matches_fence(fence)
6880 && current.status == bamboo_domain::ActorActivationStatus::Running
6881 })
6882 || run_id.is_some_and(|id| id != fence.run_id)
6883 {
6884 return Err(AgentError::LLM("actor event authority changed".into()));
6885 }
6886 Ok(entry)
6887}
6888
6889async fn inspect_active_owned_tree(
6892 store: &bamboo_storage::SessionStoreV2,
6893 binding: &SessionInboxRuntimeBinding,
6894 run_id: &str,
6895 child_id: &str,
6896 parent_id: &str,
6897 logical_session: &Session,
6898 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6899 plain_input: Option<&PlainActorActivation>,
6900 cursor: Option<&str>,
6901) -> Option<serde_json::Value> {
6902 use bamboo_agent_core::storage::Storage;
6903 let current = match store.load_session(child_id).await {
6904 Ok(Some(current)) => current,
6905 _ => {
6906 tracing::warn!("owned tree denied: canonical caller unavailable");
6907 return None;
6908 }
6909 };
6910 let current_project = match project_id_for_actor_run(¤t) {
6911 Ok(project) => project,
6912 Err(_) => {
6913 tracing::warn!("owned tree denied: invalid canonical Project");
6914 return None;
6915 }
6916 };
6917 let logical_project = match project_id_for_actor_run(logical_session) {
6918 Ok(project) => project,
6919 Err(_) => {
6920 tracing::warn!("owned tree denied: invalid logical Project");
6921 return None;
6922 }
6923 };
6924 if current.id != child_id
6925 || current.kind != bamboo_domain::SessionKind::Child
6926 || current.parent_session_id.as_deref() != Some(parent_id)
6927 || current.parent_session_id != logical_session.parent_session_id
6928 || current.root_session_id != logical_session.root_session_id
6929 || current.created_at != creation.created_at
6930 || current.created_at != logical_session.created_at
6931 || current.spawn_depth != creation.spawn_depth
6932 || current.spawn_depth != logical_session.spawn_depth
6933 || current_project != logical_project
6934 {
6935 tracing::warn!("owned tree denied: caller lifetime or Project changed");
6936 return None;
6937 }
6938 if let Some(activation) = plain_input {
6939 validate_directory_actor_event(
6940 activation.store.as_ref(),
6941 &activation.fence,
6942 ¤t,
6943 Some(run_id),
6944 )
6945 .await
6946 .ok()?;
6947 } else if !binding.router.owns_run(child_id, run_id).await {
6948 tracing::warn!("owned tree denied: activation owner changed");
6949 return None;
6950 }
6951 let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6952 store, child_id, cursor,
6953 )
6954 .await
6955 {
6956 Ok(page) => page,
6957 Err(error) => {
6958 tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6959 return None;
6960 }
6961 };
6962 let again = match store.load_session(child_id).await {
6963 Ok(Some(again)) => again,
6964 _ => {
6965 tracing::warn!("owned tree denied: canonical caller disappeared");
6966 return None;
6967 }
6968 };
6969 if again.created_at != current.created_at
6970 || again.parent_session_id != current.parent_session_id
6971 || again.root_session_id != current.root_session_id
6972 || again.spawn_depth != current.spawn_depth
6973 || project_id_for_actor_run(&again).ok()? != current_project
6974 {
6975 tracing::warn!("owned tree denied: caller changed during inspection");
6976 return None;
6977 }
6978 if let Some(activation) = plain_input {
6979 validate_directory_actor_event(
6980 activation.store.as_ref(),
6981 &activation.fence,
6982 &again,
6983 Some(run_id),
6984 )
6985 .await
6986 .ok()?;
6987 } else if !binding.router.owns_run(child_id, run_id).await {
6988 tracing::warn!("owned tree denied: activation owner changed after inspection");
6989 return None;
6990 }
6991 serde_json::to_vec(&page)
6992 .ok()
6993 .filter(|bytes| bytes.len() <= 8192)?;
6994 Some(page)
6995}
6996
6997async fn load_active_subagent_caller(
7001 store: &bamboo_storage::SessionStoreV2,
7002 binding: &SessionInboxRuntimeBinding,
7003 run_id: &str,
7004 child_id: &str,
7005 parent_id: &str,
7006 logical_session: &Session,
7007 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7008 plain_input: Option<&PlainActorActivation>,
7009) -> Option<Session> {
7010 use bamboo_agent_core::storage::Storage;
7011 let current = store.load_session(child_id).await.ok().flatten()?;
7012 let current_project = project_id_for_actor_run(¤t).ok()?;
7013 let logical_project = project_id_for_actor_run(logical_session).ok()?;
7014 if current.id != child_id
7015 || current.kind != bamboo_domain::SessionKind::Child
7016 || current.parent_session_id.as_deref() != Some(parent_id)
7017 || current.parent_session_id != logical_session.parent_session_id
7018 || current.root_session_id != logical_session.root_session_id
7019 || current.created_at != creation.created_at
7020 || current.created_at != logical_session.created_at
7021 || current.spawn_depth != creation.spawn_depth
7022 || current.spawn_depth != logical_session.spawn_depth
7023 || current_project != logical_project
7024 || bamboo_domain::ActorSession::from_session(¤t).is_err()
7025 {
7026 return None;
7027 }
7028 if let Some(activation) = plain_input {
7029 validate_directory_actor_event(
7030 activation.store.as_ref(),
7031 &activation.fence,
7032 ¤t,
7033 Some(run_id),
7034 )
7035 .await
7036 .ok()?;
7037 } else if !binding.router.owns_run(child_id, run_id).await {
7038 return None;
7039 }
7040 Some(current)
7041}
7042
7043async fn invoke_active_subagent(
7044 tool: &dyn bamboo_agent_core::tools::Tool,
7045 caller: &Session,
7046 args: serde_json::Value,
7047 tool_call_id: &str,
7048) -> serde_json::Value {
7049 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7050 let mut ctx = ToolCtx::none(tool_call_id.to_owned());
7051 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7052 let output_cap = caller
7053 .effective_token_budget()
7054 .map(|budget| budget.max_tool_output_tokens);
7055 let outcome = bamboo_agent_core::tools::scope_tool_output_cap(
7056 &caller.id,
7057 tool_call_id,
7058 output_cap,
7059 tool.invoke(args, ctx),
7060 )
7061 .await;
7062 match outcome {
7063 Ok(ToolOutcome::Completed(result))
7064 if result.images.is_empty()
7065 && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
7066 {
7067 serde_json::json!({"result":result})
7068 }
7069 _ => serde_json::json!({
7070 "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
7071 }),
7072 }
7073}
7074
7075#[allow(clippy::too_many_arguments)]
7078async fn checkpoint_active_parent_question(
7079 store: &bamboo_storage::SessionStoreV2,
7080 binding: &SessionInboxRuntimeBinding,
7081 run_id: &str,
7082 child_id: &str,
7083 parent_id: &str,
7084 logical_session: &mut Session,
7085 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7086 plain_input: Option<&PlainActorActivation>,
7087 observation: ParentQuestionCheckpointV1,
7088) -> Option<String> {
7089 use bamboo_agent_core::storage::Storage;
7090 if plain_input.is_some() || observation.validate_shape().is_err() {
7091 return None;
7092 }
7093 let _session_guard = binding
7097 .parent_question_lock
7098 .as_ref()?
7099 .acquire_lock(child_id)
7100 .await;
7101 let caller = load_active_subagent_caller(
7102 store,
7103 binding,
7104 run_id,
7105 child_id,
7106 parent_id,
7107 logical_session,
7108 creation,
7109 plain_input,
7110 )
7111 .await?;
7112 let parent = store.load_session(parent_id).await.ok().flatten()?;
7113 if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7114 return None;
7115 }
7116 let (saved, question) = store
7117 .checkpoint_parent_question(&caller, &parent, &observation)
7118 .await
7119 .ok()
7120 .flatten()?;
7121 logical_session.messages = saved.messages;
7124 logical_session.pending_question = saved.pending_question;
7125 if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7126 logical_session
7127 .metadata
7128 .insert("runtime.suspend_reason".into(), reason.clone());
7129 }
7130 if let Some(request) = saved
7131 .metadata
7132 .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7133 {
7134 logical_session.metadata.insert(
7135 bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7136 request.clone(),
7137 );
7138 }
7139 logical_session.metadata.insert(
7140 "runtime.actor_parent_question_handoff".into(),
7141 "true".into(),
7142 );
7143 Some(question.id.as_str().to_owned())
7144}
7145
7146async fn ensure_active_nested_wait(
7151 tool: &dyn bamboo_agent_core::tools::Tool,
7152 store: &bamboo_storage::SessionStoreV2,
7153 binding: &SessionInboxRuntimeBinding,
7154 run_id: &str,
7155 child_id: &str,
7156 parent_id: &str,
7157 logical_session: &Session,
7158 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7159 plain_input: Option<&PlainActorActivation>,
7160) -> Result<bool, AgentError> {
7161 fn is_terminal_status(status: &str) -> bool {
7162 matches!(
7163 status,
7164 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7165 )
7166 }
7167 use bamboo_agent_core::storage::Storage;
7168 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7169
7170 let caller = load_active_subagent_caller(
7171 store,
7172 binding,
7173 run_id,
7174 child_id,
7175 parent_id,
7176 logical_session,
7177 creation,
7178 plain_input,
7179 )
7180 .await
7181 .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7182 if caller
7183 .agent_runtime_state
7184 .as_ref()
7185 .and_then(|state| state.waiting_for_children.as_ref())
7186 .is_some()
7187 {
7188 return Ok(true);
7189 }
7190
7191 let statuses = store
7192 .list_child_run_statuses(child_id)
7193 .await
7194 .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?;
7195 let mut active = Vec::new();
7196 for (id, status) in statuses {
7197 if !status.as_deref().is_some_and(is_terminal_status)
7198 && !store
7199 .is_supervisor_observer_child(&caller, &id)
7200 .await
7201 .map_err(|error| {
7202 AgentError::LLM(format!("nested Observer classification failed: {error}"))
7203 })?
7204 {
7205 active.push(id);
7206 }
7207 }
7208 if active.is_empty() {
7209 return Ok(false);
7210 }
7211
7212 let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7213 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7214 let outcome = tokio::time::timeout(
7215 Duration::from_secs(30),
7216 tool.invoke(
7217 serde_json::json!({"action":"wait", "child_session_ids":active}),
7218 ctx,
7219 ),
7220 )
7221 .await
7222 .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7223 .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7224 let wait_registered = match outcome {
7225 ToolOutcome::Completed(result) if result.success => {
7226 let payload: serde_json::Value = serde_json::from_str(&result.result)
7227 .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7228 payload["status"] == "waiting"
7229 }
7230 _ => {
7231 return Err(AgentError::LLM(
7232 "nested wait registration was not completed".into(),
7233 ))
7234 }
7235 };
7236
7237 let latest = load_active_subagent_caller(
7240 store,
7241 binding,
7242 run_id,
7243 child_id,
7244 parent_id,
7245 logical_session,
7246 creation,
7247 plain_input,
7248 )
7249 .await
7250 .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7251 if latest
7252 .agent_runtime_state
7253 .as_ref()
7254 .and_then(|state| state.waiting_for_children.as_ref())
7255 .is_some()
7256 {
7257 return Ok(true);
7258 }
7259 let statuses = store
7260 .list_child_run_statuses(child_id)
7261 .await
7262 .map_err(|error| {
7263 AgentError::LLM(format!("nested child wait verification failed: {error}"))
7264 })?;
7265 let mut still_active = false;
7266 for (id, status) in statuses {
7267 if !status.as_deref().is_some_and(is_terminal_status)
7268 && !store
7269 .is_supervisor_observer_child(&latest, &id)
7270 .await
7271 .map_err(|error| {
7272 AgentError::LLM(format!("nested Observer classification failed: {error}"))
7273 })?
7274 {
7275 still_active = true;
7276 break;
7277 }
7278 }
7279 if still_active {
7280 return Err(AgentError::LLM(
7281 "nested child remains active without a durable parent wait".into(),
7282 ));
7283 }
7284 Ok(wait_registered)
7289}
7290
7291fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7292 let message = match error {
7293 ActorEventRouteError::SequenceGap { .. } => {
7294 "actor event sequence gap; authoritative session snapshot is required"
7295 }
7296 ActorEventRouteError::LifecycleMissing => {
7297 "actor emitted live content before its activation lifecycle event"
7298 }
7299 ActorEventRouteError::ForgedSession => {
7300 "actor event payload targets a different canonical session"
7301 }
7302 ActorEventRouteError::ConflictingReplay => {
7303 "actor replay reused an event sequence with different content"
7304 }
7305 ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7306 ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7307 };
7308 AgentError::LLM(message.into())
7309}
7310
7311const MAX_DISPLAY_CALLS: usize = 4_096;
7312const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7313const MAX_DISPLAY_ID_BYTES: usize = 256;
7314const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7315
7316fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7317 if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7318 return None;
7319 }
7320 Some(match result {
7321 HookResult::Continue => HookResult::Continue,
7322 HookResult::Mutated => HookResult::Mutated,
7323 HookResult::Allow => HookResult::Allow,
7324 HookResult::Ask => HookResult::Ask,
7325 HookResult::Deny { .. } => HookResult::Deny {
7326 reason: "Hook denied".into(),
7327 },
7328 HookResult::InjectContext { .. } => HookResult::InjectContext {
7329 text: "Hook context hidden".into(),
7330 },
7331 HookResult::WithContext { result, .. } => HookResult::WithContext {
7332 result: Box::new(hook_result_for_display(*result, depth + 1)?),
7333 text: "Hook context hidden".into(),
7334 },
7335 HookResult::Suspend { .. } => HookResult::Suspend {
7336 reason: "Hook suspended".into(),
7337 },
7338 HookResult::Abort { .. } => HookResult::Abort {
7339 reason: "Hook aborted".into(),
7340 },
7341 })
7342}
7343
7344#[derive(Clone, Copy, PartialEq, Eq)]
7345enum DisplayCallKind {
7346 Ordinary,
7347 PrivateBrowser,
7348 Reused,
7349}
7350
7351#[derive(Clone, Copy)]
7352struct DisplayCall {
7353 kind: DisplayCallKind,
7354 started: bool,
7355}
7356
7357#[derive(Default)]
7358struct ActorEventDisplay {
7359 calls: HashMap<String, DisplayCall>,
7360 hook_names: HashMap<String, usize>,
7361 children: HashMap<(String, String), Box<ActorEventDisplay>>,
7362 identities: Arc<AtomicUsize>,
7365 overflowed: bool,
7366 nested_overflowed: bool,
7367}
7368
7369fn is_browser_tool_name(tool_name: &str) -> bool {
7370 tool_name
7371 .trim()
7372 .rsplit("::")
7373 .next()
7374 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7375}
7376
7377fn known_browser_action(args: &serde_json::Value) -> bool {
7378 matches!(
7379 args.get("action").and_then(serde_json::Value::as_str),
7380 Some(
7381 "tabs"
7382 | "new_tab"
7383 | "activate_tab"
7384 | "close_tab"
7385 | "navigate"
7386 | "history"
7387 | "viewport"
7388 | "snapshot"
7389 | "click"
7390 | "click_at"
7391 | "hover"
7392 | "drag"
7393 | "fill"
7394 | "select_option"
7395 | "set_file_input"
7396 | "type"
7397 | "press"
7398 | "key"
7399 | "scroll"
7400 | "download"
7401 | "screenshot"
7402 | "dialog_respond"
7403 )
7404 )
7405}
7406
7407fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7408 is_browser_tool_name(tool_name)
7409 && (!known_browser_action(args)
7410 || args
7411 .get("action")
7412 .and_then(serde_json::Value::as_str)
7413 .is_some_and(|action| {
7414 action.eq_ignore_ascii_case("download") || action == "set_file_input"
7415 })
7416 || args.get("data_base64").is_some())
7417}
7418
7419fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7420 if is_browser_tool_name(tool_name) {
7421 if !known_browser_action(args) {
7424 return serde_json::json!({"details":"[redacted]"});
7425 }
7426 }
7427 tool_start_arguments_for_display(tool_name, args)
7428}
7429
7430impl ActorEventDisplay {
7431 fn fail_closed(&mut self) {
7432 self.identities
7433 .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7434 self.overflowed = true;
7435 self.calls.clear();
7436 self.hook_names.clear();
7437 self.nested_overflowed = true;
7438 self.children.clear();
7439 }
7440
7441 fn reserve_identity(&mut self) -> bool {
7442 if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7443 true
7444 } else {
7445 self.fail_closed();
7446 false
7447 }
7448 }
7449
7450 fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7451 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7452 return DisplayCallKind::Reused;
7453 }
7454 let kind = if private {
7455 DisplayCallKind::PrivateBrowser
7456 } else {
7457 DisplayCallKind::Ordinary
7458 };
7459 if let Some(call) = self.calls.get_mut(id) {
7460 if call.started || call.kind != kind {
7461 call.kind = DisplayCallKind::Reused;
7462 }
7463 call.started = true;
7464 return call.kind;
7465 }
7466 if !self.reserve_identity() {
7467 return DisplayCallKind::Reused;
7468 }
7469 self.calls.insert(
7470 id.to_string(),
7471 DisplayCall {
7472 kind,
7473 started: true,
7474 },
7475 );
7476 kind
7477 }
7478
7479 fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7480 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7481 return DisplayCallKind::Reused;
7482 }
7483 let kind = if private {
7484 DisplayCallKind::PrivateBrowser
7485 } else {
7486 DisplayCallKind::Ordinary
7487 };
7488 if let Some(call) = self.calls.get_mut(id) {
7489 if call.kind != kind {
7490 call.kind = DisplayCallKind::Reused;
7491 }
7492 return call.kind;
7493 }
7494 if !self.reserve_identity() {
7495 return DisplayCallKind::Reused;
7496 }
7497 self.calls.insert(
7498 id.to_string(),
7499 DisplayCall {
7500 kind,
7501 started: false,
7502 },
7503 );
7504 kind
7505 }
7506
7507 fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7508 if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7509 return None;
7510 }
7511 self.calls
7512 .get(id)
7513 .filter(|call| call.started)
7514 .map(|call| call.kind)
7515 }
7516
7517 fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7518 if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7519 self.fail_closed();
7520 return None;
7521 }
7522 if let Some(index) = self.hook_names.get(hook_name) {
7523 return Some(format!("actor-hook-{index}"));
7524 }
7525 if !self.reserve_identity() {
7526 return None;
7527 }
7528 let index = self.hook_names.len() + 1;
7529 self.hook_names.insert(hook_name.to_string(), index);
7530 Some(format!("actor-hook-{index}"))
7531 }
7532
7533 fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7534 let call_id = match &event {
7535 AgentEvent::ToolStart { tool_call_id, .. }
7536 | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7537 | AgentEvent::ToolToken { tool_call_id, .. }
7538 | AgentEvent::ToolComplete { tool_call_id, .. }
7539 | AgentEvent::ToolError { tool_call_id, .. }
7540 | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7541 _ => None,
7542 };
7543 if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7544 self.fail_closed();
7545 return None;
7546 }
7547 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7548 self.fail_closed();
7549 }
7550 Some(match event {
7551 AgentEvent::ToolStart {
7552 tool_call_id,
7553 tool_name,
7554 arguments,
7555 } => {
7556 let private = is_private_browser_call(&tool_name, &arguments);
7557 let kind = self.remember_start(&tool_call_id, private);
7558 let arguments = if kind == DisplayCallKind::Reused {
7559 serde_json::json!({"details":"[redacted]"})
7560 } else {
7561 actor_arguments_for_display(&tool_name, &arguments)
7562 };
7563 AgentEvent::ToolStart {
7564 tool_call_id,
7565 tool_name: match kind {
7566 DisplayCallKind::Reused => "unknown".into(),
7567 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7568 _ => tool_name,
7569 },
7570 arguments,
7571 }
7572 }
7573 AgentEvent::ToolApprovalRequested {
7574 tool_call_id,
7575 tool_name,
7576 parameters,
7577 } => {
7578 let private = is_private_browser_call(&tool_name, ¶meters);
7579 let kind = self.remember_approval(&tool_call_id, private);
7580 let parameters = if kind == DisplayCallKind::Reused {
7581 serde_json::json!({"details":"[redacted]"})
7582 } else {
7583 actor_arguments_for_display(&tool_name, ¶meters)
7584 };
7585 AgentEvent::ToolApprovalRequested {
7586 tool_call_id,
7587 tool_name: match kind {
7588 DisplayCallKind::Reused => "unknown".into(),
7589 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7590 _ => tool_name,
7591 },
7592 parameters,
7593 }
7594 }
7595 AgentEvent::ToolToken {
7596 tool_call_id,
7597 content,
7598 } => {
7599 let content = match self.known_kind(&tool_call_id) {
7600 Some(DisplayCallKind::Ordinary) => content,
7601 Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7602 _ => "Tool output hidden".into(),
7603 };
7604 AgentEvent::ToolToken {
7605 tool_call_id,
7606 content,
7607 }
7608 }
7609 AgentEvent::ToolComplete {
7610 tool_call_id,
7611 result,
7612 } => {
7613 let result = match self.known_kind(&tool_call_id) {
7614 Some(DisplayCallKind::Ordinary) => result,
7615 Some(DisplayCallKind::PrivateBrowser) => {
7616 bamboo_agent_core::tools::ToolResult::text(
7617 result.success,
7618 "Browser tool result hidden",
7619 )
7620 }
7621 _ => bamboo_agent_core::tools::ToolResult::text(
7622 result.success,
7623 "Tool result hidden",
7624 ),
7625 };
7626 AgentEvent::ToolComplete {
7627 tool_call_id,
7628 result,
7629 }
7630 }
7631 AgentEvent::ToolError {
7632 tool_call_id,
7633 error,
7634 } => {
7635 let error = match self.known_kind(&tool_call_id) {
7636 Some(DisplayCallKind::Ordinary) => error,
7637 Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7638 _ => "Tool failed".into(),
7639 };
7640 AgentEvent::ToolError {
7641 tool_call_id,
7642 error,
7643 }
7644 }
7645 AgentEvent::ToolLifecycle {
7646 tool_call_id,
7647 tool_name,
7648 phase,
7649 elapsed_ms,
7650 is_mutating,
7651 auto_approved,
7652 summary,
7653 error,
7654 } => {
7655 let kind = self.known_kind(&tool_call_id);
7656 let (summary, error) = match kind {
7657 Some(DisplayCallKind::Ordinary) => (summary, error),
7658 Some(DisplayCallKind::PrivateBrowser) => (
7659 summary.map(|_| "Browser tool activity hidden".into()),
7660 error.map(|_| "Browser tool failed".into()),
7661 ),
7662 _ => (
7663 summary.map(|_| "Tool activity hidden".into()),
7664 error.map(|_| "Tool failed".into()),
7665 ),
7666 };
7667 AgentEvent::ToolLifecycle {
7668 tool_call_id,
7669 tool_name: match kind {
7670 Some(DisplayCallKind::Ordinary) => tool_name,
7671 Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7672 _ => "unknown".into(),
7673 },
7674 phase: if kind == Some(DisplayCallKind::Ordinary)
7675 || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7676 {
7677 phase
7678 } else {
7679 "event".into()
7680 },
7681 elapsed_ms,
7682 is_mutating,
7683 auto_approved,
7684 summary,
7685 error,
7686 }
7687 }
7688 AgentEvent::HookLifecycle {
7689 hook_name,
7690 point,
7691 phase,
7692 duration_ms,
7693 decision,
7694 } => {
7695 let Some(decision) = hook_result_for_display(decision, 0) else {
7696 self.fail_closed();
7697 return None;
7698 };
7699 let hook_name = self.display_hook_name(&hook_name)?;
7700 let phase = match phase.as_str() {
7701 "completed" | "started" | "error" => phase,
7702 _ => "event".into(),
7703 };
7704 AgentEvent::HookLifecycle {
7705 hook_name,
7706 point,
7707 phase,
7708 duration_ms,
7709 decision,
7710 }
7711 }
7712 AgentEvent::ChildApprovalRequested {
7713 child_session_id,
7714 request_id,
7715 tool_name,
7716 permission,
7717 resource,
7718 } => {
7719 let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7720 (
7721 "browser".into(),
7722 "Browser interaction approval".into(),
7723 "[redacted]".into(),
7724 )
7725 } else {
7726 (tool_name, permission, resource)
7727 };
7728 AgentEvent::ChildApprovalRequested {
7729 child_session_id,
7730 request_id,
7731 tool_name,
7732 permission,
7733 resource,
7734 }
7735 }
7736 AgentEvent::ChildApprovalChanged { tool_name, .. }
7737 if is_browser_tool_name(&tool_name) =>
7738 {
7739 return None;
7743 }
7744 AgentEvent::SubAgentEvent {
7745 parent_session_id,
7746 child_session_id,
7747 event,
7748 } => {
7749 if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7750 return None;
7751 }
7752 if parent_session_id.is_empty()
7753 || child_session_id.is_empty()
7754 || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7755 || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7756 {
7757 self.fail_closed();
7758 return None;
7759 }
7760 let key = (parent_session_id.clone(), child_session_id.clone());
7761 if !self.children.contains_key(&key) && !self.reserve_identity() {
7762 return None;
7763 }
7764 let identities = self.identities.clone();
7765 let child = self.children.entry(key).or_insert_with(|| {
7766 Box::new(ActorEventDisplay {
7767 identities,
7768 ..Default::default()
7769 })
7770 });
7771 let event = child.project(*event, depth + 1)?;
7772 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7773 self.fail_closed();
7774 return None;
7775 }
7776 AgentEvent::SubAgentEvent {
7777 parent_session_id,
7778 child_session_id,
7779 event: Box::new(event),
7780 }
7781 }
7782 other => other,
7783 })
7784 }
7785}
7786
7787#[allow(clippy::too_many_arguments)]
7788async fn process_actor_event(
7789 event: serde_json::Value,
7790 strict_permission_events: bool,
7791 permission_handshake: &mut PermissionPostureHandshake,
7792 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7793 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7794 logical_session: &mut Session,
7795 event_tx: &mpsc::Sender<AgentEvent>,
7796 display: &mut ActorEventDisplay,
7797) -> crate::runtime::runner::Result<()> {
7798 let event = match serde_json::from_value::<AgentEvent>(event) {
7799 Ok(event) => event,
7800 Err(_) if strict_permission_events => {
7801 return Err(AgentError::LLM(
7805 "actor emitted malformed AgentEvent under a typed permission posture contract"
7806 .to_string(),
7807 ));
7808 }
7809 Err(_) => {
7810 display.fail_closed();
7814 return Ok(());
7815 }
7816 };
7817 process_typed_actor_event(
7818 event,
7819 permission_handshake,
7820 expected_permission_posture,
7821 session_inbox_runtime,
7822 logical_session,
7823 event_tx,
7824 display,
7825 )
7826 .await
7827}
7828
7829#[allow(clippy::too_many_arguments)]
7830async fn process_typed_actor_event(
7831 event: AgentEvent,
7832 permission_handshake: &mut PermissionPostureHandshake,
7833 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7834 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7835 logical_session: &mut Session,
7836 event_tx: &mpsc::Sender<AgentEvent>,
7837 display: &mut ActorEventDisplay,
7838) -> crate::runtime::runner::Result<()> {
7839 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7840 if permission_handshake.posture_was_confirmed() {
7841 return Err(AgentError::LLM(
7842 "actor emitted a duplicate permission posture activation".to_string(),
7843 ));
7844 }
7845 let seed = permission_posture_seed_from_event(logical_session, &event)
7846 .map_err(AgentError::LLM)?
7847 .ok_or_else(|| {
7848 AgentError::LLM(
7849 "actor permission posture event did not decode as a posture".to_string(),
7850 )
7851 })?;
7852 if let Some(expected) = expected_permission_posture {
7853 if seed.policy_revision != expected.policy_revision
7854 || seed.resolution != expected.resolution
7855 {
7856 return Err(AgentError::LLM(
7857 "permission posture event does not match the host-dispatched policy"
7858 .to_string(),
7859 ));
7860 }
7861 if seed.executor_mapping() != expected.executor_mapping {
7862 return Err(AgentError::LLM(
7863 "permission posture event does not match the host-dispatched executor mapping"
7864 .to_string(),
7865 ));
7866 }
7867 }
7868 if let Some(binding) = session_inbox_runtime {
7869 let saved = binding
7870 .persistence
7871 .record_permission_posture_activation(
7872 &logical_session.id,
7873 expected_permission_posture
7874 .and_then(|expected| expected.expected_audit_revision),
7875 &seed,
7876 )
7877 .await
7878 .map_err(|error| {
7879 AgentError::LLM(format!(
7880 "persist child permission posture bootstrap: {error}"
7881 ))
7882 })?
7883 .ok_or_else(|| {
7884 AgentError::LLM(
7885 "persist child permission posture bootstrap: session not found".to_string(),
7886 )
7887 })?;
7888 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7889 .ok_or_else(|| {
7890 AgentError::LLM(
7891 "persisted child permission posture audit is incomplete".to_string(),
7892 )
7893 })?;
7894 snapshot.write_to(&mut logical_session.metadata);
7895 } else {
7896 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7899 .map_err(|error| {
7900 AgentError::LLM(format!(
7901 "record in-memory child permission posture: {error}"
7902 ))
7903 })?;
7904 }
7905 *permission_handshake = PermissionPostureHandshake::Confirmed;
7908 } else if permission_handshake.is_awaiting() {
7909 return Err(AgentError::LLM(
7910 "actor emitted an execution event before permission posture confirmation".to_string(),
7911 ));
7912 }
7913 if let Some(event) = display.project(event, 0) {
7914 let _ = event_tx.send(event).await;
7915 }
7916 Ok(())
7917}
7918
7919async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7930 let ActorDriveContext {
7931 client,
7932 parent_session_id,
7933 child_session_id,
7934 child_attempt,
7935 approval_registry,
7936 approval_decider,
7937 approval_reviewer,
7938 escalation_bridge,
7939 event_tx,
7940 cancel_token,
7941 live_rx,
7942 delivery_rx,
7943 logical_session,
7944 mut expected_permission_posture,
7945 expected_creation,
7946 session_inbox_runtime,
7947 actor_directory_store,
7948 canonical_subagent_tool,
7949 ticket_service,
7950 activation_run_id,
7951 execution_epoch,
7952 expected_source_actor_id,
7953 initial_inflight_claims,
7954 first_frame_timeout,
7955 plain_actor,
7956 remote_environment_lease,
7957 mut readonly_output,
7958 local_history_tools,
7959 local_history_read_only,
7960 mut strict_history_output,
7961 plain_input,
7962 canonical_activation,
7963 canonical_placement_ref,
7964 actor_event_observer,
7965 plain_run,
7966 } = context;
7967
7968 logical_session
7972 .metadata
7973 .remove("runtime.actor_nested_wait_handoff");
7974
7975 let mut got_first_frame = false;
7982 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7983 let mut inflight_claims = initial_inflight_claims;
7984 let mut owned_inputs = plain_input
7985 .map(|activation| VecDeque::from(activation.initial_inputs.clone()))
7986 .unwrap_or_default();
7987 let continuation_limit = if owned_inputs.is_empty() { 2 } else { 1 };
7990 let mut continuations = usize::from(!owned_inputs.is_empty());
7991 let mut released_inputs: Vec<(
7992 SessionInboxOwnedClaim,
7993 bamboo_subagent::proto::InitialInputRelease,
7994 )> = Vec::new();
7995 let mut current_epoch = execution_epoch;
7996 let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7997 let approval_cancel = cancel_token.child_token();
7998 let _approval_lifetime = approval_cancel.clone().drop_guard();
7999 let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
8000 HashMap::new();
8001 let mut owned_tree_requests = 0usize;
8002 let mut canonical_subagent_requests = 0usize;
8003 let mut committed_plan_calls = HashMap::new();
8004 let mut nested_wait_registered_this_run = false;
8008 let mut parent_question_checkpoint_id: Option<String> = None;
8009 let strict_permission_events = expected_permission_posture.is_some();
8010 let mut permission_handshake =
8011 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8012 let mut next_actor_event_seq = 1u64;
8013 let mut canonical_router: Option<ActorEventRouter> = None;
8014 let canonical_fence = plain_input
8015 .map(|activation| &activation.fence)
8016 .or(canonical_activation);
8017 let canonical_store = plain_input
8018 .map(|activation| activation.store.as_ref())
8019 .or(actor_directory_store);
8020 let remote_claim_fence = actor_directory_store.zip(canonical_activation);
8021 let workflow_usage_requested = logical_session
8022 .metadata
8023 .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
8024 .is_some_and(|v| v == "true");
8025 if workflow_usage_requested {
8026 logical_session
8027 .metadata
8028 .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
8029 logical_session
8030 .metadata
8031 .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
8032 }
8033 let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
8034 let mut display = ActorEventDisplay::default();
8035 let mut readonly = readonly_output
8036 .as_ref()
8037 .map(|_| ReadOnlyActorCollector::default());
8038 let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
8039 loop {
8040 tokio::select! {
8041 _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
8042 if remote_claim_fence.is_some() || workflow_usage_requested {
8043 let _ = client.send(ParentFrame::Cancel).await;
8049 remote_cancel_deadline = Some(
8050 tokio::time::Instant::now() + Duration::from_secs(10)
8051 );
8052 continue;
8053 }
8054 break;
8056 }
8057 _ = async {
8058 if let Some(deadline) = remote_cancel_deadline {
8059 tokio::time::sleep_until(deadline).await;
8060 } else {
8061 std::future::pending::<()>().await;
8062 }
8063 } => {
8064 return Err(AgentError::Cancelled);
8065 }
8066 _ = async {
8067 match first_frame_watch.as_mut() {
8068 Some(s) => s.as_mut().await,
8069 None => std::future::pending::<()>().await,
8070 }
8071 }, if !got_first_frame => {
8072 return Err(AgentError::WorkerUnresponsive(format!(
8073 "child {child_session_id} produced no frame within {:?}",
8074 first_frame_timeout.unwrap_or_default()
8075 )));
8076 }
8077 Some(_generation) = delivery_rx.recv(),
8078 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
8079 {
8080 if plain_input.is_some() {
8081 continue;
8084 }
8085 forward_next_canonical_claim(
8086 client,
8087 session_inbox_runtime.expect("guarded"),
8088 logical_session,
8089 activation_run_id.expect("guarded"),
8090 &mut inflight_claims,
8091 remote_claim_fence,
8092 )
8093 .await?;
8094 }
8095 Some(frame) = live_rx.recv() => {
8096 if plain_actor { return Err(plain_actor_unsupported()); }
8097 if client.send(frame).await.is_err() {
8099 tracing::warn!("live steering frame could not be sent; connection failing");
8100 }
8101 }
8102 frame = client.next_frame() => {
8103 got_first_frame = true;
8106 first_frame_watch = None;
8107 match frame {
8108 Ok(Some(ChildFrame::Event { event })) => {
8109 if canonical_activation.is_some() {
8110 return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8111 }
8112 if local_history.is_some() {
8113 return Err(local_tool_history_unsupported());
8114 }
8115 if event.get("initial_input_control").is_some() {
8116 let bamboo_subagent::proto::InitialInputControl::Request { request } =
8117 bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8118 else { return Err(plain_actor_unsupported()); };
8119 if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8120 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8121 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8122 let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8123 let previous = released_inputs.iter()
8127 .find(|(_, release)| release.request == request);
8128 let (claim, previous) = match previous {
8129 Some((claim, release)) => (claim.clone(), Some(release.clone())),
8130 None => (owned_inputs.front().cloned()
8131 .ok_or_else(|| plain_actor_unsupported())?, None),
8132 };
8133 let release = activation.release_initial_input(binding, logical_session, run, current_epoch, &claim,
8134 &request, &mut expected_permission_posture, previous.as_ref()).await?;
8135 if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8136 client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8137 .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8138 if previous.is_none() {
8139 let _ = owned_inputs.pop_front();
8140 released_inputs.push((claim, release));
8141 }
8142 continue;
8143 }
8144 if expected_creation.is_some() {
8145 return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8146 }
8147 if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8148 if event["type"] == "owned_readonly_transcript" {
8149 if plain_actor { return Err(plain_actor_unsupported()); }
8150 continue;
8151 }
8152 if plain_actor && !plain_actor_event(&event)? { continue; }
8153 if let Some(activation) = plain_input {
8154 validate_directory_actor_event(
8155 activation.store.as_ref(),
8156 &activation.fence,
8157 logical_session,
8158 None,
8159 ).await?;
8160 }
8161 process_actor_event(
8165 event,
8166 strict_permission_events,
8167 &mut permission_handshake,
8168 expected_permission_posture.as_ref(),
8169 session_inbox_runtime,
8170 logical_session,
8171 event_tx,
8172 &mut display,
8173 )
8174 .await?;
8175 }
8176 Ok(Some(ChildFrame::EventBatch { batch })) => {
8177 validate_actor_event_batch(
8178 &batch,
8179 logical_session,
8180 parent_session_id,
8181 activation_run_id,
8182 current_epoch,
8183 expected_source_actor_id,
8184 expected_creation,
8185 )?;
8186 let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8187 let entry = validate_directory_actor_event(
8188 store, fence, logical_session, batch.activation_id.as_deref(),
8189 ).await?;
8190 if canonical_placement_ref.is_some_and(|expected| {
8191 entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8192 }) {
8193 return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8194 }
8195 Some(entry)
8196 } else { None };
8197 if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8198 if canonical_router.is_none() {
8199 canonical_router = Some(
8200 ActorEventRouter::new(
8201 entry,
8202 fence,
8203 current_epoch,
8204 strict_permission_events && permission_handshake.is_awaiting(),
8205 )
8206 .map_err(actor_event_route_error)?,
8207 );
8208 }
8209 }
8210 if local_history_tools.is_some()
8211 && (batch.first_seq != next_actor_event_seq
8212 || batch.qos != bamboo_subagent::ActorEventQos::Durable
8213 || batch.last_seq == u64::MAX) {
8214 return Err(local_tool_history_unsupported());
8215 }
8216 if batch.last_seq < next_actor_event_seq {
8217 continue;
8218 }
8219 if batch.first_seq > next_actor_event_seq {
8220 if plain_actor {
8221 return Err(AgentError::LLM(
8222 "actor event sequence gap; authoritative session snapshot is required".into(),
8223 ));
8224 }
8225 tracing::warn!(
8226 child_session_id,
8227 expected_seq = next_actor_event_seq,
8228 received_seq = batch.first_seq,
8229 "actor event sequence gap; authoritative session snapshot is required"
8230 );
8231 display.fail_closed();
8235 }
8236 let skip = next_actor_event_seq
8237 .saturating_sub(batch.first_seq)
8238 .min(batch.events.len() as u64) as usize;
8239 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8240 let seq = batch.first_seq + offset as u64;
8241 let mut publish = true;
8242 if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8243 if workflow_usage_requested {
8244 if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8245 let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8246 let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8247 if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8248 if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8249 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8250 }
8251 publish = false;
8252 }
8253 if publish { if let Some(collector) = local_history.as_mut() {
8254 if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8255 return Err(local_tool_history_unsupported());
8256 }
8257 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8258 publish = collector.ticket_event(&event, &committed_plan_calls)?;
8259 }
8260 publish &= collector.event(&event)?;
8261 } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8262 publish = false;
8264 }}
8265 if publish { publish = if let Some(collector) = readonly.as_mut() {
8266 if permission_handshake.is_awaiting()
8267 && event["type"] != "permission_posture_activated" {
8268 return Err(plain_actor_unsupported());
8269 }
8270 collector.event(&event)?
8271 } else if event["type"] == "owned_readonly_transcript" {
8272 if plain_actor { return Err(plain_actor_unsupported()); }
8273 false } else if plain_actor { plain_actor_event(&event)? } else { true };
8275 }
8276 if let (Some(router), Some(entry), Some(fence)) =
8277 (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8278 {
8279 let typed = if publish {
8280 Some(serde_json::from_value::<AgentEvent>(event.clone())
8281 .map_err(|_| plain_actor_unsupported())?)
8282 } else {
8283 None
8284 };
8285 match router.route(entry, fence, current_epoch, seq, typed)
8286 .map_err(actor_event_route_error)?
8287 {
8288 ActorEventRoute::Publish(envelope) => {
8289 let public_event = actor_event_observer
8290 .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8291 .map(|_| PublicActorEvent::from(envelope.as_ref()));
8292 tracing::trace!(
8293 actor_id = %envelope.actor_id,
8294 event_id = %envelope.event_id,
8295 class = ?envelope.class,
8296 "publishing canonical Actor event"
8297 );
8298 process_typed_actor_event(
8299 envelope.payload,
8300 &mut permission_handshake,
8301 expected_permission_posture.as_ref(),
8302 session_inbox_runtime,
8303 logical_session,
8304 event_tx,
8305 &mut display,
8306 ).await?;
8307 if let (Some(observer), Some(public_event)) =
8308 (actor_event_observer, public_event)
8309 {
8310 observer.publish(public_event);
8311 }
8312 }
8313 ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8314 }
8315 next_actor_event_seq = seq.saturating_add(1);
8316 continue;
8317 }
8318 if !publish {
8319 next_actor_event_seq = seq.saturating_add(1);
8320 continue;
8321 }
8322 process_actor_event(
8323 event,
8324 strict_permission_events,
8325 &mut permission_handshake,
8326 expected_permission_posture.as_ref(),
8327 session_inbox_runtime,
8328 logical_session,
8329 event_tx,
8330 &mut display,
8331 )
8332 .await?;
8333 next_actor_event_seq = seq.saturating_add(1);
8334 }
8335 }
8336 Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8337 owned_tree_requests += 1;
8338 if owned_tree_requests > 16 {
8339 return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8340 }
8341 let page = if id.len() <= 128
8342 && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8343 && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8344 && !cancel_token.is_cancelled()
8345 {
8346 match (actor_directory_store, session_inbox_runtime,
8347 activation_run_id, expected_creation) {
8348 (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8349 tokio::time::timeout(
8350 Duration::from_secs(8),
8351 inspect_active_owned_tree(
8352 store, binding, run_id, child_session_id,
8353 parent_session_id, logical_session, creation,
8354 plain_input, cursor.as_deref(),
8355 ),
8356 ).await.ok().flatten()
8357 }
8358 _ => {
8359 tracing::warn!("owned tree denied: canonical Host scope unavailable");
8360 None
8361 },
8362 }
8363 } else {
8364 tracing::warn!("owned tree denied: request bounds or cancellation");
8365 None
8366 };
8367 if cancel_token.is_cancelled() {
8368 return Err(AgentError::Cancelled);
8369 }
8370 client.send(ParentFrame::OwnedTreeReply { id, page }).await
8371 .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8372 }
8373 Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8374 canonical_subagent_requests += 1;
8375 if canonical_subagent_requests > 64 {
8376 return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8377 }
8378 let valid_shape = !id.is_empty() && id.len() <= 128
8379 && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8380 && args.is_object()
8381 && serde_json::to_vec(&args)
8382 .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8383 let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8384 Some("wait"))
8385 || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8386 && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8387 let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8388 let ticket_bootstrap_pending = valid_shape
8389 && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8390 && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8391 && permission_handshake.is_awaiting()
8392 && !cancel_token.is_cancelled();
8393 let result = if ticket_bootstrap_pending {
8394 serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8398 } else if valid_shape
8399 && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8400 || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8401 || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8402 && !cancel_token.is_cancelled()
8403 && !permission_handshake.is_awaiting()
8404 {
8405 if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8406 match (ticket_service.as_deref(), actor_directory_store,
8407 session_inbox_runtime, activation_run_id, expected_creation) {
8408 (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8409 if let Some(caller) = load_active_subagent_caller(
8410 store, binding, run_id, child_session_id,
8411 parent_session_id, logical_session, creation, plain_input,
8412 ).await {
8413 match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8414 Ok(projection) => {
8415 logical_session.task_list = projection.task_list.clone();
8416 logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8417 if let Some(question) = &projection.question {
8418 let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8419 logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8420 if let Some(collector) = local_history.as_mut() {
8421 collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8422 }
8423 }
8424 if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8425 committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8426 if let Some(task_list) = projection.task_list.clone() {
8427 let _ = event_tx.send(AgentEvent::TaskListUpdated {
8428 task_list,
8429 version: Some(projection.plan_revision),
8430 }).await;
8431 }
8432 }
8433 serde_json::json!({"result":projection})
8434 }
8435 Err(error) => serde_json::json!({"error":error.to_string()}),
8436 }
8437 } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8438 }
8439 _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8440 }
8441 } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8442 match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8443 activation_run_id, expected_creation, local_history_tools) {
8444 (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8445 if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8446 match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8447 Ok(reply)=>{
8448 let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8449 committed_plan_calls.insert(tool_call_id.clone(),serde_json::json!({"file_tool":body["tool"],"arguments":body["arguments"],"result":serde_json::to_string(&reply).map_err(|_|local_tool_history_unsupported())?}));
8450 serde_json::json!({"result":reply})
8451 },
8452 Err(error)=>serde_json::json!({"error":error.to_string()}),
8453 }
8454 } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8455 },
8456 _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8457 }
8458 } else if let Some(payload) = checkpoint_payload {
8459 let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8460 .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8461 .flatten()
8462 .filter(|observation| observation.tool_call_id == tool_call_id);
8463 match (parsed, actor_directory_store, session_inbox_runtime,
8464 activation_run_id, expected_creation) {
8465 (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8466 let receipt = tokio::time::timeout(Duration::from_secs(25),
8467 checkpoint_active_parent_question(
8468 store, binding, run_id, child_session_id,
8469 parent_session_id, logical_session, creation,
8470 plain_input, observation,
8471 )).await.ok().flatten();
8472 if let Some(request_id) = receipt {
8473 parent_question_checkpoint_id = Some(request_id.clone());
8474 serde_json::json!({"result":{"request_id":request_id}})
8475 } else {
8476 serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8477 }
8478 }
8479 _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8480 }
8481 } else {
8482 match (canonical_subagent_tool.as_deref(), actor_directory_store,
8483 session_inbox_runtime, activation_run_id, expected_creation) {
8484 (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8485 if let Some(caller) = load_active_subagent_caller(
8486 store, binding, run_id, child_session_id,
8487 parent_session_id, logical_session, creation,
8488 plain_input,
8489 ).await {
8490 tokio::time::timeout(
8491 Duration::from_secs(30),
8492 invoke_active_subagent(tool, &caller, args, &tool_call_id),
8493 ).await.unwrap_or_else(|_| serde_json::json!({
8494 "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8495 }))
8496 } else {
8497 serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8498 }
8499 }
8500 _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8501 }
8502 }
8503 } else {
8504 serde_json::json!({"error":"canonical SubAgent request rejected"})
8505 };
8506 if registers_wait
8507 && result["result"]["success"] == true
8508 && result["result"]["result"]
8509 .as_str()
8510 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8511 .is_some_and(|value| value["status"] == "waiting")
8512 {
8513 nested_wait_registered_this_run = true;
8514 }
8515 if cancel_token.is_cancelled() {
8516 return Err(AgentError::Cancelled);
8517 }
8518 client.send(ParentFrame::SubAgentReply { id, result }).await
8519 .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8520 }
8521 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8522 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8523 return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8524 }
8525 if plain_actor { return Err(plain_actor_unsupported()); }
8526 if permission_handshake.is_awaiting() {
8527 return Err(AgentError::LLM(
8528 "actor requested approval before permission posture confirmation"
8529 .to_string(),
8530 ));
8531 }
8532 if let Some(reviewer) = approval_reviewer
8538 .cloned()
8539 .or_else(child_approval_reviewer)
8540 {
8541 let child = child_session_id.to_string();
8549 let parent = parent_session_id.to_string();
8550 let req_id = id.clone();
8551 let body = body.clone();
8552 let registry = approval_registry.cloned();
8553 let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8554 tokio::spawn(async move {
8557 let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8558 reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8559 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8560 child_attempt, &req_id, approved);
8561 });
8562 continue;
8563 };
8564 let generation = body.pointer("/permission_request/request_generation")
8565 .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8566 let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8567 if original != &body || scope.stamp()["reply"] != req_id {
8568 return Err(AgentError::LLM("approval generation changed in live scope".into()));
8569 }
8570 scope.clone()
8571 } else {
8572 if approval_scopes.len() >= 64 {
8573 return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8574 }
8575 let scope = ChildApprovalScope::new(&parent, &child,
8576 (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8577 approval_cancel.clone(), approval_epoch.clone());
8578 approval_scopes.insert(generation, (body.clone(), scope.clone()));
8579 scope
8580 };
8581 tokio::spawn(async move {
8582 let result = tokio::time::timeout(
8583 CHILD_APPROVAL_TIMEOUT,
8584 reviewer.review_scoped(&parent, &child, &body, &scope),
8585 ).await.unwrap_or(ChildApprovalReview::Reply(false));
8586 if let ChildApprovalReview::Reply(approved) = result {
8587 if scope.is_current(&parent, &child).await {
8588 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8589 child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8590 }
8591 }
8592 });
8593 } else if approval_decider.is_some() {
8594 let approved =
8598 decide_child_approval(approval_decider, child_session_id, &body)
8599 .await;
8600 if client
8601 .send(ParentFrame::ApprovalReply { id, approved })
8602 .await
8603 .is_err()
8604 {
8605 tracing::warn!(
8606 "failed to answer approval_request; connection failing"
8607 );
8608 }
8609 } else if let Some(host) = escalation_bridge.clone() {
8610 let child = child_session_id.to_string();
8617 let req_id = id.clone();
8618 let body = body.clone();
8619 let registry = approval_registry.cloned();
8620 tokio::spawn(async move {
8621 let approved = match tokio::time::timeout(
8622 CHILD_APPROVAL_TIMEOUT,
8623 host.approval_call(body),
8624 )
8625 .await
8626 {
8627 Ok(Ok(reply)) => reply
8628 .get("approved")
8629 .and_then(|v| v.as_bool())
8630 .unwrap_or(false),
8631 _ => false,
8633 };
8634 super::live::deliver_approval_scoped(
8635 registry.as_ref(),
8636 &child,
8637 child_attempt,
8638 &req_id,
8639 approved,
8640 );
8641 });
8642 } else {
8643 tracing::warn!(
8647 parent_session_id,
8648 child_session_id,
8649 request_id = %id,
8650 "forced-ask request has no parent-agent reviewer; denying"
8651 );
8652 if client
8653 .send(ParentFrame::ApprovalReply {
8654 id,
8655 approved: false,
8656 })
8657 .await
8658 .is_err()
8659 {
8660 tracing::warn!(
8661 "failed to send fail-closed approval reply; connection failing"
8662 );
8663 }
8664 }
8665 }
8666 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8667 if let Some(activation) = plain_input {
8668 if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8669 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8670 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8671 let claim = owned_inputs.front().ok_or_else(|| plain_actor_unsupported())?;
8672 activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8673 let _ = owned_inputs.pop_front();
8674 continue;
8675 }
8676 let Some(binding) = session_inbox_runtime else {
8677 tracing::warn!(
8678 child_session_id,
8679 "ignoring SessionInbox confirmation without a runtime binding"
8680 );
8681 continue;
8682 };
8683 let Some(bound_run_id) = activation_run_id else {
8684 tracing::warn!(
8685 child_session_id,
8686 "ignoring SessionInbox confirmation without an activation owner"
8687 );
8688 continue;
8689 };
8690 let Some(claim) = inflight_claims.front() else {
8691 tracing::warn!(
8692 child_session_id,
8693 envelope_id = %confirmation.envelope_id,
8694 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8695 );
8696 continue;
8697 };
8698 let exact = confirmation.target_session_id == logical_session.id
8699 && confirmation.envelope_id == claim.envelope.id.as_str()
8700 && confirmation.canonical_claim_generation == claim.generation
8701 && confirmation.activation_run_id == bound_run_id;
8702 if !exact
8703 || !binding
8704 .router
8705 .owns_run(&logical_session.id, bound_run_id)
8706 .await
8707 {
8708 tracing::warn!(
8709 child_session_id,
8710 expected_target = %logical_session.id,
8711 received_target = %confirmation.target_session_id,
8712 expected_envelope_id = %claim.envelope.id,
8713 received_envelope_id = %confirmation.envelope_id,
8714 expected_generation = claim.generation,
8715 received_generation = confirmation.canonical_claim_generation,
8716 expected_run_id = bound_run_id,
8717 received_run_id = %confirmation.activation_run_id,
8718 "rejecting stale or mismatched SessionInbox admission confirmation"
8719 );
8720 continue;
8721 }
8722 let claim = inflight_claims
8723 .pop_front()
8724 .expect("validated in-flight canonical claim");
8725 checkpoint_and_ack_canonical_claim(
8728 binding,
8729 logical_session,
8730 &claim,
8731 remote_claim_fence,
8732 )
8733 .await?;
8734 if inflight_claims.is_empty() {
8738 forward_next_canonical_claim(
8739 client,
8740 binding,
8741 logical_session,
8742 bound_run_id,
8743 &mut inflight_claims,
8744 remote_claim_fence,
8745 )
8746 .await?;
8747 }
8748 }
8749 Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8750 if local_history_tools.is_some() {
8751 validate_strict_history_terminal(
8754 final_event_watermark.as_ref(), logical_session, parent_session_id,
8755 activation_run_id, current_epoch, expected_creation,
8756 next_actor_event_seq - 1,
8757 )?;
8758 }
8759 if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8760 if let Some(tools) = local_history_tools {
8761 if permission_handshake.is_awaiting()
8765 || !owned_inputs.is_empty()
8766 || !inflight_claims.is_empty()
8767 || parent_question_checkpoint_id.is_some()
8768 || nested_wait_registered_this_run
8769 || strict_history_output.is_none()
8770 {
8771 return Err(local_tool_history_unsupported());
8772 }
8773 commit_local_tool_history(
8774 local_history.take().ok_or_else(local_tool_history_unsupported)?,
8775 logical_session,
8776 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8777 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8778 (tools, local_history_read_only),
8779 LocalToolTerminal::Cancelled,
8780 cancel_token,
8781 ).await?;
8782 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8783 logical_session,
8784 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8785 current_epoch,
8786 next_actor_event_seq - 1,
8787 ).map_err(|_| local_tool_history_unsupported())?;
8788 *strict_history_output.as_deref_mut()
8789 .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8790 proof,
8791 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8792 });
8793 }
8794 client.accept_durable_terminal(status);
8800 if workflow_usage_requested {
8801 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8802 }
8803 return Err(AgentError::Cancelled);
8804 }
8805 if parent_question_checkpoint_id.is_some()
8806 && status != TerminalStatus::Suspended
8807 {
8808 return Err(AgentError::LLM(
8809 "Child question checkpoint must end in a suspended Run".into(),
8810 ));
8811 }
8812 if permission_handshake.is_awaiting() {
8813 if remote_environment_lease && status == TerminalStatus::Error {
8814 if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8815 client.accept_durable_terminal(status);
8822 if workflow_usage_requested {
8823 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8824 }
8825 return Err(AgentError::LLM(code.to_owned()));
8826 }
8827 }
8828 return Err(AgentError::LLM(
8829 "actor terminated before permission posture confirmation"
8830 .to_string(),
8831 ));
8832 }
8833 if !owned_inputs.is_empty() {
8834 return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8835 }
8836 if let Some(claim) = inflight_claims.front() {
8837 return Err(AgentError::LLM(format!(
8838 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8839 claim.envelope.id
8840 )));
8841 }
8842 if status == TerminalStatus::Completed {
8843 if let Some(collector) = local_history.take() {
8844 commit_local_tool_history(
8845 collector, logical_session,
8846 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8847 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8848 (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8849 LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8850 ).await?;
8851 }
8852 }
8853 if let Some(collector) = readonly.take() {
8854 if status == TerminalStatus::Completed {
8855 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8856 let backlog = activation.input_inbox.inspect(&logical_session.id).await
8857 .map_err(|_| plain_actor_unsupported())?;
8858 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8859 return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8860 }
8861 *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8862 collector.finish(result.as_deref())?;
8863 }
8864 } else if let Some(activation) = plain_input {
8865 if status == TerminalStatus::Completed {
8866 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8867 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8868 if continuations < continuation_limit {
8869 if let Some((claim, epoch)) = activation.continue_input(
8870 client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8871 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8872 ).await? {
8873 owned_inputs = VecDeque::from([claim]);
8874 released_inputs.clear();
8875 continuations += 1;
8876 current_epoch = epoch;
8877 approval_epoch.store(epoch, Ordering::Release);
8878 next_actor_event_seq = 1;
8881 canonical_router = None;
8882 permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8883 display = ActorEventDisplay::default();
8884 continue;
8885 }
8886 } else if activation.input_inbox.inspect(&logical_session.id).await
8887 .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8888 if continuation_limit == 1 {
8889 return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8890 }
8891 activation.append_reply(logical_session,
8896 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8897 event_tx).await?;
8898 return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8899 }
8900 }
8901 }
8902 if status == TerminalStatus::Completed {
8903 if let (Some(tool), Some(creation)) =
8908 (canonical_subagent_tool.as_deref(), expected_creation)
8909 {
8910 let (Some(store), Some(binding), Some(run_id)) =
8911 (actor_directory_store, session_inbox_runtime,
8912 activation_run_id)
8913 else {
8914 return Err(AgentError::LLM(
8915 "nested SubAgent Host authority unavailable at Child terminal"
8916 .into(),
8917 ));
8918 };
8919 let must_suspend = ensure_active_nested_wait(
8920 tool, store, binding, run_id, child_session_id,
8921 parent_session_id, logical_session, creation, plain_input,
8922 ).await?;
8923 if must_suspend || nested_wait_registered_this_run {
8924 logical_session.metadata.insert(
8931 "runtime.actor_nested_wait_handoff".into(),
8932 "true".into(),
8933 );
8934 }
8935 }
8936 }
8937 let terminal_result = match status {
8938 TerminalStatus::Completed => Ok(result),
8939 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8940 TerminalStatus::Error => Err(AgentError::LLM(
8941 error.unwrap_or_else(|| "actor child errored".to_string()),
8942 )),
8943 TerminalStatus::Suspended => {
8944 use bamboo_agent_core::storage::Storage;
8945 let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8946 (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8947 session_inbox_runtime, activation_run_id, expected_creation)
8948 else {
8949 return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8950 };
8951 if load_active_subagent_caller(
8952 store, binding, run_id, child_session_id,
8953 parent_session_id, logical_session, creation, plain_input,
8954 ).await.is_none() {
8955 return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8956 }
8957 let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8958 .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8959 let child = store.load_session(child_session_id).await
8960 .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8961 .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8962 let parent = store.load_session(parent_session_id).await
8963 .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8964 .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8965 let pending = ParentQuestion::for_pending(&parent, &child)
8966 .is_some_and(|question| question.id == id);
8967 let answered = bamboo_domain::ParentQuestionResolution::from_child(
8968 &parent, &child, &id,
8969 ).is_some();
8970 if !pending && !answered {
8971 return Err(AgentError::LLM("canonical Child question proof changed".into()));
8972 }
8973 logical_session.metadata.insert(
8974 "runtime.actor_parent_question_handoff".into(), "true".into(),
8975 );
8976 Ok(None)
8977 }
8978 };
8979 if local_history_tools.is_some() {
8980 if status != TerminalStatus::Completed {
8984 return terminal_result;
8985 }
8986 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8987 logical_session,
8988 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8989 current_epoch,
8990 next_actor_event_seq - 1,
8991 ).map_err(|_| local_tool_history_unsupported())?;
8992 let history = StrictTerminalHistory {
8993 proof,
8994 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8995 };
8996 if let Some(output) = strict_history_output.as_deref_mut() {
8997 *output = Some(history);
8998 }
8999 }
9000 client.accept_durable_terminal(status);
9007 if workflow_usage_requested {
9008 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
9009 }
9010 if status == TerminalStatus::Completed {
9011 if let Some(activation) = plain_input {
9012 *activation.completion_router.lock().recover_poison() =
9015 canonical_router.take();
9016 }
9017 }
9018 return terminal_result;
9019 }
9020 Ok(None) => {
9021 return Err(AgentError::LLM(
9022 "actor child closed before terminal".to_string(),
9023 ));
9024 }
9025 Err(e) => {
9026 return Err(AgentError::LLM(format!("actor transport error: {e}")));
9027 }
9028 }
9029 }
9030 }
9031 }
9032
9033 let _ = client.send(ParentFrame::Cancel).await;
9035 Err(AgentError::Cancelled)
9036}
9037
9038fn project_id_for_actor_run(
9040 session: &Session,
9041) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
9042 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
9043 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
9044 Ok(Some(project_id))
9045 }
9046 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
9047 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
9048 Err(AgentError::LLM(format!(
9049 "child session carries an invalid Project identity '{raw}': {message}"
9050 )))
9051 }
9052 }
9053}
9054
9055fn remote_broker_unavailable() -> AgentError {
9056 AgentError::LLM("remote_broker_activation_unavailable".into())
9057}
9058
9059fn remote_environment_error_code(value: &str) -> Option<&'static str> {
9063 const CODES: &[&str] = &[
9064 "remote_environment_lease_invalid",
9065 "remote_environment_snapshot_mismatch",
9066 "remote_environment_checkout_not_clean",
9067 "remote_environment_checkout_unsupported",
9068 "remote_environment_workspace_unavailable",
9069 "remote_environment_workspace_outside_git_root",
9070 "remote_environment_path_unsupported",
9071 "remote_environment_git_unavailable",
9072 "remote_environment_git_inspection_failed",
9073 "remote_environment_git_inspection_timed_out",
9074 "remote_environment_git_output_too_large",
9075 "remote_environment_snapshot_too_large",
9076 "remote_environment_snapshot_changed",
9077 "remote_environment_index_invalid",
9078 "remote_environment_index_unsupported",
9079 "remote_environment_host_path_or_grants_rejected",
9080 "remote_environment_worker_workspace_missing",
9081 "remote_environment_runspec_invalid",
9082 "remote_environment_lease_missing_or_host_path_present",
9083 ];
9084 CODES.iter().copied().find(|code| *code == value)
9085}
9086
9087async fn remote_canonical_lineage(
9090 binding: &SessionInboxRuntimeBinding,
9091 session: &Session,
9092) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
9093 let mut current = bamboo_domain::ActorSession::from_session(session)
9094 .map_err(|_| remote_broker_unavailable())?;
9095 let mut chain = Vec::new();
9096 loop {
9097 if chain.len() > session.spawn_depth as usize {
9098 return Err(remote_broker_unavailable());
9099 }
9100 let saved = binding
9101 .storage
9102 .load_session(¤t.actor_id)
9103 .await
9104 .map_err(|_| remote_broker_unavailable())?
9105 .ok_or_else(remote_broker_unavailable)?;
9106 let observed = bamboo_domain::ActorSession::from_session(&saved)
9107 .map_err(|_| remote_broker_unavailable())?;
9108 if observed.actor_id != current.actor_id
9109 || observed.session_created_at != current.session_created_at
9110 || observed.parent_actor_id != current.parent_actor_id
9111 || observed.root_actor_id != current.root_actor_id
9112 || observed.spawn_depth != current.spawn_depth
9113 || observed.project_id != current.project_id
9114 {
9115 return Err(remote_broker_unavailable());
9116 }
9117 chain.push(observed.clone());
9118 let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9119 if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9120 return Err(remote_broker_unavailable());
9121 }
9122 return Ok(chain);
9123 };
9124 let parent = binding
9125 .storage
9126 .load_session(parent_id)
9127 .await
9128 .map_err(|_| remote_broker_unavailable())?
9129 .ok_or_else(remote_broker_unavailable)?;
9130 let parent = bamboo_domain::ActorSession::from_session(&parent)
9131 .map_err(|_| remote_broker_unavailable())?;
9132 if parent.root_actor_id != observed.root_actor_id
9133 || parent.project_id != observed.project_id
9134 || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9135 || parent.session_created_at > observed.session_created_at
9136 {
9137 return Err(remote_broker_unavailable());
9138 }
9139 current = parent;
9140 }
9141}
9142
9143async fn canonical_child_creation(
9144 binding: &SessionInboxRuntimeBinding,
9145 session: &Session,
9146 job: &SpawnJob,
9147) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9148 let conflict = || {
9149 AgentError::LLM(
9150 "child creation identity unavailable or changed in canonical host Storage".into(),
9151 )
9152 };
9153 let current = binding
9154 .storage
9155 .load_session(&job.child_session_id)
9156 .await
9157 .map_err(|_| conflict())?
9158 .ok_or_else(conflict)?;
9159 if bamboo_domain::ActorSession::from_session(¤t).is_err()
9160 || bamboo_domain::ActorSession::from_session(session).is_err()
9161 || current.kind != bamboo_domain::SessionKind::Child
9162 || session.kind != current.kind
9163 || current.id != session.id
9164 || current.id != job.child_session_id
9165 || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9166 || current.parent_session_id != session.parent_session_id
9167 || current.root_session_id.trim().is_empty()
9168 || current.root_session_id != session.root_session_id
9169 || current.created_at != session.created_at
9170 || current.spawn_depth != session.spawn_depth
9171 || current.spawn_depth == 0
9172 || project_id_for_actor_run(¤t)? != project_id_for_actor_run(session)?
9173 {
9174 return Err(conflict());
9175 }
9176 Ok(bamboo_subagent::proto::ChildCreationIdentity {
9177 created_at: current.created_at,
9178 spawn_depth: current.spawn_depth,
9179 })
9180}
9181
9182fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9183 LogicalSessionIdentity {
9184 creation: None,
9185 session_id: session.id.clone(),
9186 parent_session_id: session
9187 .parent_session_id
9188 .clone()
9189 .or_else(|| Some(job.parent_session_id.clone())),
9190 root_session_id: if session.root_session_id.trim().is_empty() {
9191 job.parent_session_id.clone()
9192 } else {
9193 session.root_session_id.clone()
9194 },
9195 }
9196}
9197
9198fn extract_assignment(session: &Session) -> String {
9199 session
9200 .messages
9201 .iter()
9202 .rev()
9203 .find(|m| matches!(m.role, Role::User))
9204 .map(|m| m.content.clone())
9205 .unwrap_or_else(|| {
9206 session
9207 .metadata
9208 .get("title")
9209 .cloned()
9210 .unwrap_or_else(|| "Execute task".to_string())
9211 })
9212}
9213
9214#[cfg(test)]
9215mod tests {
9216 use super::*;
9217 use crate::SessionActivationRouter;
9218 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9219
9220 include!("expired_plain_owner_tests.rs");
9221
9222 include!("owned_cold_input_tests.rs");
9223
9224 struct SubAgentCapProbe;
9225
9226 #[async_trait]
9227 impl bamboo_agent_core::tools::Tool for SubAgentCapProbe {
9228 fn name(&self) -> &str {
9229 "SubAgent"
9230 }
9231
9232 fn description(&self) -> &str {
9233 "Capture the Host's output-cap observation"
9234 }
9235
9236 fn parameters_schema(&self) -> serde_json::Value {
9237 serde_json::json!({"type":"object"})
9238 }
9239
9240 async fn invoke(
9241 &self,
9242 _args: serde_json::Value,
9243 ctx: bamboo_agent_core::tools::ToolCtx,
9244 ) -> Result<bamboo_agent_core::tools::ToolOutcome, bamboo_agent_core::tools::ToolError>
9245 {
9246 let cap = bamboo_agent_core::tools::observed_tool_output_cap(&ctx);
9247 Ok(bamboo_agent_core::tools::ToolOutcome::Completed(
9248 bamboo_agent_core::tools::ToolResult::text(
9249 true,
9250 serde_json::json!(cap).to_string(),
9251 ),
9252 ))
9253 }
9254 }
9255
9256 #[tokio::test]
9257 async fn host_subagent_dispatch_scopes_the_callers_current_output_cap() {
9258 for cap in [None, Some(0), Some(512)] {
9259 let mut caller = Session::new_child("cap-child", "cap-parent", "model", "Child");
9260 caller.token_budget = cap.map(|max_tool_output_tokens| bamboo_domain::TokenBudget {
9261 max_tool_output_tokens,
9262 ..Default::default()
9263 });
9264 let response = invoke_active_subagent(
9265 &SubAgentCapProbe,
9266 &caller,
9267 serde_json::json!({}),
9268 "cap-call",
9269 )
9270 .await;
9271 let result: bamboo_agent_core::tools::ToolResult =
9272 serde_json::from_value(response["result"].clone()).unwrap();
9273 assert_eq!(
9274 serde_json::from_str::<Option<u32>>(&result.result).unwrap(),
9275 cap
9276 );
9277 }
9278 }
9279
9280 #[test]
9281 fn plain_rejection_logs_branch_and_run_without_changing_error() {
9282 let capture_interest_guard = tracing::Dispatch::new(
9284 tracing_subscriber::fmt()
9285 .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9286 .with_writer(std::io::sink)
9287 .finish(),
9288 );
9289 let output = tempfile::NamedTempFile::new().unwrap();
9290 let subscriber = tracing_subscriber::fmt()
9291 .with_ansi(false)
9292 .without_time()
9293 .with_writer(output.reopen().unwrap())
9294 .finish();
9295 let branch_line = std::cell::Cell::new(0);
9296 let error = tracing::subscriber::with_default(subscriber, || {
9297 std::thread::spawn(|| drop(plain_actor_unsupported()))
9299 .join()
9300 .expect("unscoped rejection warning thread panicked");
9301 let span = tracing::warn_span!(
9302 "actor_child_run",
9303 child_session_id = %"diagnostic-child",
9304 activation_run_id = tracing::field::Empty
9305 );
9306 let _entered = span.enter();
9307 span.record("activation_run_id", "diagnostic-run");
9308 None::<()>
9309 .ok_or_else(|| {
9310 let caller_line = line!() + 1;
9311 let error = plain_actor_unsupported();
9312 branch_line.set(caller_line);
9313 error
9314 })
9315 .unwrap_err()
9316 });
9317 assert!(matches!(&error, AgentError::LLM(message)
9318 if message.starts_with("This Actor Child supports a fresh plain activation,")
9319 && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9320 let log = std::fs::read_to_string(output.path()).unwrap();
9321 assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9322 assert!(
9323 log.contains("activation_run_id=\"diagnostic-run\""),
9324 "{log}"
9325 );
9326 assert!(
9327 log.contains(&format!("process_id={}", std::process::id())),
9328 "{log}"
9329 );
9330 assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9331 assert!(
9332 log.contains(&format!("source_line={}", branch_line.get())),
9333 "{log}"
9334 );
9335 assert!(!error.to_string().contains("diagnostic-run"));
9336 drop(capture_interest_guard);
9337 }
9338
9339 #[tokio::test]
9340 async fn completed_plain_continuation_requires_bounded_inputs_and_current_identity() {
9341 for case in [
9342 "valid",
9343 "over-batch-limit",
9344 "tool-history",
9345 "stale-lineage",
9346 "no-release",
9347 ] {
9348 let temp = tempfile::tempdir().unwrap();
9349 let store = Arc::new(
9350 bamboo_storage::SessionStoreV2::new(temp.path().into())
9351 .await
9352 .unwrap(),
9353 );
9354 let parent = Session::new("continuation-parent", "model");
9355 store.save_session(&parent).await.unwrap();
9356 let mut child =
9357 Session::new_child_of("continuation-child", &parent, "model", "first task");
9358 child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9359 if case == "tool-history" {
9360 let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9361 tool.role = Role::Tool;
9362 child.add_message(tool);
9363 }
9364 child.set_last_run_status("completed");
9365 store.save_session(&child).await.unwrap();
9366 store.ensure_actor(&child.id).await.unwrap();
9367 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9368 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9369 let now = chrono::Utc::now();
9370 let old = store
9371 .claim_activation(&ActorActivationClaim {
9372 actor_id: child.id.clone(),
9373 run_id: "first-run".into(),
9374 lease_owner: "first-owner".into(),
9375 lease_expires_at: now + chrono::Duration::minutes(1),
9376 inbox_generation: 0,
9377 placement_ref: None,
9378 now,
9379 })
9380 .await
9381 .unwrap();
9382 store.start_activation(&old.fence(), now).await.unwrap();
9383 store
9384 .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9385 .await
9386 .unwrap();
9387 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9388 store.clone(),
9389 Default::default(),
9390 ));
9391 for slot in 0..if case == "over-batch-limit" {
9392 bamboo_domain::SessionInboxLimits::default().max_claim_batch + 1
9393 } else {
9394 1
9395 } {
9396 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9397 &child.id,
9398 format!("new task {slot}"),
9399 );
9400 let receipt = inbox.deliver(&envelope).await.unwrap();
9401 inbox
9402 .mark_activation_eligible(
9403 &child.id,
9404 receipt.generation,
9405 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9406 )
9407 .await
9408 .unwrap();
9409 }
9410 let storage: Arc<dyn Storage> = store.clone();
9411 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9412 let binding = actor_binding(store.clone(), inbox, persistence);
9413 let _registration = binding
9414 .router
9415 .register_run(&child.id, "second-run")
9416 .await
9417 .unwrap();
9418 if case == "stale-lineage" {
9419 child.parent_session_id = Some("foreign-parent".into());
9420 }
9421 let result = PlainActorActivation::start(
9422 store.clone(),
9423 &child,
9424 &binding,
9425 Some("second-run"),
9426 Some(policy),
9427 true,
9428 (case != "no-release").then_some("second-worker"),
9429 )
9430 .await;
9431 if case == "valid" {
9432 let activation = result.expect("one new input on a completed plain Child");
9433 assert_eq!(activation.fence.attempt, old.attempt + 1);
9434 assert_eq!(activation.fence.actor_id, child.id);
9435 assert_eq!(activation.inbox_generation, 1);
9436 activation
9437 .finish(ActorActivationFinish::Failed)
9438 .await
9439 .unwrap();
9440 } else {
9441 assert!(
9442 result.is_err(),
9443 "unsupported continuation {case} must fail before claim"
9444 );
9445 assert_eq!(
9446 store
9447 .inspect_actor(&child.id)
9448 .await
9449 .unwrap()
9450 .actor
9451 .current_attempt,
9452 old.attempt
9453 );
9454 }
9455 let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9456 assert_eq!(
9457 canonical.parent_session_id.as_deref(),
9458 Some(parent.id.as_str())
9459 );
9460 assert!(canonical
9461 .messages
9462 .iter()
9463 .any(|message| message.content == "first result"));
9464 }
9465 }
9466
9467 #[tokio::test]
9468 async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9469 let temp = tempfile::tempdir().unwrap();
9470 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9471 .await
9472 .unwrap();
9473 let parent = Session::new("placement-parent", "model");
9474 let child = Session::new_child_of("placement-child", &parent, "model", "task");
9475 store.save_session(&parent).await.unwrap();
9476 store.save_session(&child).await.unwrap();
9477 store.ensure_actor(&child.id).await.unwrap();
9478 store
9479 .bind_control_plane(
9480 &child.id,
9481 bamboo_domain::ActorControlPlaneBinding {
9482 policy_revision: None,
9483 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9484 pool_id: "remote-worker".into(),
9485 }),
9486 },
9487 )
9488 .await
9489 .unwrap();
9490
9491 let claim = |run_id: &str| ActorActivationClaim {
9492 actor_id: child.id.clone(),
9493 run_id: run_id.into(),
9494 lease_owner: format!("host-{run_id}"),
9495 lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9496 inbox_generation: 0,
9497 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9498 class: bamboo_domain::ActorPlacementClass::Remote,
9499 lease_id: format!("slot-{run_id}"),
9500 slot_epoch: Some(1),
9501 }),
9502 now: chrono::Utc::now(),
9503 };
9504 let first = store.claim_activation(&claim("first")).await.unwrap();
9505 store
9506 .start_activation(&first.fence(), chrono::Utc::now())
9507 .await
9508 .unwrap();
9509 assert!(store.claim_activation(&claim("retry")).await.is_err());
9510
9511 fail_pre_dispatch_activation(&store, &first.fence())
9514 .await
9515 .unwrap();
9516 let failed = store.inspect_actor(&child.id).await.unwrap();
9517 assert_eq!(
9518 failed.activation.unwrap().status,
9519 bamboo_domain::ActorActivationStatus::Failed
9520 );
9521 let retry = store.claim_activation(&claim("retry")).await.unwrap();
9522 assert_eq!(retry.attempt, first.attempt + 1);
9523 assert!(fail_pre_dispatch_activation(&store, &first.fence())
9524 .await
9525 .is_err());
9526 assert_eq!(
9527 store.inspect_actor(&child.id).await.unwrap().activation,
9528 Some(retry)
9529 );
9530 }
9531
9532 #[tokio::test]
9533 async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9534 struct AcceptedWithoutReceipt {
9535 published: bool,
9536 }
9537
9538 #[async_trait]
9539 impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9540 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9541 assert!(matches!(frame, ParentFrame::Run(_)));
9542 self.published = true;
9543 Err(bamboo_subagent::TransportError::Protocol(
9544 "broker delivery receipt lost after publication".into(),
9545 ))
9546 }
9547
9548 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9549 std::future::pending().await
9550 }
9551 }
9552
9553 let temp = tempfile::tempdir().unwrap();
9554 let store = Arc::new(
9555 bamboo_storage::SessionStoreV2::new(temp.path().into())
9556 .await
9557 .unwrap(),
9558 );
9559 let parent = Session::new("dispatch-parent", "model");
9560 let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9561 store.save_session(&parent).await.unwrap();
9562 store.save_session(&child).await.unwrap();
9563 store.ensure_actor(&child.id).await.unwrap();
9564 let now = chrono::Utc::now();
9565 let claim = ActorActivationClaim {
9566 actor_id: child.id.clone(),
9567 run_id: "dispatch-run".into(),
9568 lease_owner: "dispatch-host".into(),
9569 lease_expires_at: now + chrono::Duration::seconds(80),
9570 inbox_generation: 0,
9571 placement_ref: None,
9572 now,
9573 };
9574 let activation = store.claim_activation(&claim).await.unwrap();
9575 store
9576 .start_activation(&activation.fence(), chrono::Utc::now())
9577 .await
9578 .unwrap();
9579 let mut link = AcceptedWithoutReceipt { published: false };
9580 let mut send_invoked = false;
9581 let run = RunSpec {
9582 assignment: "task".into(),
9583 logical_session: None,
9584 project_id: None,
9585 reasoning_effort: None,
9586 permission_policy: None,
9587 messages: Vec::new(),
9588 activation_run_id: Some(claim.run_id.clone()),
9589 execution_epoch: 1,
9590 initial_session_messages: Vec::new(),
9591 secrets: Default::default(),
9592 };
9593 assert!(send_actor_run(&mut link, run, &mut send_invoked)
9594 .await
9595 .is_err());
9596 assert!(send_invoked && link.published);
9597 let slot = WorkerSlotLease {
9598 lease_id: "dispatch-slot".into(),
9599 host_ref: "dispatch-worker".into(),
9600 connection_generation: "dispatch-generation".into(),
9601 slot: 0,
9602 epoch: 1,
9603 actor_id: child.id.clone(),
9604 run_id: claim.run_id.clone(),
9605 expires_at: claim.lease_expires_at,
9606 };
9607 let mut lease = ActivePlacementLease {
9608 registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9609 .await
9610 .unwrap(),
9611 actor_store: store.clone(),
9612 route: ScopedPlacementRoute {
9613 endpoint: String::new(),
9614 token: String::new(),
9615 ca_cert_file: PathBuf::new(),
9616 parent: bamboo_subagent::AgentRef {
9617 session_id: "dispatch-parent".into(),
9618 role: Some("host".into()),
9619 },
9620 worker_role: "worker".into(),
9621 pinned_mailbox: None,
9622 requirements: Default::default(),
9623 },
9624 mailbox: "dispatch-worker".into(),
9625 host_ref: "dispatch-worker".into(),
9626 fence: activation.fence(),
9627 placement_ref: bamboo_domain::ActorPlacementRef {
9628 class: bamboo_domain::ActorPlacementClass::Remote,
9629 lease_id: slot.lease_id.clone(),
9630 slot_epoch: Some(slot.epoch),
9631 },
9632 lease: Arc::new(tokio::sync::Mutex::new(slot)),
9633 heartbeat: tokio::spawn(std::future::pending()),
9634 run_cancel: CancellationToken::new(),
9635 lost: Arc::new(AtomicBool::new(false)),
9636 release_on_drop: AtomicBool::new(false),
9637 };
9638 assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9639 assert_eq!(
9640 store
9641 .inspect_actor(&child.id)
9642 .await
9643 .unwrap()
9644 .activation
9645 .unwrap()
9646 .status,
9647 bamboo_domain::ActorActivationStatus::Running
9648 );
9649 let mut retry = claim.clone();
9650 retry.run_id = "dispatch-retry".into();
9651 retry.now = chrono::Utc::now();
9652 retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9653 assert!(matches!(
9654 store.claim_activation(&retry).await,
9655 Err(bamboo_domain::ActorDirectoryError::Busy)
9656 ));
9657 }
9658
9659 #[test]
9660 fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9661 let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9662 assert_eq!(
9663 remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9664 ActorActivationFinish::Cancelled
9665 );
9666 assert_eq!(
9667 remote_activation_finish(&cancelled, None),
9668 ActorActivationFinish::Failed
9669 );
9670 assert_eq!(
9671 remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9672 ActorActivationFinish::Failed
9673 );
9674 }
9675
9676 #[tokio::test]
9677 async fn remote_reply_is_fenced_before_terminal_status_save() {
9678 let temp = tempfile::tempdir().unwrap();
9679 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9680 .await
9681 .unwrap();
9682 let parent = Session::new("remote-reply-parent", "model");
9683 let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9684 child.add_message(bamboo_agent_core::Message::user("task"));
9685 store.save_session(&parent).await.unwrap();
9686 store.save_session(&child).await.unwrap();
9687 store.ensure_actor(&child.id).await.unwrap();
9688 store
9689 .bind_control_plane(
9690 &child.id,
9691 bamboo_domain::ActorControlPlaneBinding {
9692 policy_revision: None,
9693 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9694 pool_id: "remote-worker".into(),
9695 }),
9696 },
9697 )
9698 .await
9699 .unwrap();
9700 let now = chrono::Utc::now();
9701 let activation = store
9702 .claim_activation(&ActorActivationClaim {
9703 actor_id: child.id.clone(),
9704 run_id: "remote-reply-run".into(),
9705 lease_owner: "remote-worker".into(),
9706 lease_expires_at: now + chrono::Duration::seconds(80),
9707 inbox_generation: 0,
9708 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9709 class: bamboo_domain::ActorPlacementClass::Remote,
9710 lease_id: "remote-slot".into(),
9711 slot_epoch: Some(1),
9712 }),
9713 now,
9714 })
9715 .await
9716 .unwrap();
9717 let fence = activation.fence();
9718 store.start_activation(&fence, now).await.unwrap();
9719
9720 append_fenced_remote_reply(&store, &fence, &mut child, "done")
9721 .await
9722 .unwrap();
9723 store
9724 .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9725 .await
9726 .unwrap();
9727 child.set_last_run_status("completed");
9728 store.save_session(&child).await.unwrap();
9729 let saved = store.load_session(&child.id).await.unwrap().unwrap();
9730 assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9731 assert_eq!(saved.messages.len(), 2);
9732 assert_eq!(saved.messages.last().unwrap().content, "done");
9733 assert!(
9734 append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9735 .await
9736 .is_err()
9737 );
9738 assert_eq!(
9739 store
9740 .load_session(&child.id)
9741 .await
9742 .unwrap()
9743 .unwrap()
9744 .messages
9745 .len(),
9746 2
9747 );
9748 }
9749
9750 async fn start_bus() -> (String, tempfile::TempDir) {
9751 let dir = tempfile::tempdir().unwrap();
9752 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9753 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9754 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9755 let addr = listener.local_addr().unwrap();
9756 tokio::spawn(async move {
9757 let _ = server.serve(listener).await;
9758 });
9759 (format!("ws://{addr}"), dir)
9760 }
9761
9762 struct DurableAckProbe {
9763 calls: Arc<AtomicUsize>,
9764 fail: bool,
9765 terminal_status: TerminalStatus,
9766 parent_mailbox: String,
9767 }
9768
9769 #[async_trait]
9770 impl bamboo_subagent::ChildLink for DurableAckProbe {
9771 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9772 Ok(())
9773 }
9774
9775 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9776 Ok(None)
9777 }
9778
9779 fn has_pending_durable_terminal(&self) -> bool {
9780 true
9781 }
9782
9783 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9784 Some(bamboo_subagent::DurableChildDeliveryReceipt {
9785 broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9786 parent_mailbox: self.parent_mailbox.clone(),
9787 correlation_id: "broker-run-1".into(),
9788 message_ids: vec!["broker-outcome-1".into()],
9789 terminal_status: self.terminal_status,
9790 })
9791 }
9792
9793 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9794 self.calls.fetch_add(1, Ordering::SeqCst);
9795 if self.fail {
9796 Err(bamboo_subagent::TransportError::Protocol(
9797 "injected ACK delivery failure".into(),
9798 ))
9799 } else {
9800 Ok(())
9801 }
9802 }
9803 }
9804
9805 async fn insert_ack_probe(
9806 runner: &ActorChildRunner,
9807 session: &Session,
9808 calls: &Arc<AtomicUsize>,
9809 terminal_status: TerminalStatus,
9810 ) {
9811 insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9812 }
9813
9814 async fn insert_ack_probe_with_nested_wait(
9815 runner: &ActorChildRunner,
9816 session: &Session,
9817 calls: &Arc<AtomicUsize>,
9818 terminal_status: TerminalStatus,
9819 host_nested_wait_handoff: bool,
9820 ) {
9821 runner.pending_durable_links.lock().await.insert(
9822 (session.id.clone(), session.created_at, "run-1".into()),
9823 PendingDurableChildLink {
9824 link: Box::new(DurableAckProbe {
9825 calls: calls.clone(),
9826 fail: false,
9827 terminal_status,
9828 parent_mailbox: format!("p-{}", session.id),
9829 }),
9830 host_nested_wait_handoff,
9831 strict_history: None,
9832 strict_history_required: false,
9833 strict_history_committed: false,
9834 },
9835 );
9836 }
9837
9838 #[tokio::test]
9839 async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9840 let (endpoint, broker_dir) = start_bus().await;
9841 let home = tempfile::tempdir().unwrap();
9842 let store = Arc::new(
9843 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9844 .await
9845 .unwrap(),
9846 );
9847 let root = Session::new("receipt-root", "model");
9848 store.save_session(&root).await.unwrap();
9849 let mut sender = bamboo_broker::BrokerClient::connect(
9850 &endpoint,
9851 bamboo_subagent::AgentRef {
9852 session_id: "receipt-worker".into(),
9853 role: None,
9854 },
9855 "t",
9856 )
9857 .await
9858 .unwrap();
9859 let broker_identity = sender.durable_broker_identity().await.unwrap();
9860 let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9861 "00000000-0000-4000-8000-000000000002"
9862 } else {
9863 "00000000-0000-4000-8000-000000000001"
9864 };
9865
9866 for (id, receipt_mailbox, receipt_broker) in [
9867 ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9868 (
9869 "receipt-wrong-mailbox",
9870 "p-another-child",
9871 broker_identity.as_str(),
9872 ),
9873 (
9874 "receipt-wrong-broker",
9875 "p-receipt-wrong-broker",
9876 wrong_broker_identity,
9877 ),
9878 (
9879 "receipt-local-role-changed",
9880 "p-receipt-local-role-changed",
9881 broker_identity.as_str(),
9882 ),
9883 (
9884 "receipt-remote-unproven",
9885 "remote-parent-unproven",
9886 broker_identity.as_str(),
9887 ),
9888 ] {
9889 let mut child = Session::new_child_of(id, &root, "model", "task");
9890 child
9891 .metadata
9892 .insert("runtime.kind".into(), "external".into());
9893 child
9894 .metadata
9895 .insert("external.protocol".into(), "actor".into());
9896 child.metadata.insert(
9897 "external.agent_id".into(),
9898 crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9899 );
9900 if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9901 child
9902 .metadata
9903 .insert("subagent_type".into(), "remote-worker".into());
9904 }
9905 child.add_message(bamboo_agent_core::Message::user("work"));
9906 store.save_session(&child).await.unwrap();
9907 let message_id = bamboo_subagent::MsgId::new();
9908 sender
9909 .deliver(
9910 &format!("p-{id}"),
9911 bamboo_subagent::InboxMessage {
9912 id: message_id.clone(),
9913 from: bamboo_subagent::AgentRef {
9914 session_id: "receipt-worker".into(),
9915 role: None,
9916 },
9917 kind: bamboo_subagent::InboxKind::Outcome,
9918 body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9919 "done",
9920 ))
9921 .unwrap(),
9922 created_at: chrono::Utc::now(),
9923 correlation_id: Some(bamboo_subagent::MsgId::new()),
9924 },
9925 )
9926 .await
9927 .unwrap();
9928 let mailbox =
9931 bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9932 assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9933 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9934 child.set_last_run_status("completed");
9935 if id == "receipt-good" {
9936 let prior_ids = child.messages[..child.messages.len() - 1]
9937 .iter()
9938 .map(|message| message.id.clone())
9939 .collect();
9940 crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9941 assert!(
9942 crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9943 &mut child, "run-1"
9944 )
9945 .unwrap()
9946 );
9947 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9948 &child, "run-1", 7, 1,
9949 )
9950 .unwrap();
9951 store
9952 .prepare_broker_terminal_receipt_with_completeness(
9953 &child,
9954 bamboo_storage::v2::BrokerTerminalRoute {
9955 activation_run_id: "run-1",
9956 broker_identity: receipt_broker,
9957 parent_mailbox: receipt_mailbox,
9958 broker_correlation_id: "correlation-1",
9959 message_ids: &[message_id.as_str().to_owned()],
9960 },
9961 &proof,
9962 )
9963 .await
9964 .unwrap();
9965 let mut staged = child.clone();
9966 staged.set_last_run_status("running");
9967 staged.clear_last_run_error();
9968 store.save_session(&staged).await.unwrap();
9969 store
9970 .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9971 .await
9972 .unwrap();
9973 } else {
9974 store
9975 .prepare_broker_terminal_receipt(
9976 &child,
9977 "run-1",
9978 receipt_broker,
9979 receipt_mailbox,
9980 "correlation-1",
9981 &[message_id.as_str().to_owned()],
9982 )
9983 .await
9984 .unwrap();
9985 store.save_session(&child).await.unwrap();
9986 }
9987 }
9988
9989 let mut config = bamboo_config::Config::default();
9990 config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9991 endpoint,
9992 token: "t".into(),
9993 ..Default::default()
9994 });
9995 config
9996 .subagents_mut()
9997 .remote_placements
9998 .push(bamboo_config::RemoteActorPlacement {
9999 role: "remote-worker".into(),
10000 endpoint: "ws://unscoped.example.invalid".into(),
10001 token_env: None,
10002 ca_cert_file: None,
10003 broker_peer: None,
10004 placement_requirements: None,
10005 });
10006 let cold_store = Arc::new(
10009 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10010 .await
10011 .unwrap(),
10012 );
10013 let before = cold_store
10014 .load_session("receipt-good")
10015 .await
10016 .unwrap()
10017 .unwrap();
10018 assert!(crate::execution::ChildCompletionSource::has_source_record(
10019 &before
10020 ));
10021 assert_eq!(before.last_run_status().as_deref(), Some("running"));
10022 assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
10023 let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
10024 let report = repair.reconcile_once().await;
10025 assert_eq!(report.candidates, 5);
10026 assert_eq!(report.repaired, 2);
10027 assert_eq!(report.blocked, 3);
10028 let after = cold_store
10029 .load_session("receipt-good")
10030 .await
10031 .unwrap()
10032 .unwrap();
10033 assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
10034 assert_eq!(
10035 serde_json::to_value(before.messages).unwrap(),
10036 serde_json::to_value(after.messages).unwrap()
10037 );
10038 let retry = repair.reconcile_once().await;
10039 assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
10040
10041 let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
10042 let wrong = bamboo_subagent::Mailbox::at(
10043 broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
10044 );
10045 let wrong_broker = bamboo_subagent::Mailbox::at(
10046 broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
10047 );
10048 let local_role_changed = bamboo_subagent::Mailbox::at(
10049 broker_dir
10050 .path()
10051 .join("mailboxes/p-receipt-local-role-changed"),
10052 );
10053 let remote_unproven = bamboo_subagent::Mailbox::at(
10054 broker_dir
10055 .path()
10056 .join("mailboxes/p-receipt-remote-unproven"),
10057 );
10058 assert_eq!(good.pending_count().await.unwrap(), 0);
10059 assert_eq!(wrong.pending_count().await.unwrap(), 1);
10060 assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
10061 assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
10062 assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
10063 let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
10064 let wrong_child = store
10065 .load_session("receipt-wrong-mailbox")
10066 .await
10067 .unwrap()
10068 .unwrap();
10069 let wrong_broker_child = store
10070 .load_session("receipt-wrong-broker")
10071 .await
10072 .unwrap()
10073 .unwrap();
10074 let local_role_changed_child = store
10075 .load_session("receipt-local-role-changed")
10076 .await
10077 .unwrap()
10078 .unwrap();
10079 let remote_unproven_child = store
10080 .load_session("receipt-remote-unproven")
10081 .await
10082 .unwrap()
10083 .unwrap();
10084 assert!(store
10085 .recover_broker_terminal_receipts(&good_child)
10086 .await
10087 .unwrap()
10088 .is_empty());
10089 assert_eq!(
10090 store
10091 .recover_broker_terminal_receipts(&wrong_child)
10092 .await
10093 .unwrap()
10094 .len(),
10095 1
10096 );
10097 assert_eq!(
10098 store
10099 .recover_broker_terminal_receipts(&wrong_broker_child)
10100 .await
10101 .unwrap()
10102 .len(),
10103 1
10104 );
10105 assert!(store
10106 .recover_broker_terminal_receipts(&local_role_changed_child)
10107 .await
10108 .unwrap()
10109 .is_empty());
10110 assert_eq!(
10111 store
10112 .recover_broker_terminal_receipts(&remote_unproven_child)
10113 .await
10114 .unwrap()
10115 .len(),
10116 1
10117 );
10118 }
10119
10120 #[tokio::test]
10121 async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
10122 let runner = ActorChildRunner::new(
10123 "test".into(),
10124 PathBuf::new(),
10125 vec![],
10126 PathBuf::new(),
10127 ExecutorSpec::BambooRuntime,
10128 vec![],
10129 "test".into(),
10130 1,
10131 );
10132 let home = tempfile::tempdir().unwrap();
10133 let store = Arc::new(
10134 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10135 .await
10136 .unwrap(),
10137 );
10138 let root = Session::new("ack-parent", "model");
10139 store.save_session(&root).await.unwrap();
10140 let mut child = Session::new_child_of("ack-child", &root, "model", "task");
10141 child.add_message(bamboo_agent_core::Message::user("work"));
10142 store.save_session(&child).await.unwrap();
10143 child.add_message(bamboo_agent_core::Message::assistant("done", None));
10144 child.set_last_run_status("completed");
10145 store
10146 .prepare_broker_terminal_receipt(
10147 &child,
10148 "run-1",
10149 "00000000-0000-4000-8000-000000000001",
10150 "p-ack-child",
10151 "broker-run-1",
10152 &["broker-outcome-1".into()],
10153 )
10154 .await
10155 .unwrap();
10156 store.save_session(&child).await.unwrap();
10157 runner.set_actor_directory_store(Some(store));
10158 let calls = Arc::new(AtomicUsize::new(0));
10159 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10160 runner
10161 .confirm_durable_child_delivery(&child, "wrong-run", true)
10162 .await
10163 .unwrap();
10164 let mut wrong_birth = child.clone();
10165 wrong_birth.created_at += chrono::Duration::seconds(1);
10166 runner
10167 .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
10168 .await
10169 .unwrap();
10170 assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
10171 child.set_last_run_status("completed");
10172 runner
10173 .confirm_durable_child_delivery(&child, "run-1", false)
10174 .await
10175 .unwrap();
10176 assert_eq!(calls.load(Ordering::SeqCst), 0);
10177
10178 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10179 child.set_last_run_status("error");
10180 runner
10181 .confirm_durable_child_delivery(&child, "run-1", true)
10182 .await
10183 .unwrap();
10184 assert_eq!(calls.load(Ordering::SeqCst), 0);
10185
10186 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10187 child.set_last_run_status("completed");
10188 runner
10189 .confirm_durable_child_delivery(&child, "run-1", true)
10190 .await
10191 .unwrap();
10192 assert_eq!(calls.load(Ordering::SeqCst), 1);
10193 }
10194
10195 #[tokio::test]
10196 async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10197 for commit in [false, true] {
10198 let runner = ActorChildRunner::new(
10199 "test".into(),
10200 PathBuf::new(),
10201 vec![],
10202 PathBuf::new(),
10203 ExecutorSpec::BambooRuntime,
10204 vec![],
10205 "test".into(),
10206 1,
10207 );
10208 let home = tempfile::tempdir().unwrap();
10209 let store = Arc::new(
10210 bamboo_storage::SessionStoreV2::new(home.path().into())
10211 .await
10212 .unwrap(),
10213 );
10214 let root = Session::new("strict-ack-parent", "model");
10215 store.save_session(&root).await.unwrap();
10216 let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10217 child.add_message(bamboo_agent_core::Message::user("work"));
10218 store.save_session(&child).await.unwrap();
10219 child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10220 child.set_last_run_status("completed");
10221 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10222 &child, "run-1", 7, 2,
10223 )
10224 .unwrap();
10225 let creation = bamboo_subagent::proto::ChildCreationIdentity {
10226 created_at: child.created_at,
10227 spawn_depth: child.spawn_depth,
10228 };
10229 let history = StrictTerminalHistory {
10230 proof: proof.clone(),
10231 watermark: bamboo_subagent::ActorEventWatermark {
10232 version: 1,
10233 logical_session: Some(actor_event_logical_identity(
10234 &child,
10235 &root.id,
10236 Some(&creation),
10237 )),
10238 activation_id: Some("run-1".into()),
10239 execution_epoch: 7,
10240 final_seq: 2,
10241 },
10242 };
10243 let parent_mailbox = "p-strict-ack-child";
10244 store
10245 .prepare_broker_terminal_receipt_with_completeness(
10246 &child,
10247 bamboo_storage::v2::BrokerTerminalRoute {
10248 activation_run_id: "run-1",
10249 broker_identity: "00000000-0000-4000-8000-000000000001",
10250 parent_mailbox,
10251 broker_correlation_id: "broker-run-1",
10252 message_ids: &["broker-outcome-1".into()],
10253 },
10254 &proof,
10255 )
10256 .await
10257 .unwrap();
10258 store.save_session(&child).await.unwrap();
10259 runner.set_actor_directory_store(Some(store.clone()));
10260 let calls = Arc::new(AtomicUsize::new(0));
10261 runner.pending_durable_links.lock().await.insert(
10262 (child.id.clone(), child.created_at, "run-1".into()),
10263 PendingDurableChildLink {
10264 link: Box::new(DurableAckProbe {
10265 calls: calls.clone(),
10266 fail: true,
10267 terminal_status: TerminalStatus::Completed,
10268 parent_mailbox: parent_mailbox.into(),
10269 }),
10270 host_nested_wait_handoff: false,
10271 strict_history: Some(history),
10272 strict_history_required: true,
10273 strict_history_committed: false,
10274 },
10275 );
10276 if commit {
10277 runner
10278 .commit_durable_child_delivery(&child, "run-1")
10279 .await
10280 .unwrap();
10281 assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10282 }
10283 assert!(runner
10284 .confirm_durable_child_delivery(&child, "run-1", true)
10285 .await
10286 .is_err());
10287 assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10288 let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10289 .await
10290 .unwrap();
10291 let recovered = cold.recover_broker_terminal_receipts(&child).await;
10292 if commit {
10293 let receipts = recovered.unwrap();
10294 assert_eq!(receipts.len(), 1);
10295 assert_eq!(
10296 receipts[0]
10297 .terminal_completeness
10298 .as_ref()
10299 .unwrap()
10300 .contiguous_applied_seq,
10301 2
10302 );
10303 assert_eq!(
10304 cold.load_session(&child.id)
10305 .await
10306 .unwrap()
10307 .unwrap()
10308 .last_run_status()
10309 .as_deref(),
10310 Some("completed")
10311 );
10312 } else {
10313 assert!(
10314 recovered.is_err(),
10315 "matching canonical history is not proof commitment"
10316 );
10317 }
10318 assert_eq!(
10319 serde_json::to_value(
10320 cold.load_session(&child.id)
10321 .await
10322 .unwrap()
10323 .unwrap()
10324 .messages
10325 )
10326 .unwrap(),
10327 serde_json::to_value(&child.messages).unwrap(),
10328 );
10329 }
10330 }
10331
10332 #[tokio::test]
10333 async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10334 for (terminal_status, status) in [
10335 (TerminalStatus::Error, "error"),
10336 (TerminalStatus::Cancelled, "cancelled"),
10337 ] {
10338 let runner = ActorChildRunner::new(
10339 "test".into(),
10340 PathBuf::new(),
10341 vec![],
10342 PathBuf::new(),
10343 ExecutorSpec::BambooRuntime,
10344 vec![],
10345 "test".into(),
10346 1,
10347 );
10348 let home = tempfile::tempdir().unwrap();
10349 let store = Arc::new(
10350 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10351 .await
10352 .unwrap(),
10353 );
10354 let root = Session::new("failed-ack-parent", "model");
10355 store.save_session(&root).await.unwrap();
10356 let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10357 child.add_message(bamboo_agent_core::Message::user("work"));
10358 store.save_session(&child).await.unwrap();
10359 child.set_last_run_status(status);
10360 child.set_last_run_error(format!("worker {status}"));
10361 store
10362 .prepare_broker_terminal_receipt(
10363 &child,
10364 "run-1",
10365 "00000000-0000-4000-8000-000000000001",
10366 "p-failed-ack-child",
10367 "broker-run-1",
10368 &["broker-outcome-1".into()],
10369 )
10370 .await
10371 .unwrap();
10372 store.save_session(&child).await.unwrap();
10373 runner.set_actor_directory_store(Some(store));
10374 let calls = Arc::new(AtomicUsize::new(0));
10375 insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10376 runner
10377 .confirm_durable_child_delivery(&child, "run-1", true)
10378 .await
10379 .unwrap();
10380 assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10381 }
10382 }
10383
10384 #[tokio::test]
10385 async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10386 assert_eq!(
10387 effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10388 "suspended"
10389 );
10390 for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10391 assert!(effective_broker_terminal_status(raw, true).is_err());
10392 }
10393 let runner = ActorChildRunner::new(
10394 "test".into(),
10395 PathBuf::new(),
10396 vec![],
10397 PathBuf::new(),
10398 ExecutorSpec::BambooRuntime,
10399 vec![],
10400 "test".into(),
10401 1,
10402 );
10403 let home = tempfile::tempdir().unwrap();
10404 let store = Arc::new(
10405 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10406 .await
10407 .unwrap(),
10408 );
10409 let root = Session::new("nested-ack-parent", "model");
10410 store.save_session(&root).await.unwrap();
10411 let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10412 child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10413 store.save_session(&child).await.unwrap();
10414 child.set_last_run_status("suspended");
10415 store
10416 .prepare_broker_terminal_receipt(
10417 &child,
10418 "run-1",
10419 "00000000-0000-4000-8000-000000000001",
10420 "p-nested-ack-child",
10421 "broker-run-1",
10422 &["broker-outcome-1".into()],
10423 )
10424 .await
10425 .unwrap();
10426 store.save_session(&child).await.unwrap();
10427 runner.set_actor_directory_store(Some(store.clone()));
10428 let calls = Arc::new(AtomicUsize::new(0));
10429
10430 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10431 runner
10432 .confirm_durable_child_delivery(&child, "run-1", true)
10433 .await
10434 .unwrap();
10435 assert_eq!(calls.load(Ordering::SeqCst), 0);
10436
10437 insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10438 .await;
10439 runner
10440 .confirm_durable_child_delivery(&child, "run-1", true)
10441 .await
10442 .unwrap();
10443 assert_eq!(calls.load(Ordering::SeqCst), 1);
10444 assert_eq!(
10445 store
10446 .load_session(&child.id)
10447 .await
10448 .unwrap()
10449 .unwrap()
10450 .last_run_status()
10451 .as_deref(),
10452 Some("suspended")
10453 );
10454 assert!(store
10455 .recover_broker_terminal_receipts(&child)
10456 .await
10457 .unwrap()
10458 .is_empty());
10459 }
10460
10461 #[tokio::test]
10462 async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10463 struct CommittedObserver {
10464 authority: PathBuf,
10465 events: std::sync::Mutex<Vec<PublicActorEvent>>,
10466 }
10467 impl ActorEventObserver for CommittedObserver {
10468 fn publish(&self, event: PublicActorEvent) {
10469 let entry: bamboo_domain::ActorDirectoryEntry =
10471 serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10472 let committed = entry.activation.unwrap();
10473 assert_eq!(
10474 committed.status,
10475 bamboo_domain::ActorActivationStatus::Succeeded
10476 );
10477 assert_eq!(committed.activation_id, event.activation_id);
10478 assert!(committed.finished_at.is_some());
10479 self.events.lock().unwrap().push(event);
10480 }
10481 }
10482 struct CompletionLink(VecDeque<ChildFrame>);
10483 #[async_trait]
10484 impl bamboo_subagent::ChildLink for CompletionLink {
10485 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10486 Ok(())
10487 }
10488 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10489 Ok(self.0.pop_front())
10490 }
10491 }
10492 for cut in [
10493 "success",
10494 "no_terminal",
10495 "invalid_terminal",
10496 "stale",
10497 "storage_failure",
10498 "append_failure",
10499 ] {
10500 let temp = tempfile::tempdir().unwrap();
10501 let store = Arc::new(
10502 bamboo_storage::SessionStoreV2::new(temp.path().into())
10503 .await
10504 .unwrap(),
10505 );
10506 let root = Session::new("notify-root", "model");
10507 store.save_session(&root).await.unwrap();
10508 let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10509 store.save_session(&child).await.unwrap();
10510 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10511 store.clone(),
10512 bamboo_domain::SessionInboxLimits::default(),
10513 ));
10514 let binding = actor_binding(
10515 store.clone(),
10516 inbox,
10517 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10518 );
10519 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10520 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10521 let activation = PlainActorActivation::start(
10522 store.clone(),
10523 &child,
10524 &binding,
10525 Some("notify-run"),
10526 Some(policy),
10527 false,
10528 None,
10529 )
10530 .await
10531 .unwrap();
10532 let _owner = binding
10533 .router
10534 .register_run(&child.id, "notify-run")
10535 .await
10536 .unwrap();
10537 let batch = ActorEventBatch {
10538 logical_session: Some(LogicalSessionIdentity {
10539 session_id: child.id.clone(),
10540 parent_session_id: child.parent_session_id.clone(),
10541 root_session_id: child.root_session_id.clone(),
10542 creation: None,
10543 }),
10544 activation_id: Some("notify-run".into()),
10545 execution_epoch: 9,
10546 source_node_id: None,
10547 source_actor_id: Some("notify-worker".into()),
10548 first_seq: 1,
10549 last_seq: 2,
10550 qos: bamboo_subagent::ActorEventQos::Durable,
10551 events: vec![
10552 serde_json::to_value(AgentEvent::Token {
10553 content: "PRIVATE_PROVIDER_TOKEN".into(),
10554 })
10555 .unwrap(),
10556 serde_json::to_value(AgentEvent::message_appended(
10557 &child.id,
10558 &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10559 ))
10560 .unwrap(),
10561 ],
10562 };
10563 let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10564 if cut != "no_terminal" {
10565 let mut terminal = completed_actor_frame();
10566 if cut == "invalid_terminal" {
10567 if let ChildFrame::Terminal { status, .. } = &mut terminal {
10568 *status = TerminalStatus::Suspended;
10569 }
10570 }
10571 frames.push_back(terminal);
10572 }
10573 let mut link = CompletionLink(frames);
10574 let (tx, mut rx) = mpsc::channel(8);
10575 let (_live, mut live_rx) = mpsc::unbounded_channel();
10576 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10577 let cancel = CancellationToken::new();
10578 let outcome = drive(ActorDriveContext {
10579 client: &mut link,
10580 parent_session_id: "notify-root",
10581 child_session_id: "notify-child",
10582 child_attempt: 0,
10583 approval_registry: None,
10584 approval_decider: None,
10585 approval_reviewer: None,
10586 escalation_bridge: None,
10587 event_tx: &tx,
10588 cancel_token: &cancel,
10589 live_rx: &mut live_rx,
10590 delivery_rx: &mut delivery_rx,
10591 logical_session: &mut child,
10592 expected_permission_posture: None,
10593 expected_creation: None,
10594 session_inbox_runtime: Some(&binding),
10595 actor_directory_store: Some(store.as_ref()),
10596 canonical_subagent_tool: None,
10597 ticket_service: None,
10598 activation_run_id: Some("notify-run"),
10599 execution_epoch: 9,
10600 expected_source_actor_id: "notify-worker",
10601 initial_inflight_claims: VecDeque::new(),
10602 plain_actor: true,
10603 remote_environment_lease: false,
10604 readonly_output: None,
10605 local_history_tools: None,
10606 local_history_read_only: false,
10607 strict_history_output: None,
10608 plain_input: Some(&activation),
10609 canonical_activation: None,
10610 canonical_placement_ref: None,
10611 actor_event_observer: None,
10612 plain_run: None,
10613 first_frame_timeout: Some(Duration::from_secs(1)),
10614 })
10615 .await;
10616 let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10617 assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10618 assert_eq!(
10619 activation.completion_router.lock().unwrap().is_some(),
10620 admitted,
10621 "{cut}"
10622 );
10623 while rx.try_recv().is_ok() {}
10624 let authority = temp
10625 .path()
10626 .join(store.resolve_rel_path(&child.id).await.unwrap())
10627 .join("actor-authority.json");
10628 let observer = CommittedObserver {
10629 authority: authority.clone(),
10630 events: Default::default(),
10631 };
10632 if cut == "append_failure" {
10633 child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10634 assert!(activation
10635 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10636 .await
10637 .is_err());
10638 activation
10639 .finish(ActorActivationFinish::Failed)
10640 .await
10641 .unwrap();
10642 assert!(observer.events.lock().unwrap().is_empty());
10643 continue;
10644 }
10645 activation
10646 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10647 .await
10648 .unwrap();
10649 assert!(
10650 rx.try_recv().is_ok(),
10651 "legacy content notification retained"
10652 );
10653 assert!(
10654 observer.events.lock().unwrap().is_empty(),
10655 "reply alone must not publish"
10656 );
10657 if cut == "stale" {
10658 store
10659 .retire_actor(&child.id, chrono::Utc::now())
10660 .await
10661 .unwrap();
10662 } else if cut == "storage_failure" {
10663 std::fs::remove_file(&authority).unwrap();
10666 std::fs::create_dir(&authority).unwrap();
10667 }
10668 let result = activation
10669 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10670 .await;
10671 assert_eq!(
10672 result.is_ok(),
10673 matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10674 "{cut}"
10675 );
10676 let events = observer.events.lock().unwrap().clone();
10677 assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10678 if let Some(event) = events.first() {
10679 assert_eq!(event.actor_id, child.id);
10680 assert_eq!(event.root_actor_id, root.id);
10681 assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10682 assert_eq!(event.source_order.sequence, 3);
10683 let json = serde_json::to_string(event).unwrap();
10684 assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10685 let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10686 assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10687 }
10688 assert_eq!(
10689 activation
10690 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10691 .await
10692 .is_ok(),
10693 result.is_ok(),
10694 "{cut}: exact finish retries its committed outcome"
10695 );
10696 assert_eq!(
10697 observer.events.lock().unwrap().len(),
10698 events.len(),
10699 "repeat finish cannot publish"
10700 );
10701 }
10702 }
10703
10704 async fn bind_local_control_plane(
10705 store: &bamboo_storage::SessionStoreV2,
10706 actor_id: &str,
10707 policy: &bamboo_tools::permission::PermissionConfig,
10708 ) {
10709 store
10710 .bind_control_plane(
10711 actor_id,
10712 bamboo_domain::ActorControlPlaneBinding {
10713 policy_revision: Some(policy.policy_revision()),
10714 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10715 },
10716 )
10717 .await
10718 .unwrap();
10719 }
10720
10721 fn local_tool_sample(
10722 host: &Session,
10723 denied_write: bool,
10724 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10725 local_tool_sample_with_reasoning(host, denied_write, None)
10726 }
10727
10728 fn local_tool_sample_with_reasoning(
10729 host: &Session,
10730 denied_write: bool,
10731 reasoning: Option<&str>,
10732 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10733 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10734 let calls = ["Read", "Write"]
10735 .map(|name| ToolCall {
10736 id: format!("local-{name}"),
10737 tool_type: "function".into(),
10738 function: FunctionCall {
10739 name: name.into(),
10740 arguments: "{\"file_path\":\"answer.txt\"}".into(),
10741 },
10742 })
10743 .to_vec();
10744 let mut rows = vec![Message::assistant("checking", Some(calls))];
10745 rows[0].reasoning = reasoning.map(str::to_owned);
10746 let mut collector = LocalToolCollector::default();
10747 if let Some(reasoning) = reasoning {
10748 assert!(collector
10749 .event(
10750 &serde_json::to_value(AgentEvent::ReasoningToken {
10751 content: reasoning.into(),
10752 })
10753 .unwrap()
10754 )
10755 .unwrap());
10756 }
10757 for name in ["Read", "Write"] {
10758 let denied = name == "Write" && denied_write;
10759 let id = format!("local-{name}");
10760 if !denied {
10761 collector
10762 .event(
10763 &serde_json::to_value(AgentEvent::ToolStart {
10764 tool_call_id: id.clone(),
10765 tool_name: name.into(),
10766 arguments: serde_json::json!({"file_path":"answer.txt"}),
10767 })
10768 .unwrap(),
10769 )
10770 .unwrap();
10771 }
10772 let text = if denied {
10773 "Error: native_tool_ceiling_denied: Write"
10774 } else {
10775 "actual file result"
10776 };
10777 let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10778 message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10779 "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10780 rows.push(message);
10781 let event = if denied {
10782 AgentEvent::ToolError {
10783 tool_call_id: id,
10784 error: "native_tool_ceiling_denied: Write".into(),
10785 }
10786 } else {
10787 AgentEvent::ToolComplete {
10788 tool_call_id: id,
10789 result: bamboo_agent_core::tools::ToolResult::text(true, text),
10790 }
10791 };
10792 collector
10793 .event(&serde_json::to_value(event).unwrap())
10794 .unwrap();
10795 }
10796 rows.push(Message::assistant("complete exact report", None));
10797 rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10798 if let Some(reasoning) = reasoning {
10799 assert!(collector
10800 .event(
10801 &serde_json::to_value(AgentEvent::ReasoningToken {
10802 content: reasoning.into(),
10803 })
10804 .unwrap()
10805 )
10806 .unwrap());
10807 }
10808 let mut messages = host.messages.clone();
10809 if let Some(system) = messages
10810 .iter_mut()
10811 .find(|message| message.role == Role::System)
10812 {
10813 system.content = "worker-private system never imported".into();
10814 }
10815 messages.extend(rows.iter().cloned());
10816 let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10817 version: 1,
10818 messages: messages
10819 .iter()
10820 .map(|message| serde_json::to_value(message).unwrap())
10821 .collect(),
10822 };
10823 assert!(!collector
10824 .event(&serde_json::to_value(data).unwrap())
10825 .unwrap());
10826 (collector, rows)
10827 }
10828
10829 #[test]
10830 fn ticket_task_event_requires_exact_host_callback_receipt() {
10831 let mut collector = LocalToolCollector::default();
10832 let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10833 let start = serde_json::to_value(AgentEvent::ToolStart {
10834 tool_call_id: "task-call".into(),
10835 tool_name: "Task".into(),
10836 arguments: args.clone(),
10837 })
10838 .unwrap();
10839 let mut receipts = HashMap::new();
10840 assert!(collector.ticket_event(&start, &receipts).unwrap());
10841 collector.event(&start).unwrap();
10842 let complete = serde_json::to_value(AgentEvent::ToolComplete {
10843 tool_call_id: "task-call".into(),
10844 result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10845 })
10846 .unwrap();
10847 assert!(collector.ticket_event(&complete, &receipts).is_err());
10848 receipts.insert("other-call".into(), args.clone());
10849 assert!(collector.ticket_event(&complete, &receipts).is_err());
10850 receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10851 assert!(collector.ticket_event(&complete, &receipts).is_err());
10852 receipts.insert("task-call".into(), args);
10853 assert!(collector.ticket_event(&complete, &receipts).unwrap());
10854 let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10855 task_list: bamboo_tools::TaskTool::task_list_from_args(
10856 &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10857 "root",
10858 )
10859 .unwrap(),
10860 version: Some(999),
10861 })
10862 .unwrap();
10863 assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10864 }
10865
10866 #[test]
10867 fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10868 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10869 let mut host = Session::new("question-child", "model");
10870 host.messages.push(Message::user("own assignment"));
10871 let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10872 "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10873 "generation":1, "contract_revision":1, "prompt_revision":1,
10874 "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10875 "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10876 })).unwrap();
10877 let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10878 for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10879 let mut returned =
10880 serde_json::json!({"status":"waiting_for_answer", "request":question});
10881 if case == "forged_request" {
10882 returned["request"]["id"] = serde_json::json!("sibling-question");
10883 }
10884 let text = returned.to_string();
10885 let rows = [
10886 Message::assistant(
10887 "",
10888 Some(vec![ToolCall {
10889 id: "own-call".into(),
10890 tool_type: "function".into(),
10891 function: FunctionCall {
10892 name: "Task".into(),
10893 arguments: args.to_string(),
10894 },
10895 }]),
10896 ),
10897 Message::tool_result_with_status("own-call", &text, true),
10898 ];
10899 let collector = LocalToolCollector {
10900 messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10901 starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10902 outcomes: HashMap::from([("own-call".into(), (true, text))]),
10903 ticket_yield: (case != "no_host_receipt")
10904 .then(|| ("own-call".into(), question.clone())),
10905 };
10906 let terminal = if case == "fake_report" {
10907 Some("fake delivery")
10908 } else {
10909 Some("")
10910 };
10911 assert_eq!(
10912 collector
10913 .suffix(&host, &["Task".into()], false, terminal)
10914 .is_ok(),
10915 case == "exact",
10916 "{case}"
10917 );
10918 }
10919 }
10920
10921 #[test]
10922 fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10923 let mut host = Session::new("tool-history-prefix", "model");
10924 host.messages
10925 .push(bamboo_agent_core::Message::user("assignment"));
10926 let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10927 confirmed.metadata = Some(
10928 serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10929 );
10930 host.messages.push(confirmed);
10931 let tools = vec!["Read".into(), "Write".into()];
10932 let (good, rows) = local_tool_sample(&host, false);
10933 assert_eq!(
10934 serde_json::to_value(
10935 good.suffix(&host, &tools, false, Some("complete exact report"))
10936 .unwrap()
10937 )
10938 .unwrap(),
10939 serde_json::to_value(&rows).unwrap()
10940 );
10941 for case in [
10942 "missing",
10943 "changed",
10944 "metadata",
10945 "system",
10946 "user",
10947 "collision",
10948 "dangling",
10949 "result",
10950 "terminal",
10951 "tool_metadata",
10952 "grant",
10953 "start_arguments",
10954 "ledger",
10955 ] {
10956 let (mut collector, _) = local_tool_sample(&host, false);
10957 let mut current = host.clone();
10958 let messages = collector.messages.as_mut().unwrap();
10959 match case {
10960 "missing" => {
10961 messages.remove(host.messages.len() - 1);
10962 }
10963 "changed" => messages[0].content.push('!'),
10964 "metadata" => messages[1].metadata = None,
10965 "system" => messages.last_mut().unwrap().role = Role::System,
10966 "user" => messages.last_mut().unwrap().role = Role::User,
10967 "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10968 "dangling" => {
10969 messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10970 }
10971 "result" => messages[host.messages.len() + 1].content.push('!'),
10972 "tool_metadata" => {
10973 messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10974 true.into()
10975 }
10976 "grant" => {
10977 messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10978 .function
10979 .name = "Bash".into()
10980 }
10981 "start_arguments" => {
10982 collector.starts.get_mut("local-Read").unwrap().1 =
10983 serde_json::json!({"file_path":"different.txt"})
10984 }
10985 "ledger" => {
10986 current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10987 bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10988 bamboo_domain::ProviderFamily::OpenAi,
10989 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10990 bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10991 bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10992 serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10993 ).unwrap(),
10994 ]).unwrap();
10995 }
10996 "terminal" => {}
10997 _ => unreachable!(),
10998 }
10999 let terminal = if case == "terminal" {
11000 "different"
11001 } else {
11002 "complete exact report"
11003 };
11004 assert!(
11005 collector
11006 .suffix(¤t, &tools, false, Some(terminal))
11007 .is_err(),
11008 "{case}"
11009 );
11010 }
11011 let (denied, _) = local_tool_sample(&host, true);
11012 assert!(denied
11013 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11014 .is_ok());
11015 let (mut contradicted, _) = local_tool_sample(&host, true);
11016 contradicted.starts.insert(
11017 "local-Write".into(),
11018 ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
11019 );
11020 assert!(contradicted
11021 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11022 .is_err());
11023 let (successful, _) = local_tool_sample(&host, false);
11024 assert!(successful
11025 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
11026 .is_err());
11027 let mut empty = LocalToolCollector::default();
11028 assert!(empty
11029 .event(
11030 &serde_json::to_value(AgentEvent::ReasoningToken {
11031 content: "private".into()
11032 })
11033 .unwrap()
11034 )
11035 .unwrap());
11036 assert!(
11037 empty
11038 .suffix(&host, &tools, false, Some("complete exact report"))
11039 .is_err(),
11040 "stream observations alone cannot commit history"
11041 );
11042 let (mut completed, _) = local_tool_sample(&host, false);
11043 assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
11044 }
11045
11046 struct LocalToolConcurrentCheckpoint {
11047 real: SessionInboxRuntimeBinding,
11048 claim: SessionInboxClaim,
11049 }
11050 #[async_trait]
11051 impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
11052 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11053 self.real.persistence.save_runtime_session(session).await
11054 }
11055 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11056 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11059 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
11060 .await
11061 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11062 self.real
11063 .persistence
11064 .checkpoint_runtime_session(session)
11065 .await
11066 }
11067 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11068 self.real.storage.load_session(id).await
11069 }
11070 }
11071
11072 #[tokio::test]
11073 async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
11074 for case in [
11075 "success",
11076 "reasoning",
11077 "concurrent_input",
11078 "checkpoint_error",
11079 "stale_prefix",
11080 "cancelled",
11081 "lost_owner",
11082 ] {
11083 let temp = tempfile::tempdir().unwrap();
11084 let store = Arc::new(
11085 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11086 .await
11087 .unwrap(),
11088 );
11089 let root = Session::new("local-tool-root", "model");
11090 store.save_session(&root).await.unwrap();
11091 let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
11092 let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
11093 system.role = Role::System;
11094 child.messages.push(system);
11095 child
11096 .messages
11097 .push(bamboo_agent_core::Message::user("assignment"));
11098 store.save_session(&child).await.unwrap();
11099 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11100 store.clone(),
11101 bamboo_domain::SessionInboxLimits::default(),
11102 ));
11103 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11104 let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11105 let mut owner = Some(
11106 binding
11107 .router
11108 .register_run(&child.id, "current-run")
11109 .await
11110 .unwrap(),
11111 );
11112 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
11113 &child.id,
11114 "current confirmed User",
11115 );
11116 let receipt = inbox.deliver(&envelope).await.unwrap();
11117 inbox
11118 .mark_activation_eligible(
11119 &child.id,
11120 receipt.generation,
11121 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11122 )
11123 .await
11124 .unwrap();
11125 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11126 checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
11127 .await
11128 .unwrap();
11129 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
11130 let before = serde_json::to_value(&child).unwrap();
11131 let reasoning = (case == "reasoning")
11132 .then_some("Complete plain reasoning before Read and the final reply 🪷.");
11133 let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
11134 let mut binding = binding;
11135 let late = bamboo_domain::SessionMessageEnvelope::user_input(
11136 &child.id,
11137 "concurrent confirmed User",
11138 );
11139 if case == "concurrent_input" {
11140 let receipt = inbox.deliver(&late).await.unwrap();
11141 inbox
11142 .mark_activation_eligible(
11143 &child.id,
11144 receipt.generation,
11145 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11146 )
11147 .await
11148 .unwrap();
11149 let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11150 binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
11151 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11152 claim: late_claim,
11153 });
11154 }
11155 if case == "checkpoint_error" {
11156 binding.persistence = Arc::new(ActorFaultingPersistence {
11157 inner: locked.clone(),
11158 fail_checkpoint_once: AtomicBool::new(true),
11159 });
11160 }
11161 if case == "stale_prefix" {
11162 let mut latest = child.clone();
11163 latest
11164 .messages
11165 .push(bamboo_agent_core::Message::user("new confirmed prefix"));
11166 store.save_session(&latest).await.unwrap();
11167 }
11168 let cancel = CancellationToken::new();
11169 if case == "cancelled" {
11170 cancel.cancel();
11171 }
11172 if case == "lost_owner" {
11173 owner.take().unwrap().abandon().await;
11174 }
11175 let durable_before =
11176 serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11177 let result = commit_local_tool_history(
11178 collector,
11179 &mut child,
11180 &binding,
11181 "current-run",
11182 (&["Read".into(), "Write".into()], false),
11183 LocalToolTerminal::Completed(Some("complete exact report")),
11184 &cancel,
11185 )
11186 .await;
11187 let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11188 .await
11189 .unwrap()
11190 .load_session(&child.id)
11191 .await
11192 .unwrap()
11193 .unwrap();
11194 if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11195 assert!(result.is_err(), "{case}");
11196 assert_eq!(
11197 serde_json::to_value(&child).unwrap(),
11198 before,
11199 "{case}: no partial mutable success"
11200 );
11201 assert_eq!(
11202 serde_json::to_value(Some(&cold)).unwrap(),
11203 durable_before,
11204 "{case}: no write"
11205 );
11206 continue;
11207 }
11208 result.unwrap();
11209 assert_eq!(
11210 serde_json::to_value(&cold.messages[..3]).unwrap(),
11211 before["messages"]
11212 );
11213 let suffix_start = if case == "concurrent_input" {
11214 assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11215 assert_eq!(
11216 serde_json::to_value(&cold.messages[3]).unwrap(),
11217 serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11218 );
11219 assert!(child
11220 .messages
11221 .iter()
11222 .any(|message| message.id == late.id.as_str()));
11223 4
11224 } else {
11225 3
11226 };
11227 assert_eq!(
11228 serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11229 serde_json::to_value(rows).unwrap()
11230 );
11231 assert_eq!(
11232 cold.messages
11233 .iter()
11234 .filter(|message| message.id == envelope.id.as_str())
11235 .count(),
11236 1
11237 );
11238 assert_eq!(
11239 cold.messages
11240 .iter()
11241 .filter(|message| message.content == "complete exact report")
11242 .count(),
11243 1
11244 );
11245 assert_eq!(cold.metadata, child.metadata);
11246 assert!(cold.provider_transcript.is_empty());
11247 }
11248 }
11249
11250 #[tokio::test]
11251 async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11252 for case in [
11253 "legacy",
11254 "missing",
11255 "gap",
11256 "duplicate",
11257 "epoch",
11258 "source",
11259 "birth",
11260 "qos",
11261 "ticket_forged_success",
11262 ] {
11263 let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11264 session
11265 .messages
11266 .push(bamboo_agent_core::Message::user("original"));
11267 if case == "ticket_forged_success" {
11268 session.metadata.insert(
11269 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11270 "assignment".into(),
11271 );
11272 }
11273 let before = serde_json::to_value(&session).unwrap();
11274 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11275 created_at: session.created_at,
11276 spawn_depth: session.spawn_depth,
11277 };
11278 let mut messages = session.messages.clone();
11279 messages.push(bamboo_agent_core::Message::assistant("complete", None));
11280 let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11281 version: 1,
11282 messages: messages
11283 .iter()
11284 .map(|message| serde_json::to_value(message).unwrap())
11285 .collect(),
11286 })
11287 .unwrap();
11288 let mut batch = ActorEventBatch {
11289 logical_session: Some(LogicalSessionIdentity {
11290 session_id: session.id.clone(),
11291 parent_session_id: session.parent_session_id.clone(),
11292 root_session_id: session.root_session_id.clone(),
11293 creation: Some(creation.clone()),
11294 }),
11295 activation_id: Some("current".into()),
11296 execution_epoch: 7,
11297 source_node_id: None,
11298 source_actor_id: Some("selected".into()),
11299 first_seq: 1,
11300 last_seq: 1,
11301 qos: bamboo_subagent::proto::ActorEventQos::Durable,
11302 events: vec![event.clone()],
11303 };
11304 match case {
11305 "gap" => {
11306 batch.first_seq = 2;
11307 batch.last_seq = 2;
11308 }
11309 "epoch" => batch.execution_epoch = 6,
11310 "source" => batch.source_actor_id = Some("replaced".into()),
11311 "birth" => {
11312 batch
11313 .logical_session
11314 .as_mut()
11315 .unwrap()
11316 .creation
11317 .as_mut()
11318 .unwrap()
11319 .created_at += chrono::Duration::nanoseconds(1)
11320 }
11321 "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11322 "ticket_forged_success" => {
11323 batch.last_seq = 2;
11324 batch.events = vec![
11325 serde_json::to_value(AgentEvent::ToolStart {
11326 tool_call_id: "forged".into(), tool_name: "Task".into(),
11327 arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11328 }).unwrap(),
11329 serde_json::to_value(AgentEvent::ToolComplete {
11330 tool_call_id: "forged".into(),
11331 result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11332 }).unwrap(),
11333 ];
11334 }
11335 _ => {}
11336 }
11337 let terminal = ChildFrame::Terminal {
11338 status: TerminalStatus::Completed,
11339 result: Some("complete".into()),
11340 error: None,
11341 final_event_watermark: None,
11342 transcript: vec![],
11343 };
11344 let frames = match case {
11345 "legacy" => vec![ChildFrame::Event { event }, terminal],
11346 "missing" => vec![terminal],
11347 "duplicate" => vec![
11348 ChildFrame::EventBatch {
11349 batch: batch.clone(),
11350 },
11351 ChildFrame::EventBatch { batch },
11352 terminal,
11353 ],
11354 _ => vec![ChildFrame::EventBatch { batch }, terminal],
11355 };
11356 let mut link = ConfirmationSequenceLink {
11357 frames: frames.into(),
11358 sent: vec![],
11359 };
11360 let (tx, mut rx) = mpsc::channel(32);
11361 let (_live, mut live_rx) = mpsc::unbounded_channel();
11362 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11363 let cancel = CancellationToken::new();
11364 let tools = vec![if case == "ticket_forged_success" {
11365 "Task"
11366 } else {
11367 "Read"
11368 }
11369 .into()];
11370 let outcome = drive(ActorDriveContext {
11371 client: &mut link,
11372 parent_session_id: "frame-root",
11373 child_session_id: "frame-child",
11374 child_attempt: 0,
11375 approval_registry: None,
11376 approval_decider: None,
11377 approval_reviewer: None,
11378 escalation_bridge: None,
11379 event_tx: &tx,
11380 cancel_token: &cancel,
11381 live_rx: &mut live_rx,
11382 delivery_rx: &mut delivery_rx,
11383 logical_session: &mut session,
11384 expected_permission_posture: None,
11385 expected_creation: Some(&creation),
11386 session_inbox_runtime: None,
11387 actor_directory_store: None,
11388 canonical_subagent_tool: None,
11389 ticket_service: None,
11390 activation_run_id: Some("current"),
11391 execution_epoch: 7,
11392 expected_source_actor_id: "selected",
11393 initial_inflight_claims: vec![].into(),
11394 plain_actor: false,
11395 remote_environment_lease: false,
11396 readonly_output: None,
11397 local_history_tools: Some(&tools),
11398 local_history_read_only: true,
11399 strict_history_output: None,
11400 plain_input: None,
11401 canonical_activation: None,
11402 canonical_placement_ref: None,
11403 actor_event_observer: None,
11404 plain_run: None,
11405 first_frame_timeout: Some(Duration::from_secs(1)),
11406 })
11407 .await;
11408 assert!(outcome.is_err(), "{case}: no last-text success fallback");
11409 if case == "ticket_forged_success" {
11410 assert!(outcome
11411 .unwrap_err()
11412 .to_string()
11413 .contains("no exact Host receipt"));
11414 assert!(matches!(
11415 rx.try_recv().unwrap(),
11416 AgentEvent::ToolStart { .. }
11417 ));
11418 }
11419 assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11420 assert!(link.sent.is_empty());
11421 assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11422 }
11423 }
11424
11425 struct HistoryTerminalProbe {
11426 frames: VecDeque<ChildFrame>,
11427 accepted: Option<TerminalStatus>,
11428 acks: usize,
11429 }
11430
11431 #[async_trait]
11432 impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11433 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11434 Ok(())
11435 }
11436 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11437 Ok(self.frames.pop_front())
11438 }
11439 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11440 self.accepted = Some(status);
11441 }
11442 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11443 Some(bamboo_subagent::DurableChildDeliveryReceipt {
11444 broker_identity: "fixture".into(),
11445 parent_mailbox: "parent".into(),
11446 correlation_id: "current".into(),
11447 message_ids: vec!["tail".into(), "terminal".into()],
11448 terminal_status: self.accepted?,
11449 })
11450 }
11451 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11452 self.acks += 1;
11453 Ok(())
11454 }
11455 }
11456
11457 struct CancelConcurrentInputCheckpoint {
11458 real: SessionInboxRuntimeBinding,
11459 envelope: bamboo_domain::SessionMessageEnvelope,
11460 }
11461 #[async_trait]
11462 impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11463 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11464 self.real.persistence.save_runtime_session(session).await
11465 }
11466 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11467 let receipt = self
11468 .real
11469 .inbox
11470 .deliver(&self.envelope)
11471 .await
11472 .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11473 self.real
11474 .inbox
11475 .mark_activation_eligible(
11476 &session.id,
11477 receipt.generation,
11478 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11479 )
11480 .await
11481 .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11482 let claim = self
11483 .real
11484 .inbox
11485 .claim(&session.id, 1)
11486 .await
11487 .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11488 .remove(0);
11489 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11490 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11491 .await
11492 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11493 self.real
11494 .persistence
11495 .checkpoint_runtime_session(session)
11496 .await
11497 }
11498 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11499 self.real.storage.load_session(id).await
11500 }
11501 }
11502
11503 struct CancelHistoryProbe {
11504 inner: HistoryTerminalProbe,
11505 cancel_requested: bool,
11506 }
11507
11508 #[async_trait]
11509 impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11510 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11511 self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11512 Ok(())
11513 }
11514 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11515 if !self.cancel_requested {
11516 std::future::pending().await
11517 } else {
11518 self.inner.next_frame().await
11519 }
11520 }
11521 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11522 self.inner.accept_durable_terminal(status);
11523 }
11524 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11525 self.inner.durable_delivery_receipt()
11526 }
11527 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11528 self.inner.acknowledge_durable_frames().await
11529 }
11530 }
11531
11532 #[tokio::test]
11533 async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11534 use bamboo_subagent::ChildLink;
11535 for case in [
11536 "success",
11537 "empty",
11538 "missing",
11539 "unclosed",
11540 "unobserved",
11541 "prefix",
11542 "metadata",
11543 "write",
11544 "not_read_only",
11545 "watermark",
11546 "epoch",
11547 "birth",
11548 "permission",
11549 "inbox",
11550 "pending",
11551 "pending_eligible",
11552 "claimed",
11553 "inspect_error",
11554 "checkpoint_error",
11555 "lost_owner",
11556 "concurrent_input",
11557 ] {
11558 let temp = tempfile::tempdir().unwrap();
11559 let store = Arc::new(
11560 bamboo_storage::SessionStoreV2::new(temp.path().into())
11561 .await
11562 .unwrap(),
11563 );
11564 let root = Session::new("cancel-history-root", "model");
11565 store.save_session(&root).await.unwrap();
11566 let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11567 child
11568 .messages
11569 .push(bamboo_agent_core::Message::user("exact assignment"));
11570 child.metadata.insert(
11571 bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11572 "true".into(),
11573 );
11574 store.save_session(&child).await.unwrap();
11575 let before = serde_json::to_value(&child.messages).unwrap();
11576 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11577 store.clone(),
11578 bamboo_domain::SessionInboxLimits::default(),
11579 ));
11580 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11581 let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11582 let mut owner = Some(
11583 binding
11584 .router
11585 .register_run(&child.id, "current")
11586 .await
11587 .unwrap(),
11588 );
11589 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11590 created_at: child.created_at,
11591 spawn_depth: child.spawn_depth,
11592 };
11593 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11594 let tool = if case == "write" { "Write" } else { "Read" };
11595 let arguments = serde_json::json!({"file_path":"assigned.txt"});
11596 let call = bamboo_agent_core::ToolCall {
11597 id: "cancel-read".into(),
11598 tool_type: "function".into(),
11599 function: bamboo_agent_core::FunctionCall {
11600 name: tool.into(),
11601 arguments: arguments.to_string(),
11602 },
11603 };
11604 let mut rows = child.messages.clone();
11605 let mut events = Vec::new();
11606 if case != "empty" {
11607 rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11608 events.push(
11609 serde_json::to_value(AgentEvent::ToolStart {
11610 tool_call_id: "cancel-read".into(),
11611 tool_name: tool.into(),
11612 arguments,
11613 })
11614 .unwrap(),
11615 );
11616 if case != "unclosed" {
11617 let mut result = bamboo_agent_core::Message::tool_result_with_status(
11618 "cancel-read",
11619 "exact Read result",
11620 true,
11621 );
11622 if case == "metadata" {
11623 result.metadata = Some(serde_json::json!({"permission":true}));
11624 }
11625 rows.push(result);
11626 events.push(
11627 serde_json::to_value(AgentEvent::ToolComplete {
11628 tool_call_id: "cancel-read".into(),
11629 result: bamboo_agent_core::tools::ToolResult::text(
11630 true,
11631 "exact Read result",
11632 ),
11633 })
11634 .unwrap(),
11635 );
11636 }
11637 }
11638 if case == "unobserved" {
11639 events.clear();
11640 }
11641 if case == "prefix" {
11642 rows[0].content.push('!');
11643 }
11644 events.push(
11645 serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11646 version: 1,
11647 messages: rows
11648 .iter()
11649 .map(|row| serde_json::to_value(row).unwrap())
11650 .collect(),
11651 })
11652 .unwrap(),
11653 );
11654 if case == "missing" {
11655 events.clear();
11656 }
11657 let final_seq = events.len() as u64;
11658 let mut watermark = bamboo_subagent::ActorEventWatermark {
11659 version: 1,
11660 logical_session: Some(identity.clone()),
11661 activation_id: Some("current".into()),
11662 execution_epoch: 7,
11663 final_seq,
11664 };
11665 match case {
11666 "watermark" => watermark.final_seq += 1,
11667 "epoch" => watermark.execution_epoch -= 1,
11668 "birth" => {
11669 watermark
11670 .logical_session
11671 .as_mut()
11672 .unwrap()
11673 .creation
11674 .as_mut()
11675 .unwrap()
11676 .created_at += chrono::Duration::nanoseconds(1)
11677 }
11678 _ => {}
11679 }
11680 let mut frames = VecDeque::new();
11681 if !events.is_empty() {
11682 frames.push_back(ChildFrame::EventBatch {
11683 batch: ActorEventBatch {
11684 logical_session: Some(identity),
11685 activation_id: Some("current".into()),
11686 execution_epoch: 7,
11687 source_node_id: None,
11688 source_actor_id: Some("selected".into()),
11689 first_seq: 1,
11690 last_seq: final_seq,
11691 qos: bamboo_subagent::ActorEventQos::Durable,
11692 events,
11693 },
11694 });
11695 }
11696 frames.push_back(ChildFrame::Terminal {
11697 status: TerminalStatus::Cancelled,
11698 result: None,
11699 error: None,
11700 transcript: vec![],
11701 final_event_watermark: Some(watermark),
11702 });
11703 let mut claims = VecDeque::new();
11704 let pending_input =
11705 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11706 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11707 let receipt = inbox.deliver(&pending_input).await.unwrap();
11708 if case != "pending" {
11709 inbox
11710 .mark_activation_eligible(
11711 &child.id,
11712 receipt.generation,
11713 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11714 )
11715 .await
11716 .unwrap();
11717 }
11718 if matches!(case, "inbox" | "claimed") {
11719 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11720 if case == "inbox" {
11721 claims.push_back(claim);
11722 }
11723 }
11724 }
11725 if case == "concurrent_input" {
11726 binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11727 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11728 envelope: pending_input.clone(),
11729 });
11730 }
11731 if case == "inspect_error" {
11732 let rel = store.resolve_rel_path(&child.id).await.unwrap();
11733 let dir = store.bamboo_home_dir().join(rel).join("inbox");
11734 std::fs::create_dir_all(&dir).unwrap();
11735 std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11736 assert!(inbox.inspect(&child.id).await.is_err());
11737 }
11738 let backlog_before =
11739 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11740 Some(inbox.inspect(&child.id).await.unwrap())
11741 } else {
11742 None
11743 };
11744 if case == "checkpoint_error" {
11745 binding.persistence = Arc::new(ActorFaultingPersistence {
11746 inner: locked.clone(),
11747 fail_checkpoint_once: AtomicBool::new(true),
11748 });
11749 }
11750 if case == "lost_owner" {
11751 owner.take().unwrap().abandon().await;
11752 }
11753 let mut link = CancelHistoryProbe {
11754 inner: HistoryTerminalProbe {
11755 frames,
11756 accepted: None,
11757 acks: 0,
11758 },
11759 cancel_requested: false,
11760 };
11761 let (tx, mut rx) = mpsc::channel(32);
11762 let (_live, mut live_rx) = mpsc::unbounded_channel();
11763 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11764 let cancel = CancellationToken::new();
11765 cancel.cancel();
11766 let tools = vec!["Read".into(), "Glob".into()];
11767 let mut strict_history = None;
11768 let outcome = drive(ActorDriveContext {
11769 client: &mut link,
11770 parent_session_id: &root.id,
11771 child_session_id: &child.id.clone(),
11772 child_attempt: 0,
11773 approval_registry: None,
11774 approval_decider: None,
11775 approval_reviewer: None,
11776 escalation_bridge: None,
11777 event_tx: &tx,
11778 cancel_token: &cancel,
11779 live_rx: &mut live_rx,
11780 delivery_rx: &mut delivery_rx,
11781 logical_session: &mut child,
11782 expected_permission_posture: (case == "permission")
11783 .then(|| expected_default_permission_posture(7)),
11784 expected_creation: Some(&creation),
11785 session_inbox_runtime: Some(&binding),
11786 actor_directory_store: None,
11787 canonical_subagent_tool: None,
11788 ticket_service: None,
11789 activation_run_id: Some("current"),
11790 execution_epoch: 7,
11791 expected_source_actor_id: "selected",
11792 initial_inflight_claims: claims,
11793 plain_actor: false,
11794 remote_environment_lease: false,
11795 readonly_output: None,
11796 local_history_tools: Some(&tools),
11797 local_history_read_only: case != "not_read_only",
11798 strict_history_output: Some(&mut strict_history),
11799 plain_input: None,
11800 canonical_activation: None,
11801 canonical_placement_ref: None,
11802 actor_event_observer: None,
11803 plain_run: None,
11804 first_frame_timeout: None,
11805 })
11806 .await;
11807 let proven = matches!(case, "success" | "empty");
11808 assert_eq!(strict_history.is_some(), proven, "{case}");
11809 assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11810 assert_eq!(
11811 link.inner.acks, 0,
11812 "{case}: pump cannot ACK before SDK save/proof"
11813 );
11814 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11815 .await
11816 .unwrap()
11817 .load_session(&child.id)
11818 .await
11819 .unwrap()
11820 .unwrap();
11821 if proven {
11822 assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11823 assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11824 let history = strict_history.unwrap();
11825 assert_eq!(history.proof.execution_epoch(), 7);
11826 assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11827 assert_eq!(
11828 serde_json::to_value(&cold.messages).unwrap(),
11829 serde_json::to_value(&rows).unwrap()
11830 );
11831 } else {
11832 assert!(outcome.is_err(), "{case}");
11833 if case == "concurrent_input" {
11834 assert!(
11835 cold.messages
11836 .iter()
11837 .any(|message| message.id == pending_input.id.as_str()),
11838 "new input must survive refusal"
11839 );
11840 } else {
11841 assert_eq!(
11842 serde_json::to_value(&cold.messages).unwrap(),
11843 before,
11844 "{case}: no unvalidated history write"
11845 );
11846 }
11847 }
11848 if let Some(backlog) = backlog_before {
11849 assert!(!inbox
11850 .was_admitted(&child.id, &pending_input.id)
11851 .await
11852 .unwrap());
11853 assert_eq!(
11854 inbox.inspect(&child.id).await.unwrap(),
11855 backlog,
11856 "{case}: proof refusal must neither claim nor ACK the input"
11857 );
11858 }
11859 while let Ok(event) = rx.try_recv() {
11860 assert!(
11861 !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11862 "private DATA is not a canonical public barrier"
11863 );
11864 }
11865 }
11866 }
11867
11868 #[tokio::test]
11869 async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11870 use bamboo_subagent::ChildLink;
11871 for case in [
11872 "success",
11873 "tail",
11874 "short",
11875 "missing",
11876 "version",
11877 "activation",
11878 "epoch",
11879 "birth",
11880 "duplicate",
11881 "overlap",
11882 "reorder",
11883 "midgap",
11884 "qos",
11885 "zero_missing",
11886 "zero_valid",
11887 "error_tail",
11888 "cancelled_tail",
11889 "cancelled_valid",
11890 "cancelled_zero_valid",
11891 "error_valid",
11892 "error_zero_valid",
11893 "ordinary_lossy",
11894 ] {
11895 let temp = tempfile::tempdir().unwrap();
11896 let store = Arc::new(
11897 bamboo_storage::SessionStoreV2::new(temp.path().into())
11898 .await
11899 .unwrap(),
11900 );
11901 let root = Session::new("watermark-root", "model");
11902 store.save_session(&root).await.unwrap();
11903 let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11904 child
11905 .messages
11906 .push(bamboo_agent_core::Message::user("assignment"));
11907 store.save_session(&child).await.unwrap();
11908 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11909 store.clone(),
11910 bamboo_domain::SessionInboxLimits::default(),
11911 ));
11912 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11913 let binding = actor_binding(store.clone(), inbox, locked);
11914 let _owner = binding
11915 .router
11916 .register_run(&child.id, "current")
11917 .await
11918 .unwrap();
11919 let before = serde_json::to_value(&child).unwrap();
11920 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11921 created_at: child.created_at,
11922 spawn_depth: child.spawn_depth,
11923 };
11924 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11925 let mut rows = child.messages.clone();
11926 rows.push(bamboo_agent_core::Message::assistant("complete", None));
11927 let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11928 version: 1,
11929 messages: rows
11930 .iter()
11931 .map(|row| serde_json::to_value(row).unwrap())
11932 .collect(),
11933 })
11934 .unwrap();
11935 let batch = |seq, event, qos| ActorEventBatch {
11936 logical_session: Some(identity.clone()),
11937 activation_id: Some("current".into()),
11938 execution_epoch: 7,
11939 source_node_id: None,
11940 source_actor_id: Some("selected".into()),
11941 first_seq: seq,
11942 last_seq: seq,
11943 qos,
11944 events: vec![event],
11945 };
11946 let durable = bamboo_subagent::ActorEventQos::Durable;
11947 let token = serde_json::json!({"type":"token", "content":"observation"});
11948 let mut frames = vec![ChildFrame::EventBatch {
11949 batch: batch(1, data.clone(), durable),
11950 }];
11951 let mut marker = bamboo_subagent::ActorEventWatermark {
11952 version: 1,
11953 logical_session: Some(identity.clone()),
11954 activation_id: Some("current".into()),
11955 execution_epoch: 7,
11956 final_seq: 1,
11957 };
11958 match case {
11959 "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11960 "short" => marker.final_seq = 0,
11961 "version" => marker.version = 99,
11962 "activation" => marker.activation_id = Some("old".into()),
11963 "epoch" => marker.execution_epoch = 6,
11964 "birth" => {
11965 marker
11966 .logical_session
11967 .as_mut()
11968 .unwrap()
11969 .creation
11970 .as_mut()
11971 .unwrap()
11972 .created_at += chrono::Duration::nanoseconds(1)
11973 }
11974 "duplicate" => frames.push(frames[0].clone()),
11975 "overlap" | "midgap" | "qos" => {
11976 frames = vec![
11977 ChildFrame::EventBatch {
11978 batch: batch(
11979 1,
11980 token.clone(),
11981 if case == "qos" {
11982 bamboo_subagent::ActorEventQos::Ephemeral
11983 } else {
11984 durable
11985 },
11986 ),
11987 },
11988 ChildFrame::EventBatch {
11989 batch: batch(
11990 if case == "overlap" {
11991 1
11992 } else if case == "midgap" {
11993 3
11994 } else {
11995 2
11996 },
11997 data,
11998 durable,
11999 ),
12000 },
12001 ];
12002 marker.final_seq = 2;
12003 }
12004 "reorder" => {
12005 frames = vec![
12006 ChildFrame::EventBatch {
12007 batch: batch(2, data, durable),
12008 },
12009 ChildFrame::EventBatch {
12010 batch: batch(1, token, durable),
12011 },
12012 ]
12013 }
12014 "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
12015 frames.clear();
12016 marker.final_seq = 0;
12017 }
12018 "ordinary_lossy" => {
12019 frames = vec![ChildFrame::EventBatch {
12020 batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
12021 }]
12022 }
12023 _ => {}
12024 }
12025 let status = match case {
12026 "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
12027 "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
12028 TerminalStatus::Cancelled
12029 }
12030 _ => TerminalStatus::Completed,
12031 };
12032 frames.push(ChildFrame::Terminal {
12033 status,
12034 result: Some("complete".into()),
12035 error: Some("worker failed".into()),
12036 transcript: vec![],
12037 final_event_watermark: (!matches!(
12038 case,
12039 "missing" | "zero_missing" | "ordinary_lossy"
12040 ))
12041 .then_some(marker),
12042 });
12043 let mut link = HistoryTerminalProbe {
12044 frames: frames.into(),
12045 accepted: None,
12046 acks: 0,
12047 };
12048 let (tx, mut rx) = mpsc::channel(32);
12049 let (_live, mut live_rx) = mpsc::unbounded_channel();
12050 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
12051 let cancel = CancellationToken::new();
12052 let tools = vec!["Read".into()];
12053 let mut strict_history = None;
12054 let outcome = drive(ActorDriveContext {
12055 client: &mut link,
12056 parent_session_id: &root.id,
12057 child_session_id: "watermark-child",
12058 child_attempt: 0,
12059 approval_registry: None,
12060 approval_decider: None,
12061 approval_reviewer: None,
12062 escalation_bridge: None,
12063 event_tx: &tx,
12064 cancel_token: &cancel,
12065 live_rx: &mut live_rx,
12066 delivery_rx: &mut delivery_rx,
12067 logical_session: &mut child,
12068 expected_permission_posture: None,
12069 expected_creation: Some(&creation),
12070 session_inbox_runtime: Some(&binding),
12071 actor_directory_store: None,
12072 canonical_subagent_tool: None,
12073 ticket_service: None,
12074 activation_run_id: Some("current"),
12075 execution_epoch: 7,
12076 expected_source_actor_id: "selected",
12077 initial_inflight_claims: vec![].into(),
12078 plain_actor: false,
12079 remote_environment_lease: false,
12080 readonly_output: None,
12081 local_history_tools: (case != "ordinary_lossy").then_some(&tools),
12082 local_history_read_only: true,
12083 strict_history_output: Some(&mut strict_history),
12084 plain_input: None,
12085 canonical_activation: None,
12086 canonical_placement_ref: None,
12087 actor_event_observer: None,
12088 plain_run: None,
12089 first_frame_timeout: Some(Duration::from_secs(1)),
12090 })
12091 .await;
12092 assert_eq!(strict_history.is_some(), case == "success", "{case}");
12093 if let Some(history) = strict_history {
12094 assert_eq!(history.proof.execution_epoch(), 7);
12095 assert_eq!(history.proof.contiguous_applied_seq(), 1);
12096 assert_eq!(history.watermark.final_seq, 1);
12097 }
12098 let cold = store.load_session(&child.id).await.unwrap().unwrap();
12099 if matches!(case, "success" | "ordinary_lossy") {
12100 assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
12101 } else {
12102 let error = outcome.unwrap_err();
12103 if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
12104 assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
12105 }
12106 let error = error.to_string();
12107 if !matches!(
12108 case,
12109 "duplicate"
12110 | "overlap"
12111 | "reorder"
12112 | "midgap"
12113 | "qos"
12114 | "zero_valid"
12115 | "error_valid"
12116 | "error_zero_valid"
12117 | "cancelled_valid"
12118 | "cancelled_zero_valid"
12119 ) {
12120 assert!(
12121 error.contains("actor_history_tail_incomplete"),
12122 "{case}: {error}"
12123 );
12124 }
12125 }
12126 if case == "success" {
12127 assert_eq!(
12128 serde_json::to_value(&cold.messages).unwrap(),
12129 serde_json::to_value(rows).unwrap()
12130 );
12131 } else {
12132 assert_eq!(
12133 serde_json::to_value(&cold).unwrap(),
12134 before,
12135 "{case}: no cold history commit"
12136 );
12137 assert_eq!(
12138 serde_json::to_value(&child).unwrap(),
12139 before,
12140 "{case}: no mutable history commit"
12141 );
12142 }
12143 let accepted = matches!(case, "success" | "ordinary_lossy");
12144 assert_eq!(link.accepted.is_some(), accepted, "{case}");
12145 assert_eq!(
12146 link.durable_delivery_receipt().is_some(),
12147 accepted,
12148 "{case}"
12149 );
12150 assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
12151 while let Ok(event) = rx.try_recv() {
12152 assert!(
12153 matches!(event, AgentEvent::Token { .. }),
12154 "typed DATA cannot leak"
12155 );
12156 }
12157 }
12158 }
12159
12160 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
12161 PooledWorker {
12162 worker: SpawnedChild::remote(AgentRecord {
12163 agent_id: mailbox_id.to_string(),
12164 role: "test".to_string(),
12165 labels: Vec::new(),
12166 endpoint: "ws://127.0.0.1:1".to_string(),
12167 pid: 0,
12168 version: String::new(),
12169 started_at: chrono::Utc::now(),
12170 lease_expires_at: chrono::Utc::now(),
12171 }),
12172 mailbox_id: mailbox_id.to_string(),
12173 parked_at: None,
12174 }
12175 }
12176
12177 #[test]
12178 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12179 let now = Instant::now();
12180 let timeout = Duration::from_secs(300);
12181 assert!(!parked_worker_expired(None, now, timeout));
12182 assert!(!parked_worker_expired(
12183 Some(now - timeout + Duration::from_millis(1)),
12184 now,
12185 timeout
12186 ));
12187 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12188 }
12189
12190 #[test]
12191 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12192 let mut pool = WorkerPool::new();
12193 pool.insert(
12194 "fingerprint-a".to_string(),
12195 vec![processless_pool_worker("a")],
12196 );
12197 pool.insert(
12198 "fingerprint-b".to_string(),
12199 vec![processless_pool_worker("b")],
12200 );
12201
12202 assert_eq!(
12203 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12204 Some("pool_global_limit")
12205 );
12206 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12207 assert_eq!(
12208 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12209 Some("pool_per_key_limit")
12210 );
12211 }
12212
12213 #[test]
12214 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12215 let mut pool = WorkerPool::new();
12216 pool.insert(
12217 "fingerprint-a".to_string(),
12218 vec![processless_pool_worker("dead")],
12219 );
12220
12221 let reaped = take_reapable_workers(
12222 &mut pool,
12223 Instant::now(),
12224 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12225 );
12226
12227 assert!(pool.is_empty());
12228 assert_eq!(reaped.len(), 1);
12229 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12230 }
12231
12232 #[test]
12233 fn actor_preflight_counts_only_current_session_scoped_denies() {
12234 let config = bamboo_tools::permission::PermissionConfig::new();
12235 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12236 config.deny_scoped_session_permission(
12237 "target-session",
12238 bamboo_tools::permission::PermissionType::ExecuteCommand,
12239 secret_matcher,
12240 );
12241 config.deny_scoped_session_permission(
12242 "other-session",
12243 bamboo_tools::permission::PermissionType::WriteFile,
12244 "/other/**",
12245 );
12246
12247 assert_eq!(
12248 active_scoped_session_deny_count(&config, "target-session"),
12249 1
12250 );
12251 assert_eq!(
12252 active_scoped_session_deny_count(&config, "clean-session"),
12253 0
12254 );
12255 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12256 .unwrap_err()
12257 .to_string();
12258 assert!(!error.contains(secret_matcher));
12259 ensure_no_active_scoped_session_denies(&config, "clean-session")
12260 .expect("another session's deny must not block this activation");
12261 }
12262
12263 #[test]
12264 fn permission_posture_event_rejects_oversized_executor_mapping() {
12265 let session = Session::new("mapping-bound", "model");
12266 let event = AgentEvent::PermissionPostureActivated {
12267 session_id: session.id.clone(),
12268 policy_revision: 1,
12269 requested_mode: "default".to_string(),
12270 effective_mode: "default".to_string(),
12271 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12272 };
12273
12274 assert!(permission_posture_seed_from_event(&session, &event)
12275 .unwrap_err()
12276 .contains("executor mapping"));
12277 }
12278
12279 #[test]
12280 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12281 let mut session = Session::new("host-resigns-audit", "model");
12282 let hostile_timestamp = "9".repeat(1024);
12283 let event: AgentEvent = serde_json::from_value(serde_json::json!({
12284 "type": "permission_posture_activated",
12285 "session_id": session.id.clone(),
12286 "policy_revision": 31,
12287 "requested_mode": "default",
12288 "effective_mode": "default",
12289 "executor_mapping": "codex_exec:approval_policy=never",
12290 "audit_revision": u64::MAX,
12291 "transitioned_at": hostile_timestamp,
12292 }))
12293 .expect("unknown remote audit fields are ignored by the typed event");
12294 let seed = permission_posture_seed_from_event(&session, &event)
12295 .unwrap()
12296 .expect("permission event");
12297
12298 let host_revision =
12299 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12300 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12301 .expect("host-generated complete audit");
12302 assert_eq!(host_audit.audit_revision, host_revision);
12303 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12304 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12305 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12306 }
12307
12308 struct ActorFaultingPersistence {
12309 inner: Arc<bamboo_storage::LockedSessionStore>,
12310 fail_checkpoint_once: std::sync::atomic::AtomicBool,
12311 }
12312
12313 #[async_trait]
12314 impl RuntimeSessionPersistence for ActorFaultingPersistence {
12315 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12316 self.inner.merge_save_runtime(session).await
12317 }
12318
12319 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12320 if self
12321 .fail_checkpoint_once
12322 .swap(false, std::sync::atomic::Ordering::SeqCst)
12323 {
12324 return Err(std::io::Error::other("injected actor checkpoint failure"));
12325 }
12326 self.inner.checkpoint_runtime_session(session).await
12327 }
12328
12329 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12330 self.inner.storage().load_session(session_id).await
12331 }
12332 }
12333
12334 struct ActorFailBeforeAckInbox {
12335 inner: Arc<dyn SessionInboxPort>,
12336 fail_once: std::sync::atomic::AtomicBool,
12337 }
12338
12339 #[async_trait]
12340 impl SessionInboxPort for ActorFailBeforeAckInbox {
12341 async fn deliver(
12342 &self,
12343 envelope: &bamboo_domain::SessionMessageEnvelope,
12344 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12345 self.inner.deliver(envelope).await
12346 }
12347
12348 async fn mark_activation_eligible(
12349 &self,
12350 target_session_id: &str,
12351 generation: u64,
12352 policy: bamboo_domain::SessionActivationPolicy,
12353 ) -> Result<(), bamboo_domain::SessionInboxError> {
12354 self.inner
12355 .mark_activation_eligible(target_session_id, generation, policy)
12356 .await
12357 }
12358
12359 async fn claim(
12360 &self,
12361 target_session_id: &str,
12362 limit: usize,
12363 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12364 self.inner.claim(target_session_id, limit).await
12365 }
12366
12367 async fn was_admitted(
12368 &self,
12369 target_session_id: &str,
12370 id: &bamboo_domain::SessionMessageId,
12371 ) -> Result<bool, bamboo_domain::SessionInboxError> {
12372 self.inner.was_admitted(target_session_id, id).await
12373 }
12374
12375 async fn ack(
12376 &self,
12377 target_session_id: &str,
12378 claim: &SessionInboxClaim,
12379 ) -> Result<(), bamboo_domain::SessionInboxError> {
12380 if self
12381 .fail_once
12382 .swap(false, std::sync::atomic::Ordering::SeqCst)
12383 {
12384 return Err(bamboo_domain::SessionInboxError::Storage(
12385 "injected actor pre-ack failure".to_string(),
12386 ));
12387 }
12388 self.inner.ack(target_session_id, claim).await
12389 }
12390
12391 async fn inspect(
12392 &self,
12393 target_session_id: &str,
12394 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12395 self.inner.inspect(target_session_id).await
12396 }
12397 }
12398
12399 async fn actor_inbox_fixture(
12400 session_id: &str,
12401 ) -> (
12402 tempfile::TempDir,
12403 Arc<bamboo_storage::SessionStoreV2>,
12404 Arc<bamboo_storage::LockedSessionStore>,
12405 Arc<dyn SessionInboxPort>,
12406 Session,
12407 SessionInboxClaim,
12408 ) {
12409 let temp = tempfile::tempdir().unwrap();
12410 let store = Arc::new(
12411 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12412 .await
12413 .unwrap(),
12414 );
12415 let storage: Arc<dyn Storage> = store.clone();
12416 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12417 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12418 store.clone(),
12419 bamboo_domain::SessionInboxLimits::default(),
12420 ));
12421 let session = Session::new(session_id, "model");
12422 store.save_session(&session).await.unwrap();
12423 let mut envelope =
12424 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12425 envelope.id =
12426 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12427 let receipt = inbox.deliver(&envelope).await.unwrap();
12428 inbox
12429 .mark_activation_eligible(
12430 session_id,
12431 receipt.generation,
12432 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12433 )
12434 .await
12435 .unwrap();
12436 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12437 (temp, store, locked, inbox, session, claim)
12438 }
12439
12440 fn actor_binding(
12441 store: Arc<bamboo_storage::SessionStoreV2>,
12442 inbox: Arc<dyn SessionInboxPort>,
12443 persistence: Arc<dyn RuntimeSessionPersistence>,
12444 ) -> SessionInboxRuntimeBinding {
12445 let storage: Arc<dyn Storage> = store;
12446 SessionInboxRuntimeBinding {
12447 router: SessionActivationRouter::new(),
12448 inbox,
12449 storage,
12450 persistence,
12451 parent_question_lock: None,
12452 }
12453 }
12454
12455 #[tokio::test]
12456 async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12457 let (_temp, store, locked, inbox, mut session, claim) =
12458 actor_inbox_fixture("unsupported-remote-inbox").await;
12459 let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12460 inner: inbox.clone(),
12461 fail_once: std::sync::atomic::AtomicBool::new(false),
12462 });
12463 let binding = actor_binding(store.clone(), wrapped, locked);
12464 let fence = ActorActivationFence {
12465 schema_version: 1,
12466 actor_id: session.id.clone(),
12467 activation_id: "unsupported-inbox-activation".into(),
12468 attempt: 1,
12469 run_id: "unsupported-inbox-run".into(),
12470 lease_owner: "unsupported-inbox-host".into(),
12471 lease_epoch: 1,
12472 };
12473 let before = session.clone();
12474
12475 let error = checkpoint_claim_context_before_dispatch(
12476 &binding,
12477 &mut session,
12478 &[claim],
12479 Some((store.as_ref(), &fence)),
12480 )
12481 .await
12482 .unwrap_err();
12483 assert!(error
12484 .to_string()
12485 .contains("requires canonical FileSessionInbox"));
12486 assert_eq!(
12487 serde_json::to_value(&session.messages).unwrap(),
12488 serde_json::to_value(&before.messages).unwrap()
12489 );
12490 assert_eq!(
12491 serde_json::to_value(
12492 &store
12493 .load_session(&session.id)
12494 .await
12495 .unwrap()
12496 .unwrap()
12497 .messages
12498 )
12499 .unwrap(),
12500 serde_json::to_value(&before.messages).unwrap()
12501 );
12502 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12503 }
12504
12505 #[tokio::test]
12506 async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12507 let (_temp, store, locked, inbox, parent, _claim) =
12508 actor_inbox_fixture("remote-parent").await;
12509 let child = Session::new_child_of("remote-child", &parent, "model", "child");
12510 store.save_session(&child).await.unwrap();
12511 let binding = actor_binding(store.clone(), inbox, locked);
12512 let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12513 assert_eq!(first.len(), 2);
12514 let mut changed = child.clone();
12515 changed.set_project_id_meta("different-project");
12516 assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12517 store.delete_session(&parent.id).await.unwrap();
12518 assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12519 }
12520
12521 #[tokio::test]
12522 async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12523 let (_temp, store, locked, inbox, root, _claim) =
12524 actor_inbox_fixture("deep-remote-root").await;
12525 let mut parent = root;
12526 for depth in 1..=6 {
12527 let child =
12528 Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12529 store.save_session(&child).await.unwrap();
12530 parent = child;
12531 }
12532 let binding = actor_binding(store, inbox, locked);
12533 let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12534 assert_eq!(chain.len(), 7);
12535 assert_eq!(chain.last().unwrap().spawn_depth, 0);
12536 }
12537
12538 #[tokio::test]
12539 async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12540 let (_temp, store, locked, inbox, parent, _claim) =
12541 actor_inbox_fixture("creation-parent").await;
12542 let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12543 child.set_project_id_meta("creation-project");
12544 store.save_session(&child).await.unwrap();
12545 let binding = actor_binding(store.clone(), inbox, locked);
12546 let job = SpawnJob {
12547 parent_session_id: parent.id.clone(),
12548 child_session_id: child.id.clone(),
12549 model: "model".into(),
12550 disabled_tools: None,
12551 };
12552 let creation = canonical_child_creation(&binding, &child, &job)
12553 .await
12554 .unwrap();
12555 assert_eq!(creation.created_at, child.created_at);
12556 assert_eq!(creation.spawn_depth, child.spawn_depth);
12557 for damage in 0..7 {
12558 let mut held = child.clone();
12559 match damage {
12560 0 => held.created_at += chrono::Duration::nanoseconds(1),
12561 1 => held.parent_session_id = Some("foreign-parent".into()),
12562 2 => held.root_session_id = "foreign-root".into(),
12563 3 => held.spawn_depth += 1,
12564 4 => held.set_project_id_meta("foreign-project"),
12565 5 => held.kind = bamboo_domain::SessionKind::Root,
12566 _ => held.id = " creation-child ".into(),
12567 }
12568 assert!(
12569 canonical_child_creation(&binding, &held, &job)
12570 .await
12571 .is_err(),
12572 "damage {damage}"
12573 );
12574 }
12575 store.delete_session(&child.id).await.unwrap();
12576 assert!(canonical_child_creation(&binding, &child, &job)
12577 .await
12578 .is_err());
12579 let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12580 replacement.set_project_id_meta("creation-project");
12581 assert_ne!(replacement.created_at, child.created_at);
12582 store.save_session(&replacement).await.unwrap();
12583 assert!(
12584 canonical_child_creation(&binding, &child, &job)
12585 .await
12586 .is_err(),
12587 "old held spec cannot adopt replacement birth"
12588 );
12589 canonical_child_creation(&binding, &replacement, &job)
12590 .await
12591 .unwrap();
12592 }
12593
12594 #[tokio::test]
12595 async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12596 use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12597 for policies in [
12598 [
12599 SessionActivationPolicy::RespectSpecificWait,
12600 SessionActivationPolicy::InterruptSpecificWait,
12601 ],
12602 [
12603 SessionActivationPolicy::InterruptSpecificWait,
12604 SessionActivationPolicy::RespectSpecificWait,
12605 ],
12606 ] {
12607 let temp = tempfile::tempdir().unwrap();
12608 let store = Arc::new(
12609 bamboo_storage::SessionStoreV2::new(temp.path().into())
12610 .await
12611 .unwrap(),
12612 );
12613 let session_id = "actor-mixed-intent-policy";
12614 store
12615 .save_session(&Session::new(session_id, "model"))
12616 .await
12617 .unwrap();
12618 let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12619 let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12620 let staged_receipt = inbox.deliver(&staged).await.unwrap();
12621 let mut sent = Vec::new();
12622 for policy in policies {
12623 let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12624 let receipt = inbox
12625 .deliver_with_activation_intent(&envelope, policy, None)
12626 .await
12627 .unwrap();
12628 sent.push((envelope, policy, receipt));
12629 }
12630 let restarted = Arc::new(
12631 bamboo_storage::SessionStoreV2::new(temp.path().into())
12632 .await
12633 .unwrap(),
12634 );
12635 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12636 restarted.clone(),
12637 SessionInboxLimits::default(),
12638 ));
12639 let binding = actor_binding(
12640 restarted.clone(),
12641 inbox.clone(),
12642 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12643 );
12644 let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12645 let pairs =
12646 claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12647 .await
12648 .unwrap();
12649 assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12650 for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12651 assert_eq!(claim.envelope.id, envelope.id);
12652 assert_eq!(claim.generation, receipt.generation);
12653 assert_eq!(claim.activation_policy, *policy);
12654 assert_eq!(delivery.activation_policy, *policy);
12655 checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12656 .await
12657 .unwrap();
12658 }
12659 let restarted = Arc::new(
12660 bamboo_storage::SessionStoreV2::new(temp.path().into())
12661 .await
12662 .unwrap(),
12663 );
12664 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12665 restarted.clone(),
12666 SessionInboxLimits::default(),
12667 ));
12668 let binding = actor_binding(
12669 restarted.clone(),
12670 inbox.clone(),
12671 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12672 );
12673 session = restarted.load_session(session_id).await.unwrap().unwrap();
12674 for (envelope, policy, receipt) in &sent {
12675 assert_eq!(
12676 inbox
12677 .deliver_with_activation_intent(envelope, *policy, None)
12678 .await
12679 .unwrap(),
12680 *receipt
12681 );
12682 }
12683 assert!(
12684 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12685 .await
12686 .unwrap()
12687 .is_empty()
12688 );
12689 inbox
12690 .mark_activation_eligible(
12691 session_id,
12692 staged_receipt.generation,
12693 SessionActivationPolicy::RespectSpecificWait,
12694 )
12695 .await
12696 .unwrap();
12697 let pairs =
12698 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12699 .await
12700 .unwrap();
12701 assert_eq!(pairs.len(), 1);
12702 assert_eq!(pairs[0].0.envelope.id, staged.id);
12703 assert_eq!(
12704 pairs[0].1.activation_policy,
12705 SessionActivationPolicy::RespectSpecificWait
12706 );
12707 }
12708 }
12709
12710 #[tokio::test]
12711 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12712 let (_temp, store, locked, inbox, mut session, claim) =
12713 actor_inbox_fixture("actor-live-id-collision").await;
12714 let mut forged = claim.envelope.to_provider_message().unwrap();
12715 forged.metadata = Some(serde_json::json!({
12716 "session_message": {
12717 "id": claim.envelope.id,
12718 "target_session_id": "different-session"
12719 }
12720 }));
12721 session.add_message(forged);
12722 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12723 let binding = actor_binding(store, inbox.clone(), persistence);
12724
12725 assert!(
12726 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12727 .await
12728 .is_err()
12729 );
12730 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12731 assert!(!inbox
12732 .was_admitted(&session.id, &claim.envelope.id)
12733 .await
12734 .unwrap());
12735 }
12736
12737 #[tokio::test]
12738 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12739 let (_temp, store, locked, inbox, mut session, claim) =
12740 actor_inbox_fixture("actor-durable-id-collision").await;
12741 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12742 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12743 forged.id = claim.envelope.id.to_string();
12744 concurrent.add_message(forged);
12745 store.save_session(&concurrent).await.unwrap();
12746 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12747 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12748
12749 assert!(
12750 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12751 .await
12752 .is_err()
12753 );
12754 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12755 assert!(!inbox
12756 .was_admitted(&session.id, &claim.envelope.id)
12757 .await
12758 .unwrap());
12759 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12760 assert!(!durable.messages.iter().any(|message| {
12761 bamboo_domain::is_matching_session_message(message, &claim.envelope)
12762 }));
12763 }
12764
12765 #[tokio::test]
12766 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12767 let (_temp, store, locked, inbox, mut session, claim) =
12768 actor_inbox_fixture("actor-durable-typed-body-collision").await;
12769 let mut different = claim.envelope.clone();
12770 different.body = bamboo_domain::SessionMessageBody::Content(
12771 bamboo_domain::SessionMessageContent::text("forged actor body"),
12772 );
12773 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12774 concurrent.add_message(different.to_provider_message().unwrap());
12775 store.save_session(&concurrent).await.unwrap();
12776 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12777 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12778
12779 assert!(
12780 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12781 .await
12782 .is_err()
12783 );
12784 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12785 assert!(!inbox
12786 .was_admitted(&session.id, &claim.envelope.id)
12787 .await
12788 .unwrap());
12789 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12790 assert!(!durable
12791 .messages
12792 .iter()
12793 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12794 }
12795
12796 #[tokio::test]
12797 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12798 let (_temp, store, locked, inbox, mut session, claim) =
12799 actor_inbox_fixture("actor-checkpoint-failure").await;
12800 let envelope_id = claim.envelope.id.clone();
12801 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12802 inner: locked.clone(),
12803 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12804 });
12805 let binding = actor_binding(store.clone(), inbox.clone(), fault);
12806
12807 assert!(
12808 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12809 .await
12810 .is_err()
12811 );
12812 assert!(!session
12813 .messages
12814 .iter()
12815 .any(|message| message.id == envelope_id.as_str()));
12816 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12817 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12818
12819 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12820 store.clone(),
12821 bamboo_domain::SessionInboxLimits::default(),
12822 ));
12823 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12824 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12825 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12826 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12827 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12828 .await
12829 .unwrap();
12830 assert_eq!(
12831 restarted
12832 .messages
12833 .iter()
12834 .filter(|message| message.id == envelope_id.as_str())
12835 .count(),
12836 1
12837 );
12838 assert!(reopened
12839 .was_admitted(&session.id, &envelope_id)
12840 .await
12841 .unwrap());
12842 let backlog = reopened.inspect(&session.id).await.unwrap();
12843 assert_eq!(backlog.pending + backlog.claimed, 0);
12844 }
12845
12846 #[tokio::test]
12847 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12848 let (_temp, store, locked, real_inbox, mut session, claim) =
12849 actor_inbox_fixture("actor-pre-ack-failure").await;
12850 let envelope_id = claim.envelope.id.clone();
12851 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12852 inner: real_inbox.clone(),
12853 fail_once: std::sync::atomic::AtomicBool::new(true),
12854 });
12855 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12856 let binding = actor_binding(store.clone(), faulted, persistence);
12857
12858 assert!(
12859 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12860 .await
12861 .is_err()
12862 );
12863 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12864 assert_eq!(
12865 durable
12866 .messages
12867 .iter()
12868 .filter(|message| message.id == envelope_id.as_str())
12869 .count(),
12870 1
12871 );
12872 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12873 assert!(!real_inbox
12874 .was_admitted(&session.id, &envelope_id)
12875 .await
12876 .unwrap());
12877
12878 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12879 store.clone(),
12880 bamboo_domain::SessionInboxLimits::default(),
12881 ));
12882 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12883 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12884 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12885 let mut restarted = durable;
12886 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12887 .await
12888 .unwrap();
12889 assert_eq!(
12890 restarted
12891 .messages
12892 .iter()
12893 .filter(|message| message.id == envelope_id.as_str())
12894 .count(),
12895 1
12896 );
12897 assert!(reopened
12898 .was_admitted(&session.id, &envelope_id)
12899 .await
12900 .unwrap());
12901 let backlog = reopened.inspect(&session.id).await.unwrap();
12902 assert_eq!(backlog.pending + backlog.claimed, 0);
12903 }
12904
12905 struct ConfirmationSequenceLink {
12906 frames: VecDeque<ChildFrame>,
12907 sent: Vec<ParentFrame>,
12908 }
12909
12910 #[async_trait]
12911 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12912 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12913 self.sent.push(frame);
12914 Ok(())
12915 }
12916
12917 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12918 match self.frames.pop_front() {
12919 Some(frame) => Ok(Some(frame)),
12920 None => std::future::pending().await,
12921 }
12922 }
12923 }
12924
12925 #[tokio::test]
12926 async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12927 for case in [
12928 "accepted",
12929 "readonly-refusal",
12930 "prefix-refusal",
12931 "old-claim",
12932 "old-checkpoint",
12933 "succeeded",
12934 "changed-birth",
12935 "oversize",
12936 ] {
12937 let temp = tempfile::tempdir().unwrap();
12938 let store = Arc::new(
12939 bamboo_storage::SessionStoreV2::new(temp.path().into())
12940 .await
12941 .unwrap(),
12942 );
12943 let parent = Session::new("retry-parent", "model");
12944 store.save_session(&parent).await.unwrap();
12945 let mut child =
12946 Session::new_child_of("retry-child", &parent, "model", "original assignment");
12947 child.add_message(bamboo_agent_core::Message::user("original assignment"));
12948 store.save_session(&child).await.unwrap();
12949 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12950 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12951 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12952 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12953 store.clone(),
12954 bamboo_domain::SessionInboxLimits::default(),
12955 ));
12956 let binding = actor_binding(store.clone(), inbox, locked);
12957 let registration = binding
12958 .router
12959 .register_run(&child.id, "first-run")
12960 .await
12961 .unwrap();
12962 let first = PlainActorActivation::start(
12963 store.clone(),
12964 &child,
12965 &binding,
12966 Some("first-run"),
12967 Some(policy.clone()),
12968 true,
12969 None,
12970 )
12971 .await
12972 .unwrap();
12973 if matches!(case, "old-claim" | "old-checkpoint") {
12974 let old = bamboo_domain::SessionMessageEnvelope::user_input(
12975 &child.id,
12976 "unconfirmed old input",
12977 );
12978 let receipt = first.input_inbox.deliver(&old).await.unwrap();
12979 first
12980 .input_inbox
12981 .mark_activation_eligible(
12982 &child.id,
12983 receipt.generation,
12984 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12985 )
12986 .await
12987 .unwrap();
12988 let claim = first
12989 .claim_input(&binding, &child, "first-run")
12990 .await
12991 .unwrap()
12992 .unwrap();
12993 if case == "old-checkpoint" {
12994 first
12995 .checkpoint_input(&mut child, "first-run", &claim)
12996 .await
12997 .unwrap();
12998 }
12999 }
13000 first
13001 .finish(if case == "succeeded" {
13002 ActorActivationFinish::Succeeded
13003 } else {
13004 ActorActivationFinish::Failed
13005 })
13006 .await
13007 .unwrap();
13008 registration.abandon().await;
13010 assert!(
13011 !binding.router.owns_run(&child.id, "first-run").await,
13012 "{case}: abandoned first run must release its owner before retry"
13013 );
13014 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
13015 &child.id,
13016 if case == "oversize" {
13017 "x".repeat(8193)
13018 } else {
13019 "NEW_CORRECTION".into()
13020 },
13021 );
13022 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
13023 first
13024 .input_inbox
13025 .mark_activation_eligible(
13026 &child.id,
13027 receipt.generation,
13028 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13029 )
13030 .await
13031 .unwrap();
13032 let before = store.load_session(&child.id).await.unwrap().unwrap();
13033 let expected_messages = serde_json::to_value(&before.messages).unwrap();
13034 child = before.clone();
13035 if case == "prefix-refusal" {
13036 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
13037 }
13038 if case == "changed-birth" {
13039 child.created_at += chrono::Duration::milliseconds(1);
13040 }
13041 let _retry_registration = binding
13042 .router
13043 .register_run(&child.id, "retry-run")
13044 .await
13045 .unwrap();
13046 let result = PlainActorActivation::start_and_prepare(
13047 store.clone(),
13048 &mut child,
13049 &binding,
13050 Some("retry-run"),
13051 Some(policy.clone()),
13052 case != "readonly-refusal",
13053 None,
13054 )
13055 .await;
13056 if case != "accepted" {
13057 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
13058 let actual = store.load_session(&before.id).await.unwrap().unwrap();
13059 assert_eq!(
13060 serde_json::to_value(&actual.messages).unwrap(),
13061 expected_messages,
13062 "{case}: failed preparation preserves Main history"
13063 );
13064 assert!(
13065 !first
13066 .input_inbox
13067 .was_admitted(&before.id, &envelope.id)
13068 .await
13069 .unwrap(),
13070 "{case}: no ACK"
13071 );
13072 if case == "readonly-refusal" {
13073 let entry = store.inspect_actor(&before.id).await.unwrap();
13074 assert_eq!(entry.actor.current_attempt, 1);
13075 assert_eq!(
13076 entry.activation.unwrap().activation_id,
13077 first.fence.activation_id
13078 );
13079 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
13080 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
13081 }
13082 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
13083 assert_eq!(
13084 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
13085 1
13086 );
13087 assert_eq!(
13088 store
13089 .inspect_actor(&before.id)
13090 .await
13091 .unwrap()
13092 .activation
13093 .unwrap()
13094 .status,
13095 bamboo_domain::ActorActivationStatus::Failed
13096 );
13097 }
13098 if case == "oversize" {
13099 let reopened = Arc::new(
13100 bamboo_storage::SessionStoreV2::new(temp.path().into())
13101 .await
13102 .unwrap(),
13103 );
13104 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13105 reopened,
13106 bamboo_domain::SessionInboxLimits::default(),
13107 );
13108 let now = chrono::Utc::now();
13109 let leases = cold_inbox
13110 .inspect_owned_leases(&before.id, 2, now)
13111 .await
13112 .unwrap();
13113 assert_eq!(leases.len(), 1);
13114 assert_eq!(leases[0].failure_count, 1);
13115 assert_eq!(
13116 leases[0].last_error_code.as_deref(),
13117 Some("unsupported_actor_input")
13118 );
13119 let retry_after = leases[0].retry_after.unwrap();
13120 assert!(retry_after > now);
13121 let readiness = cold_inbox
13122 .inspect_wake_readiness(&before.id, now)
13123 .await
13124 .unwrap();
13125 assert!(
13126 readiness.ready.is_none(),
13127 "failed correction must not hot-loop"
13128 );
13129 assert_eq!(readiness.next_due_at, Some(retry_after));
13130 }
13131 continue;
13132 }
13133 let (retry, prepared) = result.unwrap();
13134 let (prefix, mut deliveries) = prepared.unwrap();
13135 assert_eq!(deliveries.len(), 1);
13136 let delivery = deliveries.pop().unwrap();
13137 assert_eq!(
13138 prefix,
13139 serde_json::to_value(&before.messages)
13140 .unwrap()
13141 .as_array()
13142 .unwrap()
13143 .clone()
13144 );
13145 assert_eq!(delivery.envelope, envelope);
13146 assert_eq!(delivery.activation_run_id, "retry-run");
13147 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13148 assert!(store
13149 .validate_fence(&first.fence, chrono::Utc::now())
13150 .await
13151 .is_err());
13152 let running = store.inspect_actor(&child.id).await.unwrap();
13153 assert_eq!(running.actor.current_attempt, 2);
13154 assert_eq!(
13155 running.activation.as_ref().unwrap().status,
13156 bamboo_domain::ActorActivationStatus::Running
13157 );
13158 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13159 let claim = retry.initial_inputs.first().unwrap();
13160 assert!(child
13161 .session_inbox_admission()
13162 .unwrap()
13163 .contains(&envelope.id));
13164 assert!(
13165 !retry
13166 .input_inbox
13167 .was_admitted(&child.id, &envelope.id)
13168 .await
13169 .unwrap(),
13170 "checkpoint is not startup confirmation"
13171 );
13172 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13173 confirmation.activation_run_id = "first-run".into();
13174 assert!(retry
13175 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13176 .await
13177 .is_err());
13178 assert!(!retry
13179 .input_inbox
13180 .was_admitted(&child.id, &envelope.id)
13181 .await
13182 .unwrap());
13183 confirmation.activation_run_id = "retry-run".into();
13184 retry
13185 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13186 .await
13187 .unwrap();
13188 let (tx, mut rx) = mpsc::channel(1);
13189 retry
13190 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13191 .await
13192 .unwrap();
13193 rx.recv().await.unwrap();
13194 retry
13195 .finish(ActorActivationFinish::Succeeded)
13196 .await
13197 .unwrap();
13198 let reopened = Arc::new(
13199 bamboo_storage::SessionStoreV2::new(temp.path().into())
13200 .await
13201 .unwrap(),
13202 );
13203 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13204 assert_eq!(cold.created_at, before.created_at);
13205 assert_eq!(cold.messages.len(), before.messages.len() + 2);
13206 assert_eq!(
13207 cold.messages[before.messages.len()].id,
13208 envelope.id.as_str()
13209 );
13210 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13211 assert_eq!(
13212 cold.messages
13213 .iter()
13214 .filter(|m| m.id == envelope.id.as_str())
13215 .count(),
13216 1
13217 );
13218 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13219 reopened,
13220 bamboo_domain::SessionInboxLimits::default(),
13221 );
13222 assert!(cold_inbox
13223 .was_admitted(&cold.id, &envelope.id)
13224 .await
13225 .unwrap());
13226 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13227 }
13228 }
13229
13230 #[tokio::test]
13231 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13232 for case in [
13233 "accepted",
13234 "actor-live",
13235 "claim-live",
13236 "legacy",
13237 "ack",
13238 "readonly",
13239 "prefix",
13240 "duplicate",
13241 "missing-marker",
13242 "mixed",
13243 "project",
13244 "birth",
13245 ] {
13246 let temp = tempfile::tempdir().unwrap();
13247 let store = Arc::new(
13248 bamboo_storage::SessionStoreV2::new(temp.path().into())
13249 .await
13250 .unwrap(),
13251 );
13252 let parent = Session::new("recovery-parent", "model");
13253 store.save_session(&parent).await.unwrap();
13254 let mut child =
13255 Session::new_child_of("recovery-child", &parent, "model", "original task");
13256 child.add_message(bamboo_agent_core::Message::user("original task"));
13257 store.save_session(&child).await.unwrap();
13258 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13259 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13260 let inbox = bamboo_storage::FileSessionInbox::new(
13261 store.clone(),
13262 bamboo_domain::SessionInboxLimits::default(),
13263 );
13264 let envelope =
13266 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13267 let receipt = inbox.deliver(&envelope).await.unwrap();
13268 inbox
13269 .mark_activation_eligible(
13270 &child.id,
13271 receipt.generation,
13272 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13273 )
13274 .await
13275 .unwrap();
13276 let now = chrono::Utc::now();
13277 let cutoff = now + chrono::Duration::seconds(10);
13278 let consumer = SessionInboxConsumerId::new();
13279 let old = store
13280 .claim_activation(&ActorActivationClaim {
13281 actor_id: child.id.clone(),
13282 run_id: "old-run".into(),
13283 lease_owner: consumer.as_str().into(),
13284 lease_expires_at: if case == "actor-live" {
13285 now + chrono::Duration::minutes(5)
13286 } else {
13287 cutoff
13288 },
13289 inbox_generation: 0,
13290 placement_ref: if case == "legacy" {
13291 None
13292 } else {
13293 Some(bamboo_domain::ActorPlacementRef {
13294 class: bamboo_domain::ActorPlacementClass::Local,
13295 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13296 slot_epoch: None,
13297 })
13298 },
13299 now,
13300 })
13301 .await
13302 .unwrap();
13303 store
13304 .start_activation(&old.fence(), chrono::Utc::now())
13305 .await
13306 .unwrap();
13307 let claim_now = chrono::Utc::now();
13308 let claim = inbox
13309 .claim_owned(
13310 &child.id,
13311 1,
13312 Some("old-run"),
13313 &SessionInboxLeaseRequest {
13314 consumer,
13315 now: claim_now,
13316 duration: if case == "claim-live" {
13317 chrono::Duration::minutes(5)
13318 } else {
13319 cutoff - claim_now
13320 },
13321 },
13322 )
13323 .await
13324 .unwrap()
13325 .pop()
13326 .unwrap();
13327 child = inbox
13328 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13329 fence: old.fence(),
13330 expected_created_at: child.created_at,
13331 expected_conversation_generation: child.conversation_generation_observation(),
13332 claim: claim.clone(),
13333 expected_messages: child.messages.clone(),
13334 expected_provider_transcript: child.provider_transcript.clone(),
13335 expected_admission: None,
13336 })
13337 .await
13338 .unwrap()
13339 .session;
13340 store
13342 .validate_fence(&old.fence(), chrono::Utc::now())
13343 .await
13344 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13345 let setup_leases = inbox
13346 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13347 .await
13348 .unwrap();
13349 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13350 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13351 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13352 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13353 let setup_at = chrono::Utc::now();
13354 assert!(
13355 setup_at < old.lease_expires_at,
13356 "{case}: setup Actor expired"
13357 );
13358 assert!(
13359 setup_at < claim.lease.expires_at,
13360 "{case}: setup claim expired"
13361 );
13362 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13363 if case == "ack" {
13364 inbox
13365 .ack_owned(&child.id, &claim, chrono::Utc::now())
13366 .await
13367 .unwrap();
13368 }
13369 if case == "mixed" {
13370 inbox
13371 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13372 &child.id,
13373 "different input",
13374 ))
13375 .await
13376 .unwrap();
13377 }
13378 let directory = store
13379 .bamboo_home_dir()
13380 .join(store.resolve_rel_path(&child.id).await.unwrap());
13381 let main = std::fs::read(directory.join("session.json")).unwrap();
13382 let canonical = child.clone();
13383 match case {
13384 "prefix" => child.messages[0].content.push_str(" stale"),
13385 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13386 "missing-marker" => {
13387 child
13388 .messages
13389 .last_mut()
13390 .unwrap()
13391 .metadata
13392 .as_mut()
13393 .unwrap()
13394 .as_object_mut()
13395 .unwrap()
13396 .remove("_bamboo_owned_input_checkpoint");
13397 }
13398 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13399 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13400 _ => {}
13401 }
13402 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13405 tokio::time::sleep(delay + Duration::from_millis(10)).await;
13406 let recovery_at = chrono::Utc::now();
13407 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13408 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13409 let original = before_recovery.activation.unwrap();
13410 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13411 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13412 assert_eq!(
13413 store
13414 .validate_fence(&old.fence(), recovery_at)
13415 .await
13416 .is_ok(),
13417 case == "actor-live",
13418 "{case}: Actor expiry control"
13419 );
13420 let recovery_leases = inbox
13421 .inspect_owned_leases(&child.id, 2, recovery_at)
13422 .await
13423 .unwrap();
13424 if case == "ack" {
13425 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13426 } else {
13427 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13428 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13429 assert_eq!(
13430 recovery_leases[0].expires_at, claim.lease.expires_at,
13431 "{case}"
13432 );
13433 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13434 }
13435 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13436 let second = Arc::new(
13437 bamboo_storage::SessionStoreV2::new(temp.path().into())
13438 .await
13439 .unwrap(),
13440 );
13441 let binding = actor_binding(
13442 second.clone(),
13443 Arc::new(bamboo_storage::FileSessionInbox::new(
13444 second.clone(),
13445 bamboo_domain::SessionInboxLimits::default(),
13446 )),
13447 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13448 );
13449 let _owner = binding
13450 .router
13451 .register_run(&child.id, "replacement-run")
13452 .await
13453 .unwrap();
13454 let prepared = PlainActorActivation::start_and_prepare(
13455 second.clone(),
13456 &mut child,
13457 &binding,
13458 Some("replacement-run"),
13459 Some(policy.clone()),
13460 case != "readonly",
13461 Some("probed-new-worker"),
13462 )
13463 .await;
13464 assert_eq!(
13465 std::fs::read(directory.join("session.json")).unwrap(),
13466 main,
13467 "{case}: Already or refusal never rewrites Main"
13468 );
13469 if case != "accepted" {
13470 assert!(prepared.is_err(), "{case}");
13471 assert_eq!(
13472 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13473 case == "ack"
13474 );
13475 continue;
13476 }
13477 let (replacement, delivery) = prepared.unwrap();
13478 assert!(replacement.recovering_pre_ack);
13479 let (messages, mut deliveries) = delivery.unwrap();
13480 assert_eq!(deliveries.len(), 1);
13481 let delivery = deliveries.pop().unwrap();
13482 let new_claim = replacement.initial_inputs.first().unwrap();
13483 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13484 assert_eq!(new_claim.claim.generation, claim.claim.generation);
13485 assert_eq!(
13486 new_claim.claim.activation_policy,
13487 claim.claim.activation_policy
13488 );
13489 assert!(new_claim.lease.epoch > claim.lease.epoch);
13490 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13491 assert_eq!(
13492 replacement.inbox_generation, 1,
13493 "old Actor generation was zero"
13494 );
13495 assert_eq!(
13496 second
13497 .inspect_actor(&child.id)
13498 .await
13499 .unwrap()
13500 .actor
13501 .current_attempt,
13502 2
13503 );
13504 assert!(second
13505 .validate_fence(&old.fence(), chrono::Utc::now())
13506 .await
13507 .is_err());
13508 assert!(inbox
13509 .ack_owned(&child.id, &claim, chrono::Utc::now())
13510 .await
13511 .is_err());
13512 let authority = second.inspect_actor(&child.id).await.unwrap();
13516 let leases = inbox
13517 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13518 .await
13519 .unwrap();
13520 let delayed = inbox
13521 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13522 fence: old.fence(),
13523 expected_created_at: child.created_at,
13524 expected_conversation_generation: child.conversation_generation_observation(),
13525 claim: claim.clone(),
13526 expected_messages: child.messages.clone(),
13527 expected_provider_transcript: child.provider_transcript.clone(),
13528 expected_admission: child.session_inbox_admission().cloned(),
13529 })
13530 .await;
13531 assert!(matches!(
13532 delayed,
13533 Err(bamboo_storage::ActorInputCheckpointError::Actor(
13534 bamboo_domain::ActorDirectoryError::StaleFence
13535 ))
13536 ));
13537 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13538 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13539 assert_eq!(
13540 inbox
13541 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13542 .await
13543 .unwrap(),
13544 leases
13545 );
13546 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13547 assert_eq!(
13548 serde_json::to_value(&child.messages).unwrap(),
13549 serde_json::to_value(&canonical.messages).unwrap()
13550 );
13551 let mut worker: Vec<bamboo_agent_core::Message> = messages
13552 .iter()
13553 .cloned()
13554 .map(|v| serde_json::from_value(v).unwrap())
13555 .collect();
13556 let target = worker.last_mut().unwrap();
13557 assert!(target
13558 .metadata
13559 .as_ref()
13560 .unwrap()
13561 .get("_bamboo_owned_input_checkpoint")
13562 .is_none());
13563 assert!(bamboo_domain::is_matching_session_message(
13564 target, &envelope
13565 ));
13566 let mut wanted = canonical.messages.last().unwrap().clone();
13567 wanted
13568 .metadata
13569 .as_mut()
13570 .unwrap()
13571 .as_object_mut()
13572 .unwrap()
13573 .remove("_bamboo_owned_input_checkpoint");
13574 assert_eq!(
13575 serde_json::to_value(target).unwrap(),
13576 serde_json::to_value(wanted).unwrap()
13577 );
13578 let run = RunSpec {
13579 assignment: "original task".into(),
13580 logical_session: Some(LogicalSessionIdentity {
13581 session_id: child.id.clone(),
13582 parent_session_id: child.parent_session_id.clone(),
13583 root_session_id: child.root_session_id.clone(),
13584 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13585 created_at: child.created_at,
13586 spawn_depth: child.spawn_depth,
13587 }),
13588 }),
13589 project_id: None,
13590 reasoning_effort: None,
13591 permission_policy: Some(PermissionPolicyContext {
13592 revision: policy.policy_revision(),
13593 requested_mode: "default".into(),
13594 effective_mode: "default".into(),
13595 bypass_permissions: false,
13596 auto_approve_permissions: false,
13597 session_id: child.id.clone(),
13598 workspace_path: child.workspace.clone(),
13599 environment_lease: None,
13600 inherit_session_grants: false,
13601 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13602 }),
13603 messages,
13604 activation_run_id: Some("replacement-run".into()),
13605 execution_epoch: 2,
13606 initial_session_messages: vec![delivery.clone()],
13607 secrets: Default::default(),
13608 };
13609 let policy_revision = policy.policy_revision();
13610 let mut expected = Some(expected_default_permission_posture(policy_revision));
13611 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13612 else {
13613 unreachable!()
13614 };
13615 let (events, mut rx) = mpsc::channel(1);
13616 process_actor_event(
13617 event,
13618 true,
13619 &mut PermissionPostureHandshake::Awaiting,
13620 expected.as_ref(),
13621 Some(&binding),
13622 &mut child,
13623 &events,
13624 &mut ActorEventDisplay::default(),
13625 )
13626 .await
13627 .unwrap();
13628 rx.recv().await.unwrap();
13629 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13630 &run,
13631 &delivery,
13632 uuid::Uuid::new_v4().to_string(),
13633 )
13634 .unwrap();
13635 replacement
13636 .release_initial_input(
13637 &binding,
13638 &child,
13639 &run,
13640 2,
13641 new_claim,
13642 &request,
13643 &mut expected,
13644 None,
13645 )
13646 .await
13647 .unwrap();
13648 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13649 replacement
13650 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13651 .await
13652 .unwrap();
13653 rx.recv().await.unwrap();
13654 replacement
13655 .finish(ActorActivationFinish::Succeeded)
13656 .await
13657 .unwrap();
13658 let cold = second.load_session(&child.id).await.unwrap().unwrap();
13659 assert_eq!(cold.created_at, canonical.created_at);
13660 assert_eq!(
13661 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13662 serde_json::to_value(&canonical.messages).unwrap()
13663 );
13664 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13665 assert_eq!(
13666 cold.messages
13667 .iter()
13668 .filter(|m| m.id == envelope.id.as_str())
13669 .count(),
13670 1
13671 );
13672 }
13673 }
13674
13675 #[cfg(unix)]
13676 #[tokio::test]
13677 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13678 use bamboo_subagent::provision::*;
13679 use std::os::unix::fs::PermissionsExt;
13680 let temp = tempfile::tempdir().unwrap();
13681 let worker = temp.path().join("mixed-worker");
13682 let spawned = temp.path().join("spawned");
13683 let run = temp.path().join("run");
13684 let probes = temp.path().join("probes");
13685 let fabric = temp.path().join("fabric");
13686 let mut report = WorkerCapabilityReport::current();
13687 report
13688 .capabilities
13689 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13690 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13691 let report = serde_json::to_string(&report).unwrap();
13692 std::fs::write(&worker, format!(
13693 "#!/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"
13694 )).unwrap();
13695 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13696 let args = vec![
13697 spawned.to_str().unwrap().into(),
13698 run.to_str().unwrap().into(),
13699 probes.to_str().unwrap().into(),
13700 ];
13701 for capability in [
13703 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13704 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13705 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13706 TYPED_READ_ONLY_WORKER_CAPABILITY,
13707 ] {
13708 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13709 .await
13710 .unwrap();
13711 }
13712 let runner = ActorChildRunner::new(
13713 "mixed".into(),
13714 worker,
13715 args,
13716 fabric.clone(),
13717 ExecutorSpec::BambooRuntime,
13718 vec![],
13719 "openai".into(),
13720 1,
13721 );
13722 let error = runner
13724 .require_initial_input_release_worker()
13725 .await
13726 .unwrap_err();
13727 assert!(error
13728 .to_string()
13729 .contains("owned initial release worker capability unconfirmed"));
13730 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13731 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13732 assert!(runner.pool.lock().await.is_empty());
13733 }
13734
13735 #[tokio::test]
13736 async fn ordinary_glob_only_observes_own_initialization_signals() {
13737 let temp = tempfile::tempdir().unwrap();
13738 let home = temp.path().canonicalize().unwrap();
13739 let store = bamboo_storage::SessionStoreV2::new(home.clone())
13740 .await
13741 .unwrap();
13742 let parent = Session::new("glob-standard-parent", "model");
13743 store.save_session(&parent).await.unwrap();
13744 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13745 store.save_session(&child).await.unwrap();
13746 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13747 let unrelated = directory.parent().unwrap().join("unrelated");
13750 std::fs::create_dir(&unrelated).unwrap();
13751 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13752 require_unowned_glob(&store, &child).await.unwrap();
13753 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13754 let path = directory.join(name);
13755 std::fs::write(&path, b"invalid prior signal").unwrap();
13756 assert!(require_unowned_glob(&store, &child).await.is_err());
13757 std::fs::remove_file(&path).unwrap();
13758 }
13759 #[cfg(unix)]
13760 {
13761 let marker = directory.join("actor-authority.initialized.json");
13762 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13763 assert!(require_unowned_glob(&store, &child).await.is_err());
13764 }
13765 }
13766
13767 #[test]
13768 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13769 let call = bamboo_domain::ToolCall {
13770 id: "glob".into(),
13771 tool_type: "function".into(),
13772 function: bamboo_domain::FunctionCall {
13773 name: "Glob".into(),
13774 arguments: r#"{"pattern":"marker.txt"}"#.into(),
13775 },
13776 };
13777 let messages = [
13778 bamboo_agent_core::Message::assistant("", Some(vec![call])),
13779 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13780 bamboo_agent_core::Message::assistant("done", None),
13781 ];
13782 let start = serde_json::to_value(AgentEvent::ToolStart {
13783 tool_call_id: "glob".into(),
13784 tool_name: "Glob".into(),
13785 arguments: serde_json::json!({"pattern":"marker.txt"}),
13786 })
13787 .unwrap();
13788 let complete = serde_json::to_value(AgentEvent::ToolComplete {
13789 tool_call_id: "glob".into(),
13790 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13791 })
13792 .unwrap();
13793 let tail =
13794 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13795 messages: messages
13796 .iter()
13797 .map(serde_json::to_value)
13798 .collect::<Result<_, _>>()
13799 .unwrap(),
13800 })
13801 .unwrap();
13802 let mut ordered = ReadOnlyActorCollector::default();
13803 assert!(ordered.event(&start).unwrap());
13804 assert!(ordered.event(&complete).unwrap());
13805 assert!(!ordered.event(&tail).unwrap());
13806 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13807 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13808 assert!(ReadOnlyActorCollector::default()
13809 .finish(Some("done"))
13810 .is_err());
13811 let mut unfinished = ReadOnlyActorCollector::default();
13812 unfinished.event(&start).unwrap();
13813 unfinished.event(&complete).unwrap();
13814 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13815 let mut closed = tail.clone();
13816 closed["grant"] = true.into();
13817 assert!(
13818 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13819 .is_err()
13820 );
13821 let mut oversized = tail.clone();
13822 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13823 assert!(
13824 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13825 .unwrap()
13826 .validate()
13827 .is_err()
13828 );
13829 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13830 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13831 let mut bad = start.clone();
13832 bad["tool_name"] = "Bash".into();
13833 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13834 let mut duplicate = ReadOnlyActorCollector::default();
13835 duplicate.event(&start).unwrap();
13836 assert!(duplicate.event(&start).is_err());
13837 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13838 content: "hidden".into(),
13839 })
13840 .unwrap();
13841 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13842 let mut mismatched = ReadOnlyActorCollector::default();
13843 mismatched.event(&start).unwrap();
13844 let mut bad = complete.clone();
13845 bad["tool_call_id"] = "foreign".into();
13846 assert!(mismatched.event(&bad).is_err());
13847 mismatched.event(&complete).unwrap();
13848 let mut changed = tail.clone();
13849 changed["messages"][1]["content"] = "fabricated".into();
13850 assert!(mismatched.event(&changed).is_err());
13851 }
13852
13853 #[cfg(unix)]
13854 #[tokio::test]
13855 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13856 let temp = tempfile::tempdir().unwrap();
13857 let store = Arc::new(
13858 bamboo_storage::SessionStoreV2::new(temp.path().into())
13859 .await
13860 .unwrap(),
13861 );
13862 let parent = Session::new("release-host-parent", "model");
13863 store.save_session(&parent).await.unwrap();
13864 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13865 store.save_session(&child).await.unwrap();
13866 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13867 store.clone(),
13868 bamboo_domain::SessionInboxLimits::default(),
13869 ));
13870 let binding = actor_binding(
13871 store.clone(),
13872 inbox,
13873 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13874 );
13875 let _owner = binding
13876 .router
13877 .register_run(&child.id, "release-host-run")
13878 .await
13879 .unwrap();
13880 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13881 let revision = policy.policy_revision();
13882 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13883 let activation = PlainActorActivation::start(
13884 store.clone(),
13885 &child,
13886 &binding,
13887 Some("release-host-run"),
13888 Some(policy.clone()),
13889 true,
13890 Some("probed-physical-worker"),
13891 )
13892 .await
13893 .unwrap();
13894 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13895 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13896 activation
13897 .input_inbox
13898 .mark_activation_eligible(
13899 &child.id,
13900 receipt.generation,
13901 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13902 )
13903 .await
13904 .unwrap();
13905 let claim = activation
13906 .claim_input(&binding, &child, "release-host-run")
13907 .await
13908 .unwrap()
13909 .unwrap();
13910 let (prefix, delivery) = activation
13911 .checkpoint_input(&mut child, "release-host-run", &claim)
13912 .await
13913 .unwrap();
13914 let mut expected = Some(expected_default_permission_posture(revision));
13915 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13916 unreachable!()
13917 };
13918 let (events, mut events_rx) = mpsc::channel(1);
13919 process_actor_event(
13920 event,
13921 true,
13922 &mut PermissionPostureHandshake::Awaiting,
13923 expected.as_ref(),
13924 Some(&binding),
13925 &mut child,
13926 &events,
13927 &mut ActorEventDisplay::default(),
13928 )
13929 .await
13930 .unwrap();
13931 events_rx.recv().await.unwrap();
13932 let run = RunSpec {
13933 assignment: "assignment".into(),
13934 logical_session: Some(LogicalSessionIdentity {
13935 session_id: child.id.clone(),
13936 parent_session_id: child.parent_session_id.clone(),
13937 root_session_id: child.root_session_id.clone(),
13938 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13939 created_at: child.created_at,
13940 spawn_depth: child.spawn_depth,
13941 }),
13942 }),
13943 project_id: None,
13944 reasoning_effort: None,
13945 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13946 revision,
13947 requested_mode: "default".into(),
13948 effective_mode: "default".into(),
13949 bypass_permissions: false,
13950 auto_approve_permissions: false,
13951 session_id: child.id.clone(),
13952 workspace_path: child.workspace.clone(),
13953 environment_lease: None,
13954 inherit_session_grants: false,
13955 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13956 }),
13957 messages: prefix,
13958 activation_run_id: Some("release-host-run".into()),
13959 execution_epoch: 9,
13960 initial_session_messages: vec![delivery.clone()],
13961 secrets: Default::default(),
13962 };
13963 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13964 &run,
13965 &delivery,
13966 uuid::Uuid::new_v4().to_string(),
13967 )
13968 .unwrap();
13969 let mut stale = child.clone();
13970 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13971 assert!(activation
13972 .release_initial_input(
13973 &binding,
13974 &stale,
13975 &run,
13976 9,
13977 &claim,
13978 &request,
13979 &mut expected,
13980 None
13981 )
13982 .await
13983 .is_err());
13984 assert!(!activation
13985 .input_inbox
13986 .was_admitted(&child.id, &envelope.id)
13987 .await
13988 .unwrap());
13989 use std::os::unix::fs::PermissionsExt;
13992 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13993 impl Drop for RestorePermissions {
13994 fn drop(&mut self) {
13995 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13996 }
13997 }
13998 let admitted = store
13999 .bamboo_home_dir()
14000 .join(store.resolve_rel_path(&child.id).await.unwrap())
14001 .join("inbox/admitted");
14002 std::fs::create_dir_all(&admitted).unwrap();
14003 let restore = RestorePermissions(
14004 admitted.clone(),
14005 std::fs::metadata(&admitted).unwrap().permissions(),
14006 );
14007 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
14008 let verified = activation
14009 .input_inbox
14010 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
14011 fence: activation.fence.clone(),
14012 expected_created_at: child.created_at,
14013 expected_conversation_generation: child.conversation_generation_observation(),
14014 claim: claim.clone(),
14015 expected_messages: child.messages.clone(),
14016 expected_provider_transcript: child.provider_transcript.clone(),
14017 expected_admission: child.session_inbox_admission().cloned(),
14018 })
14019 .await
14020 .unwrap();
14021 assert_eq!(
14022 verified.status,
14023 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
14024 );
14025 let error = activation
14026 .release_initial_input(
14027 &binding,
14028 &child,
14029 &run,
14030 9,
14031 &claim,
14032 &request,
14033 &mut expected,
14034 None,
14035 )
14036 .await
14037 .unwrap_err();
14038 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
14039 drop(restore);
14040 assert!(
14041 error.to_string().contains("ACK unresolved"),
14042 "actual release error (<=512B): {}",
14043 error.to_string().chars().take(128).collect::<String>()
14044 );
14045 let release = activation
14046 .release_initial_input(
14047 &binding,
14048 &child,
14049 &run,
14050 9,
14051 &claim,
14052 &request,
14053 &mut expected,
14054 None,
14055 )
14056 .await
14057 .unwrap();
14058 assert!(activation
14059 .input_inbox
14060 .was_admitted(&child.id, &envelope.id)
14061 .await
14062 .unwrap());
14063 assert_eq!(
14064 release.expires_at,
14065 activation
14066 .actor_deadline
14067 .min(claim.lease.expires_at)
14068 .min(activation.input_deadline)
14069 );
14070 assert_eq!(
14071 activation
14072 .release_initial_input(
14073 &binding,
14074 &child,
14075 &run,
14076 9,
14077 &claim,
14078 &request,
14079 &mut expected,
14080 Some(&release)
14081 )
14082 .await
14083 .unwrap(),
14084 release
14085 );
14086 let mut wrong = request.clone();
14087 wrong.nonce = uuid::Uuid::new_v4().to_string();
14088 assert!(activation
14089 .release_initial_input(
14090 &binding,
14091 &child,
14092 &run,
14093 9,
14094 &claim,
14095 &wrong,
14096 &mut expected,
14097 Some(&release)
14098 )
14099 .await
14100 .is_err());
14101 assert!(activation
14102 .release_initial_input(
14103 &binding,
14104 &child,
14105 &run,
14106 10,
14107 &claim,
14108 &request,
14109 &mut expected,
14110 Some(&release)
14111 )
14112 .await
14113 .is_err());
14114 let deadlines = (
14116 activation.fence.clone(),
14117 activation.actor_deadline,
14118 activation.input_deadline,
14119 );
14120 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14121 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14122 activation
14123 .input_inbox
14124 .mark_activation_eligible(
14125 &child.id,
14126 receipt.generation,
14127 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14128 )
14129 .await
14130 .unwrap();
14131 let mut link = ConfirmationSequenceLink {
14132 frames: VecDeque::new(),
14133 sent: Vec::new(),
14134 };
14135 let epochs = AtomicU64::new(9);
14136 let (claim2, epoch2) = activation
14137 .continue_input(
14138 &mut link,
14139 &binding,
14140 &mut child,
14141 "release-host-run",
14142 Some((&run, &epochs)),
14143 &mut expected,
14144 "SECOND_REPLY".into(),
14145 &events,
14146 )
14147 .await
14148 .unwrap()
14149 .unwrap();
14150 events_rx.recv().await.unwrap();
14151 assert_eq!(epoch2, 10);
14152 let ParentFrame::Run(next) = &link.sent[0] else {
14153 panic!("second correlated Run")
14154 };
14155 assert_eq!(next.logical_session, run.logical_session);
14156 assert_eq!(next.activation_run_id, run.activation_run_id);
14157 assert_eq!(next.initial_session_messages.len(), 1);
14158 assert!(next
14159 .messages
14160 .iter()
14161 .any(|m| m["id"] == envelope.id.as_str()));
14162 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14163 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14164 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14165 next,
14166 &next.initial_session_messages[0],
14167 uuid::Uuid::new_v4().to_string(),
14168 )
14169 .unwrap();
14170 assert_ne!(request2.nonce, request.nonce);
14171 macro_rules! release_second {
14172 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14173 activation
14174 .release_initial_input(
14175 &binding,
14176 $candidate,
14177 next,
14178 $epoch,
14179 &claim2,
14180 $request,
14181 &mut expected,
14182 $previous,
14183 )
14184 .await
14185 };
14186 }
14187 assert!(release_second!(&child, &request, epoch2, None).is_err());
14188 assert!(release_second!(&child, &request2, 9, None).is_err());
14189 let mut stale2 = child.clone();
14190 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14191 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14192 assert!(!activation
14193 .input_inbox
14194 .was_admitted(&child.id, &second.id)
14195 .await
14196 .unwrap());
14197 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14198 assert_eq!(
14199 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14200 release2
14201 );
14202 let mut wrong2 = request2.clone();
14203 wrong2.nonce = request.nonce.clone();
14204 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14205 assert!(activation
14206 .input_inbox
14207 .was_admitted(&child.id, &second.id)
14208 .await
14209 .unwrap());
14210 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14211 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14212 activation
14213 .input_inbox
14214 .mark_activation_eligible(
14215 &child.id,
14216 receipt.generation,
14217 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14218 )
14219 .await
14220 .unwrap();
14221 let now = chrono::Utc::now();
14222 let expired_claim = activation
14223 .input_inbox
14224 .claim_owned(
14225 &child.id,
14226 1,
14227 Some("release-host-run"),
14228 &SessionInboxLeaseRequest {
14229 consumer: activation.input_consumer.clone(),
14230 now,
14231 duration: chrono::Duration::seconds(1),
14232 },
14233 )
14234 .await
14235 .unwrap()
14236 .pop()
14237 .unwrap();
14238 let (prefix, delivery3) = activation
14239 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14240 .await
14241 .unwrap();
14242 let mut expired_run = next.clone();
14243 expired_run.execution_epoch = 11;
14244 expired_run.messages = prefix;
14245 expired_run.initial_session_messages = vec![delivery3.clone()];
14246 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14247 &expired_run,
14248 &delivery3,
14249 uuid::Uuid::new_v4().to_string(),
14250 )
14251 .unwrap();
14252 tokio::time::sleep(
14253 (expired_claim.lease.expires_at - chrono::Utc::now())
14254 .to_std()
14255 .unwrap_or_default(),
14256 )
14257 .await;
14258 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14259 assert!(activation
14260 .release_initial_input(
14261 &binding,
14262 &child,
14263 &expired_run,
14264 11,
14265 &expired_claim,
14266 &expired_request,
14267 &mut expected,
14268 None
14269 )
14270 .await
14271 .is_err());
14272 assert!(!activation
14273 .input_inbox
14274 .was_admitted(&child.id, &expired.id)
14275 .await
14276 .unwrap());
14277 assert_eq!(
14278 (
14279 activation.fence.clone(),
14280 activation.actor_deadline,
14281 activation.input_deadline
14282 ),
14283 deadlines
14284 );
14285 assert_eq!(
14286 link.sent.len(),
14287 1,
14288 "no dispatch from rejected release checks"
14289 );
14290 let cold = store.load_session(&child.id).await.unwrap().unwrap();
14291 for id in [&envelope.id, &second.id] {
14292 assert_eq!(
14293 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14294 1
14295 );
14296 assert!(cold.session_inbox_admission().unwrap().contains(id));
14297 }
14298 policy.deny_scoped_session_permission(
14300 &child.id,
14301 bamboo_tools::permission::PermissionType::ExecuteCommand,
14302 "blocked-action",
14303 );
14304 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14305 assert!(activation
14306 .input_inbox
14307 .was_admitted(&child.id, &second.id)
14308 .await
14309 .unwrap());
14310 activation
14311 .finish(ActorActivationFinish::Failed)
14312 .await
14313 .unwrap();
14314 activation
14316 .input_inbox
14317 .ack_owned(&child.id, &claim, chrono::Utc::now())
14318 .await
14319 .unwrap();
14320 assert!(activation
14321 .release_initial_input(
14322 &binding,
14323 &child,
14324 &run,
14325 9,
14326 &claim,
14327 &request,
14328 &mut expected,
14329 Some(&release)
14330 )
14331 .await
14332 .is_err());
14333 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14334 .await
14335 .unwrap()
14336 .load_session(&child.id)
14337 .await
14338 .unwrap()
14339 .unwrap();
14340 assert_eq!(
14341 cold.messages
14342 .iter()
14343 .filter(|m| m.id == envelope.id.as_str())
14344 .count(),
14345 1
14346 );
14347 }
14348
14349 #[tokio::test]
14350 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14351 for reject_checkpoint in [false, true] {
14352 let temp = tempfile::tempdir().unwrap();
14353 let store = Arc::new(
14354 bamboo_storage::SessionStoreV2::new(temp.path().into())
14355 .await
14356 .unwrap(),
14357 );
14358 let parent = Session::new("owned-input-parent", "model");
14359 store.save_session(&parent).await.unwrap();
14360 let mut child =
14361 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14362 store.save_session(&child).await.unwrap();
14363 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14364 let raw_storage: Arc<dyn Storage> = store.clone();
14365 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14366 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14367 store.clone(),
14368 bamboo_domain::SessionInboxLimits::default(),
14369 ));
14370 let binding = actor_binding(store.clone(), inbox, locked);
14371 let run_id = "owned-input-run";
14372 let _registration = binding
14373 .router
14374 .register_run(&child.id, run_id)
14375 .await
14376 .unwrap();
14377 assert!(PlainActorActivation::start(
14378 store.clone(),
14379 &child,
14380 &binding,
14381 Some(run_id),
14382 None,
14383 true,
14384 None,
14385 )
14386 .await
14387 .is_err());
14388 assert!(PlainActorActivation::start(
14389 store.clone(),
14390 &child,
14391 &binding,
14392 Some(run_id),
14393 Some(policy.clone()),
14394 true,
14395 None,
14396 )
14397 .await
14398 .is_err());
14399 assert_eq!(
14400 store
14401 .inspect_actor(&child.id)
14402 .await
14403 .unwrap()
14404 .actor
14405 .current_attempt,
14406 0
14407 );
14408 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14409 let activation = PlainActorActivation::start(
14410 store.clone(),
14411 &child,
14412 &binding,
14413 Some(run_id),
14414 Some(policy.clone()),
14415 true,
14416 None,
14417 )
14418 .await
14419 .unwrap();
14420 let envelope =
14421 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14422 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14423 activation
14424 .input_inbox
14425 .mark_activation_eligible(
14426 &child.id,
14427 receipt.generation,
14428 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14429 )
14430 .await
14431 .unwrap();
14432 let mut link = ConfirmationSequenceLink {
14433 frames: VecDeque::new(),
14434 sent: Vec::new(),
14435 };
14436 let policy_revision = policy.policy_revision();
14437 let run = RunSpec {
14438 assignment: "one plain task".into(),
14439 logical_session: None,
14440 project_id: None,
14441 reasoning_effort: None,
14442 permission_policy: Some(PermissionPolicyContext {
14443 revision: policy_revision,
14444 requested_mode: "default".into(),
14445 effective_mode: "default".into(),
14446 bypass_permissions: false,
14447 auto_approve_permissions: false,
14448 session_id: child.id.clone(),
14449 workspace_path: child.workspace.clone(),
14450 environment_lease: None,
14451 inherit_session_grants: false,
14452 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14453 }),
14454 messages: vec![],
14455 activation_run_id: Some(run_id.into()),
14456 execution_epoch: 1,
14457 initial_session_messages: vec![],
14458 secrets: Default::default(),
14459 };
14460 let epochs = AtomicU64::new(1);
14461 let (tx, mut rx) = mpsc::channel(1);
14462 let mut expected = Some(expected_default_permission_posture(policy_revision));
14463 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14464 else {
14465 unreachable!()
14466 };
14467 process_actor_event(
14468 event,
14469 true,
14470 &mut PermissionPostureHandshake::Awaiting,
14471 expected.as_ref(),
14472 Some(&binding),
14473 &mut child,
14474 &tx,
14475 &mut ActorEventDisplay::default(),
14476 )
14477 .await
14478 .unwrap();
14479 rx.recv().await.unwrap();
14480 let first_audit =
14481 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14482 .unwrap()
14483 .audit_revision;
14484 if reject_checkpoint {
14485 tx.send(AgentEvent::Complete {
14488 usage: Default::default(),
14489 })
14490 .await
14491 .unwrap();
14492 }
14493 let mut operation = activation.continue_input(
14494 &mut link,
14495 &binding,
14496 &mut child,
14497 run_id,
14498 Some((&run, &epochs)),
14499 &mut expected,
14500 "FIRST_PLAIN_REPLY".into(),
14501 &tx,
14502 );
14503 if reject_checkpoint {
14504 tokio::select! {
14505 result = &mut operation => panic!("publication barrier released early: {result:?}"),
14506 _ = async {
14507 tokio::time::timeout(Duration::from_secs(5), async {
14508 loop {
14509 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14510 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14511 tokio::task::yield_now().await;
14512 }
14513 }).await.unwrap();
14514 activation.finish(ActorActivationFinish::Failed).await.unwrap();
14515 rx.recv().await.unwrap();
14516 } => {}
14517 }
14518 }
14519 let result = operation.await;
14520 if reject_checkpoint {
14521 assert!(result
14522 .unwrap_err()
14523 .to_string()
14524 .contains("checkpoint unconfirmed"));
14525 assert!(
14526 link.sent.is_empty(),
14527 "zero worker/provider correction dispatch"
14528 );
14529 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14530 assert!(durable
14531 .messages
14532 .iter()
14533 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14534 assert!(!durable
14535 .messages
14536 .iter()
14537 .any(|m| m.id == envelope.id.as_str()));
14538 assert!(!activation
14539 .input_inbox
14540 .was_admitted(&child.id, &envelope.id)
14541 .await
14542 .unwrap());
14543 assert_eq!(
14544 activation
14545 .input_inbox
14546 .inspect(&child.id)
14547 .await
14548 .unwrap()
14549 .claimed,
14550 1
14551 );
14552 continue;
14553 }
14554 let (claim, epoch) = result.unwrap().unwrap();
14555 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
14557 assert_eq!(link.sent.len(), 1);
14558 let ParentFrame::Run(next) = &link.sent[0] else {
14559 panic!("correlated continuation Run")
14560 };
14561 assert_eq!(next.execution_epoch, 2);
14562 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14563 assert!(next
14564 .messages
14565 .iter()
14566 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14567 assert!(!next
14568 .messages
14569 .iter()
14570 .any(|m| m["id"] == envelope.id.as_str()));
14571 let delivery = &next.initial_session_messages[0];
14572 assert_eq!(delivery.envelope, envelope);
14573 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14574 assert!(durable
14575 .session_inbox_admission()
14576 .unwrap()
14577 .contains(&envelope.id));
14578 assert_eq!(
14579 durable
14580 .messages
14581 .iter()
14582 .filter(|m| m.id == envelope.id.as_str())
14583 .count(),
14584 1
14585 );
14586 assert!(
14587 !activation
14588 .input_inbox
14589 .was_admitted(&child.id, &envelope.id)
14590 .await
14591 .unwrap(),
14592 "checkpoint is not a worker admission receipt"
14593 );
14594 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14595 confirmation.canonical_claim_generation += 1;
14596 assert!(activation
14597 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14598 .await
14599 .is_err());
14600 assert!(!activation
14601 .input_inbox
14602 .was_admitted(&child.id, &envelope.id)
14603 .await
14604 .unwrap());
14605 confirmation.canonical_claim_generation = claim.claim.generation;
14606 activation
14607 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14608 .await
14609 .unwrap();
14610 assert!(activation
14611 .input_inbox
14612 .was_admitted(&child.id, &envelope.id)
14613 .await
14614 .unwrap());
14615 assert_eq!(
14616 expected.as_ref().unwrap().expected_audit_revision,
14617 Some(first_audit)
14618 );
14619 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14620 else {
14621 unreachable!()
14622 };
14623 process_actor_event(
14624 event,
14625 true,
14626 &mut PermissionPostureHandshake::Awaiting,
14627 expected.as_ref(),
14628 Some(&binding),
14629 &mut child,
14630 &tx,
14631 &mut ActorEventDisplay::default(),
14632 )
14633 .await
14634 .unwrap();
14635 rx.recv().await.unwrap();
14636 assert!(
14637 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14638 .unwrap()
14639 .audit_revision
14640 > first_audit
14641 );
14642 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14643 .await
14644 .unwrap();
14645 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14646 assert_eq!(
14647 cold.messages
14648 .iter()
14649 .filter(|m| m.id == envelope.id.as_str())
14650 .count(),
14651 1
14652 );
14653 assert!(cold
14654 .session_inbox_admission()
14655 .unwrap()
14656 .contains(&envelope.id));
14657 }
14658 }
14659
14660 fn admission_confirmation(
14661 session_id: &str,
14662 claim: &SessionInboxClaim,
14663 run_id: &str,
14664 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14665 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14666 target_session_id: session_id.to_string(),
14667 envelope_id: claim.envelope.id.to_string(),
14668 canonical_claim_generation: claim.generation,
14669 activation_run_id: run_id.to_string(),
14670 }
14671 }
14672
14673 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14674 ExpectedPermissionPosture {
14675 policy_revision,
14676 resolution: bamboo_domain::PermissionModeResolution {
14677 requested: bamboo_domain::SessionPermissionMode::Default,
14678 effective: bamboo_domain::PermissionMode::Default,
14679 },
14680 expected_audit_revision: None,
14681 executor_mapping: "test_actor:permission_mode=default".to_string(),
14682 }
14683 }
14684
14685 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14686 ChildFrame::Event {
14687 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14688 session_id: session_id.to_string(),
14689 policy_revision,
14690 requested_mode: "default".to_string(),
14691 effective_mode: "default".to_string(),
14692 executor_mapping: "test_actor:permission_mode=default".to_string(),
14693 })
14694 .expect("serialize permission posture event"),
14695 }
14696 }
14697
14698 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14699 ChildFrame::Event {
14700 event: serde_json::to_value(event).expect("serialize actor event"),
14701 }
14702 }
14703
14704 #[test]
14705 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14706 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14707 let mut batch = ActorEventBatch {
14708 logical_session: Some(LogicalSessionIdentity {
14709 creation: None,
14710 session_id: session.id.clone(),
14711 parent_session_id: session.parent_session_id.clone(),
14712 root_session_id: session.root_session_id.clone(),
14713 }),
14714 activation_id: Some("activation-7".into()),
14715 execution_epoch: 11,
14716 source_node_id: Some("node-a".into()),
14717 source_actor_id: Some("worker-a".into()),
14718 first_seq: 1,
14719 last_seq: 1,
14720 qos: bamboo_subagent::ActorEventQos::Ephemeral,
14721 events: vec![serde_json::json!({"type":"token","content":"ok"})],
14722 };
14723 validate_actor_event_batch(
14724 &batch,
14725 &session,
14726 "logical-parent",
14727 Some("activation-7"),
14728 11,
14729 "worker-a",
14730 None,
14731 )
14732 .unwrap();
14733
14734 let creation = bamboo_subagent::proto::ChildCreationIdentity {
14735 created_at: session.created_at,
14736 spawn_depth: session.spawn_depth,
14737 };
14738 assert!(validate_actor_event_batch(
14739 &batch,
14740 &session,
14741 "logical-parent",
14742 Some("activation-7"),
14743 11,
14744 "worker-a",
14745 Some(&creation)
14746 )
14747 .is_err());
14748 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14749 validate_actor_event_batch(
14750 &batch,
14751 &session,
14752 "logical-parent",
14753 Some("activation-7"),
14754 11,
14755 "worker-a",
14756 Some(&creation),
14757 )
14758 .unwrap();
14759 batch
14760 .logical_session
14761 .as_mut()
14762 .unwrap()
14763 .creation
14764 .as_mut()
14765 .unwrap()
14766 .created_at += chrono::Duration::nanoseconds(1);
14767 assert!(validate_actor_event_batch(
14768 &batch,
14769 &session,
14770 "logical-parent",
14771 Some("activation-7"),
14772 11,
14773 "worker-a",
14774 Some(&creation)
14775 )
14776 .is_err());
14777 batch.logical_session.as_mut().unwrap().creation = None;
14778
14779 batch.execution_epoch = 10;
14780 assert!(validate_actor_event_batch(
14781 &batch,
14782 &session,
14783 "logical-parent",
14784 Some("activation-7"),
14785 11,
14786 "worker-a",
14787 None,
14788 )
14789 .unwrap_err()
14790 .to_string()
14791 .contains("execution epoch"));
14792 batch.execution_epoch = 11;
14793 batch.activation_id = Some("replaced-activation".into());
14794 assert!(validate_actor_event_batch(
14795 &batch,
14796 &session,
14797 "logical-parent",
14798 Some("activation-7"),
14799 11,
14800 "worker-a",
14801 None,
14802 )
14803 .unwrap_err()
14804 .to_string()
14805 .contains("stale activation"));
14806 batch.activation_id = Some("activation-7".into());
14807 batch
14808 .logical_session
14809 .as_mut()
14810 .expect("logical identity")
14811 .session_id = "foreign-child".into();
14812 assert!(validate_actor_event_batch(
14813 &batch,
14814 &session,
14815 "logical-parent",
14816 Some("activation-7"),
14817 11,
14818 "worker-a",
14819 None,
14820 )
14821 .unwrap_err()
14822 .to_string()
14823 .contains("different logical session"));
14824 batch
14825 .logical_session
14826 .as_mut()
14827 .expect("logical identity")
14828 .session_id = session.id.clone();
14829 batch.source_actor_id = Some("worker-b".into());
14830 assert!(validate_actor_event_batch(
14831 &batch,
14832 &session,
14833 "logical-parent",
14834 Some("activation-7"),
14835 11,
14836 "worker-a",
14837 None,
14838 )
14839 .unwrap_err()
14840 .to_string()
14841 .contains("different physical actor"));
14842 }
14843
14844 #[tokio::test]
14845 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14846 use bamboo_domain::Storage;
14847
14848 let temp = tempfile::tempdir().unwrap();
14849 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14850 .await
14851 .unwrap();
14852 let parent = Session::new("event-parent", "model");
14853 let child = Session::new_child_of("event-child", &parent, "model", "task");
14854 store.save_session(&parent).await.unwrap();
14855 store.save_session(&child).await.unwrap();
14856 store.ensure_actor(&child.id).await.unwrap();
14857 let now = chrono::Utc::now();
14858 let claim = |run_id: &str| ActorActivationClaim {
14859 actor_id: child.id.clone(),
14860 run_id: run_id.into(),
14861 lease_owner: "event-owner".into(),
14862 lease_expires_at: now + chrono::Duration::minutes(2),
14863 inbox_generation: 0,
14864 placement_ref: None,
14865 now,
14866 };
14867 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14868 store.start_activation(&first.fence(), now).await.unwrap();
14869 let first_fence = first.fence();
14870 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14871 .await
14872 .unwrap();
14873 validate_directory_actor_event(&store, &first_fence, &child, None)
14874 .await
14875 .unwrap(); assert!(
14877 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14878 .await
14879 .is_err()
14880 );
14881 let mut forged = child.clone();
14882 forged.root_session_id = "foreign-root".into();
14883 assert!(
14884 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14885 .await
14886 .is_err()
14887 );
14888
14889 store
14890 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14891 .await
14892 .unwrap();
14893 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14894 store.start_activation(&second.fence(), now).await.unwrap();
14895 assert!(
14896 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14897 .await
14898 .is_err()
14899 );
14900 assert!(
14901 validate_directory_actor_event(&store, &first_fence, &child, None)
14902 .await
14903 .is_err()
14904 );
14905 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14906 .await
14907 .unwrap();
14908 }
14909
14910 fn completed_actor_frame() -> ChildFrame {
14911 ChildFrame::Terminal {
14912 status: TerminalStatus::Completed,
14913 result: Some("done".to_string()),
14914 error: None,
14915 final_event_watermark: None,
14916 transcript: Vec::new(),
14917 }
14918 }
14919
14920 async fn drive_permission_handshake_frames(
14921 session_id: &str,
14922 frames: impl IntoIterator<Item = ChildFrame>,
14923 expected: ExpectedPermissionPosture,
14924 ) -> (
14925 crate::runtime::runner::Result<Option<String>>,
14926 Session,
14927 Vec<AgentEvent>,
14928 Vec<ParentFrame>,
14929 ) {
14930 let mut link = ConfirmationSequenceLink {
14931 frames: frames.into_iter().collect(),
14932 sent: Vec::new(),
14933 };
14934 let (event_tx, mut event_rx) = mpsc::channel(128);
14935 let cancel = CancellationToken::new();
14936 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14937 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14938 let mut session = Session::new(session_id, "model");
14939 let result = drive(ActorDriveContext {
14940 expected_creation: None,
14941 client: &mut link,
14942 parent_session_id: "permission-parent",
14943 child_session_id: session_id,
14944 child_attempt: 0,
14945 approval_registry: None,
14946 approval_decider: None,
14947 approval_reviewer: None,
14948 escalation_bridge: None,
14949 event_tx: &event_tx,
14950 cancel_token: &cancel,
14951 live_rx: &mut live_rx,
14952 delivery_rx: &mut delivery_rx,
14953 logical_session: &mut session,
14954 expected_permission_posture: Some(expected),
14955 session_inbox_runtime: None,
14956 actor_directory_store: None,
14957 canonical_subagent_tool: None,
14958 ticket_service: None,
14959 activation_run_id: None,
14960 execution_epoch: 0,
14961 expected_source_actor_id: session_id,
14962 initial_inflight_claims: VecDeque::new(),
14963 plain_actor: false,
14964 remote_environment_lease: false,
14965 readonly_output: None,
14966 local_history_tools: None,
14967 local_history_read_only: false,
14968 strict_history_output: None,
14969 plain_input: None,
14970 canonical_activation: None,
14971 canonical_placement_ref: None,
14972 actor_event_observer: None,
14973 plain_run: None,
14974 first_frame_timeout: Some(Duration::from_secs(1)),
14975 })
14976 .await;
14977 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14978 (result, session, events, link.sent)
14979 }
14980
14981 #[tokio::test]
14982 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14983 let session_id = "permission-missing";
14984 let (result, session, events, _) = drive_permission_handshake_frames(
14985 session_id,
14986 [completed_actor_frame()],
14987 expected_default_permission_posture(7),
14988 )
14989 .await;
14990
14991 assert!(result
14992 .unwrap_err()
14993 .to_string()
14994 .contains("terminated before permission posture confirmation"));
14995 assert!(events.is_empty());
14996 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14997 }
14998
14999 #[tokio::test]
15000 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
15001 struct TerminalProofProbe {
15002 frame: Option<ChildFrame>,
15003 accepted: bool,
15004 }
15005
15006 #[async_trait]
15007 impl bamboo_subagent::ChildLink for TerminalProofProbe {
15008 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15009 Ok(())
15010 }
15011
15012 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15013 Ok(self.frame.take())
15014 }
15015
15016 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15017 self.accepted = status == TerminalStatus::Error;
15018 }
15019
15020 fn has_pending_durable_terminal(&self) -> bool {
15021 self.accepted
15022 }
15023 }
15024
15025 for (status, error, expected_proof) in [
15026 (
15027 TerminalStatus::Error,
15028 Some("remote_environment_checkout_not_clean"),
15029 true,
15030 ),
15031 (TerminalStatus::Error, Some("untrusted worker error"), false),
15032 (
15033 TerminalStatus::Cancelled,
15034 Some("remote_environment_checkout_not_clean"),
15035 false,
15036 ),
15037 ] {
15038 let mut link = TerminalProofProbe {
15039 frame: Some(ChildFrame::Terminal {
15040 status,
15041 result: None,
15042 error: error.map(str::to_owned),
15043 final_event_watermark: None,
15044 transcript: Vec::new(),
15045 }),
15046 accepted: false,
15047 };
15048 let (event_tx, _event_rx) = mpsc::channel(8);
15049 let cancel = CancellationToken::new();
15050 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15051 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15052 let mut session = Session::new("remote-terminal-proof", "model");
15053 let result = drive(ActorDriveContext {
15054 client: &mut link,
15055 parent_session_id: "proof-parent",
15056 child_session_id: "remote-terminal-proof",
15057 child_attempt: 0,
15058 approval_registry: None,
15059 approval_decider: None,
15060 approval_reviewer: None,
15061 escalation_bridge: None,
15062 event_tx: &event_tx,
15063 cancel_token: &cancel,
15064 live_rx: &mut live_rx,
15065 delivery_rx: &mut delivery_rx,
15066 logical_session: &mut session,
15067 expected_permission_posture: Some(expected_default_permission_posture(7)),
15068 expected_creation: None,
15069 session_inbox_runtime: None,
15070 actor_directory_store: None,
15071 canonical_subagent_tool: None,
15072 ticket_service: None,
15073 activation_run_id: None,
15074 execution_epoch: 0,
15075 expected_source_actor_id: "remote-terminal-proof",
15076 initial_inflight_claims: VecDeque::new(),
15077 plain_actor: false,
15078 remote_environment_lease: true,
15079 readonly_output: None,
15080 local_history_tools: None,
15081 local_history_read_only: false,
15082 strict_history_output: None,
15083 plain_input: None,
15084 canonical_activation: None,
15085 canonical_placement_ref: None,
15086 actor_event_observer: None,
15087 plain_run: None,
15088 first_frame_timeout: Some(Duration::from_secs(1)),
15089 })
15090 .await;
15091 assert!(result.is_err());
15092 assert_eq!(
15093 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15094 expected_proof
15095 );
15096 }
15097 }
15098
15099 #[tokio::test(start_paused = true)]
15100 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
15101 struct CancelOutcomeLink {
15102 cancel_sent: bool,
15103 settle: bool,
15104 accepted: bool,
15105 }
15106
15107 #[async_trait]
15108 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
15109 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15110 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
15111 Ok(())
15112 }
15113
15114 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15115 if !self.cancel_sent || !self.settle {
15116 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15117 .await
15118 } else {
15119 self.cancel_sent = false;
15120 Ok(Some(ChildFrame::Terminal {
15121 status: TerminalStatus::Cancelled,
15122 result: None,
15123 error: None,
15124 final_event_watermark: None,
15125 transcript: Vec::new(),
15126 }))
15127 }
15128 }
15129
15130 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15131 self.accepted = status == TerminalStatus::Cancelled;
15132 }
15133
15134 fn has_pending_durable_terminal(&self) -> bool {
15135 self.accepted
15136 }
15137 }
15138
15139 let temp = tempfile::tempdir().unwrap();
15140 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15141 .await
15142 .unwrap();
15143 let fence = ActorActivationFence {
15144 schema_version: 2,
15145 actor_id: "cancelled-remote-child".into(),
15146 activation_id: "cancelled-remote-activation".into(),
15147 attempt: 1,
15148 run_id: "cancelled-remote-run".into(),
15149 lease_owner: "cancelled-remote-host".into(),
15150 lease_epoch: 1,
15151 };
15152 for settle in [true, false] {
15153 let mut link = CancelOutcomeLink {
15154 cancel_sent: false,
15155 settle,
15156 accepted: false,
15157 };
15158 let (event_tx, _event_rx) = mpsc::channel(8);
15159 let cancel = CancellationToken::new();
15160 cancel.cancel();
15161 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15162 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15163 let mut session = Session::new("cancelled-remote-child", "model");
15164 let result = drive(ActorDriveContext {
15165 expected_creation: None,
15166 client: &mut link,
15167 parent_session_id: "cancelled-remote-parent",
15168 child_session_id: "cancelled-remote-child",
15169 child_attempt: 1,
15170 approval_registry: None,
15171 approval_decider: None,
15172 approval_reviewer: None,
15173 escalation_bridge: None,
15174 event_tx: &event_tx,
15175 cancel_token: &cancel,
15176 live_rx: &mut live_rx,
15177 delivery_rx: &mut delivery_rx,
15178 logical_session: &mut session,
15179 expected_permission_posture: Some(expected_default_permission_posture(7)),
15180 session_inbox_runtime: None,
15181 actor_directory_store: Some(&store),
15182 canonical_subagent_tool: None,
15183 ticket_service: None,
15184 activation_run_id: Some("cancelled-remote-run"),
15185 execution_epoch: 0,
15186 expected_source_actor_id: "cancelled-remote-worker",
15187 initial_inflight_claims: VecDeque::new(),
15188 plain_actor: false,
15189 remote_environment_lease: true,
15190 readonly_output: None,
15191 local_history_tools: None,
15192 local_history_read_only: false,
15193 strict_history_output: None,
15194 plain_input: None,
15195 canonical_activation: Some(&fence),
15196 canonical_placement_ref: None,
15197 actor_event_observer: None,
15198 plain_run: None,
15199 first_frame_timeout: None,
15200 })
15201 .await;
15202 assert!(matches!(result, Err(AgentError::Cancelled)));
15203 assert_eq!(
15204 link.accepted, settle,
15205 "only a consumed exact terminal can release the remote fence"
15206 );
15207 assert_eq!(
15208 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15209 settle
15210 );
15211 }
15212 }
15213
15214 #[tokio::test]
15215 async fn actor_permission_handshake_rejects_malformed_agent_event() {
15216 let session_id = "permission-malformed";
15217 let (result, session, events, _) = drive_permission_handshake_frames(
15218 session_id,
15219 [ChildFrame::Event {
15220 event: serde_json::json!({"type": "token", "content": 42}),
15221 }],
15222 expected_default_permission_posture(7),
15223 )
15224 .await;
15225
15226 assert!(result
15227 .unwrap_err()
15228 .to_string()
15229 .contains("malformed AgentEvent"));
15230 assert!(events.is_empty());
15231 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15232
15233 let (result, _, events, _) = drive_permission_handshake_frames(
15234 session_id,
15235 [
15236 permission_posture_frame(session_id, 7),
15237 ChildFrame::Event {
15238 event: serde_json::json!({"type":"private-download-secret"}),
15239 },
15240 ],
15241 expected_default_permission_posture(7),
15242 )
15243 .await;
15244 assert!(!result
15245 .unwrap_err()
15246 .to_string()
15247 .contains("private-download-secret"));
15248 assert_eq!(
15249 events.len(),
15250 1,
15251 "only the confirmed posture may be forwarded"
15252 );
15253 }
15254
15255 #[tokio::test]
15256 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15257 let session_id = "permission-early-event";
15258 let early_events = [
15259 (
15260 "progress",
15261 AgentEvent::RunnerProgress {
15262 session_id: session_id.to_string(),
15263 round_count: 1,
15264 },
15265 ),
15266 (
15267 "token",
15268 AgentEvent::Token {
15269 content: "must-not-forward".to_string(),
15270 },
15271 ),
15272 (
15273 "tool",
15274 AgentEvent::ToolStart {
15275 tool_call_id: "early-tool".to_string(),
15276 tool_name: "Read".to_string(),
15277 arguments: serde_json::json!({"file_path": "README.md"}),
15278 },
15279 ),
15280 ];
15281 for (kind, event) in early_events {
15282 let (result, session, events, _) = drive_permission_handshake_frames(
15283 session_id,
15284 [
15285 actor_event_frame(event),
15286 permission_posture_frame(session_id, 7),
15287 completed_actor_frame(),
15288 ],
15289 expected_default_permission_posture(7),
15290 )
15291 .await;
15292
15293 assert!(
15294 result
15295 .unwrap_err()
15296 .to_string()
15297 .contains("execution event before permission posture confirmation"),
15298 "{kind} must fail closed before posture"
15299 );
15300 assert!(events.is_empty(), "{kind} must not be forwarded");
15301 assert!(
15302 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15303 "{kind} must not advance the permission audit"
15304 );
15305 }
15306 }
15307
15308 #[tokio::test]
15309 async fn actor_permission_handshake_rejects_approval_before_posture() {
15310 let session_id = "permission-early-approval";
15311 let (result, session, events, sent) = drive_permission_handshake_frames(
15312 session_id,
15313 [ChildFrame::ApprovalRequest {
15314 id: "approval-before-posture".to_string(),
15315 body: serde_json::json!({
15316 "tool_name": "Bash",
15317 "permission": "execute",
15318 "resource": "echo must-not-run"
15319 }),
15320 }],
15321 expected_default_permission_posture(7),
15322 )
15323 .await;
15324
15325 assert!(result
15326 .unwrap_err()
15327 .to_string()
15328 .contains("requested approval before permission posture confirmation"));
15329 assert!(events.is_empty());
15330 assert!(
15331 sent.is_empty(),
15332 "an unconfirmed actor must receive no approval reply"
15333 );
15334 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15335 }
15336
15337 #[tokio::test]
15338 async fn actor_permission_handshake_rejects_mismatched_posture() {
15339 let session_id = "permission-mismatch";
15340 let (result, session, events, _) = drive_permission_handshake_frames(
15341 session_id,
15342 [permission_posture_frame(session_id, 8)],
15343 expected_default_permission_posture(7),
15344 )
15345 .await;
15346
15347 assert!(result
15348 .unwrap_err()
15349 .to_string()
15350 .contains("does not match the host-dispatched policy"));
15351 assert!(events.is_empty());
15352 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15353 }
15354
15355 #[tokio::test]
15356 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15357 let session_id = "permission-hostile-mapping";
15358 for hostile_mapping in [
15359 "wrong_executor:permission_mode=default",
15360 "test_actor:permission_mode=default;credential=must-not-persist",
15361 ] {
15362 let frame = ChildFrame::Event {
15363 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15364 session_id: session_id.to_string(),
15365 policy_revision: 7,
15366 requested_mode: "default".to_string(),
15367 effective_mode: "default".to_string(),
15368 executor_mapping: hostile_mapping.to_string(),
15369 })
15370 .expect("serialize hostile posture fixture"),
15371 };
15372 let (result, session, events, _) = drive_permission_handshake_frames(
15373 session_id,
15374 [frame],
15375 expected_default_permission_posture(7),
15376 )
15377 .await;
15378
15379 let error = result.unwrap_err().to_string();
15380 assert!(error.contains("host-dispatched executor mapping"));
15381 assert!(
15382 !error.contains(hostile_mapping),
15383 "untrusted mapping must not be reflected in host errors"
15384 );
15385 assert!(events.is_empty());
15386 assert!(
15387 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15388 "untrusted mapping must not reach durable or in-memory audit state"
15389 );
15390 }
15391 }
15392
15393 #[tokio::test]
15394 async fn actor_permission_handshake_rejects_duplicate_posture() {
15395 let session_id = "permission-duplicate";
15396 let (result, session, events, _) = drive_permission_handshake_frames(
15397 session_id,
15398 [
15399 permission_posture_frame(session_id, 7),
15400 permission_posture_frame(session_id, 7),
15401 completed_actor_frame(),
15402 ],
15403 expected_default_permission_posture(7),
15404 )
15405 .await;
15406
15407 assert!(result
15408 .unwrap_err()
15409 .to_string()
15410 .contains("duplicate permission posture activation"));
15411 assert_eq!(
15412 events.len(),
15413 1,
15414 "only the confirmed posture may be forwarded"
15415 );
15416 assert!(matches!(
15417 events[0],
15418 AgentEvent::PermissionPostureActivated { .. }
15419 ));
15420 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15421 .expect("the first matching posture must be recorded");
15422 assert_eq!(audit.policy_revision, 7);
15423 }
15424
15425 #[tokio::test]
15426 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15427 let session_id = "permission-happy";
15428 let (result, session, events, _) = drive_permission_handshake_frames(
15429 session_id,
15430 [
15431 permission_posture_frame(session_id, 7),
15432 actor_event_frame(AgentEvent::RunnerProgress {
15433 session_id: session_id.to_string(),
15434 round_count: 1,
15435 }),
15436 actor_event_frame(AgentEvent::Token {
15437 content: "working".to_string(),
15438 }),
15439 actor_event_frame(AgentEvent::ToolStart {
15440 tool_call_id: "tool-1".to_string(),
15441 tool_name: "Read".to_string(),
15442 arguments: serde_json::json!({"file_path": "README.md"}),
15443 }),
15444 completed_actor_frame(),
15445 ],
15446 expected_default_permission_posture(7),
15447 )
15448 .await;
15449
15450 assert_eq!(result.unwrap().as_deref(), Some("done"));
15451 assert!(matches!(
15452 events.as_slice(),
15453 [
15454 AgentEvent::PermissionPostureActivated { .. },
15455 AgentEvent::RunnerProgress { .. },
15456 AgentEvent::Token { .. },
15457 AgentEvent::ToolStart { .. }
15458 ]
15459 ));
15460 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15461 .expect("matching posture must be recorded before execution events are accepted");
15462 assert_eq!(audit.policy_revision, 7);
15463 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15464 }
15465
15466 #[tokio::test]
15467 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15468 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15469 .into_iter()
15470 .enumerate()
15471 {
15472 let session_id = format!("actor-download-{index}");
15473 let args = serde_json::json!({
15474 "action":"download",
15475 "selector":"a[data-secret='private-selector']",
15476 "expected_epoch":17,
15477 "data_base64":"private-extra-bytes",
15478 "extra":{"url":"https://private.test/file"},
15479 });
15480 let original = args.clone();
15481 let read_args = serde_json::json!({"file_path":"README.md"});
15482 let (result, _session, events, _) = drive_permission_handshake_frames(
15483 &session_id,
15484 [
15485 permission_posture_frame(&session_id, 7),
15486 actor_event_frame(AgentEvent::ToolStart {
15487 tool_call_id: "download-call".to_string(),
15488 tool_name: tool_name.to_string(),
15489 arguments: args.clone(),
15490 }),
15491 actor_event_frame(AgentEvent::ToolStart {
15492 tool_call_id: "read-call".to_string(),
15493 tool_name: "Read".to_string(),
15494 arguments: read_args.clone(),
15495 }),
15496 completed_actor_frame(),
15497 ],
15498 expected_default_permission_posture(7),
15499 )
15500 .await;
15501 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15502 assert_eq!(args, original);
15503 let starts: Vec<_> = events
15504 .iter()
15505 .filter_map(|event| match event {
15506 AgentEvent::ToolStart {
15507 tool_name,
15508 arguments,
15509 ..
15510 } => Some((tool_name.as_str(), arguments)),
15511 _ => None,
15512 })
15513 .collect();
15514 assert_eq!(starts.len(), 2);
15515 assert_eq!(starts[0].0, "browser");
15516 assert_eq!(
15517 starts[0].1,
15518 &serde_json::json!({"action":"download","expected_epoch":17})
15519 );
15520 assert_eq!(starts[1], ("Read", &read_args));
15521 assert!(!format!("{events:?}").contains("private"));
15522 }
15523 }
15524
15525 #[tokio::test]
15526 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15527 let session_id = "actor-hook-display";
15528 let private = "private-selector-url-filename-response";
15529 let hook_name = format!("audit-{private}");
15530 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15531 hook_name: hook_name.clone(),
15532 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15533 phase: phase.into(),
15534 duration_ms,
15535 decision,
15536 };
15537 let decisions = [
15538 HookResult::Deny {
15539 reason: private.into(),
15540 },
15541 HookResult::InjectContext {
15542 text: private.into(),
15543 },
15544 HookResult::WithContext {
15545 result: Box::new(HookResult::WithContext {
15546 result: Box::new(HookResult::Deny {
15547 reason: private.into(),
15548 }),
15549 text: private.into(),
15550 }),
15551 text: private.into(),
15552 },
15553 HookResult::Suspend {
15554 reason: private.into(),
15555 },
15556 HookResult::Abort {
15557 reason: private.into(),
15558 },
15559 ];
15560 let originals = decisions.to_vec();
15561 let batch_events = [
15562 hook(2, "completed", decisions[1].clone()),
15563 hook(3, "completed", decisions[2].clone()),
15564 hook(4, "completed", decisions[3].clone()),
15565 ]
15566 .into_iter()
15567 .map(|event| serde_json::to_value(event).unwrap())
15568 .collect::<Vec<_>>();
15569 let batch = ActorEventBatch {
15570 logical_session: Some(LogicalSessionIdentity {
15571 creation: None,
15572 session_id: session_id.into(),
15573 parent_session_id: Some("permission-parent".into()),
15574 root_session_id: session_id.into(),
15575 }),
15576 activation_id: None,
15577 execution_epoch: 0,
15578 source_node_id: None,
15579 source_actor_id: Some(session_id.into()),
15580 first_seq: 1,
15581 last_seq: batch_events.len() as u64,
15582 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15583 events: batch_events,
15584 };
15585 let nested = AgentEvent::SubAgentEvent {
15586 parent_session_id: "parent".into(),
15587 child_session_id: "child".into(),
15588 event: Box::new(hook(5, "completed", decisions[4].clone())),
15589 };
15590 let raw = serde_json::to_string(&originals).unwrap();
15591 assert!(raw.contains(private));
15592
15593 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15594 session_id,
15595 [
15596 permission_posture_frame(session_id, 7),
15597 actor_event_frame(hook(1, private, decisions[0].clone())),
15598 ChildFrame::EventBatch { batch },
15599 actor_event_frame(nested),
15600 completed_actor_frame(),
15601 ],
15602 expected_default_permission_posture(7),
15603 )
15604 .await;
15605 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15606 let wire = serde_json::to_string(&forwarded).unwrap();
15607 assert!(
15608 !wire.contains(private),
15609 "private hook text reached SSE: {wire}"
15610 );
15611
15612 let hooks = forwarded
15613 .iter()
15614 .filter_map(|event| match event {
15615 AgentEvent::HookLifecycle {
15616 hook_name,
15617 point,
15618 phase,
15619 duration_ms,
15620 decision,
15621 } => Some((hook_name, point, phase, duration_ms, decision)),
15622 _ => None,
15623 })
15624 .collect::<Vec<_>>();
15625 assert_eq!(hooks.len(), 4);
15626 assert!(hooks.iter().all(|(name, point, _, _, _)| {
15627 name == &&"actor-hook-1".to_string()
15628 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15629 }));
15630 assert_eq!(hooks[0].2, "event");
15631 assert_eq!(hooks[1].2, "completed");
15632 assert_eq!(*hooks[0].3, 1);
15633 assert_eq!(*hooks[3].3, 4);
15634 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15635 assert!(
15636 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15637 );
15638 assert!(
15639 matches!(hooks[2].4, HookResult::WithContext { result, text }
15640 if text == "Hook context hidden" && matches!(result.as_ref(),
15641 HookResult::WithContext { result, text }
15642 if text == "Hook context hidden" && matches!(result.as_ref(),
15643 HookResult::Deny { reason } if reason == "Hook denied")))
15644 );
15645 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15646 assert!(forwarded.iter().any(|event| matches!(event,
15647 AgentEvent::SubAgentEvent { event, .. }
15648 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15649 hook_name, duration_ms: 5,
15650 decision: HookResult::Abort { reason }, ..
15651 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15652 )));
15653 }
15654
15655 #[test]
15656 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15657 let mut display = ActorEventDisplay::default();
15658 let mut deep = HookResult::Allow;
15659 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15660 deep = HookResult::WithContext {
15661 result: Box::new(deep),
15662 text: "private-download-bytes".into(),
15663 };
15664 }
15665 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15666 hook_name,
15667 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15668 phase: "completed".into(),
15669 duration_ms: 7,
15670 decision,
15671 };
15672 assert!(display.project(event("deep".into(), deep), 0).is_none());
15673 assert!(display.overflowed);
15674 assert!(display
15675 .project(
15676 event(
15677 "private-download-bytes".into(),
15678 HookResult::Deny {
15679 reason: "private-download-bytes".into(),
15680 },
15681 ),
15682 0,
15683 )
15684 .is_none());
15685
15686 let mut oversized = ActorEventDisplay::default();
15687 assert!(oversized
15688 .project(
15689 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15690 0,
15691 )
15692 .is_none());
15693 assert!(oversized.overflowed);
15694
15695 let mut reconnected = ActorEventDisplay::default();
15699 let private = "private-download-bytes";
15700 for _ in 0..2 {
15701 let projected = reconnected
15702 .project(event(private.into(), HookResult::Allow), 0)
15703 .unwrap();
15704 assert!(
15705 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15706 if hook_name == "actor-hook-1")
15707 );
15708 }
15709 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15710 for index in 1..MAX_DISPLAY_CALLS {
15711 assert!(reconnected
15712 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15713 .is_some());
15714 }
15715 assert!(reconnected
15716 .project(event("overflow-private".into(), HookResult::Allow), 0)
15717 .is_none());
15718 assert!(reconnected.overflowed);
15719 assert!(reconnected.hook_names.is_empty());
15720 }
15721
15722 #[test]
15723 fn actor_hook_display_preserves_nontext_decision_kinds() {
15724 for result in [
15725 HookResult::Continue,
15726 HookResult::Mutated,
15727 HookResult::Allow,
15728 HookResult::Ask,
15729 ] {
15730 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15731 }
15732 }
15733
15734 #[tokio::test]
15735 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15736 let session_id = "actor-malformed-hook";
15737 let private = "private-download-selector";
15738 let original = AgentEvent::HookLifecycle {
15739 hook_name: "audit".into(),
15740 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15741 phase: "completed".into(),
15742 duration_ms: 9,
15743 decision: HookResult::Deny {
15744 reason: private.into(),
15745 },
15746 };
15747 let mut invalid = serde_json::to_value(&original).unwrap();
15748 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15749 let nested = AgentEvent::SubAgentEvent {
15750 parent_session_id: "parent".into(),
15751 child_session_id: "child".into(),
15752 event: Box::new(original),
15753 };
15754 let mut invalid_nested = serde_json::to_value(nested).unwrap();
15755 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15756 let batch = ActorEventBatch {
15757 logical_session: Some(LogicalSessionIdentity {
15758 creation: None,
15759 session_id: session_id.into(),
15760 parent_session_id: Some("permission-parent".into()),
15761 root_session_id: session_id.into(),
15762 }),
15763 activation_id: None,
15764 execution_epoch: 0,
15765 source_node_id: None,
15766 source_actor_id: Some(session_id.into()),
15767 first_seq: 1,
15768 last_seq: 1,
15769 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15770 events: vec![invalid.clone()],
15771 };
15772 for frame in [
15773 ChildFrame::Event {
15774 event: invalid.clone(),
15775 },
15776 ChildFrame::EventBatch { batch },
15777 ChildFrame::Event {
15778 event: invalid_nested,
15779 },
15780 ] {
15781 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15782 session_id,
15783 [permission_posture_frame(session_id, 7), frame],
15784 expected_default_permission_posture(7),
15785 )
15786 .await;
15787 let error = outcome.unwrap_err().to_string();
15788 assert!(error.contains("malformed AgentEvent"), "{error}");
15789 assert!(!error.contains(private));
15790 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15791 }
15792
15793 let (event_tx, mut event_rx) = mpsc::channel(8);
15794 let mut session = Session::new("legacy-hook", "model");
15795 let mut handshake = PermissionPostureHandshake::NotRequired;
15796 let mut display = ActorEventDisplay::default();
15797 process_actor_event(
15798 invalid,
15799 false,
15800 &mut handshake,
15801 None,
15802 None,
15803 &mut session,
15804 &event_tx,
15805 &mut display,
15806 )
15807 .await
15808 .unwrap();
15809 assert!(display.overflowed);
15810 assert!(event_rx.try_recv().is_err());
15811 }
15812
15813 #[tokio::test]
15814 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15815 let session_id = "actor-download-batch";
15816 let private = "private-download-bytes-and-url";
15817 let events = vec![
15818 AgentEvent::ToolStart {
15819 tool_call_id: "download-call".into(),
15820 tool_name: "default::browser".into(),
15821 arguments: serde_json::json!({
15822 "action":"download", "selector": private, "expected_epoch": 17,
15823 }),
15824 },
15825 AgentEvent::ToolApprovalRequested {
15826 tool_call_id: "download-call".into(),
15827 tool_name: format!("{private}::browser"),
15828 parameters: serde_json::json!({
15829 "action":"download", "selector": private, "expected_epoch": 17,
15830 }),
15831 },
15832 AgentEvent::ToolStart {
15833 tool_call_id: "read-call".into(),
15834 tool_name: "Read".into(),
15835 arguments: serde_json::json!({"file_path":"README.md"}),
15836 },
15837 ];
15838 let batch = ActorEventBatch {
15839 logical_session: Some(LogicalSessionIdentity {
15840 creation: None,
15841 session_id: session_id.into(),
15842 parent_session_id: Some("permission-parent".into()),
15843 root_session_id: session_id.into(),
15844 }),
15845 activation_id: None,
15846 execution_epoch: 0,
15847 source_node_id: None,
15848 source_actor_id: Some(session_id.into()),
15849 first_seq: 1,
15850 last_seq: events.len() as u64,
15851 qos: bamboo_subagent::ActorEventQos::Durable,
15852 events: events
15853 .into_iter()
15854 .map(|event| serde_json::to_value(event).unwrap())
15855 .collect(),
15856 };
15857 let result = bamboo_agent_core::tools::ToolResult {
15858 success: true,
15859 result: format!("{{\"data_base64\":\"{private}\"}}"),
15860 display_preference: Some(private.into()),
15861 images: vec![bamboo_agent_core::tools::ToolResultImage {
15862 mime_type: "image/png".into(),
15863 data: private.into(),
15864 }],
15865 };
15866 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15867 session_id,
15868 [
15869 permission_posture_frame(session_id, 7),
15870 ChildFrame::EventBatch { batch },
15871 actor_event_frame(AgentEvent::ToolToken {
15872 tool_call_id: "download-call".into(),
15873 content: private.into(),
15874 }),
15875 actor_event_frame(AgentEvent::ToolToken {
15876 tool_call_id: "read-call".into(),
15877 content: "public live output".into(),
15878 }),
15879 actor_event_frame(AgentEvent::ToolComplete {
15880 tool_call_id: "download-call".into(),
15881 result,
15882 }),
15883 actor_event_frame(AgentEvent::ToolLifecycle {
15884 tool_call_id: "download-call".into(),
15885 tool_name: "browser".into(),
15886 phase: "finished".into(),
15887 elapsed_ms: Some(1),
15888 is_mutating: false,
15889 auto_approved: false,
15890 summary: Some(private.into()),
15891 error: Some(private.into()),
15892 }),
15893 actor_event_frame(AgentEvent::ToolError {
15894 tool_call_id: "download-call".into(),
15895 error: private.into(),
15896 }),
15897 actor_event_frame(AgentEvent::ToolComplete {
15898 tool_call_id: "read-call".into(),
15899 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15900 }),
15901 completed_actor_frame(),
15902 ],
15903 expected_default_permission_posture(7),
15904 )
15905 .await;
15906 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15907 let wire = serde_json::to_string(&forwarded).unwrap();
15908 assert!(
15909 !wire.contains(private),
15910 "private content reached actor SSE: {wire}"
15911 );
15912 assert!(wire.contains("public README text"));
15913 assert!(wire.contains("public live output"));
15914 assert!(forwarded.iter().any(|event| matches!(event,
15915 AgentEvent::ToolComplete { tool_call_id, result }
15916 if tool_call_id == "download-call"
15917 && result.result == "Browser tool result hidden"
15918 && result.images.is_empty() && result.display_preference.is_none()
15919 )));
15920 }
15921
15922 #[tokio::test]
15923 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15924 let private = "private-download-content";
15925 let nested = |event| AgentEvent::SubAgentEvent {
15926 parent_session_id: "parent".into(),
15927 child_session_id: "nested-child".into(),
15928 event: Box::new(event),
15929 };
15930 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15931 "actor-reorder",
15932 [
15933 permission_posture_frame("actor-reorder", 7),
15934 actor_event_frame(AgentEvent::ToolComplete {
15935 tool_call_id: "missing".into(),
15936 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15937 }),
15938 actor_event_frame(AgentEvent::ToolLifecycle {
15939 tool_call_id: "missing".into(),
15940 tool_name: "browser".into(),
15941 phase: private.into(),
15942 elapsed_ms: None,
15943 is_mutating: false,
15944 auto_approved: false,
15945 summary: Some(private.into()),
15946 error: Some(private.into()),
15947 }),
15948 actor_event_frame(AgentEvent::ToolApprovalRequested {
15949 tool_call_id: "approval-only".into(),
15950 tool_name: "browser".into(),
15951 parameters: serde_json::json!({"action":"download","selector":private}),
15952 }),
15953 actor_event_frame(AgentEvent::ToolToken {
15954 tool_call_id: "approval-only".into(),
15955 content: private.into(),
15956 }),
15957 actor_event_frame(AgentEvent::ToolStart {
15958 tool_call_id: "malformed-browser".into(),
15959 tool_name: format!("{private}::browser"),
15960 arguments: serde_json::json!({"selector":private}),
15961 }),
15962 actor_event_frame(AgentEvent::ToolComplete {
15963 tool_call_id: "malformed-browser".into(),
15964 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15965 }),
15966 actor_event_frame(AgentEvent::ToolStart {
15967 tool_call_id: "reused".into(),
15968 tool_name: "Read".into(),
15969 arguments: serde_json::json!({"file_path":"README.md"}),
15970 }),
15971 actor_event_frame(AgentEvent::ToolStart {
15972 tool_call_id: "reused".into(),
15973 tool_name: "browser".into(),
15974 arguments: serde_json::json!({"action":"download","selector":private}),
15975 }),
15976 actor_event_frame(AgentEvent::ToolToken {
15977 tool_call_id: "reused".into(),
15978 content: private.into(),
15979 }),
15980 actor_event_frame(nested(AgentEvent::ToolStart {
15981 tool_call_id: "nested".into(),
15982 tool_name: "browser".into(),
15983 arguments: serde_json::json!({"action":"download","selector":private}),
15984 })),
15985 actor_event_frame(nested(AgentEvent::ToolComplete {
15986 tool_call_id: "nested".into(),
15987 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15988 })),
15989 actor_event_frame(AgentEvent::ChildApprovalRequested {
15990 child_session_id: "nested-child".into(),
15991 request_id: "approval".into(),
15992 tool_name: format!("{private}::browser"),
15993 permission: private.into(),
15994 resource: private.into(),
15995 }),
15996 actor_event_frame(AgentEvent::ChildApprovalChanged {
15997 parent_session_id: "parent".into(),
15998 child_session_id: "nested-child".into(),
15999 child_attempt: 1,
16000 request_id: "approval".into(),
16001 version: 1,
16002 status: "pending".into(),
16003 reason: Some(private.into()),
16004 tool_name: "browser".into(),
16005 permission: private.into(),
16006 resource: private.into(),
16007 created_at: "2026-01-01T00:00:00Z".into(),
16008 resolved_at: None,
16009 }),
16010 completed_actor_frame(),
16011 ],
16012 expected_default_permission_posture(7),
16013 )
16014 .await;
16015 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16016 let wire = serde_json::to_string(&forwarded).unwrap();
16017 assert!(
16018 !wire.contains(private),
16019 "private content reached actor SSE: {wire}"
16020 );
16021 assert!(wire.contains("Tool result hidden"));
16022 assert!(wire.contains("Browser tool result hidden"));
16023 assert!(wire.contains("Browser interaction approval"));
16024 assert!(!forwarded
16025 .iter()
16026 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
16027 }
16028
16029 #[test]
16030 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
16031 let mut display = ActorEventDisplay::default();
16032 display
16033 .project(
16034 AgentEvent::ToolStart {
16035 tool_call_id: "private-call".into(),
16036 tool_name: "browser".into(),
16037 arguments: serde_json::json!({"action":"download","selector":"private"}),
16038 },
16039 0,
16040 )
16041 .unwrap();
16042 for index in 0..MAX_DISPLAY_CALLS {
16043 display
16044 .project(
16045 AgentEvent::ToolStart {
16046 tool_call_id: format!("ordinary-{index}"),
16047 tool_name: "Read".into(),
16048 arguments: serde_json::json!({"file_path":"README.md"}),
16049 },
16050 0,
16051 )
16052 .unwrap();
16053 }
16054 assert!(display.overflowed);
16055 let forwarded = display
16056 .project(
16057 AgentEvent::ToolComplete {
16058 tool_call_id: "private-call".into(),
16059 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
16060 },
16061 0,
16062 )
16063 .unwrap();
16064 assert!(!serde_json::to_string(&forwarded)
16065 .unwrap()
16066 .contains("private-bytes"));
16067 }
16068
16069 #[test]
16070 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
16071 let mut display = ActorEventDisplay::default();
16072 let nested = |index: usize| AgentEvent::SubAgentEvent {
16073 parent_session_id: "parent".into(),
16074 child_session_id: format!("child-{index}"),
16075 event: Box::new(AgentEvent::ToolStart {
16076 tool_call_id: format!("read-{index}"),
16077 tool_name: "Read".into(),
16078 arguments: serde_json::json!({"file_path":"README.md"}),
16079 }),
16080 };
16081 for index in 0..(MAX_DISPLAY_CALLS / 2) {
16082 assert!(display.project(nested(index), 0).is_some());
16083 }
16084 assert_eq!(
16085 display.identities.load(Ordering::Relaxed),
16086 MAX_DISPLAY_CALLS
16087 );
16088 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
16089 assert!(display.overflowed);
16090 assert!(display.children.is_empty());
16091
16092 let mut oversized = ActorEventDisplay::default();
16093 assert!(oversized
16094 .project(
16095 AgentEvent::SubAgentEvent {
16096 parent_session_id: "parent".into(),
16097 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16098 event: Box::new(AgentEvent::ToolComplete {
16099 tool_call_id: "call".into(),
16100 result: bamboo_agent_core::tools::ToolResult::text(
16101 true,
16102 "private-download-bytes",
16103 ),
16104 }),
16105 },
16106 0,
16107 )
16108 .is_none());
16109 assert!(oversized.overflowed);
16110 assert!(oversized
16111 .project(
16112 AgentEvent::ToolStart {
16113 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16114 tool_name: "Read".into(),
16115 arguments: serde_json::json!({"file_path":"README.md"}),
16116 },
16117 0,
16118 )
16119 .is_none());
16120 }
16121
16122 #[tokio::test]
16123 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16124 let session_id = "actor-gap";
16125 let batch = |seq, event: AgentEvent| {
16126 let event = serde_json::to_value(event).unwrap();
16127 ChildFrame::EventBatch {
16128 batch: ActorEventBatch {
16129 logical_session: Some(LogicalSessionIdentity {
16130 creation: None,
16131 session_id: session_id.into(),
16132 parent_session_id: Some("permission-parent".into()),
16133 root_session_id: session_id.into(),
16134 }),
16135 activation_id: None,
16136 execution_epoch: 0,
16137 source_node_id: None,
16138 source_actor_id: Some(session_id.into()),
16139 first_seq: seq,
16140 last_seq: seq,
16141 qos: bamboo_subagent::ActorEventQos::classify(&event),
16142 events: vec![event],
16143 },
16144 }
16145 };
16146 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16147 session_id,
16148 [
16149 permission_posture_frame(session_id, 7),
16150 batch(
16151 1,
16152 AgentEvent::ToolStart {
16153 tool_call_id: "reused-call".into(),
16154 tool_name: "Read".into(),
16155 arguments: serde_json::json!({"file_path":"README.md"}),
16156 },
16157 ),
16158 batch(
16161 3,
16162 AgentEvent::ToolComplete {
16163 tool_call_id: "reused-call".into(),
16164 result: bamboo_agent_core::tools::ToolResult::text(
16165 true,
16166 "private-download-bytes",
16167 ),
16168 },
16169 ),
16170 completed_actor_frame(),
16171 ],
16172 expected_default_permission_posture(7),
16173 )
16174 .await;
16175 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16176 let wire = serde_json::to_string(&forwarded).unwrap();
16177 assert!(!wire.contains("private-download-bytes"), "{wire}");
16178 assert!(wire.contains("Tool result hidden"));
16179 }
16180
16181 #[tokio::test]
16182 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16183 let (event_tx, mut event_rx) = mpsc::channel(8);
16184 let mut session = Session::new("legacy-malformed-private", "model");
16185 let mut handshake = PermissionPostureHandshake::NotRequired;
16186 let mut display = ActorEventDisplay::default();
16187 let read_start = serde_json::to_value(AgentEvent::ToolStart {
16188 tool_call_id: "reused-call".into(),
16189 tool_name: "Read".into(),
16190 arguments: serde_json::json!({"file_path":"README.md"}),
16191 })
16192 .unwrap();
16193 process_actor_event(
16194 read_start,
16195 false,
16196 &mut handshake,
16197 None,
16198 None,
16199 &mut session,
16200 &event_tx,
16201 &mut display,
16202 )
16203 .await
16204 .unwrap();
16205
16206 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16209 tool_call_id: "reused-call".into(),
16210 tool_name: "browser".into(),
16211 arguments: serde_json::json!({
16212 "action":"download", "selector":"private-download-selector"
16213 }),
16214 })
16215 .unwrap();
16216 malformed["tool_name"] = serde_json::json!(7);
16217 process_actor_event(
16218 malformed,
16219 false,
16220 &mut handshake,
16221 None,
16222 None,
16223 &mut session,
16224 &event_tx,
16225 &mut display,
16226 )
16227 .await
16228 .unwrap();
16229 process_actor_event(
16230 serde_json::to_value(AgentEvent::ToolComplete {
16231 tool_call_id: "reused-call".into(),
16232 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16233 })
16234 .unwrap(),
16235 false,
16236 &mut handshake,
16237 None,
16238 None,
16239 &mut session,
16240 &event_tx,
16241 &mut display,
16242 )
16243 .await
16244 .unwrap();
16245 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16246 assert_eq!(forwarded.len(), 2);
16247 let wire = serde_json::to_string(&forwarded).unwrap();
16248 assert!(!wire.contains("private-download"), "{wire}");
16249 assert!(wire.contains("Tool result hidden"));
16250 }
16251
16252 #[tokio::test]
16253 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16254 let temp = tempfile::tempdir().unwrap();
16255 let store = Arc::new(
16256 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16257 .await
16258 .unwrap(),
16259 );
16260 let storage: Arc<dyn Storage> = store.clone();
16261 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16262 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16263 store.clone(),
16264 bamboo_domain::SessionInboxLimits::default(),
16265 ));
16266 let session_id = "actor-confirmation-order";
16267 let run_id = "actor-run-current";
16268 let mut session = Session::new(session_id, "model");
16269 store.save_session(&session).await.unwrap();
16270 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16271 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16272 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16273 inbox.deliver(&envelope).await.unwrap();
16274 }
16275 inbox
16276 .mark_activation_eligible(
16277 session_id,
16278 2,
16279 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16280 )
16281 .await
16282 .unwrap();
16283 let router = SessionActivationRouter::new();
16284 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16285 let binding = SessionInboxRuntimeBinding {
16286 router,
16287 inbox: inbox.clone(),
16288 storage,
16289 persistence: locked,
16290 parent_question_lock: None,
16291 };
16292 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16293 .await
16294 .unwrap();
16295 assert_eq!(
16296 pairs
16297 .iter()
16298 .map(|(claim, _)| claim.envelope.id.as_str())
16299 .collect::<Vec<_>>(),
16300 vec!["actor-first", "actor-second"]
16301 );
16302 let seeded = store.load_session(session_id).await.unwrap().unwrap();
16303 assert_eq!(
16304 seeded
16305 .messages
16306 .iter()
16307 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16308 .map(|message| message.id.as_str())
16309 .collect::<Vec<_>>(),
16310 vec!["actor-first", "actor-second"],
16311 "host context must be durable before actor dispatch"
16312 );
16313 for (claim, _) in &pairs {
16314 assert_eq!(
16315 seeded
16316 .messages
16317 .iter()
16318 .filter(|message| bamboo_domain::is_matching_session_message(
16319 message,
16320 &claim.envelope
16321 ))
16322 .count(),
16323 1,
16324 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16325 claim.envelope.id
16326 );
16327 }
16328 assert!(
16329 seeded.session_inbox_admission().is_none_or(|cursor| {
16330 !cursor.contains(&pairs[0].0.envelope.id)
16331 && !cursor.contains(&pairs[1].0.envelope.id)
16332 }),
16333 "pre-dispatch transcript seeding must not forge worker confirmation"
16334 );
16335 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16336 let claims = pairs
16337 .into_iter()
16338 .map(|(claim, _)| claim)
16339 .collect::<VecDeque<_>>();
16340 let first = claims[0].clone();
16341 let second = claims[1].clone();
16342 let mut stale = admission_confirmation(session_id, &first, "stale-run");
16343 stale.canonical_claim_generation = second.generation;
16344 let mut link = ConfirmationSequenceLink {
16345 frames: VecDeque::from([
16346 ChildFrame::SessionMessageAdmitted {
16347 confirmation: stale,
16348 },
16349 ChildFrame::SessionMessageAdmitted {
16350 confirmation: admission_confirmation(session_id, &first, run_id),
16351 },
16352 ChildFrame::SessionMessageAdmitted {
16353 confirmation: admission_confirmation(session_id, &second, run_id),
16354 },
16355 ChildFrame::Terminal {
16356 status: TerminalStatus::Completed,
16357 result: Some("done".to_string()),
16358 error: None,
16359 final_event_watermark: None,
16360 transcript: Vec::new(),
16361 },
16362 ]),
16363 sent: Vec::new(),
16364 };
16365 let (event_tx, _event_rx) = mpsc::channel(8);
16366 let cancel = CancellationToken::new();
16367 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16368 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16369 let result = drive(ActorDriveContext {
16370 expected_creation: None,
16371 client: &mut link,
16372 parent_session_id: "parent",
16373 child_session_id: session_id,
16374 child_attempt: 0,
16375 approval_registry: None,
16376 approval_decider: None,
16377 approval_reviewer: None,
16378 escalation_bridge: None,
16379 event_tx: &event_tx,
16380 cancel_token: &cancel,
16381 live_rx: &mut live_rx,
16382 delivery_rx: &mut delivery_rx,
16383 logical_session: &mut session,
16384 expected_permission_posture: None,
16385 session_inbox_runtime: Some(&binding),
16386 actor_directory_store: None,
16387 canonical_subagent_tool: None,
16388 ticket_service: None,
16389 activation_run_id: Some(run_id),
16390 execution_epoch: 0,
16391 expected_source_actor_id: session_id,
16392 initial_inflight_claims: claims,
16393 plain_actor: false,
16394 remote_environment_lease: false,
16395 readonly_output: None,
16396 local_history_tools: None,
16397 local_history_read_only: false,
16398 strict_history_output: None,
16399 plain_input: None,
16400 canonical_activation: None,
16401 canonical_placement_ref: None,
16402 actor_event_observer: None,
16403 plain_run: None,
16404 first_frame_timeout: Some(Duration::from_secs(1)),
16405 })
16406 .await
16407 .unwrap();
16408 assert_eq!(result.as_deref(), Some("done"));
16409 assert_eq!(
16410 session
16411 .messages
16412 .iter()
16413 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16414 .map(|message| message.id.as_str())
16415 .collect::<Vec<_>>(),
16416 vec!["actor-first", "actor-second"]
16417 );
16418 let backlog = inbox.inspect(session_id).await.unwrap();
16419 assert_eq!(backlog.pending + backlog.claimed, 0);
16420 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16421 let cursor = confirmed
16422 .session_inbox_admission()
16423 .expect("exact worker confirmation must checkpoint the admission cursor");
16424 assert!(cursor.contains(&first.envelope.id));
16425 assert!(cursor.contains(&second.envelope.id));
16426 for claim in [&first, &second] {
16427 assert_eq!(
16428 confirmed
16429 .messages
16430 .iter()
16431 .filter(|message| bamboo_domain::is_matching_session_message(
16432 message,
16433 &claim.envelope
16434 ))
16435 .count(),
16436 1,
16437 "confirmation must retain one exact canonical marker for {}",
16438 claim.envelope.id
16439 );
16440 }
16441 assert!(inbox
16442 .was_admitted(session_id, &first.envelope.id)
16443 .await
16444 .unwrap());
16445 assert!(inbox
16446 .was_admitted(session_id, &second.envelope.id)
16447 .await
16448 .unwrap());
16449 owner_registration.begin_finalization().await;
16450 owner_registration.finish(2).await.unwrap();
16451 }
16452
16453 #[derive(Default)]
16454 struct RecordingCodexTokenAuthority {
16455 issued_for: std::sync::Mutex<Vec<String>>,
16456 revoked: std::sync::Mutex<Vec<String>>,
16457 }
16458
16459 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16460 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16461 self.issued_for
16462 .lock()
16463 .expect("issued fixture lock")
16464 .push(session_id.to_string());
16465 Ok(IssuedCodexRunToken {
16466 token_id: format!("id-{session_id}"),
16467 token: format!("bcx1_secret-{session_id}"),
16468 })
16469 }
16470
16471 fn revoke(&self, token_id: &str) {
16472 self.revoked
16473 .lock()
16474 .expect("revoked fixture lock")
16475 .push(token_id.to_string());
16476 }
16477 }
16478
16479 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16480 let bamboo_mode = auth_mode == Some("bamboo")
16481 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16482 ExecutorSpec::Codex {
16483 binary: None,
16484 model: None,
16485 mode: None,
16486 sandbox: None,
16487 inherit_user_config,
16488 auth_mode: auth_mode.map(str::to_string),
16489 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16490 wire_api: Some("responses".to_string()),
16491 provider_key_ref: None,
16492 forward_env: None,
16493 approval_policy: None,
16494 network_access: None,
16495 allow_danger_bypass: None,
16496 permission_profile: None,
16497 workspace_owned: None,
16498 }
16499 }
16500
16501 fn permission_resolution(
16502 requested: bamboo_domain::SessionPermissionMode,
16503 effective: bamboo_domain::PermissionMode,
16504 ) -> bamboo_domain::PermissionModeResolution {
16505 bamboo_domain::PermissionModeResolution {
16506 requested,
16507 effective,
16508 }
16509 }
16510
16511 #[test]
16512 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16513 use bamboo_domain::{PermissionMode, SessionPermissionMode};
16514
16515 let default =
16516 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16517 assert_eq!(
16518 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16519 .unwrap()
16520 .as_deref(),
16521 Some("bamboo_runtime:default")
16522 );
16523 assert_eq!(
16524 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16525 None,
16526 "transport-only Echo must not claim the typed permission contract"
16527 );
16528 assert_eq!(
16529 expected_permission_executor_mapping(
16530 &ExecutorSpec::CliAdapter {
16531 command: "must-not-appear-in-contract".to_string(),
16532 args: vec!["credential-like-argument".to_string()],
16533 },
16534 default,
16535 false,
16536 )
16537 .unwrap(),
16538 None,
16539 "unimplemented CliAdapter must not leak command data into a contract"
16540 );
16541
16542 let claude = ExecutorSpec::ClaudeCode {
16543 binary: None,
16544 model: None,
16545 permission_mode: Some("default".to_string()),
16546 inherit_user_config: None,
16547 forward_env: None,
16548 };
16549 for (resolution, mapping) in [
16550 (
16551 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16552 "claude_code:permission_mode=plan",
16553 ),
16554 (
16555 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16556 "claude_code:permission_mode=bypassPermissions",
16557 ),
16558 (
16559 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16560 "claude_code:permission_mode=acceptEdits",
16561 ),
16562 (
16563 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16564 "claude_code:permission_mode=dontAsk",
16565 ),
16566 (
16567 permission_resolution(
16568 SessionPermissionMode::Bypass,
16569 PermissionMode::BypassPermissions,
16570 ),
16571 "claude_code:permission_mode=default",
16572 ),
16573 ] {
16574 assert_eq!(
16575 expected_permission_executor_mapping(&claude, resolution, false)
16576 .unwrap()
16577 .as_deref(),
16578 Some(mapping)
16579 );
16580 }
16581 assert_eq!(
16582 expected_permission_executor_mapping(&claude, default, true)
16583 .unwrap()
16584 .as_deref(),
16585 Some("claude_code:blocked_explicit_deny")
16586 );
16587
16588 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16589 if let ExecutorSpec::Codex {
16590 approval_policy, ..
16591 } = &mut codex_exec
16592 {
16593 *approval_policy = Some("on-failure".to_string());
16594 }
16595 assert_eq!(
16596 expected_permission_executor_mapping(&codex_exec, default, false)
16597 .unwrap()
16598 .as_deref(),
16599 Some("codex_exec:approval_policy=on-failure")
16600 );
16601 assert_eq!(
16602 expected_permission_executor_mapping(
16603 &codex_exec,
16604 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16605 false,
16606 )
16607 .unwrap()
16608 .as_deref(),
16609 Some("codex_exec:approval_policy=never")
16610 );
16611 assert_eq!(
16612 expected_permission_executor_mapping(&codex_exec, default, true)
16613 .unwrap()
16614 .as_deref(),
16615 Some("codex_exec:blocked_explicit_deny")
16616 );
16617
16618 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16619 if let ExecutorSpec::Codex {
16620 mode,
16621 approval_policy,
16622 ..
16623 } = &mut codex_app_server
16624 {
16625 *mode = Some("app_server".to_string());
16626 *approval_policy = Some("on-request".to_string());
16627 }
16628 assert_eq!(
16629 expected_permission_executor_mapping(&codex_app_server, default, false)
16630 .unwrap()
16631 .as_deref(),
16632 Some("codex_app_server:approvalPolicy=on-request")
16633 );
16634 assert_eq!(
16635 expected_permission_executor_mapping(
16636 &codex_app_server,
16637 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16638 false,
16639 )
16640 .unwrap()
16641 .as_deref(),
16642 Some("codex_app_server:approvalPolicy=never")
16643 );
16644 assert_eq!(
16645 expected_permission_executor_mapping(&codex_app_server, default, true)
16646 .unwrap()
16647 .as_deref(),
16648 Some("codex_app_server:blocked_explicit_deny")
16649 );
16650 }
16651
16652 #[test]
16653 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16654 let project = tempfile::tempdir().unwrap();
16655 let managed = project.path().join(".bamboo/worktree/child-571");
16656 std::fs::create_dir_all(&managed).unwrap();
16657 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16658 let marker = project
16659 .path()
16660 .join(".bamboo/worktree/.bamboo-owned/child-571");
16661 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16662 std::fs::write(&marker, "bamboo/child-571").unwrap();
16663 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16664 let nested = managed.join("nested/path");
16665 std::fs::create_dir_all(&nested).unwrap();
16666 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16667
16668 let arbitrary = tempfile::tempdir().unwrap();
16669 assert!(!workspace_is_bamboo_owned(
16670 arbitrary.path().to_str().unwrap()
16671 ));
16672 }
16673
16674 #[test]
16675 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16676 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16677 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16678
16679 let (secrets, guard) = build_codex_run_secrets(
16680 &codex_executor(Some("bamboo"), None),
16681 Some(authority_dyn),
16682 "child-570",
16683 )
16684 .unwrap();
16685
16686 let token = secrets
16687 .codex_provider_token
16688 .as_ref()
16689 .expect("bamboo mode mints a token");
16690 assert_eq!(token.expose(), "bcx1_secret-child-570");
16691 assert!(!format!("{token:?}").contains("secret-child-570"));
16692 assert_eq!(
16693 authority.issued_for.lock().unwrap().as_slice(),
16694 ["child-570"]
16695 );
16696 assert!(authority.revoked.lock().unwrap().is_empty());
16697
16698 drop(guard);
16699 assert_eq!(
16700 authority.revoked.lock().unwrap().as_slice(),
16701 ["id-child-570"]
16702 );
16703 }
16704
16705 #[test]
16706 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16707 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16708 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16709 let (secrets, guard) = build_codex_run_secrets(
16710 &codex_executor(Some("custom"), None),
16711 Some(authority_dyn),
16712 "child-custom",
16713 )
16714 .unwrap();
16715 assert!(secrets.codex_provider_token.is_none());
16716 assert!(guard.is_none());
16717 assert!(authority.issued_for.lock().unwrap().is_empty());
16718
16719 let error = build_codex_run_secrets(
16720 &codex_executor(Some("bamboo"), None),
16721 None,
16722 "child-no-authority",
16723 )
16724 .err()
16725 .expect("bamboo mode without an authority must fail closed");
16726 assert!(error.to_string().contains("per-run token authority"));
16727 }
16728
16729 #[test]
16730 fn codex_provisioning_never_leaks_the_session_provider_credential() {
16731 let credentials = vec![ScopedCredential {
16732 provider: "openai".to_string(),
16733 api_key: "upstream-secret-must-not-cross".to_string(),
16734 base_url: None,
16735 provider_type: Some("openai".to_string()),
16736 credential_ref: Some("provider.openai.api_key".to_string()),
16737 }];
16738
16739 for (mode, label) in [
16740 (Some("inherit"), "inherit"),
16741 (Some("api_key"), "api_key"),
16742 (Some("bamboo"), "bamboo"),
16743 (None, "default-bamboo"),
16744 ] {
16745 let runner = ActorChildRunner::new(
16746 format!("codex-{label}-test"),
16747 PathBuf::from("/bin/false"),
16748 Vec::new(),
16749 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16750 codex_executor(mode, None),
16751 credentials.clone(),
16752 "openai".to_string(),
16753 1,
16754 );
16755 let mut session = Session::new(format!("child-{label}"), "model");
16756 session.add_message(bamboo_agent_core::Message::user("test"));
16757 let spec = runner.build_spec(
16758 &session,
16759 &crate::runtime::execution::SpawnJob {
16760 parent_session_id: "parent".to_string(),
16761 child_session_id: format!("child-{label}"),
16762 model: "gpt-5.4".to_string(),
16763 disabled_tools: None,
16764 },
16765 );
16766 assert!(
16767 spec.secrets.provider_credentials.is_empty(),
16768 "{label} Codex must not receive the session provider key"
16769 );
16770 }
16771 }
16772
16773 #[test]
16774 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16775 let credentials = vec![
16776 ScopedCredential {
16777 provider: "openai".to_string(),
16778 api_key: "selected-openai-secret".to_string(),
16779 base_url: None,
16780 provider_type: Some("openai".to_string()),
16781 credential_ref: Some("provider.openai.api_key".to_string()),
16782 },
16783 ScopedCredential {
16784 provider: "other".to_string(),
16785 api_key: "unrelated-secret".to_string(),
16786 base_url: None,
16787 provider_type: Some("openai".to_string()),
16788 credential_ref: Some("provider.other.api_key".to_string()),
16789 },
16790 ];
16791 let runner = ActorChildRunner::new(
16792 "echo-test".to_string(),
16793 PathBuf::from("/bin/false"),
16794 Vec::new(),
16795 std::env::temp_dir().join("bamboo-echo-provider-570"),
16796 ExecutorSpec::Echo,
16797 credentials,
16798 "openai".to_string(),
16799 1,
16800 );
16801 let spec = runner.build_spec(
16802 &Session::new("child-echo", "model"),
16803 &crate::runtime::execution::SpawnJob {
16804 parent_session_id: "parent".to_string(),
16805 child_session_id: "child-echo".to_string(),
16806 model: "gpt-5.4".to_string(),
16807 disabled_tools: None,
16808 },
16809 );
16810
16811 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16812 assert_eq!(
16813 spec.secrets.provider_credentials[0].api_key,
16814 "selected-openai-secret"
16815 );
16816 }
16817
16818 #[tokio::test]
16819 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16820 fn provider_instance(
16821 provider_type: &str,
16822 api_key: &str,
16823 ) -> bamboo_config::ProviderInstanceConfig {
16824 bamboo_config::ProviderInstanceConfig {
16825 provider_type: provider_type.to_string(),
16826 label: None,
16827 api_key: api_key.to_string(),
16828 api_key_encrypted: None,
16829 credential_ref: None,
16830 base_url: None,
16831 model: None,
16832 fast_model: None,
16833 vision_model: None,
16834 reasoning_effort: None,
16835 responses_only_models: Vec::new(),
16836 request_overrides: None,
16837 enabled: true,
16838 extra: Default::default(),
16839 }
16840 }
16841
16842 let mut initial = bamboo_llm::Config::default();
16843 initial.provider_instances.insert(
16844 "old-provider".to_string(),
16845 provider_instance("openai", "old-key"),
16846 );
16847 initial.default_provider_instance = Some("old-provider".to_string());
16848 let live = Arc::new(tokio::sync::RwLock::new(initial));
16849
16850 let runner = ActorChildRunner::new(
16851 "echo-live-config-test".to_string(),
16852 PathBuf::from("/bin/false"),
16853 Vec::new(),
16854 std::env::temp_dir().join("bamboo-echo-live-config"),
16855 ExecutorSpec::Echo,
16856 Vec::new(),
16857 "stale-provider".to_string(),
16858 1,
16859 )
16860 .with_live_provider_config(live.clone());
16861 let job = crate::runtime::execution::SpawnJob {
16862 parent_session_id: "parent".to_string(),
16863 child_session_id: "child".to_string(),
16864 model: "model".to_string(),
16865 disabled_tools: None,
16866 };
16867
16868 let first = runner
16869 .build_live_spec(&Session::new("child", "model"), &job)
16870 .await;
16871 assert_eq!(
16872 first.model.as_ref().map(|model| model.provider.as_str()),
16873 Some("old-provider")
16874 );
16875 assert_eq!(first.secrets.provider_credentials.len(), 1);
16876 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16877
16878 let mut reloaded = bamboo_llm::Config::default();
16879 reloaded.provider_instances.insert(
16880 "new-provider".to_string(),
16881 provider_instance("openai", "new-key"),
16882 );
16883 reloaded.default_provider_instance = Some("new-provider".to_string());
16884 *live.write().await = reloaded;
16885
16886 let second = runner
16887 .build_live_spec(&Session::new("child", "model"), &job)
16888 .await;
16889 assert_eq!(
16890 second.model.as_ref().map(|model| model.provider.as_str()),
16891 Some("new-provider")
16892 );
16893 assert_eq!(second.secrets.provider_credentials.len(), 1);
16894 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16895 }
16896
16897 #[test]
16898 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16899 let mut executor = codex_executor(Some("custom"), None);
16900 if let ExecutorSpec::Codex {
16901 base_url,
16902 provider_key_ref,
16903 ..
16904 } = &mut executor
16905 {
16906 *base_url = Some("https://provider.example/v1".to_string());
16907 *provider_key_ref = Some("provider.custom.api_key".to_string());
16908 }
16909 let credentials = vec![
16910 ScopedCredential {
16911 provider: "openai".to_string(),
16912 api_key: "session-provider-secret".to_string(),
16913 base_url: None,
16914 provider_type: Some("openai".to_string()),
16915 credential_ref: Some("provider.openai.api_key".to_string()),
16916 },
16917 ScopedCredential {
16918 provider: "custom".to_string(),
16919 api_key: "selected-secret".to_string(),
16920 base_url: None,
16921 provider_type: Some("openai".to_string()),
16922 credential_ref: Some("provider.custom.api_key".to_string()),
16923 },
16924 ScopedCredential {
16925 provider: "other".to_string(),
16926 api_key: "unrelated-secret".to_string(),
16927 base_url: None,
16928 provider_type: Some("openai".to_string()),
16929 credential_ref: Some("provider.other.api_key".to_string()),
16930 },
16931 ];
16932 let runner = ActorChildRunner::new(
16933 "codex-test".to_string(),
16934 PathBuf::from("/bin/false"),
16935 Vec::new(),
16936 std::env::temp_dir().join("bamboo-codex-570"),
16937 executor,
16938 credentials,
16939 "openai".to_string(),
16940 1,
16941 );
16942 let mut session = Session::new("child-custom", "model");
16943 session.add_message(bamboo_agent_core::Message::user("test"));
16944 let spec = runner.build_spec(
16945 &session,
16946 &crate::runtime::execution::SpawnJob {
16947 parent_session_id: "parent".to_string(),
16948 child_session_id: "child-custom".to_string(),
16949 model: "gpt-5.4".to_string(),
16950 disabled_tools: None,
16951 },
16952 );
16953
16954 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16955 assert_eq!(
16956 spec.secrets.provider_credentials[0]
16957 .credential_ref
16958 .as_deref(),
16959 Some("provider.custom.api_key")
16960 );
16961 assert_eq!(
16962 spec.secrets.provider_credentials[0].api_key,
16963 "selected-secret"
16964 );
16965 }
16966
16967 fn spec_with(
16968 role: &str,
16969 provider: &str,
16970 model: &str,
16971 workspace: Option<&str>,
16972 disabled: Option<Vec<&str>>,
16973 ) -> ProvisionSpec {
16974 let mut spec = ProvisionSpec::new(
16975 ChildIdentity {
16976 child_id: "c".into(),
16977 parent_id: None,
16978 project_key: None,
16979 role: role.into(),
16980 depth: 0,
16981 },
16982 ExecutorSpec::Echo,
16983 "/tmp/fab".into(),
16984 );
16985 spec.workspace = workspace.map(|w| w.to_string());
16986 spec.model = Some(ModelRefSpec {
16987 provider: provider.into(),
16988 model: model.into(),
16989 });
16990 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16991 spec
16992 }
16993
16994 #[test]
16995 fn fingerprint_matches_interchangeable_children() {
16996 let a = spec_with(
16999 "explorer",
17000 "p",
17001 "m",
17002 Some("/ws"),
17003 Some(vec!["Bash", "Edit"]),
17004 );
17005 let mut b = spec_with(
17006 "explorer",
17007 "p",
17008 "m",
17009 Some("/ws"),
17010 Some(vec!["Edit", "Bash"]),
17011 );
17012 b.identity.child_id = "other".into();
17013 assert_eq!(
17014 ActorChildRunner::fingerprint(&a),
17015 ActorChildRunner::fingerprint(&b)
17016 );
17017 }
17018
17019 #[test]
17020 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
17021 let mut session =
17022 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
17023 session.root_session_id = "logical-root-681".to_string();
17024 let job = SpawnJob {
17025 parent_session_id: "logical-parent-681".to_string(),
17026 child_session_id: "logical-child-681".to_string(),
17027 model: "model".to_string(),
17028 disabled_tools: None,
17029 };
17030 let expected = LogicalSessionIdentity {
17031 creation: None,
17032 session_id: "logical-child-681".to_string(),
17033 parent_session_id: Some("logical-parent-681".to_string()),
17034 root_session_id: "logical-root-681".to_string(),
17035 };
17036
17037 let placements_and_transport_ids = [
17038 (Placement::Local, "local-mailbox-first"),
17039 (
17040 Placement::Remote {
17041 endpoint: "wss://remote.example/actor".to_string(),
17042 },
17043 "remote-process-44",
17044 ),
17045 (
17046 Placement::Schedulable {
17047 pool: "gpu-pool".to_string(),
17048 },
17049 "scheduled-mailbox-9",
17050 ),
17051 (Placement::Local, "warm-mailbox-reused-77"),
17053 ];
17054 for (placement, transport_id) in placements_and_transport_ids {
17055 let mut provision = spec_with("worker", "provider", "model", None, None);
17056 provision.placement = placement;
17057 provision.identity.child_id = transport_id.to_string();
17058 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17059 assert_ne!(
17060 provision.identity.child_id, expected.session_id,
17061 "test fixture must prove transport identity is independent"
17062 );
17063 }
17064 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
17065 original.executor = ExecutorSpec::BambooRuntime;
17066 original.identity.depth = session.spawn_depth;
17067 original.capabilities.required_child_context = true;
17068 original.capabilities.child_creation_identity = true;
17069 original.capabilities.enforce_permissions = true;
17070 original.capabilities.native_tool_ceiling_required = true;
17071 original.capabilities.native_tool_ceiling =
17072 Some(bamboo_subagent::proto::NativeToolCeiling {
17073 version: 1,
17074 child_session_id: session.id.clone(),
17075 parent_session_id: job.parent_session_id.clone(),
17076 root_session_id: session.root_session_id.clone(),
17077 created_at: session.created_at,
17078 spawn_depth: session.spawn_depth,
17079 project_id: None,
17080 tools: vec![],
17081 });
17082 original.validate().unwrap();
17083 let first = ActorChildRunner::fresh_required_worker_spec(&original);
17084 let second = ActorChildRunner::fresh_required_worker_spec(&original);
17085 assert_ne!(first.identity.child_id, second.identity.child_id);
17086 assert_ne!(first.identity.child_id, original.identity.child_id);
17087 for physical in [first, second] {
17088 physical.validate().unwrap();
17089 let mut actual = serde_json::to_value(&physical).unwrap();
17090 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
17091 assert_eq!(actual, serde_json::to_value(&original).unwrap());
17092 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17093 }
17094 }
17095
17096 #[test]
17097 fn fingerprint_separates_distinct_runtimes() {
17098 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
17099 let base_fp = ActorChildRunner::fingerprint(&base);
17100 assert_ne!(
17102 base_fp,
17103 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
17104 );
17105 assert_ne!(
17106 base_fp,
17107 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
17108 );
17109 assert_ne!(
17110 base_fp,
17111 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
17112 );
17113 assert_ne!(
17114 base_fp,
17115 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17116 );
17117 assert_ne!(
17118 base_fp,
17119 ActorChildRunner::fingerprint(&spec_with(
17120 "explorer",
17121 "p",
17122 "m",
17123 Some("/ws"),
17124 Some(vec!["Bash"])
17125 ))
17126 );
17127 }
17128
17129 #[test]
17130 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17131 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17132 before.secrets.provider_credentials.push(ScopedCredential {
17133 provider: "provider".to_string(),
17134 api_key: "old-secret-key".to_string(),
17135 base_url: Some("https://provider.example/v1".to_string()),
17136 provider_type: Some("openai".to_string()),
17137 credential_ref: None,
17138 });
17139 let mut after = before.clone();
17140 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17141
17142 let before_fingerprint = ActorChildRunner::fingerprint(&before);
17143 let after_fingerprint = ActorChildRunner::fingerprint(&after);
17144 assert_ne!(before_fingerprint, after_fingerprint);
17145 assert!(!before_fingerprint.contains("old-secret-key"));
17146 assert!(!after_fingerprint.contains("new-secret-key"));
17147 }
17148
17149 #[test]
17150 fn fingerprint_splits_on_baked_capabilities() {
17151 let base_fp =
17156 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17157
17158 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17159 creation.capabilities.child_creation_identity = true;
17160 let typed = ActorChildRunner::fingerprint(&creation);
17161 assert_ne!(
17162 base_fp, typed,
17163 "legacy worker pool cannot satisfy birth-required Run"
17164 );
17165 creation.identity.child_id = "different-logical-child".into();
17166 assert_eq!(
17167 typed,
17168 ActorChildRunner::fingerprint(&creation),
17169 "birth-required mode retains cross-Child pool reuse"
17170 );
17171
17172 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17173 depth.identity.depth = 2;
17174 assert_ne!(
17175 base_fp,
17176 ActorChildRunner::fingerprint(&depth),
17177 "depth must split"
17178 );
17179
17180 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17181 nested.capabilities.nested_spawn = true;
17182 assert_ne!(
17183 base_fp,
17184 ActorChildRunner::fingerprint(&nested),
17185 "nested_spawn must split"
17186 );
17187
17188 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17189 bypass.capabilities.bypass = true;
17190 assert_ne!(
17191 base_fp,
17192 ActorChildRunner::fingerprint(&bypass),
17193 "bypass must split"
17194 );
17195
17196 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17197 auto.capabilities.auto_approve_permissions = true;
17198 assert_ne!(
17199 base_fp,
17200 ActorChildRunner::fingerprint(&auto),
17201 "auto_approve_permissions must split"
17202 );
17203
17204 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17205 global_auto.capabilities.permission_requested_mode = "default".to_string();
17206 global_auto.capabilities.permission_effective_mode = "auto".to_string();
17207 global_auto.capabilities.auto_approve_permissions = true;
17208 let mut explicit_auto = global_auto.clone();
17209 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17210 assert_ne!(
17211 ActorChildRunner::fingerprint(&global_auto),
17212 ActorChildRunner::fingerprint(&explicit_auto),
17213 "permission_requested_mode must split global and explicit Auto"
17214 );
17215
17216 let mut plan_overlay = explicit_auto.clone();
17217 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17218 assert_ne!(
17219 ActorChildRunner::fingerprint(&explicit_auto),
17220 ActorChildRunner::fingerprint(&plan_overlay),
17221 "permission_effective_mode must split Plan overlay from Auto"
17222 );
17223
17224 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17225 enforce.capabilities.enforce_permissions = true;
17226 assert_ne!(
17227 base_fp,
17228 ActorChildRunner::fingerprint(&enforce),
17229 "enforce_permissions must split"
17230 );
17231
17232 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17233 cap.capabilities.max_spawn_depth = Some(8);
17234 assert_ne!(
17235 base_fp,
17236 ActorChildRunner::fingerprint(&cap),
17237 "max_spawn_depth must split"
17238 );
17239
17240 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17244 nha.capabilities.no_human_approver = true;
17245 assert_ne!(
17246 base_fp,
17247 ActorChildRunner::fingerprint(&nha),
17248 "no_human_approver must split"
17249 );
17250
17251 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17254 read_only.capabilities.read_only = true;
17255 assert_ne!(
17256 base_fp,
17257 ActorChildRunner::fingerprint(&read_only),
17258 "read_only must split"
17259 );
17260
17261 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17263 gro.capabilities.guardian_read_only = true;
17264 assert_ne!(
17265 base_fp,
17266 ActorChildRunner::fingerprint(&gro),
17267 "guardian_read_only must split"
17268 );
17269 }
17270
17271 #[test]
17272 fn fingerprint_splits_codex_exec_and_app_server_workers() {
17273 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17274 exec.executor = codex_executor(Some("inherit"), None);
17275 let mut app_server = exec.clone();
17276 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17277 *mode = Some("app_server".to_string());
17278 }
17279 assert_ne!(
17280 ActorChildRunner::fingerprint(&exec),
17281 ActorChildRunner::fingerprint(&app_server)
17282 );
17283 }
17284
17285 struct StaticDecider(bool);
17286
17287 #[async_trait]
17288 impl ChildApprovalDecider for StaticDecider {
17289 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17290 self.0
17291 }
17292 }
17293
17294 struct RecordingReviewer {
17295 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17296 }
17297
17298 #[async_trait]
17299 impl ChildApprovalReviewer for RecordingReviewer {
17300 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17301 let _ = self
17302 .reviewed
17303 .send((parent.to_string(), child.to_string(), request.clone()));
17304 true
17305 }
17306 }
17307
17308 struct SilentLink;
17313 #[async_trait]
17314 impl bamboo_subagent::ChildLink for SilentLink {
17315 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17316 Ok(())
17317 }
17318 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17319 std::future::pending().await
17320 }
17321 }
17322
17323 struct InstantTerminalLink {
17325 done: bool,
17326 }
17327
17328 struct ApprovalRoundTripLink {
17329 step: u8,
17330 approval_reply: Option<(String, bool)>,
17331 }
17332
17333 #[async_trait]
17334 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17335 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17336 if let ParentFrame::ApprovalReply { id, approved } = frame {
17337 self.approval_reply = Some((id, approved));
17338 self.step = 2;
17339 }
17340 Ok(())
17341 }
17342
17343 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17344 match self.step {
17345 0 => {
17346 self.step = 1;
17347 Ok(Some(ChildFrame::ApprovalRequest {
17348 id: "approval-1".into(),
17349 body: serde_json::json!({
17350 "tool_name": "Bash",
17351 "permission": "execute",
17352 "resource": "rm -rf target",
17353 "permission_request": {"reason_code": "hard_dangerous"}
17354 }),
17355 }))
17356 }
17357 1 => std::future::pending().await,
17358 2 => {
17359 self.step = 3;
17360 Ok(Some(ChildFrame::Terminal {
17361 status: TerminalStatus::Completed,
17362 result: Some("done".into()),
17363 error: None,
17364 final_event_watermark: None,
17365 transcript: vec![],
17366 }))
17367 }
17368 _ => std::future::pending().await,
17369 }
17370 }
17371 }
17372
17373 #[tokio::test]
17374 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17375 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17376 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17377 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17378 reviewed: review_tx,
17379 });
17380 let cancel = CancellationToken::new();
17381 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17382 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17383 let mut logical_session = Session::new("child-reviewer", "model");
17384 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17385 let mut link = ApprovalRoundTripLink {
17386 step: 0,
17387 approval_reply: None,
17388 };
17389
17390 let result = tokio::time::timeout(
17391 Duration::from_secs(1),
17392 drive(ActorDriveContext {
17393 expected_creation: None,
17394 client: &mut link,
17395 parent_session_id: "parent-reviewer",
17396 child_session_id: "child-reviewer",
17397 child_attempt: 0,
17398 approval_registry: None,
17399 approval_decider: None,
17400 approval_reviewer: Some(&reviewer),
17401 escalation_bridge: None,
17402 event_tx: &event_tx,
17403 cancel_token: &cancel,
17404 live_rx: &mut live_rx,
17405 delivery_rx: &mut delivery_rx,
17406 logical_session: &mut logical_session,
17407 expected_permission_posture: None,
17408 session_inbox_runtime: None,
17409 actor_directory_store: None,
17410 canonical_subagent_tool: None,
17411 ticket_service: None,
17412 activation_run_id: None,
17413 execution_epoch: 0,
17414 expected_source_actor_id: "child-reviewer",
17415 initial_inflight_claims: VecDeque::new(),
17416 plain_actor: false,
17417 remote_environment_lease: false,
17418 readonly_output: None,
17419 local_history_tools: None,
17420 local_history_read_only: false,
17421 strict_history_output: None,
17422 plain_input: None,
17423 canonical_activation: None,
17424 canonical_placement_ref: None,
17425 actor_event_observer: None,
17426 plain_run: None,
17427 first_frame_timeout: None,
17428 }),
17429 )
17430 .await
17431 .expect("worker must receive the reviewer verdict before terminating");
17432
17433 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17434 assert_eq!(
17435 link.approval_reply,
17436 Some(("approval-1".to_string(), true)),
17437 "reviewer verdict must traverse the live route back to the worker"
17438 );
17439 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17440 .await
17441 .expect("reviewer should be invoked off-loop")
17442 .expect("review channel should remain open");
17443 assert_eq!(parent, "parent-reviewer");
17444 assert_eq!(child, "child-reviewer");
17445 assert_eq!(
17446 body.pointer("/permission_request/reason_code")
17447 .and_then(serde_json::Value::as_str),
17448 Some("hard_dangerous")
17449 );
17450 assert!(
17451 event_rx.try_recv().is_err(),
17452 "must not emit a human-review event"
17453 );
17454 drop(live_guard);
17455 }
17456
17457 #[tokio::test]
17458 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17459 let (event_tx, mut event_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-no-reviewer", "model");
17464 let mut link = ApprovalRoundTripLink {
17465 step: 0,
17466 approval_reply: None,
17467 };
17468
17469 let result = tokio::time::timeout(
17470 Duration::from_secs(1),
17471 drive(ActorDriveContext {
17472 expected_creation: None,
17473 client: &mut link,
17474 parent_session_id: "parent-no-reviewer",
17475 child_session_id: "child-no-reviewer",
17476 child_attempt: 0,
17477 approval_registry: None,
17478 approval_decider: None,
17479 approval_reviewer: None,
17480 escalation_bridge: None,
17481 event_tx: &event_tx,
17482 cancel_token: &cancel,
17483 live_rx: &mut live_rx,
17484 delivery_rx: &mut delivery_rx,
17485 logical_session: &mut logical_session,
17486 expected_permission_posture: None,
17487 session_inbox_runtime: None,
17488 actor_directory_store: None,
17489 canonical_subagent_tool: None,
17490 ticket_service: None,
17491 activation_run_id: None,
17492 execution_epoch: 0,
17493 expected_source_actor_id: "child-no-reviewer",
17494 initial_inflight_claims: VecDeque::new(),
17495 plain_actor: false,
17496 remote_environment_lease: false,
17497 readonly_output: None,
17498 local_history_tools: None,
17499 local_history_read_only: false,
17500 strict_history_output: None,
17501 plain_input: None,
17502 canonical_activation: None,
17503 canonical_placement_ref: None,
17504 actor_event_observer: None,
17505 plain_run: None,
17506 first_frame_timeout: None,
17507 }),
17508 )
17509 .await
17510 .expect("fail-closed reply must unblock the child immediately");
17511
17512 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17513 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17514 assert!(
17515 event_rx.try_recv().is_err(),
17516 "missing parent review must not surface a manual approval event"
17517 );
17518 }
17519 #[async_trait]
17520 impl bamboo_subagent::ChildLink for InstantTerminalLink {
17521 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17522 Ok(())
17523 }
17524 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17525 if self.done {
17526 std::future::pending().await
17527 } else {
17528 self.done = true;
17529 Ok(Some(ChildFrame::Terminal {
17530 status: TerminalStatus::Completed,
17531 result: Some("done".into()),
17532 error: None,
17533 final_event_watermark: None,
17534 transcript: vec![],
17535 }))
17536 }
17537 }
17538 }
17539
17540 #[tokio::test]
17541 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17542 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17543 let cancel = CancellationToken::new();
17544 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17545 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17546 let mut logical_session = Session::new("child-x", "model");
17547 let mut link = SilentLink;
17548 let r = drive(ActorDriveContext {
17549 expected_creation: None,
17550 client: &mut link,
17551 parent_session_id: "parent-x",
17552 child_session_id: "child-x",
17553 child_attempt: 0,
17554 approval_registry: None,
17555 approval_decider: None,
17556 approval_reviewer: None,
17557 escalation_bridge: None,
17558 event_tx: &event_tx,
17559 cancel_token: &cancel,
17560 live_rx: &mut live_rx,
17561 delivery_rx: &mut delivery_rx,
17562 logical_session: &mut logical_session,
17563 expected_permission_posture: None,
17564 session_inbox_runtime: None,
17565 actor_directory_store: None,
17566 canonical_subagent_tool: None,
17567 ticket_service: None,
17568 activation_run_id: None,
17569 execution_epoch: 0,
17570 expected_source_actor_id: "child-x",
17571 initial_inflight_claims: VecDeque::new(),
17572 plain_actor: false,
17573 remote_environment_lease: false,
17574 readonly_output: None,
17575 local_history_tools: None,
17576 local_history_read_only: false,
17577 strict_history_output: None,
17578 plain_input: None,
17579 canonical_activation: None,
17580 canonical_placement_ref: None,
17581 actor_event_observer: None,
17582 plain_run: None,
17583 first_frame_timeout: Some(Duration::from_millis(100)),
17584 })
17585 .await;
17586 assert!(
17587 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17588 "a silent worker must trip the first-frame watchdog, got {r:?}"
17589 );
17590 }
17591
17592 #[tokio::test]
17593 async fn drive_does_not_trip_when_a_frame_arrives() {
17594 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17595 let cancel = CancellationToken::new();
17596 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17597 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17598 let mut logical_session = Session::new("child-y", "model");
17599 let mut link = InstantTerminalLink { done: false };
17600 let r = drive(ActorDriveContext {
17603 expected_creation: None,
17604 client: &mut link,
17605 parent_session_id: "parent-y",
17606 child_session_id: "child-y",
17607 child_attempt: 0,
17608 approval_registry: None,
17609 approval_decider: None,
17610 approval_reviewer: None,
17611 escalation_bridge: None,
17612 event_tx: &event_tx,
17613 cancel_token: &cancel,
17614 live_rx: &mut live_rx,
17615 delivery_rx: &mut delivery_rx,
17616 logical_session: &mut logical_session,
17617 expected_permission_posture: None,
17618 session_inbox_runtime: None,
17619 actor_directory_store: None,
17620 canonical_subagent_tool: None,
17621 ticket_service: None,
17622 activation_run_id: None,
17623 execution_epoch: 0,
17624 expected_source_actor_id: "child-y",
17625 initial_inflight_claims: VecDeque::new(),
17626 plain_actor: false,
17627 remote_environment_lease: false,
17628 readonly_output: None,
17629 local_history_tools: None,
17630 local_history_read_only: false,
17631 strict_history_output: None,
17632 plain_input: None,
17633 canonical_activation: None,
17634 canonical_placement_ref: None,
17635 actor_event_observer: None,
17636 plain_run: None,
17637 first_frame_timeout: Some(Duration::from_millis(50)),
17638 })
17639 .await;
17640 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17641 }
17642
17643 #[tokio::test]
17644 async fn child_approval_fails_closed_without_decider() {
17645 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17647 assert!(!decide_child_approval(None, "child-1", &body).await);
17648 }
17649
17650 #[tokio::test]
17651 async fn child_approval_honors_wired_decider() {
17652 let body =
17653 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17654 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17655 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17656 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17657 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17658 }
17659
17660 use crate::runtime::execution::SpawnJob;
17663 use bamboo_agent_core::Session;
17664
17665 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17668 ActorChildRunner::new(
17669 "test-actor".into(),
17670 PathBuf::from("/bin/false"),
17671 vec![],
17672 std::env::temp_dir().join("bamboo-test-fab-193"),
17673 ExecutorSpec::Echo,
17674 vec![],
17675 "anthropic".into(),
17676 4,
17677 )
17678 .with_remote_placements(placements)
17679 }
17680
17681 fn session_of_role(role: &str, assignment: &str) -> Session {
17684 let mut s = Session::new("child-1", "test-model");
17685 s.metadata
17686 .insert("subagent_type".to_string(), role.to_string());
17687 s.add_message(bamboo_agent_core::Message::user(assignment));
17688 s
17689 }
17690
17691 fn job_for(child: &str) -> SpawnJob {
17692 SpawnJob {
17693 parent_session_id: "parent-1".into(),
17694 child_session_id: child.into(),
17695 model: String::new(),
17696 disabled_tools: None,
17697 }
17698 }
17699
17700 #[tokio::test]
17701 async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17702 let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17703 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17704 let mut remote = HashMap::new();
17705 remote.insert(
17706 "remote-role".into(),
17707 ResolvedRemotePlacement {
17708 endpoint: "wss://worker.example:8443".into(),
17709 token: None,
17710 ca_cert_file: None,
17711 host_label: None,
17712 broker_peer: None,
17713 requirements: None,
17714 },
17715 );
17716 let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17717 let root = Session::new("provision-root", "model");
17718 for role in ["local-role", "remote-role"] {
17719 let mut child =
17720 Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17721 child.metadata.insert("subagent_type".into(), role.into());
17722 let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17723 assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17724 assert!(!first.capabilities.nested_spawn);
17725 assert_eq!(
17726 matches!(first.placement, Placement::Remote { .. }),
17727 role == "remote-role"
17728 );
17729 live.write().await.subagents_mut().max_spawn_depth = Some(7);
17730 let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17731 assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17732 assert!(second.capabilities.nested_spawn);
17733 assert_ne!(
17734 ActorChildRunner::fingerprint(&first),
17735 ActorChildRunner::fingerprint(&second)
17736 );
17737 assert_eq!(second.identity.depth, 1);
17738 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17739 }
17740 let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17741 let spec = fallback.build_spec(&root, &job_for(&root.id));
17742 assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17743 assert!(!spec.capabilities.nested_spawn);
17744 }
17745
17746 struct RecordingChildSessionPort {
17747 parent: Session,
17748 saved: std::sync::Mutex<Option<Session>>,
17749 }
17750
17751 impl RecordingChildSessionPort {
17752 fn saved_child(&self) -> Session {
17753 self.saved
17754 .lock()
17755 .expect("saved-child fixture lock")
17756 .clone()
17757 .expect("create_child_action must save the child")
17758 }
17759 }
17760
17761 #[async_trait]
17762 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17763 async fn load_root_session(
17764 &self,
17765 root_id: &str,
17766 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17767 assert_eq!(root_id, self.parent.id);
17768 Ok(self.parent.clone())
17769 }
17770
17771 async fn load_child_for_parent(
17772 &self,
17773 _parent_id: &str,
17774 _child_id: &str,
17775 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17776 unreachable!("create_child_action does not reload the child")
17777 }
17778
17779 async fn save_child_session(
17780 &self,
17781 child: &mut Session,
17782 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17783 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17784 Ok(())
17785 }
17786
17787 async fn save_child_session_authoritative_flags(
17788 &self,
17789 _child: &mut Session,
17790 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17791 unreachable!("new-child creation uses the ordinary save")
17792 }
17793
17794 async fn is_child_running(&self, _child_id: &str) -> bool {
17795 false
17796 }
17797
17798 async fn list_children(
17799 &self,
17800 _parent_id: &str,
17801 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17802 Vec::new()
17803 }
17804
17805 async fn enqueue_child_run(
17806 &self,
17807 _parent: &Session,
17808 _child: &Session,
17809 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17810 unreachable!("fixture creates the child with auto_run=false")
17811 }
17812
17813 async fn cancel_child_run_and_wait(
17814 &self,
17815 _child_id: &str,
17816 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17817 unreachable!("create_child_action does not cancel")
17818 }
17819
17820 async fn delete_child_session(
17821 &self,
17822 _parent_id: &str,
17823 _child_id: &str,
17824 ) -> Result<
17825 crate::session_app::child_session::DeleteChildResult,
17826 crate::session_app::child_session::ChildSessionError,
17827 > {
17828 unreachable!("create_child_action does not delete")
17829 }
17830
17831 async fn get_child_runner_info(
17832 &self,
17833 _child_id: &str,
17834 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17835 None
17836 }
17837
17838 async fn register_parent_wait_for_child(
17839 &self,
17840 _parent_session_id: &str,
17841 _child_session_id: &str,
17842 _tool_call_id: Option<&str>,
17843 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17844 unreachable!("create_child_action does not register a wait")
17845 }
17846
17847 async fn register_parent_wait_for_children(
17848 &self,
17849 _parent_session_id: &str,
17850 _child_session_ids: &[String],
17851 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17852 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17853 unreachable!("create_child_action does not register a wait")
17854 }
17855
17856 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17857 Vec::new()
17858 }
17859
17860 async fn find_resident_child(
17861 &self,
17862 _root_session_id: &str,
17863 _resident_name: &str,
17864 ) -> Option<String> {
17865 None
17866 }
17867
17868 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17869 }
17870
17871 #[test]
17872 fn build_spec_sets_remote_placement_for_matching_role() {
17873 let mut placements = HashMap::new();
17874 placements.insert(
17875 "explorer".to_string(),
17876 ResolvedRemotePlacement {
17877 endpoint: "wss://gpu-host:8443".into(),
17878 token: Some("T-secret".into()),
17879 ca_cert_file: None,
17880 host_label: None,
17881 broker_peer: None,
17882 requirements: None,
17883 },
17884 );
17885 let runner = bogus_runner(placements);
17886
17887 let s = session_of_role("explorer", "do the thing");
17889 let spec = runner.build_spec(&s, &job_for("child-1"));
17890 match &spec.placement {
17891 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17892 other => panic!("expected Remote, got {other:?}"),
17893 }
17894 assert!(spec.secrets.worker_auth_token.is_none());
17895 }
17896
17897 #[test]
17898 fn build_spec_leaves_local_for_unmatched_role() {
17899 let mut placements = HashMap::new();
17900 placements.insert(
17901 "explorer".to_string(),
17902 ResolvedRemotePlacement {
17903 endpoint: "wss://gpu-host:8443".into(),
17904 token: Some("T".into()),
17905 ca_cert_file: None,
17906 host_label: None,
17907 broker_peer: None,
17908 requirements: None,
17909 },
17910 );
17911 let runner = bogus_runner(placements);
17912
17913 let s = session_of_role("writer", "do the thing");
17915 let spec = runner.build_spec(&s, &job_for("child-1"));
17916 assert_eq!(spec.placement, Placement::Local);
17917 assert!(spec.secrets.worker_auth_token.is_none());
17918 }
17919
17920 #[test]
17921 fn build_spec_local_when_no_placements() {
17922 let runner = bogus_runner(HashMap::new());
17923 let s = session_of_role("explorer", "do the thing");
17924 let spec = runner.build_spec(&s, &job_for("child-1"));
17925 assert_eq!(spec.placement, Placement::Local);
17926 assert!(spec.secrets.worker_auth_token.is_none());
17927 }
17928
17929 #[test]
17930 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17931 for mode in [
17932 bamboo_domain::SessionPermissionMode::Auto,
17933 bamboo_domain::SessionPermissionMode::Bypass,
17934 ] {
17935 let runner = bogus_runner(HashMap::new());
17936 let mut child = session_of_role("planner", "inspect and plan");
17937 let runtime = child
17938 .agent_runtime_state
17939 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17940 runtime.set_permission_mode(mode);
17941 runtime.read_only = true;
17942
17943 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17944
17945 assert!(spec.capabilities.read_only);
17946 assert!(spec.capabilities.read_only_enforced());
17947 assert!(
17948 spec.capabilities.guardian_read_only,
17949 "the compatibility alias must protect planners on older workers"
17950 );
17951 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17952 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17953 assert!(
17954 !spec.capabilities.bypass,
17955 "read-only must win over requested {mode:?}"
17956 );
17957 assert_eq!(
17958 spec.capabilities.auto_approve_permissions,
17959 mode == bamboo_domain::SessionPermissionMode::Auto,
17960 "Auto may suppress prompts but must not widen Plan authorization"
17961 );
17962 let disabled = spec
17963 .disabled_tools
17964 .as_ref()
17965 .expect("read-only state must provision a host-owned denylist");
17966 for tool_name in [
17967 "Edit",
17968 "Write",
17969 "Plan",
17970 "SubAgent",
17971 "session_control",
17972 "request_permissions",
17973 ] {
17974 assert!(
17975 disabled.iter().any(|name| name == tool_name),
17976 "read-only provisioning must disable {tool_name}"
17977 );
17978 }
17979 }
17980
17981 let runner = bogus_runner(HashMap::new());
17982 let cosmetic_planner = runner.build_spec(
17983 &session_of_role("planner", "label only"),
17984 &job_for("cosmetic"),
17985 );
17986 assert!(
17987 !cosmetic_planner.capabilities.read_only_enforced(),
17988 "a cosmetic role label must not change authority"
17989 );
17990 assert!(cosmetic_planner.disabled_tools.is_none());
17991
17992 let cosmetic_guardian = runner.build_spec(
17993 &session_of_role("guardian", "label only"),
17994 &job_for("cosmetic-guardian"),
17995 );
17996 assert!(
17997 !cosmetic_guardian.capabilities.read_only_enforced(),
17998 "a Guardian label without typed state or the legacy denylist is cosmetic"
17999 );
18000
18001 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
18002 let mut legacy_job = job_for("legacy-guardian");
18003 legacy_job.disabled_tools = Some(vec![
18004 "Edit".to_string(),
18005 "Write".to_string(),
18006 "SubAgent".to_string(),
18007 "WebFetch".to_string(),
18008 ]);
18009 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
18010 assert!(
18011 legacy_spec.capabilities.read_only_enforced(),
18012 "an in-flight pre-upgrade Guardian keeps its read-only authority"
18013 );
18014
18015 let mut differently_named = session_of_role("explorer", "typed authority");
18016 differently_named
18017 .agent_runtime_state
18018 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18019 .read_only = true;
18020 let typed = runner.build_spec(&differently_named, &job_for("typed"));
18021 assert!(
18022 typed.capabilities.read_only_enforced(),
18023 "typed state must enforce read-only independently of role"
18024 );
18025 }
18026
18027 #[test]
18028 fn typed_read_only_forces_native_external_executor_profiles() {
18029 let mut planner = session_of_role("planner", "inspect and plan");
18030 planner
18031 .agent_runtime_state
18032 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18033 .read_only = true;
18034
18035 let claude_runner = ActorChildRunner::new(
18036 "test-actor".into(),
18037 PathBuf::from("/bin/false"),
18038 vec![],
18039 std::env::temp_dir().join("bamboo-test-plan-claude"),
18040 ExecutorSpec::ClaudeCode {
18041 binary: None,
18042 model: None,
18043 permission_mode: Some("bypassPermissions".to_string()),
18044 inherit_user_config: Some(true),
18045 forward_env: None,
18046 },
18047 vec![],
18048 "anthropic".into(),
18049 4,
18050 );
18051 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
18052 let disabled = claude_spec
18053 .disabled_tools
18054 .as_ref()
18055 .expect("typed read-only child must receive a host-owned denylist");
18056 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
18057 assert!(
18058 disabled.iter().any(|name| name == tool),
18059 "typed read-only child must not advertise {tool}"
18060 );
18061 }
18062 assert!(matches!(
18063 claude_spec.executor,
18064 ExecutorSpec::ClaudeCode {
18065 permission_mode: Some(ref mode),
18066 inherit_user_config: Some(false),
18067 ..
18068 } if mode == "plan"
18069 ));
18070 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
18071
18072 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
18073 if let ExecutorSpec::Codex {
18074 sandbox,
18075 approval_policy,
18076 network_access,
18077 allow_danger_bypass,
18078 permission_profile,
18079 ..
18080 } = &mut codex_exec
18081 {
18082 *sandbox = Some("danger-full-access".to_string());
18083 *approval_policy = Some("on-failure".to_string());
18084 *network_access = Some(true);
18085 *allow_danger_bypass = Some(true);
18086 *permission_profile = Some("full-access".to_string());
18087 }
18088 let codex_runner = ActorChildRunner::new(
18089 "test-actor".into(),
18090 PathBuf::from("/bin/false"),
18091 vec![],
18092 std::env::temp_dir().join("bamboo-test-plan-codex"),
18093 codex_exec,
18094 vec![],
18095 "openai".into(),
18096 4,
18097 );
18098 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
18099 assert_eq!(
18100 expected_permission_executor_mapping(
18101 &codex_spec.executor,
18102 codex_spec.capabilities.permission_resolution().unwrap(),
18103 false,
18104 )
18105 .unwrap()
18106 .as_deref(),
18107 Some("codex_exec:approval_policy=never")
18108 );
18109 assert!(matches!(
18110 codex_spec.executor,
18111 ExecutorSpec::Codex {
18112 sandbox: Some(ref sandbox),
18113 inherit_user_config: Some(false),
18114 approval_policy: Some(ref approval_policy),
18115 network_access: Some(false),
18116 allow_danger_bypass: Some(false),
18117 permission_profile: Some(ref profile),
18118 ..
18119 } if sandbox == "read-only"
18120 && approval_policy == "never"
18121 && profile == "read-only"
18122 ));
18123
18124 let mut app_server = codex_executor(Some("inherit"), Some(true));
18125 if let ExecutorSpec::Codex {
18126 mode,
18127 sandbox,
18128 approval_policy,
18129 network_access,
18130 allow_danger_bypass,
18131 permission_profile,
18132 ..
18133 } = &mut app_server
18134 {
18135 *mode = Some("app_server".to_string());
18136 *sandbox = Some("danger-full-access".to_string());
18137 *approval_policy = Some("on-request".to_string());
18138 *network_access = Some(true);
18139 *allow_danger_bypass = Some(true);
18140 *permission_profile = Some("full-access".to_string());
18141 }
18142 let app_server_runner = ActorChildRunner::new(
18143 "test-actor".into(),
18144 PathBuf::from("/bin/false"),
18145 vec![],
18146 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18147 app_server,
18148 vec![],
18149 "openai".into(),
18150 4,
18151 );
18152 let app_server_spec =
18153 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18154 assert_eq!(
18155 expected_permission_executor_mapping(
18156 &app_server_spec.executor,
18157 app_server_spec
18158 .capabilities
18159 .permission_resolution()
18160 .unwrap(),
18161 false,
18162 )
18163 .unwrap()
18164 .as_deref(),
18165 Some("codex_app_server:approvalPolicy=never")
18166 );
18167 assert!(matches!(
18168 app_server_spec.executor,
18169 ExecutorSpec::Codex {
18170 mode: Some(ref mode),
18171 sandbox: Some(ref sandbox),
18172 inherit_user_config: Some(false),
18173 approval_policy: Some(ref approval_policy),
18174 network_access: Some(false),
18175 allow_danger_bypass: Some(false),
18176 permission_profile: Some(ref profile),
18177 ..
18178 } if mode == "app_server"
18179 && sandbox == "read-only"
18180 && approval_policy == "on-request"
18181 && profile == "read-only"
18182 ));
18183 }
18184
18185 #[test]
18186 fn typed_read_only_rejects_both_codex_executor_modes() {
18187 let mut planner = session_of_role("planner", "inspect and plan");
18188 planner
18189 .agent_runtime_state
18190 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18191 .read_only = true;
18192
18193 for mode in ["exec", "app_server"] {
18194 let mut executor = codex_executor(Some("inherit"), Some(true));
18195 if let ExecutorSpec::Codex {
18196 mode: configured_mode,
18197 ..
18198 } = &mut executor
18199 {
18200 *configured_mode = Some(mode.to_string());
18201 }
18202 let runner = ActorChildRunner::new(
18203 "test-actor".into(),
18204 PathBuf::from("/bin/false"),
18205 vec![],
18206 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18207 executor,
18208 vec![],
18209 "openai".into(),
18210 4,
18211 );
18212 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18213 let error = ensure_read_only_activation_is_enforceable(&spec)
18214 .expect_err("Codex command execution must fail closed for read-only activations");
18215 let message = error.to_string();
18216 assert!(message.contains("Codex executor"), "{message}");
18217 assert!(message.contains("command execution"), "{message}");
18218 }
18219 }
18220
18221 #[tokio::test]
18222 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18223 let mut planner = session_of_role("planner", "inspect and plan");
18224 planner
18225 .agent_runtime_state
18226 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18227 .read_only = true;
18228
18229 let runner = ActorChildRunner::new(
18230 "test-actor".into(),
18231 PathBuf::from("/bin/false"),
18232 vec![],
18233 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18234 codex_executor(Some("inherit"), Some(true)),
18235 vec![],
18236 "openai".into(),
18237 4,
18238 );
18239 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18240 let error = runner
18241 .execute_external_child(
18242 &mut planner,
18243 &job_for("planner-codex-no-spawn"),
18244 event_tx,
18245 CancellationToken::new(),
18246 )
18247 .await
18248 .expect_err("typed read-only Codex must fail before invoking /bin/false");
18249 let message = error.to_string();
18250 assert!(message.contains("Codex executor"), "{message}");
18251 assert!(message.contains("command execution"), "{message}");
18252 }
18253
18254 #[test]
18255 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18256 let runner = bogus_runner(HashMap::new());
18257 let ordinary = session_of_role("worker", "ordinary work");
18258 for placement in [
18259 Placement::Local,
18260 Placement::Remote {
18261 endpoint: "wss://fixture.invalid/actor".into(),
18262 },
18263 Placement::Schedulable {
18264 pool: "fixture-pool".into(),
18265 },
18266 ] {
18267 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18268 spec.placement = placement;
18269 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18270 for marker in [
18271 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18272 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18273 ] {
18274 let mut ticket = ordinary.clone();
18275 ticket.metadata.insert(marker.into(), "fixture".into());
18276 assert_eq!(
18277 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18278 matches!(spec.placement, Placement::Local)
18279 );
18280 }
18281 }
18282 }
18283
18284 #[tokio::test]
18285 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18286 for marker in [
18287 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18288 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18289 ] {
18290 for schedulable in [false, true] {
18291 let mut session = session_of_role("worker", "Ticket work");
18292 session.metadata.insert(marker.into(), "fixture".into());
18293 let before = serde_json::to_value(&session).unwrap();
18294 let remote = if schedulable {
18295 HashMap::new()
18296 } else {
18297 HashMap::from([(
18298 "worker".into(),
18299 ResolvedRemotePlacement {
18300 endpoint: "wss://fixture.invalid/actor".into(),
18301 ..Default::default()
18302 },
18303 )])
18304 };
18305 let sched = if schedulable {
18306 HashMap::from([(
18307 "worker".into(),
18308 sched_placement("fixture-pool", "wss://fixture.invalid"),
18309 )])
18310 } else {
18311 HashMap::new()
18312 };
18313 let runner = bogus_sched_runner(remote, sched);
18314 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18315 let error = runner
18316 .execute_external_child(
18317 &mut session,
18318 &job_for("ticket-no-remote"),
18319 events,
18320 CancellationToken::new(),
18321 )
18322 .await
18323 .unwrap_err();
18324 assert!(
18325 error
18326 .to_string()
18327 .contains("Ticket execution requires local placement"),
18328 "{error}"
18329 );
18330 assert_eq!(serde_json::to_value(&session).unwrap(), before);
18331 assert!(observations.try_recv().is_err());
18332 }
18333 }
18334 }
18335
18336 #[test]
18337 fn typed_read_only_rejects_resident_actor_placements() {
18338 let runner = bogus_runner(HashMap::new());
18339 let mut planner = session_of_role("planner", "inspect and plan");
18340 planner
18341 .agent_runtime_state
18342 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18343 .read_only = true;
18344
18345 for placement in [
18346 Placement::Remote {
18347 endpoint: "wss://resident.example/actor".to_string(),
18348 },
18349 Placement::Schedulable {
18350 pool: "resident-pool".to_string(),
18351 },
18352 ] {
18353 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18354 spec.placement = placement;
18355
18356 let error = ensure_read_only_activation_is_enforceable(&spec)
18357 .expect_err("resident placement must fail closed for read-only activation");
18358 let message = error.to_string();
18359 assert!(message.contains("requires local placement"), "{message}");
18360 assert!(message.contains("read-only tool boundary"), "{message}");
18361 }
18362
18363 let local = runner.build_spec(&planner, &job_for("read-only-local"));
18364 ensure_read_only_activation_is_enforceable(&local)
18365 .expect("a locally provisioned read-only worker is enforceable");
18366 }
18367
18368 #[tokio::test]
18369 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18370 for (label, mode) in [
18374 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18375 ("auto", bamboo_domain::SessionPermissionMode::Auto),
18376 ] {
18377 let runner = bogus_runner(HashMap::new());
18378 let mut parent = Session::new(format!("parent-{label}"), "test-model");
18379 parent
18380 .agent_runtime_state
18381 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18382 .set_permission_mode(mode);
18383 let workspace = tempfile::tempdir().expect("workspace fixture");
18384 let port = RecordingChildSessionPort {
18385 parent: parent.clone(),
18386 saved: Default::default(),
18387 };
18388 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18389 crate::session_app::child_session::create_child_action(
18390 &port,
18391 crate::session_app::child_session::CreateChildInput {
18392 parent_session: parent,
18393 child_id: child_id.clone(),
18394 title: format!("{label} child"),
18395 responsibility: "Run ordinary commands".to_string(),
18396 assignment_prompt: "run an ordinary command".to_string(),
18397 subagent_type: "explorer".to_string(),
18398 workspace: workspace.path().to_string_lossy().into_owned(),
18399 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18400 model_override: None,
18401 model_ref_override: None,
18402 runtime_metadata: HashMap::new(),
18403 read_only: false,
18404 auto_run: false,
18405 reasoning_effort: None,
18406 lifecycle: None,
18407 resident_name: None,
18408 resident_context: None,
18409 disabled_tools: None,
18410 context_fork: None,
18411 },
18412 )
18413 .await
18414 .expect("create inherited-permission child");
18415 let child = port.saved_child();
18416
18417 assert_eq!(
18418 child
18419 .agent_runtime_state
18420 .as_ref()
18421 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18422 Some(mode),
18423 "create_child_action must inherit {label} from the parent"
18424 );
18425
18426 let spec = runner.build_spec(&child, &job_for(&child_id));
18427
18428 assert_eq!(
18429 spec.capabilities.bypass,
18430 mode == bamboo_domain::SessionPermissionMode::Bypass
18431 );
18432 assert_eq!(
18433 spec.capabilities.auto_approve_permissions,
18434 mode == bamboo_domain::SessionPermissionMode::Auto
18435 );
18436 assert!(
18437 spec.capabilities.enforce_permissions,
18438 "policy evaluation must remain active under {label}"
18439 );
18440 }
18441 }
18442
18443 #[tokio::test]
18444 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18445 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18446 let workspace = tempfile::tempdir().expect("workspace fixture");
18447
18448 for (role, lifecycle, resident_name) in [
18449 ("explorer", None, None),
18450 ("resident", Some("resident"), Some("stable-reviewer")),
18451 ("guardian", None, None),
18452 ] {
18453 let mut parent = Session::new(format!("parent-{role}"), "test-model");
18454 parent.set_project_id_meta(project_id.to_string());
18455 let port = RecordingChildSessionPort {
18456 parent: parent.clone(),
18457 saved: Default::default(),
18458 };
18459 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18460 crate::session_app::child_session::create_child_action(
18461 &port,
18462 crate::session_app::child_session::CreateChildInput {
18463 parent_session: parent,
18464 child_id: child_id.clone(),
18465 title: format!("{role} child"),
18466 responsibility: "Review the assigned work".to_string(),
18467 assignment_prompt: "inspect the change".to_string(),
18468 subagent_type: role.to_string(),
18469 workspace: workspace.path().to_string_lossy().into_owned(),
18470 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18471 model_override: None,
18472 model_ref_override: None,
18473 runtime_metadata: HashMap::new(),
18474 read_only: false,
18475 auto_run: false,
18476 reasoning_effort: None,
18477 lifecycle: lifecycle.map(str::to_string),
18478 resident_name: resident_name.map(str::to_string),
18479 resident_context: None,
18480 disabled_tools: None,
18481 context_fork: None,
18482 },
18483 )
18484 .await
18485 .expect("create Project-inheriting child");
18486 let child = port.saved_child();
18487 assert_eq!(
18488 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18489 Some(project_id.clone()),
18490 "{role} child must inherit its parent's Project"
18491 );
18492
18493 assert_eq!(
18494 project_id_for_actor_run(&child).expect("valid actor Project identity"),
18495 Some(project_id.clone()),
18496 "{role} actor RunSpec must preserve inherited Project identity"
18497 );
18498 }
18499 }
18500
18501 #[test]
18502 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18503 assert_eq!(placement_metadata(&Placement::Local, None), None);
18505
18506 let r = placement_metadata(
18508 &Placement::Remote {
18509 endpoint: "wss://10.0.0.5:8443/stream".into(),
18510 },
18511 None,
18512 )
18513 .unwrap();
18514 assert!(r.contains(r#""kind":"remote""#), "{r}");
18515 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18516
18517 let labeled = placement_metadata(
18519 &Placement::Remote {
18520 endpoint: "ws://169.254.230.101:8899".into(),
18521 },
18522 Some("mini"),
18523 )
18524 .unwrap();
18525 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18526
18527 let s = placement_metadata(
18529 &Placement::Schedulable {
18530 pool: "explorers".into(),
18531 },
18532 Some("mini"),
18533 )
18534 .unwrap();
18535 assert!(s.contains(r#""kind":"remote""#), "{s}");
18536 assert!(s.contains(r#""host":"mini""#), "{s}");
18537
18538 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18540 assert_eq!(p.kind, "remote");
18541 assert_eq!(p.host, "mini");
18542 }
18543
18544 #[tokio::test]
18547 async fn execute_external_child_rejects_direct_remote_worker() {
18548 let mut placements = HashMap::new();
18549 placements.insert(
18550 "explorer".to_string(),
18551 ResolvedRemotePlacement {
18552 endpoint: "ws://127.0.0.1:1".into(),
18553 token: Some("remote-test-token".into()),
18554 ca_cert_file: None,
18555 host_label: Some("mini-e2e".into()),
18556 broker_peer: None,
18557 requirements: None,
18558 },
18559 );
18560 let runner = bogus_runner(placements);
18561 let mut session = session_of_role("explorer", "hello remote");
18562 let job = job_for("child-1");
18563 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18564 let error = runner
18565 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18566 .await
18567 .expect_err("direct remote transport must fail closed");
18568 assert!(error
18569 .to_string()
18570 .contains("remote_broker_activation_unavailable"));
18571 assert!(session
18572 .messages
18573 .iter()
18574 .all(|message| message.role != Role::Assistant));
18575 }
18576
18577 fn bogus_sched_runner(
18583 remote: HashMap<String, ResolvedRemotePlacement>,
18584 sched: HashMap<String, ResolvedSchedulablePlacement>,
18585 ) -> ActorChildRunner {
18586 ActorChildRunner::new(
18587 "test-actor".into(),
18588 PathBuf::from("/bin/false"),
18589 vec![],
18590 std::env::temp_dir().join("bamboo-test-fab-181"),
18591 ExecutorSpec::Echo,
18592 vec![],
18593 "anthropic".into(),
18594 4,
18595 )
18596 .with_remote_placements(remote)
18597 .with_schedulable_placements(sched)
18598 }
18599
18600 fn sched_placement(
18601 pool: &str,
18602 _registry_url: impl Into<String>,
18603 ) -> ResolvedSchedulablePlacement {
18604 ResolvedSchedulablePlacement {
18605 pool: pool.into(),
18606 host_label: None,
18607 requirements: None,
18608 ..Default::default()
18609 }
18610 }
18611
18612 #[test]
18613 fn build_spec_sets_schedulable_placement_for_matching_role() {
18614 let mut sched = HashMap::new();
18615 sched.insert(
18616 "explorer".to_string(),
18617 sched_placement("gpu-pool", "unused"),
18618 );
18619 let runner = bogus_sched_runner(HashMap::new(), sched);
18620
18621 let s = session_of_role("explorer", "do the thing");
18622 let spec = runner.build_spec(&s, &job_for("child-1"));
18623 match &spec.placement {
18624 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18625 other => panic!("expected Schedulable, got {other:?}"),
18626 }
18627 assert!(spec.secrets.worker_auth_token.is_none());
18629 }
18630
18631 #[test]
18632 fn build_spec_remote_wins_when_role_in_both_maps() {
18633 let mut remote = HashMap::new();
18636 remote.insert(
18637 "explorer".to_string(),
18638 ResolvedRemotePlacement {
18639 endpoint: "wss://fixed-host:8443".into(),
18640 token: Some("T-remote".into()),
18641 ca_cert_file: None,
18642 host_label: None,
18643 broker_peer: None,
18644 requirements: None,
18645 },
18646 );
18647 let mut sched = HashMap::new();
18648 sched.insert(
18649 "explorer".to_string(),
18650 sched_placement("gpu-pool", "https://control-plane:9562"),
18651 );
18652 let runner = bogus_sched_runner(remote, sched);
18653
18654 let s = session_of_role("explorer", "do the thing");
18655 let spec = runner.build_spec(&s, &job_for("child-1"));
18656 match &spec.placement {
18657 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18658 other => panic!("expected Remote (precedence), got {other:?}"),
18659 }
18660 assert!(spec.secrets.worker_auth_token.is_none());
18661 }
18662
18663 #[test]
18664 fn build_spec_local_for_unmatched_schedulable_role() {
18665 let mut sched = HashMap::new();
18666 sched.insert(
18667 "explorer".to_string(),
18668 sched_placement("gpu-pool", "https://control-plane:9562"),
18669 );
18670 let runner = bogus_sched_runner(HashMap::new(), sched);
18671 let s = session_of_role("writer", "do the thing");
18672 let spec = runner.build_spec(&s, &job_for("child-1"));
18673 assert_eq!(spec.placement, Placement::Local);
18674 assert!(spec.secrets.worker_auth_token.is_none());
18675 }
18676
18677 #[test]
18682 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18683 let mut remote = HashMap::new();
18685 remote.insert(
18686 "explorer".to_string(),
18687 ResolvedRemotePlacement {
18688 endpoint: "ws://169.254.230.101:8899".into(),
18689 token: None,
18690 ca_cert_file: None,
18691 host_label: Some("mini".into()),
18692 broker_peer: None,
18693 requirements: None,
18694 },
18695 );
18696 let runner = bogus_runner(remote);
18697 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18698 let stamp = runner
18699 .placement_stamp_for(&spec)
18700 .expect("remote child is stamped");
18701 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18702 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18703
18704 let mut remote_nolabel = HashMap::new();
18706 remote_nolabel.insert(
18707 "explorer".to_string(),
18708 ResolvedRemotePlacement {
18709 endpoint: "ws://169.254.230.101:8899".into(),
18710 token: None,
18711 ca_cert_file: None,
18712 host_label: None,
18713 broker_peer: None,
18714 requirements: None,
18715 },
18716 );
18717 let r2 = bogus_runner(remote_nolabel);
18718 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18719 assert!(r2
18720 .placement_stamp_for(&spec2)
18721 .unwrap()
18722 .contains(r#""host":"169.254.230.101""#));
18723
18724 let mut sched = HashMap::new();
18726 sched.insert(
18727 "mac-mini-monitor".to_string(),
18728 ResolvedSchedulablePlacement {
18729 pool: "mac-mini-monitor".into(),
18730 host_label: Some("mini".into()),
18731 requirements: None,
18732 ..Default::default()
18733 },
18734 );
18735 let sr = bogus_sched_runner(HashMap::new(), sched);
18736 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18737 let stamp3 = sr
18738 .placement_stamp_for(&spec3)
18739 .expect("scheduled child is stamped");
18740 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18741 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18742
18743 let local = bogus_runner(HashMap::new());
18745 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18746 assert_eq!(local.placement_stamp_for(&spec4), None);
18747 }
18748
18749 #[tokio::test]
18750 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18751 let mut sched = HashMap::new();
18752 sched.insert(
18753 "explorer".to_string(),
18754 sched_placement("gpu-pool", "unused"),
18755 );
18756 let runner = bogus_sched_runner(HashMap::new(), sched);
18757 let mut session = session_of_role("explorer", "do the thing");
18758 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18759 let error = runner
18760 .execute_external_child(
18761 &mut session,
18762 &job_for("child-1"),
18763 event_tx,
18764 CancellationToken::new(),
18765 )
18766 .await
18767 .expect_err("legacy bus presence cannot grant placement");
18768 assert!(error
18769 .to_string()
18770 .contains("remote_broker_activation_unavailable"));
18771 }
18772}