1use std::collections::{BTreeSet, HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use crate::placement_scheduler::PlacementScheduler;
20use async_trait::async_trait;
21use bamboo_agent_core::storage::Storage;
22use bamboo_agent_core::tools::tool_start_arguments_for_display;
23use bamboo_agent_core::{AgentError, AgentEvent, Role, Session};
24use bamboo_domain::poison::PoisonRecover;
25use bamboo_domain::{
26 ActorActivationClaim, ActorActivationFence, ActorActivationFinish, ActorDirectoryPort,
27 ActorLogicalState, ActorSnapshotLimits, ActorSnapshotPort, ActorSnapshotPrincipal, HookResult,
28 ParentQuestion, ParentQuestionCheckpointV1, SessionInboxClaim, SessionInboxConsumerId,
29 SessionInboxFailureReport, SessionInboxLeaseRequest, SessionInboxOwnedClaim, SessionInboxPort,
30 PARENT_QUESTION_CHECKPOINT_ACTION,
31};
32use bamboo_domain::{HostPlacementIntent, HostPlacementRequest, WorkerSlotLease};
33use sha2::{Digest, Sha256};
34use tokio::sync::mpsc;
35use tokio_util::sync::CancellationToken;
36
37use bamboo_subagent::fleet::{spawn_worker_on_bus, SpawnedChild};
38use bamboo_subagent::proto::{
39 ActorEventBatch, AgentRecord, ChildFrame, LogicalSessionIdentity, ParentFrame,
40 PermissionPolicyContext, RunSpec, SessionMessageDelivery, TerminalStatus,
41};
42use bamboo_subagent::provision::{
43 ChildIdentity, ExecutorSpec, ModelRefSpec, Placement, ProvisionSpec, ScopedCredential,
44};
45
46use super::actor_event_router::{
47 ActorEventClass, ActorEventRoute, ActorEventRouteError, ActorEventRouter,
48};
49use super::actor_event_stream::{ActorEventObserver, PublicActorEvent};
50use crate::runtime::execution::{ExternalChildRunner, SessionInboxRuntimeBinding, SpawnJob};
51
52pub const DEFAULT_MAX_CONCURRENT_ACTORS: usize = 200;
56
57pub use bamboo_config::DEFAULT_MAX_SPAWN_DEPTH as MAX_SPAWN_DEPTH;
59
60const DEFAULT_MAX_IDLE_PER_KEY: usize = 4;
62
63const DEFAULT_MAX_IDLE_TOTAL: usize = 16;
67
68const POOLED_IDLE_TIMEOUT_SECS: u64 = 300;
71
72const POOLED_REAPER_INTERVAL: Duration = Duration::from_secs(30);
76
77const WORKER_FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(60);
84
85fn active_scoped_session_deny_count(
86 config: &bamboo_tools::permission::PermissionConfig,
87 session_id: &str,
88) -> usize {
89 config
90 .temporary_grants()
91 .into_iter()
92 .filter(|grant| {
93 grant.scope == bamboo_tools::permission::TemporaryPermissionGrantScope::Session
94 && grant.effect == bamboo_tools::permission::TemporaryPermissionGrantEffect::Deny
95 && grant.session_id.as_deref() == Some(session_id)
96 })
97 .count()
98}
99
100fn ensure_no_active_scoped_session_denies(
101 config: &bamboo_tools::permission::PermissionConfig,
102 session_id: &str,
103) -> Result<(), AgentError> {
104 let count = active_scoped_session_deny_count(config, session_id);
105 if count == 0 {
106 Ok(())
107 } else {
108 Err(AgentError::LLM(format!(
109 "external executor activation blocked by {count} active session-scoped explicit deny rule(s)"
110 )))
111 }
112}
113
114pub struct IssuedCodexRunToken {
117 pub token_id: String,
118 pub token: String,
119}
120
121pub trait CodexRunTokenAuthority: Send + Sync + 'static {
124 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String>;
125 fn revoke(&self, token_id: &str);
126}
127
128struct CodexRunTokenGuard {
129 authority: Arc<dyn CodexRunTokenAuthority>,
130 token_id: String,
131}
132
133impl Drop for CodexRunTokenGuard {
134 fn drop(&mut self) {
135 self.authority.revoke(&self.token_id);
136 }
137}
138
139fn executor_uses_bamboo_codex(executor: &ExecutorSpec) -> bool {
140 matches!(
141 executor,
142 ExecutorSpec::Codex {
143 auth_mode: Some(mode),
144 ..
145 } if mode == "bamboo"
146 ) || matches!(
147 executor,
148 ExecutorSpec::Codex {
149 auth_mode: None,
150 inherit_user_config,
151 ..
152 } if !inherit_user_config.unwrap_or(false)
153 )
154}
155
156fn executor_has_read_only_permission_profile(executor: &ExecutorSpec) -> bool {
157 match executor {
158 ExecutorSpec::ClaudeCode {
159 permission_mode, ..
160 } => permission_mode
161 .as_deref()
162 .is_some_and(|mode| mode.eq_ignore_ascii_case("plan")),
163 ExecutorSpec::Codex {
164 permission_profile,
165 sandbox,
166 ..
167 } => {
168 permission_profile
169 .as_deref()
170 .is_some_and(|profile| profile.eq_ignore_ascii_case("read-only"))
171 || sandbox
172 .as_deref()
173 .is_some_and(|value| value.eq_ignore_ascii_case("read-only"))
174 }
175 _ => false,
176 }
177}
178
179fn expected_permission_executor_mapping(
187 executor: &ExecutorSpec,
188 resolution: bamboo_domain::PermissionModeResolution,
189 has_explicit_deny: bool,
190) -> Result<Option<String>, AgentError> {
191 let mapping = match executor {
192 ExecutorSpec::Echo | ExecutorSpec::CliAdapter { .. } => return Ok(None),
193 ExecutorSpec::BambooRuntime => {
194 format!("bamboo_runtime:{}", resolution.effective.as_str())
195 }
196 ExecutorSpec::ClaudeCode {
197 permission_mode, ..
198 } => {
199 if has_explicit_deny {
200 "claude_code:blocked_explicit_deny".to_string()
201 } else {
202 let mode = match resolution.effective {
203 bamboo_domain::PermissionMode::Plan => "plan",
204 bamboo_domain::PermissionMode::Auto => "bypassPermissions",
205 bamboo_domain::PermissionMode::AcceptEdits => "acceptEdits",
206 bamboo_domain::PermissionMode::DontAsk => "dontAsk",
207 bamboo_domain::PermissionMode::Default
208 | bamboo_domain::PermissionMode::BypassPermissions => {
209 permission_mode.as_deref().unwrap_or("default")
210 }
211 };
212 format!("claude_code:permission_mode={mode}")
213 }
214 }
215 ExecutorSpec::Codex {
216 mode,
217 sandbox,
218 approval_policy,
219 allow_danger_bypass,
220 ..
221 } => match mode.as_deref().unwrap_or("exec") {
222 "exec" => {
223 let approval_policy = expected_codex_exec_approval_policy(
224 sandbox.as_deref(),
225 approval_policy.as_deref(),
226 allow_danger_bypass.unwrap_or(false),
227 resolution,
228 )?;
229 if has_explicit_deny {
230 "codex_exec:blocked_explicit_deny".to_string()
231 } else {
232 format!("codex_exec:approval_policy={approval_policy}")
233 }
234 }
235 "app_server" => {
236 if !matches!(approval_policy.as_deref(), None | Some("on-request")) {
237 return Err(AgentError::LLM(
238 "invalid Codex app-server permission posture configuration".to_string(),
239 ));
240 }
241 if has_explicit_deny {
242 "codex_app_server:blocked_explicit_deny".to_string()
243 } else {
244 let approval_policy = if resolution.suppress_approval_prompts()
245 || resolution.effective == bamboo_domain::PermissionMode::Plan
246 {
247 "never"
248 } else {
249 "on-request"
250 };
251 format!("codex_app_server:approvalPolicy={approval_policy}")
252 }
253 }
254 _ => {
255 return Err(AgentError::LLM(
256 "unsupported Codex executor mode for permission posture contract".to_string(),
257 ));
258 }
259 },
260 };
261 Ok(Some(mapping))
262}
263
264fn expected_codex_exec_approval_policy(
265 sandbox: Option<&str>,
266 approval_policy: Option<&str>,
267 allow_danger_bypass: bool,
268 resolution: bamboo_domain::PermissionModeResolution,
269) -> Result<&'static str, AgentError> {
270 let configured = match approval_policy {
271 None | Some("never") => "never",
272 Some("on-failure") => "on-failure",
273 Some(_) => {
274 return Err(AgentError::LLM(
275 "invalid Codex exec permission posture configuration".to_string(),
276 ));
277 }
278 };
279 if resolution.suppress_approval_prompts()
280 || resolution.effective == bamboo_domain::PermissionMode::Plan
281 {
282 return Ok("never");
283 }
284 match sandbox {
285 Some("danger-full-access") => Ok("never"),
286 Some("read-only") | Some("workspace-write") => Ok(configured),
287 None if allow_danger_bypass || resolution.bypass_permissions() => Ok("never"),
288 None => Ok(configured),
289 Some(_) => Err(AgentError::LLM(
290 "invalid Codex exec permission posture configuration".to_string(),
291 )),
292 }
293}
294
295fn workspace_is_bamboo_owned(raw: &str) -> bool {
296 let workspace = std::fs::canonicalize(raw).unwrap_or_else(|_| PathBuf::from(raw));
297 let configured_root = bamboo_config::paths::resolve_workspace_root();
298 let configured_root = std::fs::canonicalize(&configured_root).unwrap_or(configured_root);
299 if workspace.starts_with(&configured_root) {
300 return true;
301 }
302
303 workspace.ancestors().any(|candidate| {
308 let Some(name) = candidate.file_name().and_then(|name| name.to_str()) else {
309 return false;
310 };
311 let Some(worktree_root) = candidate.parent() else {
312 return false;
313 };
314 if worktree_root.file_name() != Some(std::ffi::OsStr::new("worktree"))
315 || worktree_root.parent().and_then(Path::file_name)
316 != Some(std::ffi::OsStr::new(".bamboo"))
317 {
318 return false;
319 }
320 let marker = worktree_root.join(".bamboo-owned").join(name);
321 std::fs::read_to_string(marker).is_ok_and(|branch| branch == format!("bamboo/{name}"))
322 })
323}
324
325fn build_codex_run_secrets(
326 executor: &ExecutorSpec,
327 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
328 child_session_id: &str,
329) -> Result<
330 (
331 bamboo_subagent::proto::RunSecrets,
332 Option<CodexRunTokenGuard>,
333 ),
334 AgentError,
335> {
336 if !executor_uses_bamboo_codex(executor) {
337 return Ok((bamboo_subagent::proto::RunSecrets::default(), None));
338 }
339
340 let authority = authority.ok_or_else(|| {
341 AgentError::LLM(
342 "Codex auth mode 'bamboo' requires the server per-run token authority".to_string(),
343 )
344 })?;
345 let issued = authority
346 .issue(child_session_id)
347 .map_err(|error| AgentError::LLM(format!("mint Codex per-run provider token: {error}")))?;
348 let guard = CodexRunTokenGuard {
349 authority,
350 token_id: issued.token_id,
351 };
352 Ok((
353 bamboo_subagent::proto::RunSecrets {
354 codex_provider_token: Some(bamboo_subagent::proto::SecretValue::new(issued.token)),
355 },
356 Some(guard),
357 ))
358}
359
360struct PooledWorker {
366 worker: SpawnedChild,
367 mailbox_id: String,
369 parked_at: Option<Instant>,
372}
373
374type WorkerPool = HashMap<String, Vec<PooledWorker>>;
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377enum PoolReapReason {
378 ProcessExited,
379 IdleTimeout,
380}
381
382impl PoolReapReason {
383 fn as_str(self) -> &'static str {
384 match self {
385 Self::ProcessExited => "process_exited_while_parked",
386 Self::IdleTimeout => "pool_idle_timeout",
387 }
388 }
389}
390
391fn parked_worker_expired(parked_at: Option<Instant>, now: Instant, timeout: Duration) -> bool {
392 parked_at.is_some_and(|parked_at| now.saturating_duration_since(parked_at) >= timeout)
393}
394
395fn idle_pool_limit_reason(
396 pool: &WorkerPool,
397 key: &str,
398 max_per_key: usize,
399 max_total: usize,
400) -> Option<&'static str> {
401 if pool.get(key).map_or(0, Vec::len) >= max_per_key {
402 Some("pool_per_key_limit")
403 } else if pool.values().map(Vec::len).sum::<usize>() >= max_total {
404 Some("pool_global_limit")
405 } else {
406 None
407 }
408}
409
410fn take_reapable_workers(
411 pool: &mut WorkerPool,
412 now: Instant,
413 timeout: Duration,
414) -> Vec<(String, PoolReapReason, PooledWorker)> {
415 let mut reaped = Vec::new();
416 for (key, bucket) in pool.iter_mut() {
417 let mut retained = Vec::with_capacity(bucket.len());
418 for mut worker in std::mem::take(bucket) {
419 let reason = if !worker.worker.is_alive() {
420 Some(PoolReapReason::ProcessExited)
421 } else if parked_worker_expired(worker.parked_at, now, timeout) {
422 Some(PoolReapReason::IdleTimeout)
423 } else {
424 None
425 };
426 if let Some(reason) = reason {
427 reaped.push((key.clone(), reason, worker));
428 } else {
429 retained.push(worker);
430 }
431 }
432 *bucket = retained;
433 }
434 pool.retain(|_, bucket| !bucket.is_empty());
435 reaped
436}
437
438async fn reap_worker_pool_once(pool: &Arc<tokio::sync::Mutex<WorkerPool>>) -> usize {
439 let now = Instant::now();
440 let timeout = Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS);
441 let reaped = {
442 let mut pool = pool.lock().await;
443 take_reapable_workers(&mut pool, now, timeout)
444 };
445 let count = reaped.len();
446 for (pool_key, reason, worker) in reaped {
447 let idle_ms = worker
448 .parked_at
449 .map(|parked_at| now.saturating_duration_since(parked_at).as_millis() as u64);
450 tracing::info!(
451 pool_key,
452 mailbox_id = %worker.mailbox_id,
453 worker_pid = ?worker.worker.pid(),
454 worker_idle_ms = ?idle_ms,
455 shutdown_reason = reason.as_str(),
456 "reaping parked sub-agent worker"
457 );
458 worker.worker.kill().await;
459 }
460 count
461}
462
463#[derive(Clone, Default)]
468pub struct ResolvedRemotePlacement {
469 pub endpoint: String,
470 pub token: Option<String>,
471 pub ca_cert_file: Option<PathBuf>,
472 pub host_label: Option<String>,
476 pub broker_peer: Option<Result<bamboo_config::RemoteBrokerPeer, ()>>,
478 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
479}
480impl std::fmt::Debug for ResolvedRemotePlacement {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 f.debug_struct("ResolvedRemotePlacement")
483 .field("broker_peer", &self.broker_peer.is_some())
484 .finish_non_exhaustive()
485 }
486}
487
488#[derive(Debug, Clone, Default)]
495pub struct ResolvedSchedulablePlacement {
496 pub pool: String,
497 pub host_label: Option<String>,
501 pub endpoint: String,
502 pub token: Option<String>,
503 pub ca_cert_file: Option<PathBuf>,
504 pub broker_parent: Option<bamboo_config::SchedulableBrokerParent>,
505 pub requirements: Option<bamboo_config::OperatorPlacementRequirements>,
506}
507
508#[derive(Clone)]
509struct ScopedPlacementRoute {
510 endpoint: String,
511 token: String,
512 ca_cert_file: PathBuf,
513 parent: bamboo_subagent::AgentRef,
514 worker_role: String,
515 pinned_mailbox: Option<String>,
516 requirements: bamboo_config::OperatorPlacementRequirements,
517}
518
519impl ScopedPlacementRoute {
520 async fn query(&self) -> Result<bamboo_broker::BrokerClient, AgentError> {
521 let tls = bamboo_broker::client_config_trusting_cert(&self.ca_cert_file)
522 .map_err(|_| remote_broker_unavailable())?;
523 bamboo_broker::BrokerClient::connect_with_tls(
524 &self.endpoint,
525 self.parent.clone(),
526 &self.token,
527 Some(tls),
528 )
529 .await
530 .map_err(|_| remote_broker_unavailable())
531 }
532}
533
534struct ActivePlacementLease {
539 registry: bamboo_storage::v2::FileHostRegistry,
540 actor_store: Arc<bamboo_storage::SessionStoreV2>,
541 route: ScopedPlacementRoute,
542 mailbox: String,
543 host_ref: String,
544 fence: ActorActivationFence,
545 placement_ref: bamboo_domain::ActorPlacementRef,
546 lease: Arc<tokio::sync::Mutex<WorkerSlotLease>>,
547 heartbeat: tokio::task::JoinHandle<()>,
548 run_cancel: CancellationToken,
549 lost: Arc<AtomicBool>,
550 release_on_drop: AtomicBool,
551}
552
553async fn fail_pre_dispatch_activation(
557 actor_store: &bamboo_storage::SessionStoreV2,
558 fence: &ActorActivationFence,
559) -> Result<(), bamboo_domain::ActorDirectoryError> {
560 actor_store
561 .finish_activation(fence, chrono::Utc::now(), ActorActivationFinish::Failed)
562 .await
563 .map(|_| ())
564}
565
566async fn send_actor_run(
570 client: &mut dyn bamboo_subagent::ChildLink,
571 spec: RunSpec,
572 send_invoked: &mut bool,
573) -> Result<(), AgentError> {
574 *send_invoked = true;
575 client
576 .send(ParentFrame::Run(spec))
577 .await
578 .map_err(|error| AgentError::LLM(format!("actor run dispatch failed: {error}")))
579}
580
581fn remote_activation_finish(
582 result: &Result<Option<String>, AgentError>,
583 accepted_terminal: Option<TerminalStatus>,
584) -> ActorActivationFinish {
585 if result.is_ok() {
586 ActorActivationFinish::Succeeded
587 } else if matches!(result, Err(AgentError::Cancelled))
588 && accepted_terminal == Some(TerminalStatus::Cancelled)
589 {
590 ActorActivationFinish::Cancelled
591 } else {
592 ActorActivationFinish::Failed
593 }
594}
595
596async fn append_fenced_remote_reply(
600 actor_store: &bamboo_storage::SessionStoreV2,
601 fence: &ActorActivationFence,
602 session: &mut Session,
603 text: &str,
604) -> Result<(), AgentError> {
605 let message = bamboo_agent_core::Message::assistant(text, None);
606 let committed = actor_store
607 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
608 fence: fence.clone(),
609 expected_created_at: session.created_at,
610 expected_conversation_generation: session.conversation_generation_observation(),
611 expected_messages: session.messages.clone(),
612 expected_provider_transcript: session.provider_transcript.clone(),
613 messages: vec![message],
614 native_groups: Vec::new(),
615 })
616 .await
617 .map_err(|error| AgentError::LLM(format!("remote actor reply commit failed: {error}")))?;
618 session.messages = committed.messages;
619 session.provider_transcript = committed.provider_transcript;
620 session.updated_at = committed.updated_at;
621 Ok(())
622}
623
624impl ActivePlacementLease {
625 async fn reserve(
626 registry: bamboo_storage::v2::FileHostRegistry,
627 actor_store: Arc<bamboo_storage::SessionStoreV2>,
628 route: ScopedPlacementRoute,
629 mailbox: String,
630 actor_id: String,
631 run_id: String,
632 project_id: Option<bamboo_domain::ProjectId>,
633 required_tools: BTreeSet<String>,
634 parent_cancel: &CancellationToken,
635 ) -> Result<Self, AgentError> {
636 let scheduler = PlacementScheduler::new(registry.clone());
637 let mut query = route.query().await?;
638 let observed = query
639 .observe_host_capacity(&mailbox, &route.worker_role)
640 .await
641 .map_err(|_| remote_broker_unavailable())?
642 .ok_or_else(remote_broker_unavailable)?;
643 let host = scheduler
644 .observe_scoped_host(
645 &mut query,
646 &observed.host_ref,
647 &mailbox,
648 &route.worker_role,
649 chrono::Duration::seconds(110),
650 )
651 .await
652 .map_err(|_| remote_broker_unavailable())?;
653 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
654 || host.connection_generation != observed.connection_generation
655 || route
656 .pinned_mailbox
657 .as_deref()
658 .is_some_and(|pinned| pinned != mailbox)
659 {
660 return Err(remote_broker_unavailable());
661 }
662 let now = chrono::Utc::now();
663 let request = HostPlacementRequest {
664 intent: HostPlacementIntent::PinnedHost {
665 host_ref: host.host_ref.clone(),
666 },
667 actor_id,
668 run_id,
669 project_id: project_id.clone(),
670 trust_zone: route.requirements.trust_zone.clone(),
671 workspace_label: route.requirements.workspace_label.clone(),
672 executor: "bamboo-runtime".into(),
673 required_tools,
674 network_zone: route.requirements.network_zone.clone(),
675 require_network_isolation: route.requirements.require_network_isolation,
676 preferred_host_ref: None,
677 now,
678 lease_expires_at: now + chrono::Duration::seconds(80),
679 };
680 if !request.eligible(&host) {
681 return Err(remote_broker_unavailable());
682 }
683 let lease = scheduler
684 .reserve(request)
685 .await
686 .map_err(|_| remote_broker_unavailable())?;
687 let placement_ref = bamboo_domain::ActorPlacementRef {
688 class: bamboo_domain::ActorPlacementClass::Remote,
689 lease_id: lease.lease_id.clone(),
690 slot_epoch: Some(lease.epoch),
691 };
692 let mut claimed_fence = None;
693 let actor_binding = async {
694 let actor = actor_store
695 .ensure_actor(&lease.actor_id)
696 .await
697 .map_err(|_| remote_broker_unavailable())?;
698 if actor.actor.project_id != project_id.as_ref().map(|id| id.to_string()) {
699 return Err(remote_broker_unavailable());
700 }
701 let placement_intent = if route.pinned_mailbox.is_some() {
702 bamboo_domain::ActorPlacementIntent::PinnedHost {
703 host_ref: host.host_ref.clone(),
704 }
705 } else {
706 bamboo_domain::ActorPlacementIntent::Pool {
707 pool_id: route.worker_role.clone(),
708 }
709 };
710 actor_store
711 .bind_control_plane(
712 &lease.actor_id,
713 bamboo_domain::ActorControlPlaneBinding {
714 policy_revision: None,
715 placement_intent: Some(placement_intent),
716 },
717 )
718 .await
719 .map_err(|_| {
720 tracing::warn!(stage = "actor_bind", "remote placement admission failed");
721 remote_broker_unavailable()
722 })?;
723 let activation = actor_store
724 .claim_activation(&ActorActivationClaim {
725 actor_id: lease.actor_id.clone(),
726 run_id: lease.run_id.clone(),
727 lease_owner: format!("worker-host-{}", uuid::Uuid::new_v4()),
728 lease_expires_at: lease.expires_at,
729 inbox_generation: 0,
730 placement_ref: Some(placement_ref.clone()),
731 now: chrono::Utc::now(),
732 })
733 .await
734 .map_err(|_| {
735 tracing::warn!(stage = "actor_claim", "remote placement admission failed");
736 remote_broker_unavailable()
737 })?;
738 let fence = activation.fence();
739 claimed_fence = Some(fence.clone());
740 actor_store
741 .start_activation(&fence, chrono::Utc::now())
742 .await
743 .map_err(|_| {
744 tracing::warn!(stage = "actor_start", "remote placement admission failed");
745 remote_broker_unavailable()
746 })?;
747 let reservation = registry
748 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
749 placement_ref: placement_ref.clone(),
750 actor_id: lease.actor_id.clone(),
751 run_id: lease.run_id.clone(),
752 observed_host_ref: host.host_ref.clone(),
753 observed_connection_generation: host.connection_generation.clone(),
754 now: chrono::Utc::now(),
755 })
756 .await
757 .map_err(|error| {
758 tracing::warn!(stage = "slot_preflight", reason = %error, "remote placement admission failed");
759 remote_broker_unavailable()
760 })?;
761 if reservation.mailbox != mailbox || reservation.lease != lease {
762 tracing::warn!(stage = "slot_binding", "remote placement admission failed");
763 return Err(remote_broker_unavailable());
764 }
765 Ok::<_, AgentError>(fence)
766 }
767 .await;
768 let fence = match actor_binding {
769 Ok(fence) => fence,
770 Err(error) => {
771 if let Some(fence) = claimed_fence.as_ref() {
772 if let Err(rollback_error) =
773 fail_pre_dispatch_activation(actor_store.as_ref(), fence).await
774 {
775 tracing::warn!(
776 stage = "actor_pre_dispatch_rollback",
777 reason = %rollback_error,
778 "remote placement activation rollback failed"
779 );
780 }
781 }
782 let _ = registry.release_slot(&lease).await;
783 return Err(error);
784 }
785 };
786 let lease = Arc::new(tokio::sync::Mutex::new(lease));
787 let host_ref = host.host_ref.clone();
788 let lost = Arc::new(AtomicBool::new(false));
789 let run_cancel = parent_cancel.child_token();
790 let heartbeat = {
791 let registry = registry.clone();
792 let actor_store = actor_store.clone();
793 let route = route.clone();
794 let host_ref = host_ref.clone();
795 let mailbox = mailbox.clone();
796 let fence = fence.clone();
797 let placement_ref = placement_ref.clone();
798 let lease = lease.clone();
799 let lost = lost.clone();
800 let run_cancel = run_cancel.clone();
801 tokio::spawn(async move {
802 let mut interval = tokio::time::interval(Duration::from_secs(30));
803 interval.tick().await;
804 loop {
805 tokio::select! {
806 _ = run_cancel.cancelled() => break,
807 _ = interval.tick() => {}
808 }
809 let refreshed = async {
810 let mut query = route.query().await?;
811 let host = PlacementScheduler::new(registry.clone())
812 .observe_scoped_host(
813 &mut query,
814 &host_ref,
815 &mailbox,
816 &route.worker_role,
817 chrono::Duration::seconds(110),
818 )
819 .await
820 .map_err(|_| remote_broker_unavailable())?;
821 let current = lease.lock().await.clone();
822 if host.connection_generation != current.connection_generation {
823 return Err(remote_broker_unavailable());
824 }
825 let entry = actor_store
826 .inspect_actor(¤t.actor_id)
827 .await
828 .map_err(|_| remote_broker_unavailable())?;
829 if entry.activation.as_ref().is_none_or(|activation| {
830 activation.fence() != fence
831 || activation.placement_ref.as_ref() != Some(&placement_ref)
832 }) {
833 return Err(remote_broker_unavailable());
834 }
835 let now = chrono::Utc::now();
836 registry
837 .validate_run_reservation(bamboo_domain::WorkerRunReservationCheck {
838 placement_ref: placement_ref.clone(),
839 actor_id: current.actor_id.clone(),
840 run_id: current.run_id.clone(),
841 observed_host_ref: host.host_ref.clone(),
842 observed_connection_generation: host.connection_generation.clone(),
843 now,
844 })
845 .await
846 .map_err(|_| remote_broker_unavailable())?;
847 let renewed = registry
848 .renew_slot(¤t, now, now + chrono::Duration::seconds(80))
849 .await
850 .map_err(|_| remote_broker_unavailable())?;
851 *lease.lock().await = renewed;
852 let expiry = lease.lock().await.expires_at;
853 actor_store
854 .renew_activation(&fence, now, expiry)
855 .await
856 .map_err(|_| remote_broker_unavailable())?;
857 Ok::<(), AgentError>(())
858 }
859 .await;
860 if refreshed.is_err() {
861 lost.store(true, Ordering::SeqCst);
862 run_cancel.cancel();
863 break;
864 }
865 }
866 })
867 };
868 Ok(Self {
869 registry,
870 actor_store,
871 route,
872 mailbox,
873 host_ref,
874 fence,
875 placement_ref,
876 lease,
877 heartbeat,
878 run_cancel,
879 lost,
880 release_on_drop: AtomicBool::new(false),
881 })
882 }
883
884 fn lost(&self) -> bool {
885 self.lost.load(Ordering::SeqCst)
886 }
887
888 async fn abort_if_run_unsent(&mut self, send_invoked: bool) -> Result<bool, AgentError> {
889 if send_invoked {
890 return Ok(false);
891 }
892 self.abort_before_dispatch().await?;
893 Ok(true)
894 }
895
896 async fn abort_before_dispatch(&mut self) -> Result<(), AgentError> {
897 self.heartbeat.abort();
898 let _ = (&mut self.heartbeat).await;
899 let current = self.lease.lock().await.clone();
900 let failed = fail_pre_dispatch_activation(self.actor_store.as_ref(), &self.fence).await;
901 let released = self.registry.release_slot(¤t).await;
902 if let Err(error) = failed {
903 tracing::warn!(stage = "actor_pre_dispatch_rollback", reason = %error, "remote activation failure could not be recorded");
904 return Err(remote_broker_unavailable());
905 }
906 if let Err(error) = released {
907 if !matches!(error, bamboo_domain::HostRegistryError::StaleLease) {
908 tracing::warn!(stage = "slot_pre_dispatch_rollback", reason = %error, "remote slot release failed");
909 return Err(remote_broker_unavailable());
910 }
911 }
912 Ok(())
913 }
914
915 async fn complete(
916 &mut self,
917 outcome: ActorActivationFinish,
918 terminal_proven: bool,
919 ) -> Result<(), AgentError> {
920 if !terminal_proven {
921 return if outcome == ActorActivationFinish::Succeeded {
924 Err(remote_broker_unavailable())
925 } else {
926 Ok(())
927 };
928 }
929 self.heartbeat.abort();
932 let _ = (&mut self.heartbeat).await;
933 let current = self.lease.lock().await.clone();
934 let mut query = self.route.query().await?;
938 let observed = query
939 .observe_host_capacity(&self.mailbox, &self.route.worker_role)
940 .await
941 .map_err(|_| remote_broker_unavailable())?
942 .ok_or_else(remote_broker_unavailable)?;
943 if observed.host_ref != self.host_ref
944 || observed.connection_generation != current.connection_generation
945 || observed.mailbox != self.mailbox
946 || observed.role.as_deref() != Some(self.route.worker_role.as_str())
947 {
948 return Err(remote_broker_unavailable());
949 }
950 let entry = self
951 .actor_store
952 .inspect_actor(¤t.actor_id)
953 .await
954 .map_err(|_| remote_broker_unavailable())?;
955 if entry.activation.as_ref().is_none_or(|activation| {
956 activation.fence() != self.fence
957 || activation.placement_ref.as_ref() != Some(&self.placement_ref)
958 }) {
959 return Err(remote_broker_unavailable());
960 }
961 self.registry
962 .validate_slot(¤t, chrono::Utc::now())
963 .await
964 .map_err(|_| remote_broker_unavailable())?;
965 self.actor_store
966 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
967 .await
968 .map_err(|_| remote_broker_unavailable())?;
969 self.release_on_drop.store(true, Ordering::SeqCst);
972 match self.registry.release_slot(¤t).await {
973 Ok(()) | Err(bamboo_domain::HostRegistryError::StaleLease) => {
974 self.release_on_drop.store(false, Ordering::SeqCst);
975 Ok(())
976 }
977 Err(error) => {
978 tracing::warn!(stage = "slot_terminal_release", reason = %error, "remote slot release failed");
979 Err(remote_broker_unavailable())
980 }
981 }
982 }
983}
984
985impl Drop for ActivePlacementLease {
986 fn drop(&mut self) {
987 self.run_cancel.cancel();
988 self.heartbeat.abort();
989 if !self.release_on_drop.load(Ordering::SeqCst) {
990 return;
991 }
992 let registry = self.registry.clone();
993 let actor_store = self.actor_store.clone();
994 let fence = self.fence.clone();
995 let placement_ref = self.placement_ref.clone();
996 let lease = self.lease.clone();
997 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
998 runtime.spawn(async move {
999 let current = lease.lock().await.clone();
1000 if actor_store
1001 .inspect_actor(¤t.actor_id)
1002 .await
1003 .ok()
1004 .and_then(|entry| entry.activation)
1005 .is_some_and(|activation| {
1006 activation.fence() == fence
1007 && activation.placement_ref.as_ref() == Some(&placement_ref)
1008 })
1009 {
1010 let _ = registry.release_slot(¤t).await;
1011 }
1012 });
1013 }
1014 }
1015}
1016
1017pub struct BrokerTerminalReceiptReconciler {
1022 store: Arc<bamboo_storage::SessionStoreV2>,
1023 bus: Option<bamboo_subagent::BusEndpoint>,
1024 remote: HashMap<String, Option<ReceiptRemoteRoute>>,
1025 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
1026}
1027
1028struct ReceiptRemoteRoute {
1029 endpoint: String,
1030 token: String,
1031 ca_cert_file: PathBuf,
1032 parent: bamboo_subagent::AgentRef,
1033}
1034
1035#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1036pub struct BrokerReceiptRepairReport {
1037 pub candidates: usize,
1038 pub repaired: usize,
1039 pub blocked: usize,
1040}
1041
1042impl BrokerTerminalReceiptReconciler {
1043 pub fn new(store: Arc<bamboo_storage::SessionStoreV2>, config: &bamboo_config::Config) -> Self {
1044 let subagents = config.subagents();
1045 let mut counts = HashMap::<&str, usize>::new();
1046 for placement in &subagents.remote_placements {
1047 *counts.entry(placement.role.as_str()).or_default() += 1;
1048 }
1049 let mut remote = HashMap::new();
1050 for placement in &subagents.remote_placements {
1051 let route = (counts.get(placement.role.as_str()) == Some(&1))
1052 .then(|| receipt_remote_route(placement))
1053 .flatten();
1054 remote.insert(placement.role.clone(), route);
1055 }
1056 Self {
1057 store,
1058 ticket_service: None,
1059 bus: subagents.broker.clone().and_then(|broker| {
1060 (!broker.endpoint.trim().is_empty() && !broker.token.is_empty()).then_some(
1061 bamboo_subagent::BusEndpoint {
1062 endpoint: broker.endpoint,
1063 token: broker.token,
1064 },
1065 )
1066 }),
1067 remote,
1068 }
1069 }
1070
1071 pub fn with_ticket_service(
1072 mut self,
1073 service: Option<Arc<bamboo_tickets::TicketService>>,
1074 ) -> Self {
1075 self.ticket_service = service;
1076 self
1077 }
1078
1079 pub async fn reconcile_once(&self) -> BrokerReceiptRepairReport {
1082 let candidates = match self
1083 .store
1084 .discover_unconfirmed_broker_terminal_children()
1085 .await
1086 {
1087 Ok(candidates) => candidates,
1088 Err(error) => {
1089 tracing::warn!(%error, "broker terminal receipt scan blocked; no ACK attempted");
1090 return BrokerReceiptRepairReport {
1091 blocked: 1,
1092 ..BrokerReceiptRepairReport::default()
1093 };
1094 }
1095 };
1096 let mut report = BrokerReceiptRepairReport {
1097 candidates: candidates.len(),
1098 ..BrokerReceiptRepairReport::default()
1099 };
1100 for child in candidates {
1101 let result =
1102 tokio::time::timeout(Duration::from_secs(15), self.reconcile_child(&child)).await;
1103 match result {
1104 Ok(Ok(())) => report.repaired += 1,
1105 Ok(Err(reason)) => {
1106 report.blocked += 1;
1107 tracing::warn!(child_id = %child.id, reason, "broker terminal receipt retained for retry");
1108 }
1109 Err(_) => {
1110 report.blocked += 1;
1111 tracing::warn!(child_id = %child.id, "broker terminal receipt repair timed out; retained for retry");
1112 }
1113 }
1114 }
1115 report
1116 }
1117
1118 async fn reconcile_child(&self, child: &Session) -> Result<(), &'static str> {
1119 if child.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1120 || child.metadata.get("external.protocol").map(String::as_str) != Some("actor")
1121 || child.metadata.get("external.agent_id").map(String::as_str)
1122 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1123 {
1124 return Err("Child has no trusted built-in actor recovery route");
1125 }
1126 let current = self
1127 .store
1128 .load_session(&child.id)
1129 .await
1130 .map_err(|_| "canonical Child reload failed")?
1131 .ok_or("canonical Child disappeared")?;
1132 if current.kind != bamboo_domain::SessionKind::Child
1133 || current.id != child.id
1134 || current.created_at != child.created_at
1135 || current.parent_session_id != child.parent_session_id
1136 || current.root_session_id != child.root_session_id
1137 || current.project_id_meta() != child.project_id_meta()
1138 || current.metadata.get("runtime.kind").map(String::as_str) != Some("external")
1139 || current
1140 .metadata
1141 .get("external.protocol")
1142 .map(String::as_str)
1143 != Some("actor")
1144 || current
1145 .metadata
1146 .get("external.agent_id")
1147 .map(String::as_str)
1148 != Some(super::config::LOCAL_ACTOR_AGENT_ID)
1149 || bamboo_domain::ActorSession::from_session(¤t).is_err()
1150 {
1151 return Err("canonical Child birth, Root, or Project changed");
1152 }
1153 let root = self
1154 .store
1155 .load_session(&child.root_session_id)
1156 .await
1157 .map_err(|_| "canonical Root reload failed")?
1158 .ok_or("canonical Root disappeared")?;
1159 if root.kind != bamboo_domain::SessionKind::Root
1160 || root.id != child.root_session_id
1161 || root.root_session_id != child.root_session_id
1162 || root.project_id_meta() != child.project_id_meta()
1163 {
1164 return Err("canonical Root identity changed");
1165 }
1166 let parent_id = child
1167 .parent_session_id
1168 .as_deref()
1169 .ok_or("Child parent identity missing")?;
1170 let parent = self
1171 .store
1172 .load_session(parent_id)
1173 .await
1174 .map_err(|_| "canonical parent reload failed")?
1175 .ok_or("canonical parent disappeared")?;
1176 if parent.id != parent_id
1177 || !matches!(
1178 parent.kind,
1179 bamboo_domain::SessionKind::Root | bamboo_domain::SessionKind::Child
1180 )
1181 || parent.root_session_id != child.root_session_id
1182 || parent.project_id_meta() != child.project_id_meta()
1183 || parent.spawn_depth.checked_add(1) != Some(current.spawn_depth)
1184 {
1185 return Err("canonical parent lineage changed");
1186 }
1187
1188 let receipts = self
1192 .store
1193 .recover_broker_terminal_receipts(¤t)
1194 .await
1195 .map_err(|_| "Host broker terminal checkpoint unverified")?;
1196 if receipts.is_empty() {
1197 return Ok(());
1198 }
1199 let role = current
1200 .metadata
1201 .get("subagent_type")
1202 .map(String::as_str)
1203 .unwrap_or("worker");
1204 for receipt in receipts {
1205 publish_recovered_completion_source(&self.store, &receipt)
1206 .await
1207 .map_err(|_| "committed Child completion source remains pending")?;
1208 if current
1209 .metadata
1210 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
1211 {
1212 let service = self
1213 .ticket_service
1214 .as_ref()
1215 .ok_or("Ticket authority unavailable; no result ACK")?;
1216 crate::ticket_runtime::checkpoint_ticket_result(
1217 service,
1218 self.store.as_ref(),
1219 ¤t,
1220 &receipt.activation_run_id,
1221 )
1222 .await
1223 .map_err(|_| "Ticket result not checkpointed; broker receipt retained")?;
1224 }
1225 let local_mailbox = format!("p-{}", child.id);
1226 let mut link = if receipt.parent_mailbox == local_mailbox {
1229 let bus = self
1230 .bus
1231 .as_ref()
1232 .ok_or("local broker receipt bus unavailable")?;
1233 bamboo_broker::BrokerChildLink::connect_receipt_recovery(
1234 &bus.endpoint,
1235 bamboo_subagent::AgentRef {
1236 session_id: local_mailbox,
1237 role: None,
1238 },
1239 &bus.token,
1240 )
1241 .await
1242 .map_err(|_| "local broker receipt connection unavailable")?
1243 } else if let Some(remote) = self.remote.get(role) {
1244 let route = remote
1245 .as_ref()
1246 .ok_or("fixed remote broker route is unavailable or ambiguous")?;
1247 if receipt.parent_mailbox != route.parent.session_id {
1248 return Err("fixed remote parent mailbox changed since Host checkpoint");
1249 }
1250 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
1251 .map_err(|_| "fixed remote broker TLS pin unavailable")?;
1252 bamboo_broker::BrokerChildLink::connect_receipt_recovery_with_tls(
1253 &route.endpoint,
1254 route.parent.clone(),
1255 &route.token,
1256 tls,
1257 )
1258 .await
1259 .map_err(|_| "fixed remote broker receipt connection unavailable")?
1260 } else {
1261 return Err("committed broker receipt route is unavailable");
1262 };
1263 if link.parent_mailbox() != receipt.parent_mailbox {
1264 return Err("broker receipt link mailbox differs from Host checkpoint");
1265 }
1266 bamboo_subagent::ChildLink::acknowledge_recovered_durable_frames(
1267 &mut link,
1268 &receipt.broker_identity,
1269 &receipt.message_ids,
1270 )
1271 .await
1272 .map_err(|_| "broker identity changed or exact ACK unconfirmed")?;
1273 self.store
1274 .clear_acknowledged_broker_terminal_receipt(
1275 &receipt.session_id,
1276 receipt.created_at,
1277 &receipt.activation_run_id,
1278 &receipt.parent_mailbox,
1279 )
1280 .await
1281 .map_err(|_| "broker ACK succeeded but Host receipt cleanup failed")?;
1282 }
1283 Ok(())
1284 }
1285}
1286
1287async fn publish_recovered_completion_source(
1291 store: &Arc<bamboo_storage::SessionStoreV2>,
1292 receipt: &bamboo_storage::v2::BrokerTerminalReceipt,
1293) -> Result<(), String> {
1294 let expected = store
1295 .load_session(&receipt.session_id)
1296 .await
1297 .map_err(|error| format!("Child completion recovery reload failed: {error}"))?
1298 .ok_or("Child completion recovery snapshot missing")?;
1299 if expected.id != receipt.session_id
1300 || expected.created_at != receipt.created_at
1301 || expected.parent_session_id.as_deref() != Some(receipt.parent_session_id.as_str())
1302 || expected.root_session_id != receipt.root_session_id
1303 || expected.project_id_meta() != receipt.project_id
1304 {
1305 return Err("Child completion recovery identity changed".into());
1306 }
1307 let mut candidate = expected.clone();
1308 if candidate.last_run_status().as_deref() == Some("running") {
1309 candidate.set_last_run_status(receipt.terminal_status.clone());
1310 if let Some(error) = &receipt.terminal_error {
1311 candidate.set_last_run_error(error.clone());
1312 } else {
1313 candidate.clear_last_run_error();
1314 }
1315 }
1316 if crate::execution::ChildCompletionSource::publish_after_durable_delivery(
1317 &mut candidate,
1318 &receipt.activation_run_id,
1319 )? {
1320 let source = crate::execution::ChildCompletionSource::from_committed_session(&candidate)
1321 .ok_or("Child completion recovery source invalid")?;
1322 let published = candidate
1323 .metadata
1324 .get("runtime.child_completion_source_v1")
1325 .ok_or("Child completion recovery source missing")?;
1326 let saved = store
1330 .publish_broker_terminal_source(receipt, &expected, published)
1331 .await
1332 .map_err(|error| format!("Child completion recovery publication failed: {error}"))?;
1333 if crate::execution::ChildCompletionSource::from_committed_session(&saved).as_ref()
1334 != Some(&source)
1335 {
1336 return Err("Child completion recovery source changed".into());
1337 }
1338 }
1339 Ok(())
1340}
1341
1342fn receipt_remote_route(
1343 placement: &bamboo_config::RemoteActorPlacement,
1344) -> Option<ReceiptRemoteRoute> {
1345 let peer = placement.broker_peer.as_ref().filter(|peer| peer.valid())?;
1346 let endpoint = url::Url::parse(&placement.endpoint).ok()?;
1347 if placement.endpoint.len() > 2048
1348 || endpoint.scheme() != "wss"
1349 || endpoint.host_str().is_none()
1350 || !endpoint.username().is_empty()
1351 || endpoint.password().is_some()
1352 || endpoint.query().is_some()
1353 || endpoint.fragment().is_some()
1354 {
1355 return None;
1356 }
1357 let token_env = placement
1358 .token_env
1359 .as_deref()
1360 .filter(|name| !name.is_empty() && name.len() <= 256)?;
1361 let token = std::env::var(token_env).ok()?;
1362 if !(32..=256).contains(&token.len()) || !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1363 return None;
1364 }
1365 let ca_cert_file = PathBuf::from(placement.ca_cert_file.as_deref()?);
1366 bamboo_broker::client_config_trusting_cert(&ca_cert_file).ok()?;
1367 Some(ReceiptRemoteRoute {
1368 endpoint: placement.endpoint.clone(),
1369 token,
1370 ca_cert_file,
1371 parent: bamboo_subagent::AgentRef {
1372 session_id: peer.parent_mailbox.clone(),
1373 role: peer.parent_role.clone(),
1374 },
1375 })
1376}
1377
1378enum PlacementKind {
1383 Local,
1384 Remote,
1385 Schedulable,
1386}
1387
1388fn ensure_ticket_placement_is_enforceable(
1390 session: &Session,
1391 spec: &ProvisionSpec,
1392) -> Result<(), AgentError> {
1393 let ticket = session
1394 .metadata
1395 .contains_key(crate::ticket_runtime::TICKET_DISPATCH_KEY)
1396 || session
1397 .metadata
1398 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY);
1399 if ticket && !matches!(spec.placement, Placement::Local) {
1400 return Err(AgentError::LLM(
1401 "Ticket execution requires local placement and owned stop proof".into(),
1402 ));
1403 }
1404 Ok(())
1405}
1406
1407fn ensure_read_only_activation_is_enforceable(spec: &ProvisionSpec) -> Result<(), AgentError> {
1416 if !spec.capabilities.read_only_enforced() {
1417 return Ok(());
1418 }
1419
1420 if matches!(spec.executor, ExecutorSpec::Codex { .. }) {
1421 return Err(AgentError::LLM(format!(
1422 "read-only actor role '{}' cannot use the Codex executor because its read-only sandbox still permits command execution; use bamboo_runtime or claude_code",
1423 spec.identity.role
1424 )));
1425 }
1426
1427 if !matches!(spec.placement, Placement::Local) {
1428 return Err(AgentError::LLM(format!(
1429 "read-only actor role '{}' requires local placement; remote and schedulable resident workers cannot verify this activation's read-only tool boundary",
1430 spec.identity.role
1431 )));
1432 }
1433
1434 Ok(())
1435}
1436
1437pub struct ActorChildRunner {
1439 approval_registry: Option<super::approval_registry::SharedApprovalRegistry>,
1440 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
1441 agent_id: String,
1442 worker_bin: PathBuf,
1443 worker_args: Vec<String>,
1444 builtin_required_context_route: bool,
1445 native_tool_ceiling: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1446 fabric_dir: PathBuf,
1447 executor: ExecutorSpec,
1448 credentials: Vec<ScopedCredential>,
1453 default_provider: String,
1455 max_spawn_depth: u32,
1457 live_provider_config: Option<Arc<tokio::sync::RwLock<bamboo_llm::Config>>>,
1461 bus: Option<bamboo_subagent::BusEndpoint>,
1464 concurrency: std::sync::Arc<tokio::sync::Semaphore>,
1468 pool: Arc<tokio::sync::Mutex<WorkerPool>>,
1474 max_idle_per_key: usize,
1475 max_idle_total: usize,
1476 pool_reaper_started: AtomicBool,
1477 next_execution_epoch: AtomicU64,
1481 approval_decider: Option<Arc<dyn ChildApprovalDecider>>,
1485 approval_reviewer: Option<Arc<dyn ChildApprovalReviewer>>,
1489 escalation_bridge: Arc<std::sync::Mutex<Option<bamboo_subagent::executor::HostBridge>>>,
1500 remote_placements: HashMap<String, ResolvedRemotePlacement>,
1506 strict_remote_subscription: tokio::sync::Mutex<()>,
1508 schedulable_placements: HashMap<String, ResolvedSchedulablePlacement>,
1516 codex_run_tokens: Option<Arc<dyn CodexRunTokenAuthority>>,
1522 session_inbox_runtime: Arc<std::sync::Mutex<Option<SessionInboxRuntimeBinding>>>,
1525 actor_directory_store: std::sync::Mutex<Option<Arc<bamboo_storage::SessionStoreV2>>>,
1526 canonical_subagent_tool: std::sync::Mutex<Option<Arc<dyn bamboo_agent_core::tools::Tool>>>,
1527 ticket_service: std::sync::Mutex<Option<Arc<bamboo_tickets::TicketService>>>,
1528 actor_event_observer: std::sync::Mutex<Option<Arc<dyn ActorEventObserver>>>,
1529 pending_durable_links: tokio::sync::Mutex<
1532 HashMap<(String, chrono::DateTime<chrono::Utc>, String), PendingDurableChildLink>,
1533 >,
1534}
1535
1536struct PendingDurableChildLink {
1537 link: Box<dyn bamboo_subagent::ChildLink>,
1538 host_nested_wait_handoff: bool,
1541 strict_history: Option<StrictTerminalHistory>,
1543 strict_history_required: bool,
1544 strict_history_committed: bool,
1545}
1546
1547#[derive(Clone)]
1548struct StrictTerminalHistory {
1549 proof: bamboo_storage::v2::HostTerminalCompleteness,
1550 watermark: bamboo_subagent::ActorEventWatermark,
1551}
1552
1553fn effective_broker_terminal_status(
1554 raw: TerminalStatus,
1555 host_nested_wait_handoff: bool,
1556) -> Result<&'static str, String> {
1557 if host_nested_wait_handoff {
1558 if raw != TerminalStatus::Completed {
1559 return Err("nested wait cannot project a failed broker terminal".into());
1560 }
1561 Ok("suspended")
1562 } else {
1563 Ok(broker_terminal_status_label(raw))
1564 }
1565}
1566
1567fn broker_terminal_status_label(status: TerminalStatus) -> &'static str {
1568 match status {
1569 TerminalStatus::Completed => "completed",
1570 TerminalStatus::Suspended => "suspended",
1571 TerminalStatus::Error => "error",
1572 TerminalStatus::Cancelled => "cancelled",
1573 }
1574}
1575
1576#[async_trait]
1586pub trait ChildApprovalDecider: Send + Sync {
1587 async fn decide(&self, child_session_id: &str, request: &serde_json::Value) -> bool;
1590}
1591
1592async fn decide_child_approval(
1595 decider: Option<&Arc<dyn ChildApprovalDecider>>,
1596 child_session_id: &str,
1597 request: &serde_json::Value,
1598) -> bool {
1599 match decider {
1600 Some(decider) => decider.decide(child_session_id, request).await,
1601 None => false,
1602 }
1603}
1604
1605const CHILD_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
1609
1610#[derive(Clone)]
1621pub struct ChildApprovalScope {
1622 stamp: serde_json::Value,
1623 deadline: chrono::DateTime<chrono::Utc>,
1624 parent: String,
1625 child: String,
1626 run: String,
1627 epoch: u64,
1628 current_epoch: Arc<AtomicU64>,
1629 cancel: CancellationToken,
1630 router: Arc<crate::SessionActivationRouter>,
1631 admission: Arc<AtomicUsize>,
1632}
1633
1634pub struct ChildApprovalAdmission(Arc<AtomicUsize>);
1635impl Drop for ChildApprovalAdmission {
1636 fn drop(&mut self) {
1637 self.0.store(2, Ordering::Release);
1638 }
1639}
1640
1641impl ChildApprovalScope {
1642 pub fn new(
1644 parent: &str,
1645 child: &str,
1646 transport: (u32, &str, u64, &str, chrono::DateTime<chrono::Utc>),
1647 router: Arc<crate::SessionActivationRouter>,
1648 cancel: CancellationToken,
1649 current_epoch: Arc<AtomicU64>,
1650 ) -> Self {
1651 let (attempt, run, epoch, reply, deadline) = transport;
1652 let deadline = deadline.min(chrono::Utc::now() + chrono::Duration::seconds(240));
1653 Self {
1654 stamp: serde_json::json!({"host_scope": uuid::Uuid::new_v4().to_string(),
1655 "attempt": attempt, "run": run, "epoch": epoch, "reply": reply,
1656 "deadline": deadline}),
1657 deadline,
1658 parent: parent.into(),
1659 child: child.into(),
1660 run: run.into(),
1661 epoch,
1662 current_epoch,
1663 cancel,
1664 router,
1665 admission: Arc::new(AtomicUsize::new(0)),
1666 }
1667 }
1668 pub fn stamp(&self) -> &serde_json::Value {
1669 &self.stamp
1670 }
1671 pub fn deadline(&self) -> chrono::DateTime<chrono::Utc> {
1672 self.deadline
1673 }
1674 pub fn first_admission(&self) -> Option<ChildApprovalAdmission> {
1675 self.admission
1676 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1677 .ok()
1678 .map(|_| ChildApprovalAdmission(self.admission.clone()))
1679 }
1680 pub fn is_admitting(&self) -> bool {
1681 self.admission.load(Ordering::Acquire) == 1
1682 }
1683 pub async fn is_current(&self, parent: &str, child: &str) -> bool {
1684 self.parent == parent
1685 && self.child == child
1686 && !self.cancel.is_cancelled()
1687 && self.current_epoch.load(Ordering::Acquire) == self.epoch
1688 && self.router.owns_run(child, &self.run).await
1689 }
1690}
1691
1692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1693pub enum ChildApprovalReview {
1694 Reply(bool),
1695 NoReply,
1696}
1697
1698#[async_trait]
1699pub trait ChildApprovalReviewer: Send + Sync {
1700 async fn review(
1703 &self,
1704 parent_session_id: &str,
1705 child_session_id: &str,
1706 request: &serde_json::Value,
1707 ) -> bool;
1708
1709 async fn review_scoped(
1710 &self,
1711 parent: &str,
1712 child: &str,
1713 request: &serde_json::Value,
1714 _scope: &ChildApprovalScope,
1715 ) -> ChildApprovalReview {
1716 ChildApprovalReview::Reply(self.review(parent, child, request).await)
1717 }
1718}
1719
1720fn child_approval_reviewer_slot() -> &'static std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> {
1721 static SLOT: std::sync::OnceLock<Arc<dyn ChildApprovalReviewer>> = std::sync::OnceLock::new();
1722 &SLOT
1723}
1724
1725pub fn set_child_approval_reviewer(reviewer: Arc<dyn ChildApprovalReviewer>) {
1727 let _ = child_approval_reviewer_slot().set(reviewer);
1728}
1729
1730pub fn child_approval_reviewer() -> Option<Arc<dyn ChildApprovalReviewer>> {
1732 child_approval_reviewer_slot().get().cloned()
1733}
1734
1735impl ActorChildRunner {
1736 #[allow(clippy::too_many_arguments)]
1737 pub fn new(
1738 agent_id: String,
1739 worker_bin: PathBuf,
1740 worker_args: Vec<String>,
1741 fabric_dir: PathBuf,
1742 executor: ExecutorSpec,
1743 credentials: Vec<ScopedCredential>,
1744 default_provider: String,
1745 max_concurrent: usize,
1746 ) -> Self {
1747 Self {
1748 approval_registry: None,
1749 permission_config: None,
1750 agent_id,
1751 worker_bin,
1752 worker_args,
1753 builtin_required_context_route: false,
1754 fabric_dir,
1755 executor,
1756 credentials,
1757 default_provider,
1758 max_spawn_depth: MAX_SPAWN_DEPTH,
1759 live_provider_config: None,
1760 native_tool_ceiling: None,
1761 bus: None,
1762 concurrency: std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1))),
1763 pool: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1764 max_idle_per_key: DEFAULT_MAX_IDLE_PER_KEY,
1765 max_idle_total: DEFAULT_MAX_IDLE_TOTAL,
1766 pool_reaper_started: AtomicBool::new(false),
1767 next_execution_epoch: AtomicU64::new(0),
1768 approval_decider: None,
1769 approval_reviewer: None,
1770 escalation_bridge: Arc::new(std::sync::Mutex::new(None)),
1771 remote_placements: HashMap::new(),
1772 strict_remote_subscription: tokio::sync::Mutex::new(()),
1773 schedulable_placements: HashMap::new(),
1774 codex_run_tokens: None,
1775 session_inbox_runtime: Arc::new(std::sync::Mutex::new(None)),
1776 actor_directory_store: std::sync::Mutex::new(None),
1777 canonical_subagent_tool: std::sync::Mutex::new(None),
1778 ticket_service: std::sync::Mutex::new(None),
1779 actor_event_observer: std::sync::Mutex::new(None),
1780 pending_durable_links: tokio::sync::Mutex::new(HashMap::new()),
1781 }
1782 }
1783
1784 pub(crate) fn with_builtin_required_context_route(mut self, supported: bool) -> Self {
1789 self.builtin_required_context_route = supported;
1790 self
1791 }
1792
1793 pub(crate) fn with_native_tool_ceiling_source(
1794 mut self,
1795 source: Option<Arc<dyn super::runtime::NativeToolCeilingSource>>,
1796 ) -> Self {
1797 self.native_tool_ceiling = source;
1798 self
1799 }
1800
1801 async fn require_initial_input_release_worker(&self) -> crate::runtime::runner::Result<()> {
1802 bamboo_subagent::fleet::require_worker_capability(
1803 &self.worker_bin,
1804 &self.worker_args,
1805 bamboo_subagent::provision::INITIAL_INPUT_RELEASE_WORKER_CAPABILITY,
1806 )
1807 .await
1808 .map_err(|_| AgentError::LLM("owned initial release worker capability unconfirmed".into()))
1809 }
1810
1811 pub fn with_live_provider_config(
1812 mut self,
1813 config: Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
1814 ) -> Self {
1815 self.live_provider_config = Some(config);
1816 self
1817 }
1818
1819 pub fn with_max_spawn_depth(mut self, max_spawn_depth: u32) -> Self {
1820 self.max_spawn_depth = max_spawn_depth;
1821 self
1822 }
1823
1824 pub fn with_approval_registry(
1825 mut self,
1826 registry: super::approval_registry::SharedApprovalRegistry,
1827 ) -> Self {
1828 self.approval_registry = Some(registry);
1829 self
1830 }
1831
1832 pub fn with_permission_config(
1833 mut self,
1834 config: Arc<bamboo_tools::permission::PermissionConfig>,
1835 ) -> Self {
1836 self.permission_config = Some(config);
1837 self
1838 }
1839
1840 pub fn with_bus(mut self, bus: Option<bamboo_subagent::BusEndpoint>) -> Self {
1845 self.bus = bus.filter(|b| !b.endpoint.trim().is_empty());
1846 self
1847 }
1848
1849 pub fn with_approval_decider(mut self, decider: Arc<dyn ChildApprovalDecider>) -> Self {
1852 self.approval_decider = Some(decider);
1853 self
1854 }
1855
1856 pub fn with_approval_reviewer(mut self, reviewer: Arc<dyn ChildApprovalReviewer>) -> Self {
1857 self.approval_reviewer = Some(reviewer);
1858 self
1859 }
1860
1861 pub fn with_codex_run_tokens(
1862 mut self,
1863 authority: Option<Arc<dyn CodexRunTokenAuthority>>,
1864 ) -> Self {
1865 self.codex_run_tokens = authority;
1866 self
1867 }
1868
1869 pub fn with_remote_placements(
1875 mut self,
1876 placements: HashMap<String, ResolvedRemotePlacement>,
1877 ) -> Self {
1878 self.remote_placements = placements;
1879 self
1880 }
1881
1882 pub fn with_schedulable_placements(
1888 mut self,
1889 placements: HashMap<String, ResolvedSchedulablePlacement>,
1890 ) -> Self {
1891 self.schedulable_placements = placements;
1892 self
1893 }
1894
1895 fn fingerprint(spec: &ProvisionSpec) -> String {
1914 let role = spec.identity.role.as_str();
1915 let (provider, model) = spec
1916 .model
1917 .as_ref()
1918 .map(|m| (m.provider.as_str(), m.model.as_str()))
1919 .unwrap_or(("", ""));
1920 let workspace = spec.workspace.as_deref().unwrap_or("");
1921 let mut tools = spec.disabled_tools.clone().unwrap_or_default();
1922 tools.sort();
1923 let caps = &spec.capabilities;
1924 let credential_revision = if spec.secrets.provider_credentials.is_empty() {
1929 "none".to_string()
1930 } else {
1931 let serialized = serde_json::to_vec(&spec.secrets.provider_credentials)
1932 .expect("scoped provider credentials are serializable");
1933 hex::encode(Sha256::digest(serialized))
1934 };
1935 let executor = serde_json::to_string(&spec.executor).unwrap_or_default();
1938 format!(
1939 "{role}\u{1}{provider}\u{1}{model}\u{1}cred={credential_revision}\u{1}{workspace}\u{1}{}\u{1}d={}\u{1}ns={}\u{1}pr={}\u{1}pe={}\u{1}by={}\u{1}auto={}\u{1}ep={}\u{1}md={}\u{1}nha={}\u{1}ro={}\u{1}gro={}\u{1}ci={}\u{1}executor={executor}",
1940 tools.join(","),
1941 spec.identity.depth,
1942 caps.nested_spawn,
1943 caps.permission_requested_mode,
1944 caps.permission_effective_mode,
1945 caps.bypass,
1946 caps.auto_approve_permissions,
1947 caps.enforce_permissions,
1948 caps.max_spawn_depth.unwrap_or(0),
1949 caps.no_human_approver,
1956 caps.read_only,
1959 caps.guardian_read_only,
1962 caps.child_creation_identity,
1963 )
1964 }
1965
1966 fn ensure_pool_reaper(&self) {
1969 if self
1970 .pool_reaper_started
1971 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1972 .is_err()
1973 {
1974 return;
1975 }
1976 let pool = Arc::downgrade(&self.pool);
1977 tokio::spawn(async move {
1978 let mut interval = tokio::time::interval(POOLED_REAPER_INTERVAL);
1979 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1980 interval.tick().await;
1983 loop {
1984 interval.tick().await;
1985 let Some(pool) = pool.upgrade() else {
1986 break;
1987 };
1988 let _ = reap_worker_pool_once(&pool).await;
1989 }
1990 });
1991 }
1992
1993 fn fresh_required_worker_spec(spec: &ProvisionSpec) -> ProvisionSpec {
1994 let mut physical = spec.clone();
1995 physical.identity.child_id = format!("required-worker-{}", uuid::Uuid::new_v4());
1999 physical
2000 }
2001
2002 async fn acquire_bus_worker(
2008 &self,
2009 key: &str,
2010 spec: &ProvisionSpec,
2011 ) -> crate::runtime::runner::Result<PooledWorker> {
2012 if spec.capabilities.required_child_context {
2013 let physical = Self::fresh_required_worker_spec(spec);
2014 let worker = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, &physical)
2015 .await
2016 .map_err(|error| {
2017 AgentError::LLM(format!("required-context worker spawn failed: {error}"))
2018 })?;
2019 let mailbox_id = worker.record.agent_id.clone();
2020 return Ok(PooledWorker {
2021 worker,
2022 mailbox_id,
2023 parked_at: None,
2024 });
2025 }
2026 self.ensure_pool_reaper();
2027 loop {
2031 let candidate = {
2032 let mut pool = self.pool.lock().await;
2033 let candidate = pool.get_mut(key).and_then(|bucket| bucket.pop());
2034 if pool.get(key).is_some_and(Vec::is_empty) {
2035 pool.remove(key);
2036 }
2037 candidate
2038 };
2039 let Some(mut candidate) = candidate else {
2040 break;
2041 };
2042 let now = Instant::now();
2043 let expired = parked_worker_expired(
2044 candidate.parked_at,
2045 now,
2046 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
2047 );
2048 if candidate.worker.is_alive() && !expired {
2049 candidate.parked_at = None;
2050 return Ok(candidate);
2051 }
2052 tracing::info!(
2053 pool_key = key,
2054 mailbox_id = %candidate.mailbox_id,
2055 worker_pid = ?candidate.worker.pid(),
2056 shutdown_reason = if expired {
2057 PoolReapReason::IdleTimeout.as_str()
2058 } else {
2059 PoolReapReason::ProcessExited.as_str()
2060 },
2061 "discarding unusable parked sub-agent worker during checkout"
2062 );
2063 candidate.worker.kill().await;
2064 }
2065
2066 let spawned = spawn_worker_on_bus(&self.worker_bin, &self.worker_args, spec)
2067 .await
2068 .map_err(|e| AgentError::LLM(format!("actor spawn (bus) failed: {e}")))?;
2069 let mailbox_id = spawned.record.agent_id.clone();
2070 Ok(PooledWorker {
2071 worker: spawned,
2072 mailbox_id,
2073 parked_at: None,
2074 })
2075 }
2076
2077 async fn release_bus_worker(&self, key: &str, mut worker: PooledWorker) {
2081 self.ensure_pool_reaper();
2082 if !worker.worker.is_alive() {
2083 tracing::info!(
2084 pool_key = key,
2085 mailbox_id = %worker.mailbox_id,
2086 worker_pid = ?worker.worker.pid(),
2087 shutdown_reason = "process_exited_before_park",
2088 "discarding sub-agent worker instead of parking"
2089 );
2090 worker.worker.kill().await;
2091 return;
2092 }
2093 worker.parked_at = Some(Instant::now());
2094 let mut pool = self.pool.lock().await;
2095 let key_count = pool.get(key).map_or(0, Vec::len);
2096 let total_count = pool.values().map(Vec::len).sum::<usize>();
2097 let limit_reason =
2098 idle_pool_limit_reason(&pool, key, self.max_idle_per_key, self.max_idle_total);
2099 if let Some(shutdown_reason) = limit_reason {
2100 drop(pool);
2101 tracing::info!(
2102 pool_key = key,
2103 mailbox_id = %worker.mailbox_id,
2104 worker_pid = ?worker.worker.pid(),
2105 idle_workers_for_key = key_count,
2106 idle_workers_total = total_count,
2107 max_idle_per_key = self.max_idle_per_key,
2108 max_idle_total = self.max_idle_total,
2109 shutdown_reason,
2110 "discarding sub-agent worker because the warm pool is full"
2111 );
2112 worker.worker.kill().await;
2113 return;
2114 }
2115 pool.entry(key.to_string()).or_default().push(worker);
2116 }
2117
2118 fn build_spec_with_provider_config(
2121 &self,
2122 session: &Session,
2123 job: &SpawnJob,
2124 credentials: &[ScopedCredential],
2125 default_provider: &str,
2126 max_spawn_depth: u32,
2127 ) -> ProvisionSpec {
2128 let mut spec = ProvisionSpec::new(
2129 ChildIdentity {
2130 child_id: job.child_session_id.clone(),
2131 parent_id: Some(job.parent_session_id.clone()),
2132 project_key: None,
2133 role: session
2134 .metadata
2135 .get("subagent_type")
2136 .cloned()
2137 .unwrap_or_else(|| "worker".to_string()),
2138 depth: session.spawn_depth,
2143 },
2144 self.executor.clone(),
2145 self.fabric_dir.to_string_lossy().into_owned(),
2146 );
2147 spec.workspace = session.workspace.clone();
2148 if let ExecutorSpec::Codex {
2149 workspace_owned, ..
2150 } = &mut spec.executor
2151 {
2152 *workspace_owned = Some(
2153 spec.workspace
2154 .as_deref()
2155 .is_some_and(workspace_is_bamboo_owned),
2156 );
2157 }
2158 spec.bus = self.bus.clone();
2161 spec.model = session
2164 .model_ref
2165 .as_ref()
2166 .map(|r| ModelRefSpec {
2167 provider: r.provider.clone(),
2168 model: r.model.clone(),
2169 })
2170 .or_else(|| {
2171 let m = job.model.trim();
2172 (!m.is_empty()).then(|| ModelRefSpec {
2173 provider: default_provider.to_string(),
2174 model: m.to_string(),
2175 })
2176 });
2177 spec.disabled_tools = job.disabled_tools.clone();
2178 match &spec.executor {
2179 ExecutorSpec::Codex {
2185 auth_mode,
2186 provider_key_ref,
2187 ..
2188 } => {
2189 if auth_mode.as_deref() == Some("custom") {
2190 if let Some(reference) = provider_key_ref {
2191 if let Some(credential) = credentials.iter().find(|credential| {
2192 credential.credential_ref.as_deref() == Some(reference)
2193 }) {
2194 spec.secrets.provider_credentials.push(credential.clone());
2195 } else {
2196 tracing::warn!(
2197 "actor child {}: custom Codex credential reference '{}' did not resolve",
2198 job.child_session_id,
2199 reference
2200 );
2201 }
2202 } else {
2203 tracing::warn!(
2204 "actor child {}: custom Codex executor has no credential reference",
2205 job.child_session_id
2206 );
2207 }
2208 }
2209 }
2210 _ => {
2213 let provider = spec
2214 .model
2215 .as_ref()
2216 .map(|model| model.provider.as_str())
2217 .filter(|provider| !provider.trim().is_empty())
2218 .unwrap_or(default_provider);
2219 if let Some(credential) = credentials
2220 .iter()
2221 .find(|credential| credential.provider == provider)
2222 {
2223 spec.secrets.provider_credentials.push(credential.clone());
2224 } else {
2225 tracing::warn!(
2226 "actor child {}: no credential found for provider '{}'",
2227 job.child_session_id,
2228 provider
2229 );
2230 }
2231 }
2232 }
2233 spec.capabilities.nested_spawn = session.spawn_depth < max_spawn_depth;
2236 spec.capabilities.max_spawn_depth = Some(max_spawn_depth);
2237 spec.capabilities.enforce_permissions = true;
2243 let requested_permission_mode = session
2248 .agent_runtime_state
2249 .as_ref()
2250 .map(|state| state.effective_permission_mode())
2251 .unwrap_or_default();
2252 let configured_permission_mode = self
2253 .permission_config
2254 .as_ref()
2255 .map(|config| config.mode())
2256 .unwrap_or_default();
2257 spec.capabilities.no_human_approver = session
2262 .agent_runtime_state
2263 .as_ref()
2264 .is_some_and(|s| s.no_human_approver);
2265 let typed_read_only = session
2271 .agent_runtime_state
2272 .as_ref()
2273 .is_some_and(|state| state.read_only);
2274 let legacy_guardian_read_only = spec.identity.role == "guardian"
2275 && spec.disabled_tools.as_ref().is_some_and(|disabled| {
2276 ["Edit", "Write", "SubAgent", "WebFetch"]
2277 .iter()
2278 .all(|name| disabled.iter().any(|disabled_name| disabled_name == *name))
2279 });
2280 let read_only = typed_read_only || legacy_guardian_read_only;
2281 spec.capabilities.read_only = read_only;
2282 spec.capabilities.guardian_read_only = read_only;
2286 if spec.capabilities.read_only_enforced() {
2287 spec.capabilities.nested_spawn = false;
2290 spec.disabled_tools
2295 .get_or_insert_with(Default::default)
2296 .extend(crate::runtime::guardian_state::read_only_child_disabled_tools());
2297 match &mut spec.executor {
2298 ExecutorSpec::ClaudeCode {
2299 permission_mode,
2300 inherit_user_config,
2301 ..
2302 } => {
2303 *permission_mode = Some("plan".to_string());
2304 *inherit_user_config = Some(false);
2307 }
2308 ExecutorSpec::Codex {
2309 mode,
2310 sandbox,
2311 inherit_user_config,
2312 approval_policy,
2313 network_access,
2314 allow_danger_bypass,
2315 permission_profile,
2316 ..
2317 } => {
2318 *sandbox = Some("read-only".to_string());
2319 *inherit_user_config = Some(false);
2320 *approval_policy = Some(
2323 if mode.as_deref() == Some("app_server") {
2324 "on-request"
2325 } else {
2326 "never"
2327 }
2328 .to_string(),
2329 );
2330 *network_access = Some(false);
2331 *allow_danger_bypass = Some(false);
2332 *permission_profile = Some("read-only".to_string());
2333 }
2334 _ => {}
2335 }
2336 }
2337 let read_only_overlay = session
2338 .agent_runtime_state
2339 .as_ref()
2340 .is_some_and(|state| state.plan_mode.is_some())
2341 || spec.capabilities.read_only_enforced()
2342 || executor_has_read_only_permission_profile(&spec.executor);
2343 let permission_resolution = bamboo_domain::resolve_permission_mode_with_read_only(
2344 requested_permission_mode,
2345 configured_permission_mode,
2346 read_only_overlay,
2347 );
2348 spec.capabilities.bypass = permission_resolution.bypass_permissions();
2349 spec.capabilities.auto_approve_permissions =
2350 permission_resolution.suppress_approval_prompts();
2351 spec.capabilities.permission_requested_mode =
2352 permission_resolution.requested.as_str().to_string();
2353 spec.capabilities.permission_effective_mode =
2354 permission_resolution.effective.as_str().to_string();
2355 if let Some(placement) = self.remote_placements.get(spec.identity.role.as_str()) {
2362 spec.placement = Placement::Remote {
2363 endpoint: placement.endpoint.clone(),
2364 };
2365 } else if let Some(placement) = self.schedulable_placements.get(spec.identity.role.as_str())
2366 {
2367 spec.placement = Placement::Schedulable {
2375 pool: placement.pool.clone(),
2376 };
2377 }
2378 spec
2379 }
2380
2381 fn build_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2384 self.build_spec_with_provider_config(
2385 session,
2386 job,
2387 &self.credentials,
2388 &self.default_provider,
2389 self.max_spawn_depth,
2390 )
2391 }
2392
2393 async fn build_live_spec(&self, session: &Session, job: &SpawnJob) -> ProvisionSpec {
2398 let Some(config) = self.live_provider_config.as_ref() else {
2399 return self.build_spec(session, job);
2400 };
2401
2402 let config = config.read().await;
2403 let credentials = super::runtime::extract_provider_credentials(&config);
2404 let default_provider = config.effective_default_provider().to_string();
2405 self.build_spec_with_provider_config(
2406 session,
2407 job,
2408 &credentials,
2409 &default_provider,
2410 config.subagents().effective_max_spawn_depth(),
2411 )
2412 }
2413
2414 fn placement_stamp_for(&self, spec: &ProvisionSpec) -> Option<String> {
2420 let host_label = match &spec.placement {
2421 Placement::Remote { .. } => self
2422 .remote_placements
2423 .get(spec.identity.role.as_str())
2424 .and_then(|p| p.host_label.as_deref()),
2425 Placement::Schedulable { .. } => self
2426 .schedulable_placements
2427 .get(spec.identity.role.as_str())
2428 .and_then(|p| p.host_label.as_deref()),
2429 Placement::Local => None,
2430 };
2431 placement_metadata(&spec.placement, host_label)
2432 }
2433
2434 fn scoped_placement_route(
2435 &self,
2436 spec: &ProvisionSpec,
2437 ) -> Result<Option<ScopedPlacementRoute>, AgentError> {
2438 match &spec.placement {
2439 Placement::Local => Ok(None),
2440 Placement::Remote { .. } => {
2441 let placement = self
2442 .remote_placements
2443 .get(&spec.identity.role)
2444 .ok_or_else(remote_broker_unavailable)?;
2445 let peer = placement
2446 .broker_peer
2447 .as_ref()
2448 .and_then(|peer| peer.as_ref().ok())
2449 .ok_or_else(remote_broker_unavailable)?;
2450 let worker_role = peer
2451 .worker_role
2452 .clone()
2453 .ok_or_else(remote_broker_unavailable)?;
2454 Ok(Some(ScopedPlacementRoute {
2455 endpoint: placement.endpoint.clone(),
2456 token: placement
2457 .token
2458 .clone()
2459 .ok_or_else(remote_broker_unavailable)?,
2460 ca_cert_file: placement
2461 .ca_cert_file
2462 .clone()
2463 .ok_or_else(remote_broker_unavailable)?,
2464 parent: bamboo_subagent::AgentRef {
2465 session_id: peer.parent_mailbox.clone(),
2466 role: peer.parent_role.clone(),
2467 },
2468 worker_role,
2469 pinned_mailbox: Some(peer.worker_mailbox.clone()),
2470 requirements: placement
2471 .requirements
2472 .clone()
2473 .ok_or_else(remote_broker_unavailable)?,
2474 }))
2475 }
2476 Placement::Schedulable { .. } => {
2477 let placement = self
2478 .schedulable_placements
2479 .get(&spec.identity.role)
2480 .ok_or_else(remote_broker_unavailable)?;
2481 let parent = placement
2482 .broker_parent
2483 .as_ref()
2484 .ok_or_else(remote_broker_unavailable)?;
2485 Ok(Some(ScopedPlacementRoute {
2486 endpoint: placement.endpoint.clone(),
2487 token: placement
2488 .token
2489 .clone()
2490 .ok_or_else(remote_broker_unavailable)?,
2491 ca_cert_file: placement
2492 .ca_cert_file
2493 .clone()
2494 .ok_or_else(remote_broker_unavailable)?,
2495 parent: bamboo_subagent::AgentRef {
2496 session_id: parent.parent_mailbox.clone(),
2497 role: Some(parent.parent_role.clone()),
2498 },
2499 worker_role: placement.pool.clone(),
2500 pinned_mailbox: None,
2501 requirements: placement
2502 .requirements
2503 .clone()
2504 .ok_or_else(remote_broker_unavailable)?,
2505 }))
2506 }
2507 }
2508 }
2509
2510 async fn select_scoped_worker(
2511 &self,
2512 route: &ScopedPlacementRoute,
2513 registry: &bamboo_storage::v2::FileHostRegistry,
2514 actor_id: &str,
2515 project_id: Option<bamboo_domain::ProjectId>,
2516 required_tools: &BTreeSet<String>,
2517 ) -> Result<String, AgentError> {
2518 if let Some(mailbox) = &route.pinned_mailbox {
2519 return Ok(mailbox.clone());
2520 }
2521 let mut query = route.query().await?;
2522 let candidates = query
2523 .list_connected(&route.worker_role)
2524 .await
2525 .map_err(|_| remote_broker_unavailable())?;
2526 let scheduler = PlacementScheduler::new(registry.clone());
2527 let mut selected: Option<(String, usize, u16)> = None;
2528 for mailbox in candidates {
2529 let Some(observed) = query
2530 .observe_host_capacity(&mailbox, &route.worker_role)
2531 .await
2532 .map_err(|_| remote_broker_unavailable())?
2533 else {
2534 continue;
2535 };
2536 let Ok(host) = scheduler
2537 .observe_scoped_host(
2538 &mut query,
2539 &observed.host_ref,
2540 &mailbox,
2541 &route.worker_role,
2542 chrono::Duration::seconds(110),
2543 )
2544 .await
2545 else {
2546 continue;
2547 };
2548 let now = chrono::Utc::now();
2549 let request = HostPlacementRequest {
2550 intent: HostPlacementIntent::Pool {
2551 pool_id: route.worker_role.clone(),
2552 },
2553 actor_id: actor_id.to_owned(),
2554 run_id: "selection".into(),
2555 project_id: project_id.clone(),
2556 trust_zone: route.requirements.trust_zone.clone(),
2557 workspace_label: route.requirements.workspace_label.clone(),
2558 executor: "bamboo-runtime".into(),
2559 required_tools: required_tools.clone(),
2560 network_zone: route.requirements.network_zone.clone(),
2561 require_network_isolation: route.requirements.require_network_isolation,
2562 preferred_host_ref: None,
2563 now,
2564 lease_expires_at: now + chrono::Duration::seconds(80),
2565 };
2566 if host.capabilities.placement_class != bamboo_domain::ActorPlacementClass::Remote
2567 || !request.eligible(&host)
2568 {
2569 continue;
2570 }
2571 let load = host.active_slots(now);
2572 if selected.as_ref().is_none_or(|(_, old_load, old_slots)| {
2573 load * usize::from(*old_slots) < *old_load * usize::from(host.max_slots)
2574 }) {
2575 selected = Some((mailbox, load, host.max_slots));
2576 }
2577 }
2578 selected
2579 .map(|(mailbox, _, _)| mailbox)
2580 .ok_or_else(remote_broker_unavailable)
2581 }
2582}
2583
2584#[async_trait]
2585impl ExternalChildRunner for ActorChildRunner {
2586 async fn validate_required_child_context_route(&self, session: &Session) -> Result<(), String> {
2587 let role = session
2588 .metadata
2589 .get("subagent_type")
2590 .map(String::as_str)
2591 .unwrap_or("worker");
2592 let current = std::env::current_exe()
2593 .ok()
2594 .and_then(|path| path.canonicalize().ok());
2595 if self.native_tool_ceiling.is_none()
2596 || !self.builtin_required_context_route
2597 || !self.should_handle(session).await
2598 || current.is_none()
2599 || self.worker_bin.canonicalize().ok() != current
2600 || self.worker_args != ["subagent-worker".to_string()]
2601 || !matches!(self.executor, ExecutorSpec::BambooRuntime)
2602 || self.bus.is_none()
2603 || self.remote_placements.contains_key(role)
2604 || self.schedulable_placements.contains_key(role)
2605 {
2606 return Err(
2607 "required_child_context_unsupported: built-in fresh local Bamboo worker required"
2608 .into(),
2609 );
2610 }
2611 bamboo_subagent::fleet::require_worker_capability(
2612 &self.worker_bin,
2613 &self.worker_args,
2614 bamboo_subagent::provision::REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
2615 )
2616 .await
2617 .map_err(|_| {
2618 "required_child_context_unsupported: worker capability unconfirmed".to_string()
2619 })?;
2620 bamboo_subagent::fleet::require_worker_capability(
2621 &self.worker_bin,
2622 &self.worker_args,
2623 bamboo_subagent::provision::NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
2624 )
2625 .await
2626 .map_err(|_| {
2627 "native_tool_ceiling_unsupported: worker capability unconfirmed".to_string()
2628 })?;
2629 bamboo_subagent::fleet::require_worker_capability(
2630 &self.worker_bin,
2631 &self.worker_args,
2632 bamboo_subagent::provision::CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
2633 )
2634 .await
2635 .map_err(|_| {
2636 "required_child_context_unsupported: worker birth capability unconfirmed".into()
2637 })
2638 }
2639 async fn should_handle(&self, session: &Session) -> bool {
2640 session.metadata.get("runtime.kind") == Some(&"external".to_string())
2641 && session.metadata.get("external.protocol") == Some(&"actor".to_string())
2642 && session.metadata.get("external.agent_id") == Some(&self.agent_id)
2643 }
2644
2645 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
2646 *self.escalation_bridge.lock().recover_poison() = bridge;
2647 }
2648
2649 fn set_session_inbox_runtime(&self, binding: Option<SessionInboxRuntimeBinding>) {
2650 *self.session_inbox_runtime.lock().recover_poison() = binding;
2651 }
2652
2653 fn set_actor_directory_store(&self, store: Option<Arc<bamboo_storage::SessionStoreV2>>) {
2654 *self.actor_directory_store.lock().recover_poison() = store;
2655 }
2656
2657 fn set_canonical_subagent_tool(&self, tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>) {
2658 *self.canonical_subagent_tool.lock().recover_poison() = tool;
2659 }
2660
2661 fn set_ticket_service(&self, service: Option<Arc<bamboo_tickets::TicketService>>) {
2662 *self.ticket_service.lock().recover_poison() = service;
2663 }
2664
2665 fn set_actor_event_observer(&self, observer: Option<Arc<dyn ActorEventObserver>>) {
2666 *self.actor_event_observer.lock().recover_poison() = observer;
2667 }
2668
2669 async fn prepare_durable_child_delivery(
2670 &self,
2671 session: &Session,
2672 activation_run_id: &str,
2673 ) -> Result<bool, String> {
2674 let key = (
2675 session.id.clone(),
2676 session.created_at,
2677 activation_run_id.to_owned(),
2678 );
2679 let (delivery, host_nested_wait_handoff, strict_history) = {
2680 let pending = self.pending_durable_links.lock().await;
2681 let Some(pending) = pending.get(&key) else {
2682 return Ok(false);
2683 };
2684 if pending.strict_history_required && pending.strict_history.is_none() {
2685 return Err("Host strict terminal completeness proof missing".into());
2686 }
2687 (
2688 pending
2689 .link
2690 .durable_delivery_receipt()
2691 .ok_or_else(|| "broker terminal has no exact durable receipt".to_string())?,
2692 pending.host_nested_wait_handoff,
2693 pending.strict_history.clone(),
2694 )
2695 };
2696 let expected_status =
2697 effective_broker_terminal_status(delivery.terminal_status, host_nested_wait_handoff)?;
2698 if session.last_run_status().as_deref() != Some(expected_status) {
2699 tracing::warn!(
2700 child_session_id = %session.id,
2701 activation_run_id,
2702 expected_status,
2703 actual_status = ?session.last_run_status(),
2704 "Host final status differs from accepted broker terminal"
2705 );
2706 return Err("Host final status differs from accepted broker terminal".into());
2707 }
2708 let store = self
2709 .actor_directory_store
2710 .lock()
2711 .recover_poison()
2712 .clone()
2713 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2714 let binding = self
2715 .session_inbox_runtime
2716 .lock()
2717 .recover_poison()
2718 .clone()
2719 .ok_or_else(|| "Host activation fence unavailable for broker receipt".to_string())?;
2720 if !binding
2721 .router
2722 .owns_run(&session.id, activation_run_id)
2723 .await
2724 {
2725 return Err("stale Child activation cannot prepare broker receipt".into());
2726 }
2727 let ticket_service = self.ticket_service.lock().recover_poison().clone();
2728 let yielded_call = if expected_status == "completed"
2729 && session
2730 .metadata
2731 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2732 {
2733 let service = ticket_service
2734 .as_deref()
2735 .ok_or("Ticket authority unavailable for broker question")?;
2736 crate::ticket_runtime::question_terminal_call_id(service, session, activation_run_id)
2737 .map_err(|e| e.to_string())?
2738 } else {
2739 None
2740 };
2741 let route = bamboo_storage::v2::BrokerTerminalRoute {
2742 activation_run_id,
2743 broker_identity: &delivery.broker_identity,
2744 parent_mailbox: &delivery.parent_mailbox,
2745 broker_correlation_id: &delivery.correlation_id,
2746 message_ids: &delivery.message_ids,
2747 };
2748 if let Some(call_id) = yielded_call {
2749 let proof = bamboo_storage::v2::HostToolYield::from_verified_host(
2750 session,
2751 activation_run_id,
2752 &call_id,
2753 )
2754 .map_err(|error| error.to_string())?;
2755 if let Some(history) = strict_history.as_ref() {
2756 store
2757 .prepare_broker_tool_yield_receipt_with_completeness(
2758 session,
2759 route,
2760 &proof,
2761 &history.proof,
2762 )
2763 .await
2764 } else {
2765 store
2766 .prepare_broker_tool_yield_receipt(session, route, &proof)
2767 .await
2768 }
2769 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2770 } else {
2771 if let Some(history) = strict_history.as_ref() {
2772 store
2773 .prepare_broker_terminal_receipt_with_completeness(
2774 session,
2775 route,
2776 &history.proof,
2777 )
2778 .await
2779 } else {
2780 store
2781 .prepare_broker_terminal_receipt(
2782 session,
2783 activation_run_id,
2784 &delivery.broker_identity,
2785 &delivery.parent_mailbox,
2786 &delivery.correlation_id,
2787 &delivery.message_ids,
2788 )
2789 .await
2790 }
2791 .map_err(|error| format!("Host broker receipt prepare failed: {error}"))?;
2792 }
2793 Ok(true)
2794 }
2795
2796 async fn commit_durable_child_delivery(
2797 &self,
2798 session: &Session,
2799 activation_run_id: &str,
2800 ) -> Result<(), String> {
2801 let key = (
2802 session.id.clone(),
2803 session.created_at,
2804 activation_run_id.to_owned(),
2805 );
2806 let mut pending_links = self.pending_durable_links.lock().await;
2807 let Some(pending) = pending_links.get_mut(&key) else {
2808 return Ok(());
2809 };
2810 let delivery = pending
2811 .link
2812 .durable_delivery_receipt()
2813 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2814 let expected_status = effective_broker_terminal_status(
2815 delivery.terminal_status,
2816 pending.host_nested_wait_handoff,
2817 )?;
2818 if session.last_run_status().as_deref() != Some(expected_status) {
2819 return Err("Host final status differs from accepted broker terminal".into());
2820 }
2821 if pending.strict_history_required && pending.strict_history.is_none() {
2822 return Err("Host strict terminal completeness proof missing".into());
2823 }
2824 let store = self
2825 .actor_directory_store
2826 .lock()
2827 .recover_poison()
2828 .clone()
2829 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2830 let committed = if let Some(history) = pending.strict_history.as_ref() {
2831 store
2832 .commit_broker_terminal_receipt_with_completeness(
2833 session,
2834 activation_run_id,
2835 &history.proof,
2836 )
2837 .await
2838 } else {
2839 store
2840 .commit_broker_terminal_receipt(session, activation_run_id)
2841 .await
2842 }
2843 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2844 if committed.broker_identity != delivery.broker_identity
2845 || committed.parent_mailbox != delivery.parent_mailbox
2846 || committed.broker_correlation_id != delivery.correlation_id
2847 || committed.message_ids != delivery.message_ids
2848 || committed.terminal_status != expected_status
2849 {
2850 return Err("Host broker receipt changed before completion".into());
2851 }
2852 if let Some(history) = pending.strict_history.as_ref() {
2853 let proof = committed
2854 .terminal_completeness
2855 .as_ref()
2856 .ok_or_else(|| "Host strict terminal completeness readback missing".to_string())?;
2857 let creation = bamboo_subagent::proto::ChildCreationIdentity {
2858 created_at: session.created_at,
2859 spawn_depth: session.spawn_depth,
2860 };
2861 validate_strict_history_terminal(
2862 Some(&history.watermark),
2863 session,
2864 session
2865 .parent_session_id
2866 .as_deref()
2867 .ok_or("Child parent missing")?,
2868 Some(activation_run_id),
2869 proof.execution_epoch,
2870 Some(&creation),
2871 proof.contiguous_applied_seq,
2872 )
2873 .map_err(|error| error.to_string())?;
2874 }
2875 publish_recovered_completion_source(&store, &committed).await?;
2879 pending.strict_history_committed = true;
2880 Ok(())
2881 }
2882
2883 async fn confirm_durable_child_delivery(
2884 &self,
2885 session: &Session,
2886 activation_run_id: &str,
2887 save_succeeded: bool,
2888 ) -> Result<(), String> {
2889 if save_succeeded
2890 && session
2891 .metadata
2892 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
2893 {
2894 let service = self
2895 .ticket_service
2896 .lock()
2897 .recover_poison()
2898 .clone()
2899 .ok_or_else(|| {
2900 "Ticket authority unavailable for terminal checkpoint".to_string()
2901 })?;
2902 let runtime = self
2903 .session_inbox_runtime
2904 .lock()
2905 .recover_poison()
2906 .clone()
2907 .ok_or_else(|| "Ticket runtime unavailable for terminal checkpoint".to_string())?;
2908 crate::ticket_runtime::checkpoint_ticket_result(
2909 &service,
2910 runtime.storage.as_ref(),
2911 session,
2912 activation_run_id,
2913 )
2914 .await
2915 .map_err(|e| e.to_string())?;
2916 }
2917 let key = (
2918 session.id.clone(),
2919 session.created_at,
2920 activation_run_id.to_owned(),
2921 );
2922 let Some(mut pending) = self.pending_durable_links.lock().await.remove(&key) else {
2923 return Ok(());
2924 };
2925 let delivery = pending
2928 .link
2929 .durable_delivery_receipt()
2930 .ok_or_else(|| "broker terminal lost its exact durable receipt".to_string())?;
2931 let expected_status = effective_broker_terminal_status(
2932 delivery.terminal_status,
2933 pending.host_nested_wait_handoff,
2934 )?;
2935 if !save_succeeded || session.last_run_status().as_deref() != Some(expected_status) {
2936 return Ok(());
2937 }
2938 let store = self
2939 .actor_directory_store
2940 .lock()
2941 .recover_poison()
2942 .clone()
2943 .ok_or_else(|| "Host broker receipt store unavailable".to_string())?;
2944 let committed = if pending.strict_history_required {
2945 if !pending.strict_history_committed {
2946 return Err("Host strict completeness was not committed before ACK".into());
2947 }
2948 let history = pending
2949 .strict_history
2950 .as_ref()
2951 .ok_or_else(|| "Host strict terminal completeness proof missing".to_string())?;
2952 store
2953 .commit_broker_terminal_receipt_with_completeness(
2954 session,
2955 activation_run_id,
2956 &history.proof,
2957 )
2958 .await
2959 } else {
2960 store
2961 .commit_broker_terminal_receipt(session, activation_run_id)
2962 .await
2963 }
2964 .map_err(|error| format!("Host broker receipt checkpoint unconfirmed: {error}"))?;
2965 if committed.broker_identity != delivery.broker_identity
2966 || committed.parent_mailbox != delivery.parent_mailbox
2967 || committed.broker_correlation_id != delivery.correlation_id
2968 || committed.message_ids != delivery.message_ids
2969 || committed.terminal_status != expected_status
2970 {
2971 return Err("Host broker receipt changed before ACK".into());
2972 }
2973 pending
2974 .link
2975 .acknowledge_durable_frames()
2976 .await
2977 .map_err(|error| format!("child broker durable ACK unconfirmed: {error}"))?;
2978 store
2979 .clear_acknowledged_broker_terminal_receipt(
2980 &session.id,
2981 session.created_at,
2982 activation_run_id,
2983 &delivery.parent_mailbox,
2984 )
2985 .await
2986 .map_err(|error| {
2987 format!("broker ACK succeeded but Host receipt cleanup failed: {error}")
2988 })
2989 }
2990
2991 #[tracing::instrument(
2992 name = "actor_child_run",
2993 level = "warn",
2994 skip_all,
2995 fields(child_session_id = %job.child_session_id, activation_run_id = tracing::field::Empty)
2996 )]
2997 async fn execute_external_child(
2998 &self,
2999 session: &mut Session,
3000 job: &SpawnJob,
3001 event_tx: mpsc::Sender<AgentEvent>,
3002 cancel_token: CancellationToken,
3003 ) -> crate::runtime::runner::Result<()> {
3004 crate::session_app::child_session::named_profile::validate_named_profile(session)
3005 .map_err(|error| AgentError::LLM(error.to_string()))?;
3006 let escalation = self.escalation_bridge.lock().recover_poison().clone();
3015 let session_inbox_runtime = self.session_inbox_runtime.lock().recover_poison().clone();
3016 let actor_directory_store = self.actor_directory_store.lock().recover_poison().clone();
3017 let canonical_subagent_tool = self.canonical_subagent_tool.lock().recover_poison().clone();
3018 let ticket_service = self.ticket_service.lock().recover_poison().clone();
3019 let actor_event_observer = self.actor_event_observer.lock().recover_poison().clone();
3020 let required_context = bamboo_domain::ChildContextBinding::from_session(session)
3021 .map_err(|error| AgentError::Budget(error.to_string()))?;
3022 if crate::session_app::child_session::named_profile::has_named_profile(session)
3023 && required_context.is_none()
3024 {
3025 return Err(AgentError::LLM(
3026 "named_profile_requires_strict_context_route".into(),
3027 ));
3028 }
3029 let assignment = required_context
3030 .as_ref()
3031 .map(|binding| binding.payload.required_assignment.clone())
3032 .unwrap_or_else(|| extract_assignment(session));
3033 let mut spec = self.build_live_spec(session, job).await;
3034 ensure_ticket_placement_is_enforceable(session, &spec)?;
3037 let scoped_route = self.scoped_placement_route(&spec)?;
3038 let strict_remote = scoped_route.as_ref();
3039 if strict_remote.is_some() && !matches!(spec.executor, ExecutorSpec::BambooRuntime) {
3040 return Err(remote_broker_unavailable());
3041 }
3042 let lineage = if strict_remote.is_some() {
3043 Some(
3044 remote_canonical_lineage(
3045 session_inbox_runtime
3046 .as_ref()
3047 .ok_or_else(remote_broker_unavailable)?,
3048 session,
3049 )
3050 .await?,
3051 )
3052 } else {
3053 None
3054 };
3055 let creation = if (matches!(spec.placement, Placement::Local) || strict_remote.is_some())
3056 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3057 {
3058 let binding = session_inbox_runtime.as_ref().ok_or_else(|| {
3059 AgentError::LLM(
3060 "child creation identity unavailable: canonical host Storage required".into(),
3061 )
3062 })?;
3063 spec.capabilities.child_creation_identity = true;
3064 Some(canonical_child_creation(binding, session, job).await?)
3065 } else {
3066 None
3067 };
3068 spec.reusable = required_context.is_none();
3071 spec.capabilities.required_child_context = required_context.is_some();
3072 if required_context.is_some() {
3073 self.validate_required_child_context_route(session)
3074 .await
3075 .map_err(AgentError::LLM)?;
3076 }
3077 if required_context.is_some() {
3078 let names = self
3079 .native_tool_ceiling
3080 .as_ref()
3081 .unwrap()
3082 .observe(session)
3083 .await
3084 .map_err(AgentError::LLM)?;
3085 let birth = creation
3086 .as_ref()
3087 .ok_or_else(|| AgentError::LLM("native_tool_ceiling_birth_missing".into()))?;
3088 spec.capabilities.native_tool_ceiling_required = true;
3089 spec.capabilities.native_tool_ceiling =
3090 Some(bamboo_subagent::proto::NativeToolCeiling {
3091 version: 1,
3092 child_session_id: session.id.clone(),
3093 parent_session_id: job.parent_session_id.clone(),
3094 root_session_id: session.root_session_id.clone(),
3095 created_at: birth.created_at,
3096 spawn_depth: birth.spawn_depth,
3097 project_id: project_id_for_actor_run(session)?,
3098 tools: names,
3099 });
3100 spec.capabilities.mcp = None;
3102 spec.capabilities.mcp_proxy = None;
3103 spec.capabilities.skills_dir = None;
3104 spec.capabilities.nested_spawn = false;
3105 spec.validate()
3106 .map_err(|_| AgentError::LLM("native_tool_ceiling_invalid".into()))?;
3107 }
3108 let first_reply_store = actor_directory_store.clone().filter(|_| {
3111 required_context.is_some()
3112 && crate::session_app::child_session::named_profile::has_named_profile(session)
3113 && matches!(spec.placement, Placement::Local)
3114 && matches!(spec.executor, ExecutorSpec::BambooRuntime)
3115 && spec.capabilities.native_tool_ceiling_required
3116 && spec
3117 .capabilities
3118 .native_tool_ceiling
3119 .as_ref()
3120 .is_some_and(|ceiling| {
3121 ceiling.tools.is_empty()
3122 || (spec.capabilities.read_only_enforced() && ceiling.tools == ["Glob"])
3123 })
3124 });
3125 let plain_actor_store = if let Some(store) = first_reply_store {
3126 let root = bamboo_agent_core::storage::Storage::load_session(
3127 store.as_ref(),
3128 &session.root_session_id,
3129 )
3130 .await
3131 .map_err(|_| plain_actor_unsupported())?
3132 .ok_or_else(|| plain_actor_unsupported())?;
3133 if root.id != session.root_session_id || root.kind != bamboo_domain::SessionKind::Root {
3134 return Err(plain_actor_unsupported());
3135 }
3136 if root.root_orchestration_only_enabled() {
3137 if !plain_initial_history(session)
3138 && (spec
3139 .capabilities
3140 .native_tool_ceiling
3141 .as_ref()
3142 .is_none_or(|ceiling| !ceiling.tools.is_empty())
3143 || (!plain_completed_history(session) && pre_ack_input(session)?.is_none()))
3144 {
3145 return Err(plain_actor_unsupported());
3146 }
3147 Some(store)
3148 } else if spec
3149 .capabilities
3150 .native_tool_ceiling
3151 .as_ref()
3152 .is_some_and(|ceiling| ceiling.tools == ["Glob"])
3153 {
3154 require_unowned_glob(store.as_ref(), session).await?;
3157 None
3158 } else {
3159 let observed = store
3162 .actor_subtree_snapshot(
3163 ActorSnapshotPrincipal::host_owner(),
3164 &session.root_session_id,
3165 &session.id,
3166 ActorSnapshotLimits::default(),
3167 )
3168 .await
3169 .map_err(|_| plain_actor_unsupported())?;
3170 if observed
3171 .nodes
3172 .iter()
3173 .any(|node| node.actor_id == session.id && node.activation.is_some())
3174 {
3175 return Err(plain_actor_unsupported());
3176 }
3177 None
3178 }
3179 } else {
3180 None
3181 };
3182 let readonly_actor = plain_actor_store.is_some()
3183 && spec
3184 .capabilities
3185 .native_tool_ceiling
3186 .as_ref()
3187 .is_some_and(|ceiling| ceiling.tools == ["Glob"]);
3188 let local_history_tools = (plain_actor_store.is_none()
3189 && required_context.is_some()
3190 && (crate::session_app::child_session::named_profile::has_named_profile(session)
3191 || session
3192 .metadata
3193 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY))
3194 && matches!(spec.placement, Placement::Local)
3195 && matches!(spec.executor, ExecutorSpec::BambooRuntime))
3196 .then_some(spec.capabilities.native_tool_ceiling.as_ref())
3197 .flatten()
3198 .filter(|ceiling| {
3199 bamboo_subagent::proto::LocalToolMessages::supports_tools(
3200 &ceiling.tools,
3201 spec.capabilities.read_only_enforced(),
3202 )
3203 })
3204 .map(|ceiling| ceiling.tools.clone());
3205 if plain_actor_store.is_some() && !readonly_actor {
3206 self.require_initial_input_release_worker().await?;
3207 spec.capabilities.initial_input_release_required = true;
3208 spec.validate().map_err(|_| plain_actor_unsupported())?;
3209 }
3210 let mut readonly_messages = Vec::new();
3211 let mut plain_activation = None;
3212 if spec.limits.idle_timeout_secs.is_none() {
3213 spec.limits.idle_timeout_secs = Some(POOLED_IDLE_TIMEOUT_SECS);
3214 }
3215 ensure_read_only_activation_is_enforceable(&spec)?;
3216 let pool_key = Self::fingerprint(&spec);
3217
3218 if executor_uses_bamboo_codex(&spec.executor) && !matches!(spec.placement, Placement::Local)
3223 {
3224 return Err(AgentError::LLM(
3225 "Codex auth mode 'bamboo' requires local actor placement; use custom mode with a reachable URL for remote workers"
3226 .to_string(),
3227 ));
3228 }
3229 let project_id = project_id_for_actor_run(session)?;
3230 let requested_permission_mode = session
3231 .agent_runtime_state
3232 .as_ref()
3233 .map(|state| state.effective_permission_mode())
3234 .unwrap_or_default();
3235 let permission_policy = if let Some(config) = self.permission_config.as_ref() {
3240 ensure_no_active_scoped_session_denies(config, &session.id)?;
3241 let read_only_overlay = session
3242 .agent_runtime_state
3243 .as_ref()
3244 .is_some_and(|state| state.plan_mode.is_some())
3245 || spec.capabilities.read_only_enforced()
3246 || executor_has_read_only_permission_profile(&spec.executor);
3247 let resolution = bamboo_domain::resolve_permission_mode_with_read_only(
3248 requested_permission_mode,
3249 config.mode(),
3250 read_only_overlay,
3251 );
3252 let policy = serde_json::to_value(config.to_serializable()).map_err(|error| {
3253 AgentError::LLM(format!(
3254 "failed to serialize permission policy for external executor: {error}"
3255 ))
3256 })?;
3257 Some(PermissionPolicyContext {
3258 revision: config.policy_revision(),
3259 requested_mode: resolution.requested.as_str().to_string(),
3260 effective_mode: resolution.effective.as_str().to_string(),
3261 bypass_permissions: resolution.bypass_permissions(),
3262 auto_approve_permissions: resolution.suppress_approval_prompts(),
3263 session_id: session.id.clone(),
3264 workspace_path: session.workspace.clone(),
3265 environment_lease: None,
3266 inherit_session_grants: false,
3267 policy,
3268 })
3269 } else {
3270 None
3271 };
3272 let provisioned_permission =
3273 spec.capabilities.permission_resolution().map_err(|error| {
3274 AgentError::LLM(format!("invalid provisioned permission posture: {error}"))
3275 })?;
3276 let policy_resolution = permission_policy
3277 .as_ref()
3278 .map(|context| {
3279 context.resolved_modes().map(|(requested, effective)| {
3280 bamboo_domain::PermissionModeResolution {
3281 requested,
3282 effective,
3283 }
3284 })
3285 })
3286 .transpose()
3287 .map_err(|error| {
3288 AgentError::LLM(format!("invalid host permission posture: {error}"))
3289 })?;
3290 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata);
3291 let has_explicit_deny = self.permission_config.as_ref().is_some_and(|config| {
3292 bamboo_tools::permission::explicit_deny_policy_reason(&config.to_serializable())
3293 .is_some()
3294 });
3295 let expected_executor_mapping = expected_permission_executor_mapping(
3296 &spec.executor,
3297 policy_resolution.unwrap_or(provisioned_permission),
3298 has_explicit_deny,
3299 )?;
3300 let mut expected_permission_posture =
3301 expected_executor_mapping.map(|executor_mapping| ExpectedPermissionPosture {
3302 policy_revision: permission_policy
3303 .as_ref()
3304 .map(|context| context.revision)
3305 .or_else(|| host_audit.as_ref().map(|audit| audit.policy_revision))
3306 .unwrap_or_default(),
3307 resolution: policy_resolution.unwrap_or(provisioned_permission),
3308 expected_audit_revision: host_audit.as_ref().map(|audit| audit.audit_revision),
3309 executor_mapping,
3310 });
3311
3312 let _slot = if strict_remote.is_some() {
3317 tokio::select! { biased;
3318 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3319 permit = self.concurrency.acquire() => permit.map_err(|_| remote_broker_unavailable())?,
3320 }
3321 } else {
3322 self.concurrency
3323 .acquire()
3324 .await
3325 .map_err(|_| AgentError::LLM("actor concurrency limiter closed".into()))?
3326 };
3327 let _remote_subscription = if strict_remote.is_some() {
3328 Some(tokio::select! { biased;
3329 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3330 guard = self.strict_remote_subscription.lock() => guard,
3331 })
3332 } else {
3333 None
3334 };
3335
3336 let (run_secrets, _codex_token_guard) = build_codex_run_secrets(
3343 &spec.executor,
3344 self.codex_run_tokens.clone(),
3345 &job.child_session_id,
3346 )?;
3347
3348 let kind = match spec.placement {
3356 Placement::Remote { .. } => PlacementKind::Remote,
3357 Placement::Schedulable { .. } => PlacementKind::Schedulable,
3358 Placement::Local => PlacementKind::Local,
3359 };
3360 let remote = !matches!(kind, PlacementKind::Local);
3361 let scoped_registry = if remote {
3362 let store = actor_directory_store
3363 .as_ref()
3364 .ok_or_else(remote_broker_unavailable)?;
3365 Some(
3366 bamboo_storage::v2::FileHostRegistry::new(store.bamboo_home_dir().to_path_buf())
3367 .await
3368 .map_err(|_| remote_broker_unavailable())?,
3369 )
3370 } else {
3371 None
3372 };
3373 let scoped_tools = scoped_route.as_ref().map(|route| {
3374 let mut tools = route.requirements.required_tools.clone();
3375 if let Some(ceiling) = &spec.capabilities.native_tool_ceiling {
3376 tools.extend(ceiling.tools.iter().cloned());
3377 }
3378 tools
3379 });
3380
3381 if let Some(placement_meta) = self.placement_stamp_for(&spec) {
3387 session
3388 .metadata
3389 .insert("placement".to_string(), placement_meta);
3390 }
3391
3392 let mut attempt = 0u8;
3399 let (result, actor) = loop {
3400 let execution_epoch = self.next_execution_epoch.fetch_add(1, Ordering::Relaxed) + 1;
3401 let (actor, mut client) = match kind {
3402 PlacementKind::Remote => {
3403 let route = scoped_route
3404 .as_ref()
3405 .ok_or_else(remote_broker_unavailable)?;
3406 let mailbox_id = route
3407 .pinned_mailbox
3408 .clone()
3409 .ok_or_else(remote_broker_unavailable)?;
3410 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3411 .map_err(|_| remote_broker_unavailable())?;
3412 let connect =
3413 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3414 &route.endpoint,
3415 route.parent.clone(),
3416 &route.token,
3417 bamboo_subagent::AgentRef {
3418 session_id: mailbox_id.clone(),
3419 role: Some(route.worker_role.clone()),
3420 },
3421 tls,
3422 );
3423 let link = tokio::select! { biased;
3424 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3425 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3426 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3427 };
3428 let record = AgentRecord {
3429 agent_id: job.child_session_id.clone(),
3430 role: spec.identity.role.clone(),
3431 labels: Vec::new(),
3432 endpoint: String::new(),
3433 pid: 0,
3434 version: String::new(),
3435 started_at: chrono::Utc::now(),
3436 lease_expires_at: chrono::Utc::now(),
3437 };
3438 (
3439 PooledWorker {
3440 worker: SpawnedChild::remote(record),
3441 mailbox_id,
3442 parked_at: None,
3443 },
3444 Box::new(link) as Box<dyn bamboo_subagent::ChildLink>,
3445 )
3446 }
3447 PlacementKind::Schedulable => {
3448 let route = scoped_route
3449 .as_ref()
3450 .ok_or_else(remote_broker_unavailable)?;
3451 let registry = scoped_registry
3452 .as_ref()
3453 .ok_or_else(remote_broker_unavailable)?;
3454 let mailbox_id = self
3455 .select_scoped_worker(
3456 route,
3457 registry,
3458 &session.id,
3459 project_id.clone(),
3460 scoped_tools
3461 .as_ref()
3462 .ok_or_else(remote_broker_unavailable)?,
3463 )
3464 .await?;
3465 let tls = bamboo_broker::client_config_trusting_cert(&route.ca_cert_file)
3466 .map_err(|_| remote_broker_unavailable())?;
3467 let connect =
3468 bamboo_broker::BrokerChildLink::connect_strict_with_tls_environment_lease(
3469 &route.endpoint,
3470 route.parent.clone(),
3471 &route.token,
3472 bamboo_subagent::AgentRef {
3473 session_id: mailbox_id.clone(),
3474 role: Some(route.worker_role.clone()),
3475 },
3476 tls,
3477 );
3478 let link = tokio::select! { biased;
3479 _ = cancel_token.cancelled() => return Err(AgentError::Cancelled),
3480 result = tokio::time::timeout(Duration::from_secs(30), connect) =>
3481 result.map_err(|_| remote_broker_unavailable())?.map_err(|_| remote_broker_unavailable())?,
3482 };
3483 let actor = PooledWorker {
3484 worker: SpawnedChild::remote(AgentRecord {
3485 agent_id: mailbox_id.clone(),
3486 role: spec.identity.role.clone(),
3487 labels: Vec::new(),
3488 endpoint: route.endpoint.clone(),
3489 pid: 0,
3490 version: String::new(),
3491 started_at: chrono::Utc::now(),
3492 lease_expires_at: chrono::Utc::now(),
3493 }),
3494 mailbox_id,
3495 parked_at: None,
3496 };
3497 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3498 (actor, client)
3499 }
3500 PlacementKind::Local => {
3501 let bus = self.bus.as_ref().ok_or_else(|| {
3508 AgentError::LLM(
3509 "local sub-agents require a mailbox bus (subagents.broker); none is \
3510 configured and the bus could not be embedded"
3511 .to_string(),
3512 )
3513 })?;
3514 let actor = self.acquire_bus_worker(&pool_key, &spec).await?;
3515 let parent = bamboo_subagent::AgentRef {
3516 session_id: format!("p-{}", job.child_session_id),
3517 role: None,
3518 };
3519 let link = bamboo_broker::BrokerChildLink::connect(
3520 &bus.endpoint,
3521 parent,
3522 &bus.token,
3523 actor.mailbox_id.clone(),
3524 )
3525 .await
3526 .map_err(|e| {
3527 AgentError::LLM(format!("broker child link connect failed: {e}"))
3528 })?;
3529 let client: Box<dyn bamboo_subagent::ChildLink> = Box::new(link);
3530 (actor, client)
3531 }
3532 };
3533
3534 if let Some(store) = actor_directory_store.as_ref() {
3540 let recovery = async {
3541 let receipts = store
3542 .recover_broker_terminal_receipts(session)
3543 .await
3544 .map_err(|error| {
3545 format!("Host broker receipt recovery blocked: {error}")
3546 })?;
3547 for receipt in receipts {
3548 if client.broker_parent_mailbox() != Some(receipt.parent_mailbox.as_str()) {
3552 return Err(
3553 "old Child broker parent mailbox changed before ACK".to_string()
3554 );
3555 }
3556 client
3557 .acknowledge_recovered_durable_frames(
3558 &receipt.broker_identity,
3559 &receipt.message_ids,
3560 )
3561 .await
3562 .map_err(|error| {
3563 format!("old Child broker ACK unconfirmed: {error}")
3564 })?;
3565 store
3566 .clear_acknowledged_broker_terminal_receipt(
3567 &receipt.session_id,
3568 receipt.created_at,
3569 &receipt.activation_run_id,
3570 &receipt.parent_mailbox,
3571 )
3572 .await
3573 .map_err(|error| {
3574 format!("old Child broker ACK cleanup failed: {error}")
3575 })?;
3576 }
3577 Ok::<(), String>(())
3578 }
3579 .await;
3580 if let Err(error) = recovery {
3581 if !remote {
3582 actor.worker.kill().await;
3583 }
3584 return Err(AgentError::LLM(error));
3585 }
3586 }
3587
3588 let (delivery_tx, mut delivery_rx) = mpsc::unbounded_channel::<u64>();
3593 let bound_activation_run_id = match session_inbox_runtime.as_ref() {
3594 Some(binding) => {
3595 let run_id = binding
3596 .router
3597 .attach_delivery_sink(&job.child_session_id, delivery_tx.clone())
3598 .await;
3599 if run_id.is_none() {
3600 tracing::debug!(
3601 session_id = %job.child_session_id,
3602 "actor driver had no current SessionInbox activation owner to bind"
3603 );
3604 }
3605 run_id
3606 }
3607 None => None,
3608 };
3609 tracing::Span::current().record(
3610 "activation_run_id",
3611 bound_activation_run_id.as_deref().unwrap_or("unbound"),
3612 );
3613 drop(delivery_tx);
3614 let initial_pairs = match (
3615 session_inbox_runtime.as_ref(),
3616 bound_activation_run_id.as_deref(),
3617 ) {
3618 (Some(binding), Some(run_id)) if plain_actor_store.is_none() && !remote => {
3619 match claim_canonical_deliveries(binding, session, run_id, usize::MAX, None)
3620 .await
3621 {
3622 Ok(deliveries) => deliveries,
3623 Err(error) => {
3624 binding
3625 .router
3626 .detach_delivery_sink(&job.child_session_id, run_id)
3627 .await;
3628 if !remote {
3629 actor.worker.kill().await;
3630 }
3631 return Err(error);
3632 }
3633 }
3634 }
3635 _ => Vec::new(),
3636 };
3637 let mut initial_session_messages = initial_pairs
3638 .iter()
3639 .map(|(_, delivery)| delivery.clone())
3640 .collect::<Vec<_>>();
3641 let mut initial_inflight_claims = initial_pairs
3642 .into_iter()
3643 .map(|(claim, _)| claim)
3644 .collect::<VecDeque<_>>();
3645 let mut messages = if let Some(binding) = &required_context {
3648 binding
3649 .validate_messages(&session.id, &session.messages)
3650 .map_err(|error| AgentError::Budget(error.to_string()))?;
3651 session
3652 .messages
3653 .iter()
3654 .map(serde_json::to_value)
3655 .collect::<std::result::Result<Vec<_>, _>>()
3656 .map_err(|_| {
3657 AgentError::Budget(
3658 bamboo_domain::ChildContextPacketError::Invalid.to_string(),
3659 )
3660 })?
3661 } else {
3662 session
3663 .messages
3664 .iter()
3665 .filter_map(|message| serde_json::to_value(message).ok())
3666 .collect()
3667 };
3668
3669 if strict_remote.is_some() {
3670 let current =
3671 remote_canonical_lineage(session_inbox_runtime.as_ref().unwrap(), session)
3672 .await;
3673 if current.as_ref().ok() != lineage.as_ref()
3674 || bound_activation_run_id.as_deref().is_none_or(str::is_empty)
3675 || cancel_token.is_cancelled()
3676 {
3677 if let (Some(binding), Some(run_id)) = (
3678 session_inbox_runtime.as_ref(),
3679 bound_activation_run_id.as_deref(),
3680 ) {
3681 binding
3682 .router
3683 .detach_delivery_sink(&job.child_session_id, run_id)
3684 .await;
3685 }
3686 return Err(if cancel_token.is_cancelled() {
3687 AgentError::Cancelled
3688 } else {
3689 remote_broker_unavailable()
3690 });
3691 }
3692 }
3693 if let Some(expected) = creation.as_ref() {
3694 let checked =
3695 canonical_child_creation(session_inbox_runtime.as_ref().unwrap(), session, job)
3696 .await;
3697 if checked.as_ref().ok() != Some(expected) {
3698 if let (Some(binding), Some(run_id)) = (
3699 session_inbox_runtime.as_ref(),
3700 bound_activation_run_id.as_deref(),
3701 ) {
3702 binding
3703 .router
3704 .detach_delivery_sink(&job.child_session_id, run_id)
3705 .await;
3706 }
3707 actor.worker.kill().await;
3708 return Err(AgentError::LLM(
3709 "child creation identity changed before dispatch".into(),
3710 ));
3711 }
3712 }
3713 if let Some(store) = &plain_actor_store {
3714 let started = async {
3715 let policy = permission_policy
3720 .as_ref()
3721 .ok_or_else(|| plain_actor_unsupported())?;
3722 let config = self
3723 .permission_config
3724 .as_ref()
3725 .ok_or_else(|| plain_actor_unsupported())?;
3726 if config.policy_revision() != policy.revision
3727 || !matches!(spec.placement, Placement::Local)
3728 {
3729 return Err(plain_actor_unsupported());
3730 }
3731 store
3732 .bind_control_plane(
3733 &session.id,
3734 bamboo_domain::ActorControlPlaneBinding {
3735 policy_revision: Some(policy.revision),
3736 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
3737 },
3738 )
3739 .await
3740 .map_err(|error| AgentError::LLM(error.to_string()))?;
3741 PlainActorActivation::start_and_prepare(
3742 store.clone(),
3743 session,
3744 session_inbox_runtime.as_ref().unwrap(),
3745 bound_activation_run_id.as_deref(),
3746 self.permission_config.clone(),
3747 !readonly_actor,
3748 spec.capabilities
3749 .initial_input_release_required
3750 .then_some(actor.mailbox_id.as_str()),
3751 )
3752 .await
3753 }
3754 .await;
3755 match started {
3756 Ok((activation, prepared)) => {
3757 if let Some((prefix, deliveries)) = prepared {
3758 messages = prefix;
3759 initial_session_messages = deliveries;
3760 }
3761 plain_activation = Some(activation);
3762 }
3763 Err(error) => {
3764 if let Some(run_id) = bound_activation_run_id.as_deref() {
3765 session_inbox_runtime
3766 .as_ref()
3767 .unwrap()
3768 .router
3769 .detach_delivery_sink(&job.child_session_id, run_id)
3770 .await;
3771 }
3772 actor.worker.kill().await;
3773 return Err(error);
3774 }
3775 }
3776 }
3777 let mut logical_identity = logical_identity_for_actor_run(session, job);
3778 logical_identity.creation = creation.clone();
3779 let run_permission_policy = if strict_remote.is_some() {
3780 let prepared = async {
3781 let mut policy = permission_policy.clone().ok_or_else(|| {
3782 AgentError::LLM("remote_environment_permission_policy_missing".into())
3783 })?;
3784 let workspace = session.workspace.as_deref().ok_or_else(|| {
3785 AgentError::LLM("remote_environment_workspace_missing".into())
3786 })?;
3787 let run_id = bound_activation_run_id.as_deref().ok_or_else(|| {
3788 AgentError::LLM("remote_environment_activation_missing".into())
3789 })?;
3790 let lease = bamboo_subagent::environment::EnvironmentLease::capture(
3791 workspace,
3792 &session.id,
3793 run_id,
3794 execution_epoch,
3795 )
3796 .await
3797 .map_err(|error| AgentError::LLM(error.into()))?;
3798 policy.workspace_path = None;
3799 policy.environment_lease = Some(lease);
3800 Ok::<_, AgentError>(Some(policy))
3801 }
3802 .await;
3803 match prepared {
3804 Ok(policy) => policy,
3805 Err(error) => {
3806 if let (Some(binding), Some(run_id)) = (
3807 session_inbox_runtime.as_ref(),
3808 bound_activation_run_id.as_deref(),
3809 ) {
3810 binding
3811 .router
3812 .detach_delivery_sink(&job.child_session_id, run_id)
3813 .await;
3814 }
3815 return Err(error);
3816 }
3817 }
3818 } else {
3819 permission_policy.clone()
3820 };
3821 if session
3822 .metadata
3823 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
3824 {
3825 let admission = async {
3826 let service = ticket_service
3827 .as_ref()
3828 .ok_or_else(|| AgentError::LLM("Ticket authority unavailable".into()))?;
3829 let runtime = session_inbox_runtime.as_ref().ok_or_else(|| {
3830 AgentError::LLM("Ticket activation runtime unavailable".into())
3831 })?;
3832 let run_id = bound_activation_run_id
3833 .as_deref()
3834 .ok_or_else(|| AgentError::LLM("Ticket activation owner missing".into()))?;
3835 crate::ticket_runtime::admit_ticket_run(service, runtime, session, run_id)
3836 .await
3837 .map_err(|e| AgentError::LLM(e.to_string()))
3838 }
3839 .await;
3840 if let Err(error) = admission {
3841 if let (Some(runtime), Some(run_id)) = (
3842 session_inbox_runtime.as_ref(),
3843 bound_activation_run_id.as_deref(),
3844 ) {
3845 runtime
3846 .router
3847 .detach_delivery_sink(&job.child_session_id, run_id)
3848 .await;
3849 }
3850 actor.worker.kill().await;
3851 return Err(error);
3852 }
3853 }
3854 let mut run_spec = RunSpec {
3855 assignment: assignment.clone(),
3857 logical_session: Some(logical_identity),
3858 project_id: project_id.clone(),
3859 reasoning_effort: if !remote && matches!(self.executor, ExecutorSpec::BambooRuntime)
3863 {
3864 session
3865 .reasoning_effort
3866 .map(|effort| effort.as_str().to_owned())
3867 } else {
3868 None
3869 },
3870 permission_policy: run_permission_policy,
3871 messages,
3872 activation_run_id: bound_activation_run_id.clone(),
3873 execution_epoch,
3874 initial_session_messages,
3875 secrets: run_secrets.clone(),
3876 };
3877 let mut active_lease = if remote {
3878 let reserve = ActivePlacementLease::reserve(
3879 scoped_registry
3880 .as_ref()
3881 .ok_or_else(remote_broker_unavailable)?
3882 .clone(),
3883 actor_directory_store
3884 .as_ref()
3885 .ok_or_else(remote_broker_unavailable)?
3886 .clone(),
3887 scoped_route
3888 .as_ref()
3889 .ok_or_else(remote_broker_unavailable)?
3890 .clone(),
3891 actor.mailbox_id.clone(),
3892 session.id.clone(),
3893 bound_activation_run_id
3894 .clone()
3895 .ok_or_else(remote_broker_unavailable)?,
3896 project_id.clone(),
3897 scoped_tools.clone().ok_or_else(remote_broker_unavailable)?,
3898 &cancel_token,
3899 )
3900 .await;
3901 match reserve {
3902 Ok(lease) => Some(lease),
3903 Err(error) => {
3904 if let (Some(binding), Some(run_id)) = (
3905 session_inbox_runtime.as_ref(),
3906 bound_activation_run_id.as_deref(),
3907 ) {
3908 binding
3909 .router
3910 .detach_delivery_sink(&job.child_session_id, run_id)
3911 .await;
3912 }
3913 return Err(error);
3914 }
3915 }
3916 } else {
3917 None
3918 };
3919 if let Some(lease) = active_lease.as_mut() {
3920 let seeded = async {
3921 let binding = session_inbox_runtime
3922 .as_ref()
3923 .ok_or_else(remote_broker_unavailable)?;
3924 let run_id = bound_activation_run_id
3925 .as_deref()
3926 .ok_or_else(remote_broker_unavailable)?;
3927 let pairs = claim_canonical_deliveries(
3928 binding,
3929 session,
3930 run_id,
3931 usize::MAX,
3932 Some((lease.actor_store.as_ref(), &lease.fence)),
3933 )
3934 .await?;
3935 if let Some(binding) = &required_context {
3936 binding
3937 .validate_messages(&session.id, &session.messages)
3938 .map_err(|error| AgentError::Budget(error.to_string()))?;
3939 }
3940 run_spec.messages = session
3941 .messages
3942 .iter()
3943 .map(serde_json::to_value)
3944 .collect::<std::result::Result<Vec<_>, _>>()
3945 .map_err(|_| remote_broker_unavailable())?;
3946 run_spec.initial_session_messages =
3947 pairs.iter().map(|(_, delivery)| delivery.clone()).collect();
3948 initial_inflight_claims = pairs
3949 .into_iter()
3950 .map(|(claim, _)| claim)
3951 .collect::<VecDeque<_>>();
3952 Ok::<(), AgentError>(())
3953 }
3954 .await;
3955 if let Err(error) = seeded {
3956 if let Err(rollback_error) = lease.abort_before_dispatch().await {
3957 tracing::warn!(stage = "claim_pre_dispatch_rollback", reason = %rollback_error, "remote claim rollback failed");
3958 }
3959 if let (Some(binding), Some(run_id)) = (
3960 session_inbox_runtime.as_ref(),
3961 bound_activation_run_id.as_deref(),
3962 ) {
3963 binding
3964 .router
3965 .detach_delivery_sink(&job.child_session_id, run_id)
3966 .await;
3967 }
3968 return Err(error);
3969 }
3970 }
3971 let mut run_send_invoked = false;
3972 let dispatch = async {
3973 if let Some(activation) = plain_activation
3974 .as_ref()
3975 .filter(|activation| !activation.initial_inputs.is_empty())
3976 {
3977 activation.refresh_continuation_posture(
3978 session,
3979 &run_spec,
3980 &mut expected_permission_posture,
3981 )?;
3982 }
3983 send_actor_run(&mut *client, run_spec.clone(), &mut run_send_invoked).await
3984 }
3985 .await;
3986 if let Err(e) = dispatch {
3987 if remote {
3988 tracing::warn!(stage = "run_dispatch", "remote placement admission failed");
3989 }
3990 if let (Some(binding), Some(run_id)) = (
3991 session_inbox_runtime.as_ref(),
3992 bound_activation_run_id.as_deref(),
3993 ) {
3994 binding
3995 .router
3996 .detach_delivery_sink(&job.child_session_id, run_id)
3997 .await;
3998 }
3999 if !remote {
4000 actor.worker.kill().await;
4001 }
4002 if let Some(activation) = &plain_activation {
4003 activation.finish(ActorActivationFinish::Failed).await?;
4004 }
4005 if let Some(lease) = active_lease.as_mut() {
4006 match lease.abort_if_run_unsent(run_send_invoked).await {
4007 Ok(false) => {
4008 tracing::warn!(stage = "run_dispatch_unconfirmed", "remote Run send failed after publication became possible; retaining Actor activation and slot until terminal proof or expiry");
4009 }
4010 Ok(true) => {}
4011 Err(rollback_error) => {
4012 tracing::warn!(stage = "run_pre_dispatch_rollback", reason = %rollback_error, "remote Run rollback failed");
4013 }
4014 }
4015 }
4016 return Err(e);
4017 }
4018
4019 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
4023 let live_guard = super::live::register(
4024 &job.child_session_id,
4025 live_tx,
4026 attempt as u32,
4027 self.approval_registry.clone(),
4028 );
4029
4030 let mut strict_history = None;
4031 let result = drive(ActorDriveContext {
4032 client: &mut *client,
4033 parent_session_id: &job.parent_session_id,
4034 child_session_id: &job.child_session_id,
4035 child_attempt: attempt as u32,
4036 approval_registry: self.approval_registry.as_ref(),
4037 approval_decider: self.approval_decider.as_ref(),
4038 approval_reviewer: self.approval_reviewer.as_ref(),
4039 escalation_bridge: escalation.clone(),
4040 event_tx: &event_tx,
4041 cancel_token: active_lease
4042 .as_ref()
4043 .map_or(&cancel_token, |lease| &lease.run_cancel),
4044 live_rx: &mut live_rx,
4045 delivery_rx: &mut delivery_rx,
4046 logical_session: session,
4047 expected_permission_posture: expected_permission_posture.clone(),
4048 expected_creation: creation.as_ref(),
4049 session_inbox_runtime: session_inbox_runtime.as_ref(),
4050 actor_directory_store: actor_directory_store.as_deref(),
4051 canonical_subagent_tool: canonical_subagent_tool.clone(),
4052 ticket_service: ticket_service.clone(),
4053 activation_run_id: bound_activation_run_id.as_deref(),
4054 execution_epoch,
4055 expected_source_actor_id: &actor.mailbox_id,
4056 initial_inflight_claims,
4057 plain_actor: plain_activation.is_some(),
4064 remote_environment_lease: strict_remote.is_some(),
4065 readonly_output: if readonly_actor {
4066 Some(&mut readonly_messages)
4067 } else {
4068 None
4069 },
4070 local_history_tools: local_history_tools.as_deref(),
4071 local_history_read_only: spec.capabilities.read_only_enforced(),
4072 strict_history_output: Some(&mut strict_history),
4073 plain_input: plain_activation.as_ref(),
4074 canonical_activation: active_lease.as_ref().map(|lease| &lease.fence),
4075 canonical_placement_ref: active_lease.as_ref().map(|lease| &lease.placement_ref),
4076 actor_event_observer: actor_event_observer.as_deref(),
4077 plain_run: self
4080 .bus
4081 .as_ref()
4082 .map(|_| (&run_spec, &self.next_execution_epoch)),
4083 first_frame_timeout: Some(WORKER_FIRST_FRAME_TIMEOUT),
4084 })
4085 .await;
4086 let mut result = if active_lease
4087 .as_ref()
4088 .is_some_and(ActivePlacementLease::lost)
4089 {
4090 Err(remote_broker_unavailable())
4091 } else {
4092 result
4093 };
4094 if local_history_tools.is_none() {
4095 let reply = result
4096 .as_ref()
4097 .ok()
4098 .and_then(|reply| reply.as_deref())
4099 .filter(|reply| !reply.is_empty())
4100 .map(str::to_owned);
4101 if let (Some(lease), Some(reply)) = (active_lease.as_ref(), reply) {
4102 if let Err(error) = append_fenced_remote_reply(
4103 lease.actor_store.as_ref(),
4104 &lease.fence,
4105 session,
4106 &reply,
4107 )
4108 .await
4109 {
4110 result = Err(error);
4111 }
4112 }
4113 }
4114 if remote {
4115 if let Err(error) = &result {
4116 let code = match error {
4117 AgentError::LLM(message) if message.starts_with("actor event") => {
4118 "actor_event"
4119 }
4120 AgentError::LLM(message) if message.starts_with("remote_actor") => {
4121 "remote_actor"
4122 }
4123 AgentError::LLM(message) if message.starts_with("remote_environment") => {
4124 if message.len() <= 128
4125 && message.bytes().all(|byte| {
4126 byte.is_ascii_lowercase()
4127 || byte.is_ascii_digit()
4128 || byte == b'_'
4129 })
4130 {
4131 message.as_str()
4132 } else {
4133 "environment"
4134 }
4135 }
4136 AgentError::LLM(message) if message.contains("permission posture") => {
4137 "permission_posture"
4138 }
4139 AgentError::LLM(message) if message.contains("broker") => "broker",
4140 AgentError::LLM(_) => "other_llm",
4141 AgentError::WorkerUnresponsive(_) => "unresponsive",
4142 AgentError::Cancelled => "cancelled",
4143 _ => "other",
4144 };
4145 tracing::warn!(stage = "actor_drive", code, "remote actor run failed");
4146 }
4147 }
4148 if let Some(lease) = active_lease.as_mut() {
4149 let accepted_terminal = client
4150 .durable_delivery_receipt()
4151 .map(|receipt| receipt.terminal_status);
4152 if let Err(error) = lease
4153 .complete(
4154 remote_activation_finish(&result, accepted_terminal),
4155 client.has_pending_durable_terminal(),
4156 )
4157 .await
4158 {
4159 tracing::warn!(
4160 stage = "actor_complete",
4161 "remote placement admission failed"
4162 );
4163 if let (Some(binding), Some(run_id)) = (
4164 session_inbox_runtime.as_ref(),
4165 bound_activation_run_id.as_deref(),
4166 ) {
4167 binding
4168 .router
4169 .detach_delivery_sink(&job.child_session_id, run_id)
4170 .await;
4171 }
4172 drop(live_guard);
4173 drop(client); drop(active_lease);
4175 return Err(error);
4176 }
4177 }
4178 drop(active_lease);
4179 if let (Some(binding), Some(run_id)) = (
4180 session_inbox_runtime.as_ref(),
4181 bound_activation_run_id.as_deref(),
4182 ) {
4183 binding
4184 .router
4185 .detach_delivery_sink(&job.child_session_id, run_id)
4186 .await;
4187 }
4188 drop(live_guard);
4194 if client.has_pending_durable_terminal() {
4198 if let Some(run_id) = bound_activation_run_id.as_deref() {
4199 let key = (session.id.clone(), session.created_at, run_id.to_owned());
4200 let host_nested_wait_handoff = session
4201 .metadata
4202 .get("runtime.actor_nested_wait_handoff")
4203 .is_some_and(|value| value == "true");
4204 let mut pending = self.pending_durable_links.lock().await;
4205 if pending.contains_key(&key) {
4206 return Err(AgentError::LLM(
4207 "child broker terminal receipt already awaits Host checkpoint".into(),
4208 ));
4209 }
4210 pending.insert(
4211 key,
4212 PendingDurableChildLink {
4213 link: client,
4214 host_nested_wait_handoff,
4215 strict_history,
4216 strict_history_required: local_history_tools.is_some(),
4217 strict_history_committed: false,
4218 },
4219 );
4220 } else {
4221 drop(client);
4223 }
4224 } else {
4225 drop(client);
4226 }
4227
4228 if required_context.is_none()
4232 && attempt == 0
4233 && matches!(kind, PlacementKind::Local)
4234 && matches!(result, Err(AgentError::WorkerUnresponsive(_)))
4235 {
4236 tracing::warn!(
4237 "actor child {} got no first frame; reaping the worker and respawning once",
4238 job.child_session_id
4239 );
4240 actor.worker.kill().await;
4241 attempt += 1;
4242 continue;
4243 }
4244 break (result, actor);
4245 };
4246
4247 let result = if strict_remote.is_some() {
4248 result.map_err(|error| match &error {
4249 AgentError::Cancelled => error,
4250 AgentError::LLM(message) => remote_environment_error_code(message)
4251 .map(|code| AgentError::LLM(code.to_owned()))
4252 .unwrap_or_else(remote_broker_unavailable),
4253 _ => remote_broker_unavailable(),
4254 })
4255 } else {
4256 result
4257 };
4258
4259 if remote {
4263 drop(actor);
4264 } else if required_context.is_some() {
4265 if session
4266 .metadata
4267 .contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
4268 {
4269 let service = self.ticket_service.lock().recover_poison().clone();
4270 let runtime = self.session_inbox_runtime.lock().recover_poison().clone();
4271 let owned_run_id = crate::ticket_runtime::read_dispatch(session)
4272 .ok()
4273 .flatten()
4274 .and_then(|dispatch| dispatch.receipt.map(|receipt| receipt.run_id));
4275 match actor.worker.kill_confirmed().await {
4276 Ok(proof) => {
4277 if let (Some(service), Some(runtime), Some(run_id)) =
4278 (service, runtime, owned_run_id.as_deref())
4279 {
4280 if let Err(error) = crate::ticket_runtime::checkpoint_owned_ticket_stop(
4281 &service,
4282 &runtime,
4283 session,
4284 run_id,
4285 &proof,
4286 result.is_ok(),
4287 )
4288 .await
4289 {
4290 tracing::warn!(child_id = %session.id, %error, "Ticket owned-process stop publication retained for reconciliation");
4293 }
4294 }
4295 }
4296 Err(error) => {
4297 tracing::warn!(child_id = %session.id, %error, "Ticket process stop remains unconfirmed; resources retained");
4298 }
4299 }
4300 } else {
4301 actor.worker.kill().await;
4302 }
4303 } else {
4304 match &result {
4305 Ok(_) => self.release_bus_worker(&pool_key, actor).await,
4306 Err(_) => actor.worker.kill().await,
4307 }
4308 }
4309
4310 if let Some(activation) = plain_activation {
4311 return match result {
4312 Ok(Some(text)) if !text.is_empty() => {
4313 if readonly_actor {
4314 let messages = std::mem::take(&mut readonly_messages);
4315 let committed = activation
4316 .store
4317 .append_actor_transcript(bamboo_storage::ActorTranscriptAppend {
4318 fence: activation.fence.clone(),
4319 expected_created_at: activation.created_at,
4320 expected_conversation_generation: activation
4321 .conversation_generation
4322 .clone(),
4323 expected_messages: session.messages.clone(),
4324 expected_provider_transcript: activation
4325 .provider_transcript
4326 .clone(),
4327 messages: messages.clone(),
4328 native_groups: Vec::new(),
4329 })
4330 .await;
4331 return match committed {
4332 Ok(committed) => {
4333 *session = committed;
4334 for message in messages {
4335 let _ = event_tx
4336 .send(AgentEvent::message_appended(&session.id, &message))
4337 .await;
4338 }
4339 activation
4340 .finish_with_observer(
4341 ActorActivationFinish::Succeeded,
4342 actor_event_observer.as_deref(),
4343 )
4344 .await
4345 }
4346 Err(error) => {
4347 let _ = activation.finish(ActorActivationFinish::Failed).await;
4348 Err(AgentError::LLM(format!(
4349 "actor reply commit failed: {error}"
4350 )))
4351 }
4352 };
4353 }
4354 match activation.append_reply(session, text, &event_tx).await {
4355 Ok(()) => {
4356 activation
4357 .finish_with_observer(
4358 ActorActivationFinish::Succeeded,
4359 actor_event_observer.as_deref(),
4360 )
4361 .await
4362 }
4363 Err(error) => {
4364 let _ = activation.finish(ActorActivationFinish::Failed).await;
4366 Err(AgentError::LLM(format!(
4367 "actor reply commit failed: {error}"
4368 )))
4369 }
4370 }
4371 }
4372 other => {
4373 let outcome = if matches!(&other, Err(AgentError::Cancelled)) {
4374 ActorActivationFinish::Cancelled
4375 } else {
4376 ActorActivationFinish::Failed
4377 };
4378 activation.finish(outcome).await?;
4379 match other {
4380 Err(error) => Err(error),
4381 _ => Err(plain_actor_unsupported()),
4382 }
4383 }
4384 };
4385 }
4386
4387 match result {
4391 Ok(Some(text)) => {
4392 if !remote && local_history_tools.is_none() && !text.is_empty() {
4393 session.add_message(bamboo_agent_core::Message::assistant(text, None));
4394 }
4395 Ok(())
4396 }
4397 Ok(None) => Ok(()),
4398 Err(e) => Err(e),
4399 }
4400 }
4401}
4402
4403async fn require_unowned_glob(
4406 store: &bamboo_storage::SessionStoreV2,
4407 session: &Session,
4408) -> Result<(), AgentError> {
4409 let rel = store
4410 .resolve_rel_path(&session.id)
4411 .await
4412 .ok_or_else(|| plain_actor_unsupported())?;
4413 if !store.bamboo_home_dir().is_absolute()
4414 || rel
4415 != format!(
4416 "sessions/{}/children/{}",
4417 session.root_session_id, session.id
4418 )
4419 {
4420 return Err(plain_actor_unsupported());
4421 }
4422 let directory = store.bamboo_home_dir().join(rel);
4423 let mut checked = PathBuf::new();
4424 for component in directory.components() {
4425 if !matches!(
4426 component,
4427 std::path::Component::RootDir | std::path::Component::Normal(_)
4428 ) {
4429 return Err(plain_actor_unsupported());
4430 }
4431 checked.push(component.as_os_str());
4432 if !tokio::fs::symlink_metadata(&checked)
4433 .await
4434 .map_err(|_| plain_actor_unsupported())?
4435 .is_dir()
4436 {
4437 return Err(plain_actor_unsupported());
4438 }
4439 }
4440 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
4441 match tokio::fs::symlink_metadata(directory.join(name)).await {
4442 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4443 _ => return Err(plain_actor_unsupported()),
4444 }
4445 }
4446 Ok(())
4447}
4448
4449struct PlainActorActivation {
4455 store: Arc<bamboo_storage::SessionStoreV2>,
4456 fence: ActorActivationFence,
4457 inbox_generation: u64,
4458 created_at: chrono::DateTime<chrono::Utc>,
4459 conversation_generation: bamboo_domain::ConversationGenerationObservation,
4460 provider_transcript: bamboo_domain::ProviderTranscriptState,
4461 input_consumer: SessionInboxConsumerId,
4462 input_deadline: chrono::DateTime<chrono::Utc>,
4463 actor_deadline: chrono::DateTime<chrono::Utc>,
4464 initial_release_required: bool,
4465 input_inbox: bamboo_storage::FileSessionInbox,
4466 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4467 initial_inputs: Vec<SessionInboxOwnedClaim>,
4468 initial_input_count: usize,
4469 recovering_pre_ack: bool,
4470 completion_router: std::sync::Mutex<Option<ActorEventRouter>>,
4471}
4472type PlainInitialDelivery = (Vec<serde_json::Value>, SessionMessageDelivery);
4473type PlainInitialBatch = (Vec<serde_json::Value>, Vec<SessionMessageDelivery>);
4474#[track_caller]
4475fn plain_actor_unsupported() -> AgentError {
4476 let source = std::panic::Location::caller();
4477 tracing::warn!(
4478 process_id = std::process::id(),
4479 source_file = source.file(),
4480 source_line = source.line(),
4481 source_column = source.column(),
4482 "plain Actor continuation unsupported"
4483 );
4484 AgentError::LLM("This Actor Child supports a fresh plain activation, a completed plain Child with a bounded batch of new inputs, two bounded corrections while Running, a Failed retry with one new input, an expired initial Local owner with one new input, or verified expired pre-ACK input recovery through run(reset_to_last_user=false). Other continuation is unsupported; durable history is preserved.".into())
4485}
4486fn plain_initial_history(session: &Session) -> bool {
4487 !session.messages.iter().any(|message| {
4488 matches!(message.role, Role::Assistant | Role::Tool)
4489 || message
4490 .metadata
4491 .as_ref()
4492 .is_some_and(|metadata| metadata.get("_bamboo_owned_input_checkpoint").is_some())
4493 }) && session.provider_transcript.is_empty()
4494 && session.session_inbox_admission().is_none()
4495 && session.pending_injected_messages().is_none()
4496}
4497fn plain_completed_history(session: &Session) -> bool {
4500 session.provider_transcript.is_empty()
4501 && session.pending_injected_messages().is_none()
4502 && session
4503 .messages
4504 .last()
4505 .is_some_and(|message| message.role == Role::Assistant && !message.content.is_empty())
4506 && !session
4507 .messages
4508 .iter()
4509 .any(|message| message.role == Role::Tool || message.tool_calls.is_some())
4510}
4511fn pre_ack_input(
4514 session: &Session,
4515) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4516 let Some(message) = session.messages.last() else {
4517 return Ok(None);
4518 };
4519 let Some(proof) = message
4520 .metadata
4521 .as_ref()
4522 .and_then(|v| v.get("_bamboo_owned_input_checkpoint"))
4523 else {
4524 return Ok(None);
4525 };
4526 let id = bamboo_domain::SessionMessageId::parse(message.id.clone())
4527 .map_err(|_| plain_actor_unsupported())?;
4528 let generation = proof
4529 .get("generation")
4530 .and_then(serde_json::Value::as_u64)
4531 .filter(|g| *g > 0)
4532 .ok_or_else(|| plain_actor_unsupported())?;
4533 if message.role != Role::User
4534 || session
4535 .messages
4536 .iter()
4537 .filter(|m| m.id == message.id)
4538 .count()
4539 != 1
4540 || !session
4541 .session_inbox_admission()
4542 .is_some_and(|cursor| cursor.contains(&id))
4543 || !session.provider_transcript.is_empty()
4544 || session.pending_injected_messages().is_some()
4545 || session
4546 .messages
4547 .iter()
4548 .any(|m| m.role == Role::Tool || m.tool_calls.is_some())
4549 {
4550 return Err(plain_actor_unsupported());
4551 }
4552 Ok(Some((id, generation)))
4553}
4554fn qualified_pre_ack_owner(
4555 entry: &bamboo_domain::ActorDirectoryEntry,
4556 now: chrono::DateTime<chrono::Utc>,
4557) -> bool {
4558 let Some(old) = entry.activation.as_ref() else {
4559 return false;
4560 };
4561 let recoverable = (entry.actor.state == ActorLogicalState::Failed
4562 && old.status == bamboo_domain::ActorActivationStatus::Failed)
4563 || (entry.actor.state == ActorLogicalState::Active
4564 && old.status.is_live()
4565 && old.lease_expires_at <= now);
4566 let trusted = old.placement_ref.as_ref().is_some_and(|placement| {
4567 placement.class == bamboo_domain::ActorPlacementClass::Local
4568 && placement
4569 .lease_id
4570 .strip_prefix("owned-initial-release-v1:")
4571 .is_some_and(|worker| {
4572 !worker.is_empty()
4573 && worker.len() <= 256
4574 && !worker.contains("..")
4575 && worker.bytes().all(|b| {
4576 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
4577 })
4578 })
4579 });
4580 recoverable && trusted
4581}
4582async fn expired_pre_ack_input(
4583 inbox: &bamboo_storage::FileSessionInbox,
4584 session: &Session,
4585) -> Result<Option<(bamboo_domain::SessionMessageId, u64)>, AgentError> {
4586 let Some((id, generation)) = pre_ack_input(session)? else {
4587 return Ok(None);
4588 };
4589 let backlog = inbox
4590 .inspect(&session.id)
4591 .await
4592 .map_err(|_| plain_actor_unsupported())?;
4593 let leases = inbox
4594 .inspect_owned_leases(&session.id, 2, chrono::Utc::now())
4595 .await
4596 .map_err(|_| plain_actor_unsupported())?;
4597 if backlog.pending != 0
4598 || backlog.claimed != 1
4599 || backlog.generation != generation
4600 || backlog.oldest_generation != Some(generation)
4601 || !backlog.activation_pending()
4602 || leases.len() != 1
4603 || !leases[0].expired
4604 || leases[0].generation != generation
4605 || inbox
4606 .was_admitted(&session.id, &id)
4607 .await
4608 .map_err(|_| plain_actor_unsupported())?
4609 {
4610 return Err(plain_actor_unsupported());
4611 }
4612 Ok(Some((id, generation)))
4613}
4614impl PlainActorActivation {
4615 fn start<'a>(
4618 store: Arc<bamboo_storage::SessionStoreV2>,
4619 session: &'a Session,
4620 binding: &'a SessionInboxRuntimeBinding,
4621 run_id: Option<&'a str>,
4622 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4623 allow_failed_retry: bool,
4624 release_worker: Option<&'a str>,
4625 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<Self, AgentError>> + Send + 'a>>
4626 {
4627 Box::pin(async move {
4628 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4629 let backlog = binding
4630 .inbox
4631 .inspect(&session.id)
4632 .await
4633 .map_err(|_| plain_actor_unsupported())?;
4634 let entry = store
4635 .ensure_actor(&session.id)
4636 .await
4637 .map_err(|error| AgentError::LLM(error.to_string()))?;
4638 let config = permission_config
4639 .as_ref()
4640 .ok_or_else(|| plain_actor_unsupported())?;
4641 if entry.actor.policy_revision != Some(config.policy_revision())
4642 || entry.actor.placement_intent != Some(bamboo_domain::ActorPlacementIntent::Local)
4643 {
4644 return Err(plain_actor_unsupported());
4645 }
4646 let fresh = entry.actor.current_attempt == 0
4647 && entry.actor.state == ActorLogicalState::Cold
4648 && entry.activation.is_none()
4649 && backlog.pending == 0
4650 && backlog.claimed == 0
4651 && backlog.generation == 0;
4652 let new_input = backlog.pending == 1
4653 && backlog.claimed == 0
4654 && backlog.oldest_generation == Some(backlog.generation)
4655 && backlog.activation_pending();
4656 let retry = allow_failed_retry
4657 && entry.actor.state == ActorLogicalState::Failed
4658 && entry.actor.current_attempt > 0
4659 && entry.activation.as_ref().is_some_and(|old| {
4660 old.status == bamboo_domain::ActorActivationStatus::Failed
4661 && backlog.generation > old.inbox_generation
4662 })
4663 && new_input;
4664 let new_batch = backlog.pending > 1
4668 && backlog.pending <= bamboo_domain::SessionInboxLimits::default().max_claim_batch
4669 && backlog.claimed == 0
4670 && backlog
4671 .oldest_generation
4672 .is_some_and(|oldest| oldest < backlog.generation)
4673 && backlog.activation_pending();
4674 let continuation = allow_failed_retry
4675 && release_worker.is_some()
4676 && entry.actor.state == ActorLogicalState::Cold
4677 && entry.actor.current_attempt > 0
4678 && entry.activation.as_ref().is_some_and(|old| {
4679 old.status == bamboo_domain::ActorActivationStatus::Succeeded
4680 && backlog
4681 .oldest_generation
4682 .is_some_and(|oldest| oldest > old.inbox_generation)
4683 })
4684 && (new_input || new_batch)
4685 && plain_completed_history(session);
4686 let input_inbox = bamboo_storage::FileSessionInbox::new(
4687 store.clone(),
4688 bamboo_domain::SessionInboxLimits::default(),
4689 );
4690 let now = chrono::Utc::now();
4691 let replacement = allow_failed_retry
4695 && release_worker.is_some()
4696 && entry.actor.state == ActorLogicalState::Active
4697 && qualified_pre_ack_owner(&entry, now)
4698 && entry.activation.as_ref().is_some_and(|old| {
4699 old.status.is_live() && old.lease_expires_at <= now && old.inbox_generation == 0
4700 })
4701 && new_input
4702 && plain_initial_history(session);
4703 let recovery = if allow_failed_retry
4704 && release_worker.is_some()
4705 && qualified_pre_ack_owner(&entry, now)
4706 {
4707 expired_pre_ack_input(&input_inbox, session).await?
4708 } else {
4709 None
4710 };
4711 if (recovery.is_some() || continuation || replacement)
4712 && (!entry.actor.matches_session(session)
4713 || entry.actor.project_id
4714 != project_id_for_actor_run(session)?.map(|id| id.to_string()))
4715 {
4716 return Err(plain_actor_unsupported());
4717 }
4718 if recovery.is_none()
4719 && !continuation
4720 && !replacement
4721 && ((!fresh && !retry) || !plain_initial_history(session))
4722 {
4723 return Err(plain_actor_unsupported());
4724 }
4725 let duration = crate::runtime::execution::spawn::watchdog_policy_for_session(session)
4726 .max_total_secs
4727 .checked_add(60)
4728 .and_then(chrono::Duration::try_seconds)
4729 .ok_or_else(|| plain_actor_unsupported())?;
4730 let expires = now
4731 .checked_add_signed(duration)
4732 .ok_or_else(|| plain_actor_unsupported())?;
4733 let input_consumer = SessionInboxConsumerId::new();
4734 let input_deadline = expires.min(now + chrono::Duration::hours(1));
4735 let recovered = if let Some((id, generation)) = &recovery {
4736 if !binding.router.owns_run(&session.id, run_id).await {
4737 return Err(plain_actor_unsupported());
4738 }
4739 let lease = SessionInboxLeaseRequest {
4740 consumer: input_consumer.clone(),
4741 now,
4742 duration: input_deadline - now,
4743 };
4744 let mut claims = input_inbox
4745 .claim_owned(&session.id, 1, Some(run_id), &lease)
4746 .await
4747 .map_err(|_| plain_actor_unsupported())?;
4748 let claim = claims.pop().ok_or_else(|| plain_actor_unsupported())?;
4749 if claim.claim.envelope.id != *id
4750 || claim.claim.generation != *generation
4751 || !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4752 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4753 {
4754 return Err(plain_actor_unsupported());
4755 }
4756 Some(claim)
4757 } else {
4758 None
4759 };
4760 let claimed = store
4761 .claim_activation(&ActorActivationClaim {
4762 actor_id: session.id.clone(),
4763 run_id: run_id.into(),
4764 lease_owner: input_consumer.as_str().into(),
4765 lease_expires_at: expires,
4766 inbox_generation: recovered
4767 .as_ref()
4768 .map_or(backlog.generation, |claim| claim.claim.generation),
4769 placement_ref: release_worker.map(|worker| bamboo_domain::ActorPlacementRef {
4770 class: bamboo_domain::ActorPlacementClass::Local,
4771 lease_id: format!("owned-initial-release-v1:{worker}"),
4774 slot_epoch: None,
4775 }),
4776 now,
4777 })
4778 .await
4779 .map_err(|error| AgentError::LLM(error.to_string()))?;
4780 let activation = Self {
4781 store,
4782 fence: claimed.fence(),
4783 inbox_generation: claimed.inbox_generation,
4784 created_at: session.created_at,
4785 conversation_generation: session.conversation_generation_observation(),
4786 provider_transcript: session.provider_transcript.clone(),
4787 input_consumer,
4788 input_deadline,
4789 actor_deadline: claimed.lease_expires_at,
4790 initial_release_required: release_worker.is_some(),
4791 input_inbox,
4792 permission_config,
4793 initial_inputs: recovered.into_iter().collect(),
4794 initial_input_count: if continuation {
4795 backlog.pending
4796 } else {
4797 usize::from(claimed.inbox_generation > 0)
4798 },
4799 recovering_pre_ack: recovery.is_some(),
4800 completion_router: std::sync::Mutex::new(None),
4801 };
4802 if let Err(error) = activation
4803 .store
4804 .start_activation(&activation.fence, chrono::Utc::now())
4805 .await
4806 {
4807 let _ = activation.finish(ActorActivationFinish::Failed).await;
4808 return Err(AgentError::LLM(error.to_string()));
4809 }
4810 Ok(activation)
4811 })
4812 }
4813 fn start_and_prepare<'a>(
4814 store: Arc<bamboo_storage::SessionStoreV2>,
4815 session: &'a mut Session,
4816 binding: &'a SessionInboxRuntimeBinding,
4817 run_id: Option<&'a str>,
4818 permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
4819 allow_failed_retry: bool,
4820 release_worker: Option<&'a str>,
4821 ) -> std::pin::Pin<
4822 Box<
4823 impl std::future::Future<Output = Result<(Self, Option<PlainInitialBatch>), AgentError>>
4824 + Send
4825 + 'a,
4826 >,
4827 > {
4828 Box::pin(async move {
4829 let mut activation = Self::start(
4830 store,
4831 session,
4832 binding,
4833 run_id,
4834 permission_config,
4835 allow_failed_retry,
4836 release_worker,
4837 )
4838 .await?;
4839 if activation.inbox_generation == 0 {
4840 return Ok((activation, None));
4841 }
4842 let prepared = async {
4843 let run_id = run_id.ok_or_else(|| plain_actor_unsupported())?;
4844 let backlog = activation
4845 .input_inbox
4846 .inspect(&session.id)
4847 .await
4848 .map_err(|_| plain_actor_unsupported())?;
4849 let expected = if activation.recovering_pre_ack {
4850 (0, 1)
4851 } else {
4852 (activation.initial_input_count, 0)
4853 };
4854 if (backlog.pending, backlog.claimed) != expected
4855 || backlog.generation != activation.inbox_generation
4856 {
4857 return Err(plain_actor_unsupported());
4858 }
4859 let claims = if activation.initial_inputs.is_empty() {
4860 activation
4864 .claim_inputs(binding, session, run_id, activation.initial_input_count)
4865 .await?
4866 } else {
4867 activation.initial_inputs.clone()
4868 };
4869 if claims.len() != activation.initial_input_count
4870 || claims.last().map(|claim| claim.claim.generation)
4871 != Some(activation.inbox_generation)
4872 || claims
4873 .windows(2)
4874 .any(|pair| pair[0].claim.generation >= pair[1].claim.generation)
4875 {
4876 return Err(plain_actor_unsupported());
4877 }
4878 let mut prefix = None;
4883 let mut deliveries = Vec::with_capacity(claims.len());
4884 for claim in &claims {
4885 let (messages, delivery) =
4886 activation.checkpoint_input(session, run_id, claim).await?;
4887 prefix.get_or_insert(messages);
4888 deliveries.push(delivery);
4889 }
4890 activation.initial_inputs = claims;
4891 Ok((prefix.ok_or_else(|| plain_actor_unsupported())?, deliveries))
4892 }
4893 .await;
4894 match prepared {
4895 Ok(delivery) => Ok((activation, Some(delivery))),
4896 Err(error) => {
4897 activation.finish(ActorActivationFinish::Failed).await?;
4898 Err(error)
4899 }
4900 }
4901 })
4902 }
4903 fn claim_input<'a>(
4904 &'a self,
4905 binding: &'a SessionInboxRuntimeBinding,
4906 session: &'a Session,
4907 run_id: &'a str,
4908 ) -> std::pin::Pin<
4909 Box<
4910 impl std::future::Future<Output = Result<Option<SessionInboxOwnedClaim>, AgentError>>
4911 + Send
4912 + 'a,
4913 >,
4914 > {
4915 Box::pin(async move { Ok(self.claim_inputs(binding, session, run_id, 1).await?.pop()) })
4916 }
4917 fn claim_inputs<'a>(
4918 &'a self,
4919 binding: &'a SessionInboxRuntimeBinding,
4920 session: &'a Session,
4921 run_id: &'a str,
4922 limit: usize,
4923 ) -> std::pin::Pin<
4924 Box<
4925 impl std::future::Future<Output = Result<Vec<SessionInboxOwnedClaim>, AgentError>>
4926 + Send
4927 + 'a,
4928 >,
4929 > {
4930 Box::pin(async move {
4931 if limit == 0 || limit > bamboo_domain::SessionInboxLimits::default().max_claim_batch {
4932 return Err(plain_actor_unsupported());
4933 }
4934 if !self
4935 .input_inbox
4936 .inspect(&session.id)
4937 .await
4938 .map_err(|_| plain_actor_unsupported())?
4939 .activation_pending()
4940 {
4941 return Ok(Vec::new());
4942 }
4943 if run_id != self.fence.run_id || !binding.router.owns_run(&session.id, run_id).await {
4944 return Err(plain_actor_unsupported());
4945 }
4946 let now = chrono::Utc::now();
4947 if now >= self.input_deadline {
4948 return Err(plain_actor_unsupported());
4949 }
4950 let lease = SessionInboxLeaseRequest {
4951 consumer: self.input_consumer.clone(),
4952 now,
4953 duration: self.input_deadline - now,
4954 };
4955 let claims = self
4956 .input_inbox
4957 .claim_owned(&session.id, limit, Some(run_id), &lease)
4958 .await
4959 .map_err(|error| {
4960 tracing::warn!(%error, "Actor correction claim unconfirmed");
4961 AgentError::LLM(
4962 "Actor correction claim unconfirmed; durable input is preserved".into(),
4963 )
4964 })?;
4965 for claim in &claims {
4966 if !matches!(&claim.claim.envelope.body, bamboo_domain::SessionMessageBody::Content(content)
4967 if content.parts.is_empty() && !content.text.trim().is_empty() && content.text.len() <= 8192)
4968 {
4969 self.input_inbox
4970 .fail_owned(
4971 &session.id,
4972 claim,
4973 &SessionInboxFailureReport {
4974 now: chrono::Utc::now(),
4975 error_code: "unsupported_actor_input".into(),
4976 },
4977 )
4978 .await
4979 .map_err(|error| {
4980 tracing::warn!(%error, "Actor correction failure report unconfirmed");
4981 AgentError::LLM(
4982 "Actor correction failure report unconfirmed; durable input is preserved".into(),
4983 )
4984 })?;
4985 return Err(plain_actor_unsupported());
4986 }
4987 }
4988 Ok(claims)
4989 })
4990 }
4991 fn checkpoint_input<'a>(
4992 &'a self,
4993 session: &'a mut Session,
4994 run_id: &'a str,
4995 claim: &'a SessionInboxOwnedClaim,
4996 ) -> std::pin::Pin<
4997 Box<
4998 impl std::future::Future<Output = Result<PlainInitialDelivery, AgentError>> + Send + 'a,
4999 >,
5000 > {
5001 Box::pin(async move {
5002 let request = bamboo_storage::ActorInputCheckpoint {
5005 fence: self.fence.clone(),
5006 expected_created_at: self.created_at,
5007 expected_conversation_generation: self.conversation_generation.clone(),
5008 claim: claim.clone(),
5009 expected_messages: session.messages.clone(),
5010 expected_provider_transcript: session.provider_transcript.clone(),
5011 expected_admission: session.session_inbox_admission().cloned(),
5012 };
5013 let mut retries = 0;
5014 let committed = loop {
5015 let result = self.input_inbox.checkpoint_actor_input(request.clone()).await;
5016 if matches!(&result, Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict))
5017 && retries < 3
5018 {
5019 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5025 retries += 1;
5026 continue;
5027 }
5028 break result;
5029 }.map_err(|error| {
5030 tracing::warn!(%error, "Actor correction checkpoint rejected or unconfirmed; no worker dispatch or ACK");
5031 AgentError::LLM("Actor correction checkpoint unconfirmed; durable Inbox and history are preserved".into())
5032 })?;
5033 if self.recovering_pre_ack
5034 && committed.status
5035 != bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5036 {
5037 return Err(plain_actor_unsupported());
5038 }
5039 let messages = session
5040 .messages
5041 .iter()
5042 .map(|message| {
5043 let mut worker = message.clone();
5044 if committed.status
5045 == bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
5046 && worker.id == committed.envelope.id.as_str()
5047 {
5048 worker
5049 .metadata
5050 .as_mut()
5051 .and_then(serde_json::Value::as_object_mut)
5052 .ok_or_else(|| plain_actor_unsupported())?
5053 .remove("_bamboo_owned_input_checkpoint");
5054 if !bamboo_domain::is_matching_session_message(&worker, &committed.envelope)
5055 {
5056 return Err(plain_actor_unsupported());
5057 }
5058 }
5059 serde_json::to_value(worker).map_err(|_| plain_actor_unsupported())
5060 })
5061 .collect::<Result<Vec<_>, _>>()?;
5062 *session = committed.session;
5063 Ok((
5064 messages,
5065 SessionMessageDelivery {
5066 target_session_id: session.id.clone(),
5067 envelope: committed.envelope,
5068 canonical_claim_generation: committed.generation,
5069 activation_run_id: run_id.into(),
5070 activation_policy: claim.claim.activation_policy,
5071 },
5072 ))
5073 })
5074 }
5075 fn append_reply<'a>(
5076 &'a self,
5077 session: &'a mut Session,
5078 text: String,
5079 event_tx: &'a mpsc::Sender<AgentEvent>,
5080 ) -> std::pin::Pin<Box<impl std::future::Future<Output = Result<(), AgentError>> + Send + 'a>>
5081 {
5082 Box::pin(async move {
5083 let message = bamboo_agent_core::Message::assistant(text, None);
5084 let request = bamboo_storage::ActorTranscriptAppend {
5085 fence: self.fence.clone(),
5086 expected_created_at: self.created_at,
5087 expected_conversation_generation: self.conversation_generation.clone(),
5088 expected_messages: session.messages.clone(),
5089 expected_provider_transcript: self.provider_transcript.clone(),
5090 messages: vec![message.clone()],
5091 native_groups: Vec::new(),
5092 };
5093 let mut retries = 0;
5094 *session = loop {
5095 let result = self.store.append_actor_transcript(request.clone()).await;
5096 if matches!(
5097 &result,
5098 Err(bamboo_storage::ActorTranscriptAppendError::PrefixConflict)
5099 ) && retries < 3
5100 {
5101 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5106 retries += 1;
5107 continue;
5108 }
5109 break result;
5110 }
5111 .map_err(|error| {
5112 tracing::warn!(
5113 %error,
5114 "Actor reply checkpoint rejected or unconfirmed; durable history is preserved"
5115 );
5116 AgentError::LLM(format!("actor reply commit failed: {error}"))
5117 })?;
5118 let _ = event_tx
5119 .send(AgentEvent::message_appended(&session.id, &message))
5120 .await;
5121 Ok(())
5122 })
5123 }
5124 fn continue_input<'a>(
5128 &'a self,
5129 client: &'a mut dyn bamboo_subagent::ChildLink,
5130 binding: &'a SessionInboxRuntimeBinding,
5131 session: &'a mut Session,
5132 run_id: &'a str,
5133 run: Option<(&'a RunSpec, &'a AtomicU64)>,
5134 expected_posture: &'a mut Option<ExpectedPermissionPosture>,
5135 text: String,
5136 event_tx: &'a mpsc::Sender<AgentEvent>,
5137 ) -> std::pin::Pin<
5138 Box<
5139 impl std::future::Future<
5140 Output = Result<Option<(SessionInboxOwnedClaim, u64)>, AgentError>,
5141 > + Send
5142 + 'a,
5143 >,
5144 > {
5145 Box::pin(async move {
5146 let Some(claim) = self.claim_input(binding, session, run_id).await? else {
5147 return Ok(None);
5148 };
5149 let (run, epochs) = run.ok_or_else(|| plain_actor_unsupported())?;
5150 self.append_reply(session, text, event_tx).await?;
5151 let (messages, delivery) = self.checkpoint_input(session, run_id, &claim).await?;
5152 self.refresh_continuation_posture(session, run, expected_posture)?;
5154 let epoch = epochs
5155 .fetch_add(1, Ordering::Relaxed)
5156 .checked_add(1)
5157 .ok_or_else(|| plain_actor_unsupported())?;
5158 let mut next = run.clone();
5159 next.messages = messages;
5160 next.initial_session_messages = vec![delivery];
5161 next.execution_epoch = epoch;
5162 client.send(ParentFrame::Run(next)).await.map_err(|_| {
5163 AgentError::LLM(
5164 "Actor correction dispatch unconfirmed; durable input is preserved".into(),
5165 )
5166 })?;
5167 Ok(Some((claim, epoch)))
5168 })
5169 }
5170 fn refresh_continuation_posture(
5171 &self,
5172 session: &Session,
5173 run: &RunSpec,
5174 expected: &mut Option<ExpectedPermissionPosture>,
5175 ) -> Result<(), AgentError> {
5176 let Some(expected) = expected.as_mut() else {
5177 return Err(plain_actor_unsupported());
5178 };
5179 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
5180 .ok_or_else(|| plain_actor_unsupported())?;
5181 if audit.policy_revision != expected.policy_revision
5182 || audit.resolution != expected.resolution
5183 || audit.executor_mapping != expected.executor_mapping
5184 {
5185 return Err(plain_actor_unsupported());
5186 }
5187 match (&self.permission_config, &run.permission_policy) {
5188 (Some(config), Some(policy)) => {
5189 ensure_no_active_scoped_session_denies(config, &session.id)?;
5190 if config.policy_revision() != policy.revision
5191 || policy.revision != audit.policy_revision
5192 || policy.session_id != session.id
5193 || policy.workspace_path != session.workspace
5194 || policy.inherit_session_grants
5195 || serde_json::to_value(config.to_serializable())
5196 .map_err(|_| plain_actor_unsupported())?
5197 != policy.policy
5198 {
5199 return Err(plain_actor_unsupported());
5200 }
5201 }
5202 (None, None) => {}
5203 _ => return Err(plain_actor_unsupported()),
5204 }
5205 expected.expected_audit_revision = Some(audit.audit_revision);
5206 Ok(())
5207 }
5208 fn release_initial_input<'a>(
5209 &'a self,
5210 binding: &'a SessionInboxRuntimeBinding,
5211 session: &'a Session,
5212 run: &'a RunSpec,
5213 epoch: u64,
5214 claim: &'a SessionInboxOwnedClaim,
5215 request: &'a bamboo_subagent::proto::InitialInputReleaseRequest,
5216 expected: &'a mut Option<ExpectedPermissionPosture>,
5217 previous: Option<&'a bamboo_subagent::proto::InitialInputRelease>,
5218 ) -> std::pin::Pin<
5219 Box<
5220 impl std::future::Future<
5221 Output = Result<bamboo_subagent::proto::InitialInputRelease, AgentError>,
5222 > + Send
5223 + 'a,
5224 >,
5225 > {
5226 Box::pin(async move {
5227 if !self.initial_release_required {
5228 return Err(plain_actor_unsupported());
5229 }
5230 let delivery = SessionMessageDelivery {
5231 target_session_id: session.id.clone(),
5232 envelope: claim.claim.envelope.clone(),
5233 canonical_claim_generation: claim.claim.generation,
5234 activation_run_id: self.fence.run_id.clone(),
5235 activation_policy: claim.claim.activation_policy,
5236 };
5237 let mut current_run = run.clone();
5238 current_run.execution_epoch = epoch;
5239 let exact = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
5240 ¤t_run,
5241 &delivery,
5242 request.nonce.clone(),
5243 )
5244 .map_err(|_| plain_actor_unsupported())?;
5245 if &exact != request
5246 || request.child_id != session.id
5247 || request.created_at != session.created_at
5248 || request.spawn_depth != session.spawn_depth
5249 || request.parent_id != session.parent_session_id.as_deref().unwrap_or_default()
5250 || request.root_id != session.root_session_id
5251 || request.project_id != project_id_for_actor_run(session)?
5252 || !binding
5253 .router
5254 .owns_run(&session.id, &self.fence.run_id)
5255 .await
5256 {
5257 return Err(plain_actor_unsupported());
5258 }
5259 let deadline = self
5260 .actor_deadline
5261 .min(self.input_deadline)
5262 .min(claim.lease.expires_at);
5263 if chrono::Utc::now() >= deadline {
5264 return Err(plain_actor_unsupported());
5265 }
5266 self.store
5267 .validate_fence(&self.fence, chrono::Utc::now())
5268 .await
5269 .map_err(|_| plain_actor_unsupported())?;
5270 let actual =
5271 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5272 .await
5273 .map_err(|_| plain_actor_unsupported())?
5274 .ok_or_else(|| plain_actor_unsupported())?;
5275 if actual.created_at != session.created_at
5276 || actual.root_session_id != session.root_session_id
5277 || actual.parent_session_id != session.parent_session_id
5278 || actual.spawn_depth != session.spawn_depth
5279 || project_id_for_actor_run(&actual)? != request.project_id
5280 || serde_json::to_value(&actual.messages).map_err(|_| plain_actor_unsupported())?
5281 != serde_json::to_value(&session.messages)
5282 .map_err(|_| plain_actor_unsupported())?
5283 || serde_json::to_value(&actual.provider_transcript)
5284 .map_err(|_| plain_actor_unsupported())?
5285 != serde_json::to_value(&session.provider_transcript)
5286 .map_err(|_| plain_actor_unsupported())?
5287 || serde_json::to_value(actual.session_inbox_admission())
5288 .map_err(|_| plain_actor_unsupported())?
5289 != serde_json::to_value(session.session_inbox_admission())
5290 .map_err(|_| plain_actor_unsupported())?
5291 {
5292 return Err(plain_actor_unsupported());
5293 }
5294 self.refresh_continuation_posture(&actual, ¤t_run, expected)?;
5295 if let Some(previous) = previous {
5296 if !previous.permits(request, chrono::Utc::now())
5297 || previous.expires_at != deadline
5298 || !self
5299 .input_inbox
5300 .was_admitted(&session.id, &claim.claim.envelope.id)
5301 .await
5302 .map_err(|_| plain_actor_unsupported())?
5303 {
5304 return Err(plain_actor_unsupported());
5305 }
5306 } else {
5307 let checkpoint = bamboo_storage::ActorInputCheckpoint {
5310 fence: self.fence.clone(),
5311 expected_created_at: self.created_at,
5312 expected_conversation_generation: self.conversation_generation.clone(),
5313 claim: claim.clone(),
5314 expected_messages: actual.messages.clone(),
5315 expected_provider_transcript: actual.provider_transcript.clone(),
5316 expected_admission: actual.session_inbox_admission().cloned(),
5317 };
5318 let mut retries = 0;
5319 loop {
5320 let result = self
5321 .input_inbox
5322 .checkpoint_actor_input(checkpoint.clone())
5323 .await;
5324 if matches!(
5325 &result,
5326 Err(bamboo_storage::ActorInputCheckpointError::PrefixConflict)
5327 ) && retries < 3
5328 {
5329 tracing::warn!(retries, "Actor initial release prefix changed before publication; retrying same claim");
5333 tokio::time::sleep(Duration::from_millis(10 << retries)).await;
5334 retries += 1;
5335 continue;
5336 }
5337 result.map_err(|error| {
5338 tracing::warn!(%error, "Actor initial release checkpoint rejected or unconfirmed");
5339 plain_actor_unsupported()
5340 })?;
5341 break;
5342 }
5343 self.confirm_input(
5344 binding,
5345 &actual,
5346 &self.fence.run_id,
5347 claim,
5348 &bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
5349 target_session_id: request.child_id.clone(),
5350 envelope_id: request.envelope_id.clone(),
5351 canonical_claim_generation: request.generation,
5352 activation_run_id: request.activation_run_id.clone(),
5353 },
5354 )
5355 .await?;
5356 }
5357 if chrono::Utc::now() >= deadline
5360 || !binding
5361 .router
5362 .owns_run(&session.id, &self.fence.run_id)
5363 .await
5364 {
5365 return Err(plain_actor_unsupported());
5366 }
5367 self.store
5368 .validate_fence(&self.fence, chrono::Utc::now())
5369 .await
5370 .map_err(|_| plain_actor_unsupported())?;
5371 let final_session =
5372 bamboo_agent_core::storage::Storage::load_session(self.store.as_ref(), &session.id)
5373 .await
5374 .map_err(|_| plain_actor_unsupported())?
5375 .ok_or_else(|| plain_actor_unsupported())?;
5376 self.store
5377 .validate_fence(&self.fence, chrono::Utc::now())
5378 .await
5379 .map_err(|_| plain_actor_unsupported())?;
5380 self.refresh_continuation_posture(&final_session, ¤t_run, expected)?;
5381 if chrono::Utc::now() >= deadline {
5382 return Err(plain_actor_unsupported());
5383 }
5384 Ok(bamboo_subagent::proto::InitialInputRelease {
5385 request: request.clone(),
5386 expires_at: deadline,
5387 })
5388 })
5389 }
5390 async fn confirm_input(
5391 &self,
5392 binding: &SessionInboxRuntimeBinding,
5393 session: &Session,
5394 run_id: &str,
5395 claim: &SessionInboxOwnedClaim,
5396 confirmation: &bamboo_subagent::proto::SessionMessageAdmissionConfirmation,
5397 ) -> Result<(), AgentError> {
5398 if confirmation.target_session_id != session.id
5399 || confirmation.envelope_id != claim.claim.envelope.id.as_str()
5400 || confirmation.canonical_claim_generation != claim.claim.generation
5401 || confirmation.activation_run_id != run_id
5402 || run_id != self.fence.run_id
5403 || !binding.router.owns_run(&session.id, run_id).await
5404 {
5405 return Err(plain_actor_unsupported());
5406 }
5407 self.input_inbox
5408 .ack_owned(&session.id, claim, chrono::Utc::now())
5409 .await
5410 .map_err(|error| {
5411 tracing::warn!(%error, "Actor correction ACK unresolved");
5412 AgentError::LLM(
5413 "Actor correction ACK unresolved; durable input is preserved".into(),
5414 )
5415 })
5416 }
5417 async fn finish(&self, outcome: ActorActivationFinish) -> Result<(), AgentError> {
5418 self.finish_with_observer(outcome, None).await
5419 }
5420
5421 async fn finish_with_observer(
5422 &self,
5423 outcome: ActorActivationFinish,
5424 observer: Option<&dyn ActorEventObserver>,
5425 ) -> Result<(), AgentError> {
5426 let committed = self
5427 .store
5428 .finish_activation(&self.fence, chrono::Utc::now(), outcome)
5429 .await
5430 .map_err(|error| AgentError::LLM(format!("Actor completion unconfirmed: {error}")))?;
5431 let router = self.completion_router.lock().recover_poison().take();
5434 if let (Some(observer), Some(router)) = (observer, router) {
5435 match self.store.inspect_actor(&self.fence.actor_id).await {
5439 Ok(entry) => match router.into_host_completion(&entry, &committed) {
5440 Ok(event) => observer.publish(event),
5441 Err(_) => tracing::debug!(actor_id = %self.fence.actor_id,
5442 "Actor completion notification superseded"),
5443 },
5444 Err(error) => tracing::warn!(actor_id = %self.fence.actor_id, %error,
5445 "Actor completion committed; notification snapshot unavailable"),
5446 }
5447 }
5448 Ok(())
5449 }
5450}
5451fn local_tool_history_unsupported() -> AgentError {
5452 AgentError::LLM("local_tool_history_unsupported".into())
5453}
5454
5455#[derive(Clone, Copy)]
5456enum LocalToolTerminal<'a> {
5457 Completed(Option<&'a str>),
5458 Cancelled,
5461}
5462
5463impl LocalToolTerminal<'_> {
5464 fn permits_checkpoint(self, cancel: &CancellationToken) -> bool {
5465 match self {
5466 Self::Completed(_) => !cancel.is_cancelled(),
5467 Self::Cancelled => cancel.is_cancelled(),
5468 }
5469 }
5470}
5471
5472#[derive(Default)]
5473struct LocalToolCollector {
5474 messages: Option<Vec<bamboo_agent_core::Message>>,
5475 starts: HashMap<String, (String, serde_json::Value)>,
5476 outcomes: HashMap<String, (bool, String)>,
5477 ticket_yield: Option<(String, bamboo_tickets::PendingRequest)>,
5480}
5481
5482impl LocalToolCollector {
5483 fn ticket_event(
5484 &self,
5485 value: &serde_json::Value,
5486 committed: &HashMap<String, serde_json::Value>,
5487 ) -> Result<bool, AgentError> {
5488 if value["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
5489 return Ok(true);
5490 }
5491 match serde_json::from_value::<AgentEvent>(value.clone())
5492 .map_err(|_| local_tool_history_unsupported())?
5493 {
5494 AgentEvent::ToolStart { tool_name, .. }
5495 if !matches!(tool_name.as_str(), "Task" | "Read" | "Write") =>
5496 {
5497 Err(local_tool_history_unsupported())
5498 }
5499 AgentEvent::ToolComplete {
5500 tool_call_id,
5501 result,
5502 } if result.success => {
5503 let Some((name, args)) = self.starts.get(&tool_call_id) else {
5504 return Err(local_tool_history_unsupported());
5505 };
5506 let valid = if name == "Task" {
5507 committed.get(&tool_call_id) == Some(args)
5508 } else {
5509 committed.get(&tool_call_id).is_some_and(|proof| {
5510 proof["file_tool"] == *name
5511 && proof["arguments"] == *args
5512 && proof["result"] == result.result
5513 })
5514 };
5515 if !valid {
5516 return Err(AgentError::LLM(
5517 "LocalPlan success has no exact Host receipt".into(),
5518 ));
5519 }
5520 Ok(true)
5521 }
5522 AgentEvent::TaskListUpdated { .. } => Ok(false),
5524 _ => Ok(true),
5525 }
5526 }
5527
5528 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5529 use bamboo_subagent::proto::LocalToolMessages;
5530 if self.messages.is_some() {
5531 return Err(local_tool_history_unsupported());
5532 }
5533 if value["type"] == LocalToolMessages::TYPE {
5534 let observation: LocalToolMessages = serde_json::from_value(value.clone())
5535 .map_err(|_| local_tool_history_unsupported())?;
5536 self.messages = Some(
5537 observation
5538 .validate()
5539 .map_err(|_| local_tool_history_unsupported())?,
5540 );
5541 return Ok(false);
5542 }
5543 let event: AgentEvent =
5544 serde_json::from_value(value.clone()).map_err(|_| local_tool_history_unsupported())?;
5545 match event {
5546 AgentEvent::ToolStart {
5547 tool_call_id,
5548 tool_name,
5549 arguments,
5550 } => {
5551 if !arguments.is_object()
5552 || !matches!(tool_name.as_str(), "Read" | "Glob" | "Write" | "Task")
5553 || tool_call_id.is_empty()
5554 || tool_call_id.len() > 128
5555 || self.starts.len() >= LocalToolMessages::MAX_PAIRS
5556 || self.outcomes.contains_key(&tool_call_id)
5557 || self
5558 .starts
5559 .insert(tool_call_id, (tool_name, arguments))
5560 .is_some()
5561 {
5562 return Err(local_tool_history_unsupported());
5563 }
5564 }
5565 AgentEvent::ToolComplete {
5566 tool_call_id,
5567 result,
5568 } => {
5569 if !result.images.is_empty() {
5570 return Err(local_tool_history_unsupported());
5571 }
5572 self.outcome(tool_call_id, result.success, result.result)?;
5573 }
5574 AgentEvent::ToolError {
5575 tool_call_id,
5576 error,
5577 } => {
5578 self.outcome(tool_call_id, false, format!("Error: {error}"))?;
5579 }
5580 AgentEvent::ToolToken { tool_call_id, .. } => {
5581 if !self.starts.contains_key(&tool_call_id)
5582 || self.outcomes.contains_key(&tool_call_id)
5583 {
5584 return Err(local_tool_history_unsupported());
5585 }
5586 }
5587 AgentEvent::ToolLifecycle {
5588 tool_call_id,
5589 tool_name,
5590 is_mutating,
5591 auto_approved,
5592 phase,
5593 ..
5594 } => {
5595 if self.starts.get(&tool_call_id).map(|(name, _)| name) != Some(&tool_name)
5596 || is_mutating != matches!(tool_name.as_str(), "Write" | "Task")
5597 || auto_approved != !matches!(tool_name.as_str(), "Write" | "Task")
5598 || !matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
5599 {
5600 return Err(local_tool_history_unsupported());
5601 }
5602 }
5603 AgentEvent::ReasoningToken { .. } => {
5604 }
5608 AgentEvent::ContextSummarized { .. }
5609 | AgentEvent::ContextArchived { .. }
5610 | AgentEvent::ContextCompressionStatus { .. }
5611 | AgentEvent::NeedClarification { .. }
5612 | AgentEvent::SubAgentStarted { .. }
5613 | AgentEvent::SubAgentEvent { .. } => return Err(local_tool_history_unsupported()),
5614 AgentEvent::SessionHistoryCommitted { .. } | AgentEvent::MessageAppended { .. } => {
5616 return Ok(false)
5617 }
5618 _ => {}
5619 }
5620 Ok(true)
5621 }
5622
5623 fn outcome(&mut self, id: String, success: bool, text: String) -> Result<(), AgentError> {
5624 if id.is_empty()
5625 || id.len() > 128
5626 || text.len() > bamboo_subagent::proto::LocalToolMessages::MAX_BYTES
5627 || self.outcomes.len() >= bamboo_subagent::proto::LocalToolMessages::MAX_PAIRS
5628 || self.outcomes.insert(id, (success, text)).is_some()
5629 {
5630 return Err(local_tool_history_unsupported());
5631 }
5632 Ok(())
5633 }
5634
5635 fn suffix(
5636 self,
5637 host: &Session,
5638 tools: &[String],
5639 read_only: bool,
5640 terminal: Option<&str>,
5641 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5642 self.suffix_for_terminal(
5643 host,
5644 tools,
5645 read_only,
5646 LocalToolTerminal::Completed(terminal),
5647 )
5648 }
5649
5650 fn suffix_for_terminal(
5651 self,
5652 host: &Session,
5653 tools: &[String],
5654 read_only: bool,
5655 terminal: LocalToolTerminal<'_>,
5656 ) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
5657 use bamboo_subagent::proto::LocalToolMessages;
5658 let cancelled = matches!(terminal, LocalToolTerminal::Cancelled);
5659 if cancelled
5660 && (!read_only
5661 || tools.is_empty()
5662 || tools
5663 .iter()
5664 .any(|name| !matches!(name.as_str(), "Read" | "Glob"))
5665 || self.ticket_yield.is_some())
5666 {
5667 return Err(local_tool_history_unsupported());
5668 }
5669 let data = self.messages.ok_or_else(local_tool_history_unsupported)?;
5670 let candidate: Vec<_> = data.iter().skip_while(|m| m.role == Role::System).collect();
5671 let prefix: Vec<_> = host
5672 .messages
5673 .iter()
5674 .filter(|m| m.role != Role::System)
5675 .collect();
5676 if !host.provider_transcript.groups().is_empty()
5677 || candidate.len() < prefix.len()
5678 || (!cancelled && candidate.len() == prefix.len())
5679 || serde_json::to_value(&candidate[..prefix.len()])
5680 .map_err(|_| local_tool_history_unsupported())?
5681 != serde_json::to_value(&prefix).map_err(|_| local_tool_history_unsupported())?
5682 {
5683 return Err(local_tool_history_unsupported());
5684 }
5685 let mut ids = std::collections::HashSet::new();
5686 let mut calls = std::collections::HashSet::new();
5687 for message in &host.messages {
5688 if !ids.insert(message.id.clone()) {
5689 return Err(local_tool_history_unsupported());
5690 }
5691 for call in message.tool_calls.iter().flatten() {
5692 calls.insert(call.id.clone());
5693 }
5694 if let Some(id) = &message.tool_call_id {
5695 calls.insert(id.clone());
5696 }
5697 }
5698 let suffix: Vec<_> = candidate[prefix.len()..]
5699 .iter()
5700 .map(|message| (**message).clone())
5701 .collect();
5702 let mut pending = HashMap::new();
5703 let mut used = std::collections::HashSet::new();
5704 for message in &suffix {
5705 if !ids.insert(message.id.clone()) || message.never_compress {
5706 return Err(local_tool_history_unsupported());
5707 }
5708 match message.role {
5709 Role::Assistant => {
5710 if !pending.is_empty()
5711 || message.metadata.is_some()
5712 || message.tool_call_id.is_some()
5713 || message.tool_success.is_some()
5714 {
5715 return Err(local_tool_history_unsupported());
5716 }
5717 if let Some(batch) = &message.tool_calls {
5718 if batch.is_empty() {
5719 return Err(local_tool_history_unsupported());
5720 }
5721 for call in batch {
5722 let arguments =
5723 serde_json::from_str::<serde_json::Value>(&call.function.arguments)
5724 .ok()
5725 .filter(|arguments| arguments.is_object())
5726 .ok_or_else(local_tool_history_unsupported)?;
5727 if call.id.is_empty()
5728 || call.id.len() > 128
5729 || call.tool_type != "function"
5730 || !calls.insert(call.id.clone())
5731 || calls.len() > LocalToolMessages::MAX_MESSAGES
5732 || !matches!(
5733 call.function.name.as_str(),
5734 "Read" | "Glob" | "Write" | "Task"
5735 )
5736 || (cancelled
5737 && !matches!(call.function.name.as_str(), "Read" | "Glob"))
5738 || self.starts.get(&call.id).is_some_and(|(name, actual)| {
5739 name != &call.function.name || actual != &arguments
5740 })
5741 {
5742 return Err(local_tool_history_unsupported());
5743 }
5744 pending.insert(call.id.clone(), call.function.name.clone());
5745 }
5746 } else if message.content.trim().is_empty() {
5747 return Err(local_tool_history_unsupported());
5748 }
5749 }
5750 Role::Tool => {
5751 let id = message
5752 .tool_call_id
5753 .as_ref()
5754 .ok_or_else(local_tool_history_unsupported)?;
5755 let name = pending
5756 .remove(id)
5757 .ok_or_else(local_tool_history_unsupported)?;
5758 let success = message
5759 .tool_success
5760 .ok_or_else(local_tool_history_unsupported)?;
5761 if message.tool_calls.is_some()
5762 || message.phase.is_some()
5763 || self.outcomes.get(id) != Some(&(success, message.content.clone()))
5764 || !used.insert(id.clone())
5765 || used.len() > LocalToolMessages::MAX_PAIRS
5766 {
5767 return Err(local_tool_history_unsupported());
5768 }
5769 let denied_write = name == "Write" && (read_only || !tools.contains(&name));
5770 if denied_write {
5771 if success
5772 || !(message.content.contains("native_tool_ceiling_denied")
5773 || message
5774 .content
5775 .contains("not callable at the current conversation position"))
5776 {
5777 return Err(local_tool_history_unsupported());
5778 }
5779 } else if !tools.contains(&name)
5780 || self.starts.get(id).map(|(actual, _)| actual) != Some(&name)
5781 {
5782 return Err(local_tool_history_unsupported());
5783 }
5784 if let Some(metadata) = &message.metadata {
5785 let object = metadata
5786 .as_object()
5787 .ok_or_else(local_tool_history_unsupported)?;
5788 if object.len() != 5
5789 || metadata["elapsed_ms"].as_u64().is_none()
5790 || metadata["tool_name"].as_str() != Some(name.as_str())
5791 || metadata["is_mutating"].as_bool()
5792 != Some(matches!(name.as_str(), "Write" | "Task"))
5793 || metadata["auto_approved"].as_bool()
5794 != Some(!matches!(name.as_str(), "Write" | "Task"))
5795 || metadata["success"].as_bool() != Some(success)
5796 {
5797 return Err(local_tool_history_unsupported());
5798 }
5799 }
5800 }
5801 _ => return Err(local_tool_history_unsupported()),
5802 }
5803 }
5804 let last = suffix.last();
5805 let completed_text = match terminal {
5806 LocalToolTerminal::Completed(text) => text,
5807 LocalToolTerminal::Cancelled => None,
5808 };
5809 let question_terminal =
5810 self.ticket_yield.as_ref().is_some_and(|(id, question)| {
5811 last.is_some_and(|last| last.role == Role::Tool
5812 && last.tool_call_id.as_ref() == Some(id)
5813 && last.tool_success == Some(true)
5814 && self.starts.get(id).is_some_and(|(name, _)| name == "Task")
5815 && completed_text.is_none_or(str::is_empty)
5816 && serde_json::from_str::<serde_json::Value>(&last.content).ok()
5817 == Some(serde_json::json!({"status":"waiting_for_answer", "request":question})))
5818 });
5819 let report_terminal = last.is_some_and(|last| {
5820 last.role == Role::Assistant
5821 && last.tool_calls.is_none()
5822 && last.phase != Some(bamboo_domain::MessagePhase::Commentary)
5823 && completed_text == Some(last.content.as_str())
5824 });
5825 if !pending.is_empty()
5826 || used.len() != self.outcomes.len()
5827 || self.starts.keys().any(|id| !used.contains(id))
5828 || !(cancelled || report_terminal || question_terminal)
5829 {
5830 return Err(local_tool_history_unsupported());
5831 }
5832 Ok(suffix)
5833 }
5834}
5835
5836async fn validate_local_tool_checkpoint(
5837 session: &Session,
5838 binding: &SessionInboxRuntimeBinding,
5839 run_id: &str,
5840 terminal: LocalToolTerminal<'_>,
5841 cancel: &CancellationToken,
5842) -> Result<(), AgentError> {
5843 if !terminal.permits_checkpoint(cancel) || !binding.router.owns_run(&session.id, run_id).await {
5844 return Err(local_tool_history_unsupported());
5845 }
5846 if matches!(terminal, LocalToolTerminal::Cancelled) {
5847 let backlog = binding
5848 .inbox
5849 .inspect(&session.id)
5850 .await
5851 .map_err(|_| local_tool_history_unsupported())?;
5852 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
5853 return Err(local_tool_history_unsupported());
5856 }
5857 }
5858 Ok(())
5859}
5860
5861async fn commit_local_tool_history(
5862 collector: LocalToolCollector,
5863 session: &mut Session,
5864 binding: &SessionInboxRuntimeBinding,
5865 run_id: &str,
5866 selection: (&[String], bool),
5867 terminal: LocalToolTerminal<'_>,
5868 cancel: &CancellationToken,
5869) -> Result<(), AgentError> {
5870 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5871 let latest = binding
5872 .storage
5873 .load_session(&session.id)
5874 .await
5875 .map_err(|_| local_tool_history_unsupported())?
5876 .ok_or_else(local_tool_history_unsupported)?;
5877 if latest.id != session.id
5878 || latest.kind != session.kind
5879 || latest.created_at != session.created_at
5880 || latest.root_session_id != session.root_session_id
5881 || latest.parent_session_id != session.parent_session_id
5882 || latest.spawn_depth != session.spawn_depth
5883 || latest.project_id_meta() != session.project_id_meta()
5884 || session.check_conversation_generation(&latest).is_err()
5885 || serde_json::to_value(&latest.provider_transcript)
5886 .map_err(|_| local_tool_history_unsupported())?
5887 != serde_json::to_value(&session.provider_transcript)
5888 .map_err(|_| local_tool_history_unsupported())?
5889 {
5890 return Err(local_tool_history_unsupported());
5891 }
5892 let suffix = match terminal {
5893 LocalToolTerminal::Completed(text) => {
5894 collector.suffix(&latest, selection.0, selection.1, text)?
5895 }
5896 LocalToolTerminal::Cancelled => {
5897 collector.suffix_for_terminal(&latest, selection.0, selection.1, terminal)?
5898 }
5899 };
5900 let before = session.clone();
5901 let mut staged = session.clone();
5902 staged.messages = latest.messages.clone();
5903 staged.messages.extend(suffix.iter().cloned());
5904 staged.updated_at = chrono::Utc::now();
5905 let cancelled_messages = if matches!(terminal, LocalToolTerminal::Cancelled) {
5906 Some(serde_json::to_value(&staged.messages).map_err(|_| local_tool_history_unsupported())?)
5907 } else {
5908 None
5909 };
5910 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5911 binding
5912 .persistence
5913 .checkpoint_runtime_session(&mut staged)
5914 .await
5915 .map_err(|_| local_tool_history_unsupported())?;
5916 let saved = binding
5917 .storage
5918 .load_session(&session.id)
5919 .await
5920 .map_err(|_| local_tool_history_unsupported())?
5921 .ok_or_else(local_tool_history_unsupported)?;
5922 if session.check_conversation_generation(&saved).is_err()
5923 || serde_json::to_value(&saved.provider_transcript)
5924 .map_err(|_| local_tool_history_unsupported())?
5925 != serde_json::to_value(&latest.provider_transcript)
5926 .map_err(|_| local_tool_history_unsupported())?
5927 {
5928 return Err(local_tool_history_unsupported());
5929 }
5930 let start = match suffix.first() {
5931 Some(first) => saved
5932 .messages
5933 .iter()
5934 .position(|message| message.id == first.id)
5935 .ok_or_else(local_tool_history_unsupported)?,
5936 None => latest.messages.len(),
5937 };
5938 if cancelled_messages.as_ref().is_some_and(|expected| {
5939 serde_json::to_value(&saved.messages).ok().as_ref() != Some(expected)
5940 }) {
5941 return Err(local_tool_history_unsupported());
5944 }
5945 if saved.messages.len() < latest.messages.len()
5946 || serde_json::to_value(&saved.messages[..latest.messages.len()])
5947 .map_err(|_| local_tool_history_unsupported())?
5948 != serde_json::to_value(&latest.messages)
5949 .map_err(|_| local_tool_history_unsupported())?
5950 || serde_json::to_value(saved.messages.get(start..start + suffix.len()))
5951 .map_err(|_| local_tool_history_unsupported())?
5952 != serde_json::to_value(Some(&suffix[..]))
5953 .map_err(|_| local_tool_history_unsupported())?
5954 {
5955 return Err(local_tool_history_unsupported());
5956 }
5957 let expected = latest.messages.iter().chain(&suffix);
5960 for message in expected {
5961 let matches: Vec<_> = saved
5962 .messages
5963 .iter()
5964 .filter(|current| current.id == message.id)
5965 .collect();
5966 if matches.len() != 1
5967 || serde_json::to_value(matches[0]).map_err(|_| local_tool_history_unsupported())?
5968 != serde_json::to_value(message).map_err(|_| local_tool_history_unsupported())?
5969 {
5970 *session = before;
5971 return Err(local_tool_history_unsupported());
5972 }
5973 }
5974 validate_local_tool_checkpoint(session, binding, run_id, terminal, cancel).await?;
5975 staged.messages = saved.messages;
5976 *session = staged;
5977 Ok(())
5978}
5979
5980#[derive(Default)]
5981struct ReadOnlyActorCollector {
5982 start: Option<(String, serde_json::Value)>,
5983 result: Option<String>,
5984 messages: Option<Vec<bamboo_agent_core::Message>>,
5985}
5986impl ReadOnlyActorCollector {
5987 fn event(&mut self, value: &serde_json::Value) -> Result<bool, AgentError> {
5988 if self.messages.is_some() {
5989 return Err(plain_actor_unsupported());
5990 }
5991 if value["type"] == "owned_readonly_transcript" {
5992 let tail: bamboo_subagent::proto::ReadOnlyActorTranscript =
5993 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
5994 tail.validate().map_err(|_| plain_actor_unsupported())?;
5995 let bamboo_subagent::proto::ReadOnlyActorTranscript::Complete { messages } = tail;
5996 let typed: Vec<bamboo_agent_core::Message> = messages
5997 .iter()
5998 .map(|raw| {
5999 let message: bamboo_agent_core::Message =
6000 serde_json::from_value(raw.clone())
6001 .map_err(|_| plain_actor_unsupported())?;
6002 if serde_json::to_value(&message).map_err(|_| plain_actor_unsupported())?
6003 != *raw
6004 {
6005 return Err(plain_actor_unsupported());
6006 }
6007 Ok(message)
6008 })
6009 .collect::<Result<_, _>>()?;
6010 let (id, args) = self
6011 .start
6012 .as_ref()
6013 .ok_or_else(|| plain_actor_unsupported())?;
6014 let call = typed[0]
6015 .tool_calls
6016 .as_ref()
6017 .and_then(|calls| {
6018 if calls.len() == 1 {
6019 calls.first()
6020 } else {
6021 None
6022 }
6023 })
6024 .ok_or_else(|| plain_actor_unsupported())?;
6025 if call.id != *id
6026 || call.function.name != "Glob"
6027 || serde_json::from_str::<serde_json::Value>(&call.function.arguments)
6028 .map_err(|_| plain_actor_unsupported())?
6029 != *args
6030 || typed[1].tool_call_id.as_ref() != Some(id)
6031 || typed[1].tool_success != Some(true)
6032 || self.result.as_ref() != Some(&typed[1].content)
6033 {
6034 return Err(plain_actor_unsupported());
6035 }
6036 self.messages = Some(typed);
6037 return Ok(false);
6038 }
6039 let event: AgentEvent =
6040 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6041 match event {
6042 AgentEvent::ToolStart {
6043 tool_call_id,
6044 tool_name,
6045 arguments,
6046 } => {
6047 if tool_name != "Glob"
6048 || tool_call_id.trim().is_empty()
6049 || self.start.is_some()
6050 || !arguments.is_object()
6051 {
6052 return Err(plain_actor_unsupported());
6053 }
6054 self.start = Some((tool_call_id, arguments));
6055 Ok(true)
6056 }
6057 AgentEvent::ToolComplete {
6058 tool_call_id,
6059 result,
6060 } => {
6061 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6062 || self.result.is_some()
6063 || !result.success
6064 || !result.images.is_empty()
6065 {
6066 return Err(plain_actor_unsupported());
6067 }
6068 self.result = Some(result.result);
6069 Ok(true)
6070 }
6071 AgentEvent::ToolToken { tool_call_id, .. } => {
6072 if self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id) || self.result.is_some()
6073 {
6074 return Err(plain_actor_unsupported());
6075 }
6076 Ok(true)
6077 }
6078 AgentEvent::ToolLifecycle {
6079 tool_call_id,
6080 tool_name,
6081 is_mutating,
6082 phase,
6083 error,
6084 ..
6085 } => {
6086 if tool_name != "Glob"
6087 || is_mutating
6088 || !matches!(phase.as_str(), "begin" | "finished")
6089 || error.is_some()
6090 || self.start.as_ref().map(|s| &s.0) != Some(&tool_call_id)
6091 {
6092 return Err(plain_actor_unsupported());
6093 }
6094 Ok(true)
6095 }
6096 _ => plain_actor_event(value),
6097 }
6098 }
6099 fn finish(self, terminal: Option<&str>) -> Result<Vec<bamboo_agent_core::Message>, AgentError> {
6100 let messages = self.messages.ok_or_else(|| plain_actor_unsupported())?;
6101 if terminal != Some(messages[2].content.as_str()) || messages[2].content.trim().is_empty() {
6102 return Err(plain_actor_unsupported());
6103 }
6104 Ok(messages)
6105 }
6106}
6107
6108fn plain_actor_event(value: &serde_json::Value) -> Result<bool, AgentError> {
6109 let event: AgentEvent =
6110 serde_json::from_value(value.clone()).map_err(|_| plain_actor_unsupported())?;
6111 match event {
6112 AgentEvent::ReasoningToken { .. }
6113 | AgentEvent::ToolToken { .. }
6114 | AgentEvent::ToolStart { .. }
6115 | AgentEvent::ToolComplete { .. }
6116 | AgentEvent::ToolError { .. }
6117 | AgentEvent::ToolLifecycle { .. }
6118 | AgentEvent::ContextSummarized { .. }
6119 | AgentEvent::ContextArchived { .. }
6120 | AgentEvent::ContextCompressionStatus { .. }
6121 | AgentEvent::NeedClarification { .. }
6122 | AgentEvent::SubAgentStarted { .. }
6123 | AgentEvent::SubAgentEvent { .. }
6124 | AgentEvent::ChildApprovalRequested { .. } => Err(plain_actor_unsupported()),
6125 AgentEvent::MessageAppended { .. }
6128 | AgentEvent::SessionHistoryCommitted { .. }
6129 | AgentEvent::VisibleMessageStart { .. }
6130 | AgentEvent::VisibleMessageDiscard { .. } => Ok(false),
6131 event if event.is_durable_change() => Ok(false),
6132 _ => Ok(true),
6133 }
6134}
6135
6136fn placement_metadata(placement: &Placement, host_label: Option<&str>) -> Option<String> {
6142 let value = match placement {
6145 Placement::Local => return None,
6146 Placement::Remote { endpoint } => serde_json::json!({
6147 "kind": "remote",
6148 "host": host_label.map(str::to_string).unwrap_or_else(|| host_of_endpoint(endpoint)),
6149 }),
6150 Placement::Schedulable { pool } => serde_json::json!({
6151 "kind": "remote",
6152 "host": host_label.unwrap_or(pool),
6153 }),
6154 };
6155 serde_json::to_string(&value).ok()
6156}
6157
6158fn host_of_endpoint(endpoint: &str) -> String {
6160 endpoint
6161 .trim()
6162 .trim_start_matches("wss://")
6163 .trim_start_matches("ws://")
6164 .split(['/', ':'])
6165 .next()
6166 .unwrap_or(endpoint)
6167 .to_string()
6168}
6169
6170async fn reconcile_already_admitted_claim(
6171 binding: &SessionInboxRuntimeBinding,
6172 session: &mut Session,
6173 claim: &SessionInboxClaim,
6174) -> crate::runtime::runner::Result<()> {
6175 let latest = binding
6176 .storage
6177 .load_session(&session.id)
6178 .await
6179 .map_err(|error| {
6180 AgentError::LLM(format!(
6181 "load canonical SessionInbox checkpoint for {}: {error}",
6182 session.id
6183 ))
6184 })?
6185 .ok_or_else(|| {
6186 AgentError::LLM(format!(
6187 "canonical SessionInbox target disappeared: {}",
6188 session.id
6189 ))
6190 })?;
6191 session
6192 .check_conversation_generation(&latest)
6193 .map_err(|error| {
6194 AgentError::LLM(format!(
6195 "canonical SessionInbox conversation changed: {error}"
6196 ))
6197 })?;
6198 let Some(message) = latest
6199 .messages
6200 .iter()
6201 .find(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6202 .cloned()
6203 else {
6204 return Err(AgentError::LLM(format!(
6205 "canonical admitted receipt for {} exists without transcript message {}",
6206 session.id, claim.envelope.id
6207 )));
6208 };
6209 if let Some(existing) = session
6210 .messages
6211 .iter_mut()
6212 .find(|existing| existing.id == message.id)
6213 {
6214 *existing = message;
6215 } else {
6216 session.add_message(message);
6217 }
6218 bamboo_domain::merge_session_inbox_admission(session, &latest);
6219 binding
6220 .inbox
6221 .ack(&session.id, claim)
6222 .await
6223 .map_err(|error| {
6224 AgentError::LLM(format!(
6225 "ack recovered canonical SessionInbox claim {}: {error}",
6226 claim.envelope.id
6227 ))
6228 })
6229}
6230
6231async fn checkpoint_and_ack_canonical_claim(
6235 binding: &SessionInboxRuntimeBinding,
6236 session: &mut Session,
6237 claim: &SessionInboxClaim,
6238 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6239) -> crate::runtime::runner::Result<()> {
6240 if claim.envelope.target_session_id != session.id {
6241 return Err(AgentError::LLM(format!(
6242 "canonical SessionInbox claim target {} does not match active logical session {}",
6243 claim.envelope.target_session_id, session.id
6244 )));
6245 }
6246 if binding
6247 .inbox
6248 .was_admitted(&session.id, &claim.envelope.id)
6249 .await
6250 .map_err(|error| {
6251 AgentError::LLM(format!(
6252 "inspect canonical admitted receipt {}: {error}",
6253 claim.envelope.id
6254 ))
6255 })?
6256 {
6257 return reconcile_already_admitted_claim(binding, session, claim).await;
6258 }
6259
6260 if let Some((store, fence)) = fenced {
6261 let committed = store
6262 .confirm_actor_claim_context(
6263 concrete_file_inbox(binding)?,
6264 bamboo_storage::ActorClaimContextSeed {
6265 fence: fence.clone(),
6266 expected_created_at: session.created_at,
6267 expected_conversation_generation: session.conversation_generation_observation(),
6268 expected_messages: session.messages.clone(),
6269 expected_provider_transcript: session.provider_transcript.clone(),
6270 expected_admission: session.session_inbox_admission().cloned(),
6271 claims: vec![claim.clone()],
6272 },
6273 )
6274 .await;
6275 let committed = match committed {
6276 Ok(committed) => committed,
6277 Err(error) => {
6278 adopt_latest_actor_context(store, session).await;
6282 return Err(AgentError::LLM(format!(
6283 "confirm fenced Actor claim context unconfirmed: {error}"
6284 )));
6285 }
6286 };
6287 bamboo_domain::merge_session_inbox_admission(session, &committed);
6288 session.messages = committed.messages;
6289 session.provider_transcript = committed.provider_transcript;
6290 session.updated_at = committed.updated_at;
6291 if !session
6292 .messages
6293 .iter()
6294 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6295 || !session
6296 .session_inbox_admission()
6297 .is_some_and(|cursor| cursor.contains(&claim.envelope.id))
6298 {
6299 return Err(AgentError::LLM(
6300 "fenced Actor claim confirmation lost durable proof".into(),
6301 ));
6302 }
6303 return binding
6304 .inbox
6305 .ack(&session.id, claim)
6306 .await
6307 .map_err(|error| {
6308 AgentError::LLM(format!(
6309 "ack fenced Actor claim {} after confirmation: {error}",
6310 claim.envelope.id
6311 ))
6312 });
6313 }
6314
6315 let transcript_has_id = session
6316 .messages
6317 .iter()
6318 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6319 if session
6320 .messages
6321 .iter()
6322 .any(|message| message.id == claim.envelope.id.as_str())
6323 && !transcript_has_id
6324 {
6325 return Err(AgentError::LLM(format!(
6326 "canonical SessionInbox id {} collides with a non-matching transcript message",
6327 claim.envelope.id
6328 )));
6329 }
6330 let cursor_has_id = session
6331 .session_inbox_admission()
6332 .is_some_and(|state| state.contains(&claim.envelope.id));
6333 if cursor_has_id && !transcript_has_id {
6334 return Err(AgentError::LLM(format!(
6335 "canonical SessionInbox cursor exists without transcript message {}",
6336 claim.envelope.id
6337 )));
6338 }
6339 let before = session.clone();
6340 if !transcript_has_id {
6341 let message = claim.envelope.to_provider_message().map_err(|error| {
6342 AgentError::LLM(format!(
6343 "translate canonical SessionInbox envelope {}: {error}",
6344 claim.envelope.id
6345 ))
6346 })?;
6347 session.add_message(message);
6348 }
6349 session
6350 .session_inbox_admission_mut()
6351 .record(claim.envelope.id.clone(), claim.generation);
6352 session.updated_at = chrono::Utc::now();
6353
6354 if let Err(error) = binding
6355 .persistence
6356 .checkpoint_runtime_session(session)
6357 .await
6358 {
6359 *session = before;
6360 return Err(AgentError::LLM(format!(
6361 "checkpoint canonical SessionInbox claim {}: {error}",
6362 claim.envelope.id
6363 )));
6364 }
6365 if !session
6366 .messages
6367 .iter()
6368 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6369 {
6370 *session = before;
6371 return Err(AgentError::LLM(format!(
6372 "canonical SessionInbox checkpoint lost typed transcript proof for {}",
6373 claim.envelope.id
6374 )));
6375 }
6376 binding
6377 .inbox
6378 .ack(&session.id, claim)
6379 .await
6380 .map_err(|error| {
6381 AgentError::LLM(format!(
6382 "ack canonical SessionInbox claim {} after checkpoint: {error}",
6383 claim.envelope.id
6384 ))
6385 })
6386}
6387
6388fn concrete_file_inbox(
6389 binding: &SessionInboxRuntimeBinding,
6390) -> Result<bamboo_storage::FileSessionInbox, AgentError> {
6391 binding
6392 .inbox
6393 .as_any()
6394 .and_then(|inbox| inbox.downcast_ref::<bamboo_storage::FileSessionInbox>())
6395 .cloned()
6396 .ok_or_else(|| AgentError::LLM("remote Actor requires canonical FileSessionInbox".into()))
6397}
6398
6399async fn adopt_latest_actor_context(store: &bamboo_storage::SessionStoreV2, session: &mut Session) {
6400 if let Ok(Some(latest)) = store.load_session(&session.id).await {
6401 if latest.created_at == session.created_at
6402 && session.check_conversation_generation(&latest).is_ok()
6403 {
6404 bamboo_domain::merge_session_inbox_admission(session, &latest);
6405 session.messages = latest.messages;
6406 session.provider_transcript = latest.provider_transcript;
6407 session.updated_at = latest.updated_at;
6408 }
6409 }
6410}
6411
6412async fn checkpoint_claim_context_before_dispatch(
6423 binding: &SessionInboxRuntimeBinding,
6424 session: &mut Session,
6425 claims: &[SessionInboxClaim],
6426 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6427) -> crate::runtime::runner::Result<()> {
6428 if claims.is_empty() {
6429 return Ok(());
6430 }
6431 let before = session.clone();
6432 let mut additions = Vec::new();
6433 for claim in claims {
6434 if claim.envelope.target_session_id != session.id {
6435 return Err(AgentError::LLM(format!(
6436 "canonical SessionInbox claim target {} does not match actor session {}",
6437 claim.envelope.target_session_id, session.id
6438 )));
6439 }
6440 let matching = session
6441 .messages
6442 .iter()
6443 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope));
6444 if session
6445 .messages
6446 .iter()
6447 .any(|message| message.id == claim.envelope.id.as_str())
6448 && !matching
6449 {
6450 return Err(AgentError::LLM(format!(
6451 "canonical SessionInbox id {} collides before actor dispatch",
6452 claim.envelope.id
6453 )));
6454 }
6455 if session
6456 .session_inbox_admission()
6457 .is_some_and(|state| state.contains(&claim.envelope.id))
6458 && !matching
6459 {
6460 return Err(AgentError::LLM(format!(
6461 "canonical SessionInbox cursor exists without transcript proof for {}",
6462 claim.envelope.id
6463 )));
6464 }
6465 if !matching {
6466 let message = claim.envelope.to_provider_message().map_err(|error| {
6467 AgentError::LLM(format!(
6468 "translate canonical SessionInbox envelope {} before actor dispatch: {error}",
6469 claim.envelope.id
6470 ))
6471 })?;
6472 additions.push(message);
6473 }
6474 }
6475 let fenced_seed = fenced.is_some();
6476 if let Some((store, fence)) = fenced {
6477 let committed = store
6478 .seed_actor_claim_context(
6479 concrete_file_inbox(binding)?,
6480 bamboo_storage::ActorClaimContextSeed {
6481 fence: fence.clone(),
6482 expected_created_at: session.created_at,
6483 expected_conversation_generation: session.conversation_generation_observation(),
6484 expected_messages: session.messages.clone(),
6485 expected_provider_transcript: session.provider_transcript.clone(),
6486 expected_admission: session.session_inbox_admission().cloned(),
6487 claims: claims.to_vec(),
6488 },
6489 )
6490 .await;
6491 let committed = match committed {
6492 Ok(committed) => committed,
6493 Err(error) => {
6494 adopt_latest_actor_context(store, session).await;
6498 return Err(AgentError::LLM(format!(
6499 "seed fenced Actor claim context unconfirmed: {error}"
6500 )));
6501 }
6502 };
6503 bamboo_domain::merge_session_inbox_admission(session, &committed);
6504 session.messages = committed.messages;
6505 session.provider_transcript = committed.provider_transcript;
6506 session.updated_at = committed.updated_at;
6507 } else {
6508 for message in additions {
6509 session.add_message(message);
6510 }
6511 session.updated_at = chrono::Utc::now();
6512 if let Err(error) = binding
6513 .persistence
6514 .checkpoint_runtime_session(session)
6515 .await
6516 {
6517 *session = before;
6518 return Err(AgentError::LLM(format!(
6519 "checkpoint canonical SessionInbox actor context: {error}"
6520 )));
6521 }
6522 }
6523 for claim in claims {
6524 if !session
6525 .messages
6526 .iter()
6527 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope))
6528 {
6529 if !fenced_seed {
6530 *session = before;
6531 }
6532 return Err(AgentError::LLM(format!(
6533 "actor context checkpoint lost typed transcript proof for {}",
6534 claim.envelope.id
6535 )));
6536 }
6537 }
6538 Ok(())
6539}
6540
6541async fn claim_canonical_deliveries(
6542 binding: &SessionInboxRuntimeBinding,
6543 session: &mut Session,
6544 activation_run_id: &str,
6545 limit: usize,
6546 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6547) -> crate::runtime::runner::Result<Vec<(SessionInboxClaim, SessionMessageDelivery)>> {
6548 let claims = binding
6549 .inbox
6550 .claim_for_turn(&session.id, limit, Some(activation_run_id))
6551 .await
6552 .map_err(|error| {
6553 AgentError::LLM(format!(
6554 "claim canonical SessionInbox for active actor {}: {error}",
6555 session.id
6556 ))
6557 })?;
6558 if claims.is_empty() {
6559 return Ok(Vec::new());
6560 }
6561 let mut unconfirmed = Vec::with_capacity(claims.len());
6562 for claim in claims {
6563 if binding
6564 .inbox
6565 .was_admitted(&session.id, &claim.envelope.id)
6566 .await
6567 .map_err(|error| {
6568 AgentError::LLM(format!(
6569 "inspect canonical SessionInbox claim {}: {error}",
6570 claim.envelope.id
6571 ))
6572 })?
6573 {
6574 reconcile_already_admitted_claim(binding, session, &claim).await?;
6575 continue;
6576 }
6577 unconfirmed.push(claim);
6578 }
6579 checkpoint_claim_context_before_dispatch(binding, session, &unconfirmed, fenced).await?;
6580
6581 let mut deliveries = Vec::with_capacity(unconfirmed.len());
6582 for claim in unconfirmed {
6583 if session
6588 .session_inbox_admission()
6589 .is_some_and(|state| state.contains(&claim.envelope.id))
6590 {
6591 binding
6592 .inbox
6593 .ack(&session.id, &claim)
6594 .await
6595 .map_err(|error| {
6596 AgentError::LLM(format!(
6597 "finish confirmed canonical SessionInbox ack {}: {error}",
6598 claim.envelope.id
6599 ))
6600 })?;
6601 continue;
6602 }
6603 let delivery = SessionMessageDelivery {
6604 target_session_id: session.id.clone(),
6605 envelope: claim.envelope.clone(),
6606 canonical_claim_generation: claim.generation,
6607 activation_run_id: activation_run_id.to_string(),
6608 activation_policy: claim.activation_policy,
6609 };
6610 deliveries.push((claim, delivery));
6611 }
6612 Ok(deliveries)
6613}
6614
6615async fn forward_next_canonical_claim(
6616 client: &mut dyn bamboo_subagent::ChildLink,
6617 binding: &SessionInboxRuntimeBinding,
6618 session: &mut Session,
6619 activation_run_id: &str,
6620 inflight: &mut VecDeque<SessionInboxClaim>,
6621 fenced: Option<(&bamboo_storage::SessionStoreV2, &ActorActivationFence)>,
6622) -> crate::runtime::runner::Result<()> {
6623 if !inflight.is_empty() {
6624 return Ok(());
6625 }
6626 let Some((claim, delivery)) =
6627 claim_canonical_deliveries(binding, session, activation_run_id, 1, fenced)
6628 .await?
6629 .pop()
6630 else {
6631 return Ok(());
6632 };
6633 client
6634 .send(ParentFrame::SessionMessage { delivery })
6635 .await
6636 .map_err(|error| {
6637 AgentError::LLM(format!(
6638 "forward canonical SessionInbox claim {} to active actor: {error}",
6639 claim.envelope.id
6640 ))
6641 })?;
6642 inflight.push_back(claim);
6643 Ok(())
6644}
6645
6646struct ActorDriveContext<'a> {
6648 client: &'a mut dyn bamboo_subagent::ChildLink,
6649 parent_session_id: &'a str,
6650 child_session_id: &'a str,
6651 child_attempt: u32,
6652 approval_registry: Option<&'a super::approval_registry::SharedApprovalRegistry>,
6653 approval_decider: Option<&'a Arc<dyn ChildApprovalDecider>>,
6654 approval_reviewer: Option<&'a Arc<dyn ChildApprovalReviewer>>,
6655 escalation_bridge: Option<bamboo_subagent::executor::HostBridge>,
6656 event_tx: &'a mpsc::Sender<AgentEvent>,
6657 cancel_token: &'a CancellationToken,
6658 live_rx: &'a mut mpsc::UnboundedReceiver<ParentFrame>,
6659 delivery_rx: &'a mut mpsc::UnboundedReceiver<u64>,
6660 logical_session: &'a mut Session,
6661 expected_permission_posture: Option<ExpectedPermissionPosture>,
6662 expected_creation: Option<&'a bamboo_subagent::proto::ChildCreationIdentity>,
6663 session_inbox_runtime: Option<&'a SessionInboxRuntimeBinding>,
6664 actor_directory_store: Option<&'a bamboo_storage::SessionStoreV2>,
6665 canonical_subagent_tool: Option<Arc<dyn bamboo_agent_core::tools::Tool>>,
6666 ticket_service: Option<Arc<bamboo_tickets::TicketService>>,
6667 activation_run_id: Option<&'a str>,
6668 execution_epoch: u64,
6669 expected_source_actor_id: &'a str,
6670 initial_inflight_claims: VecDeque<SessionInboxClaim>,
6671 plain_actor: bool,
6672 remote_environment_lease: bool,
6673 readonly_output: Option<&'a mut Vec<bamboo_agent_core::Message>>,
6674 local_history_tools: Option<&'a [String]>,
6675 local_history_read_only: bool,
6676 strict_history_output: Option<&'a mut Option<StrictTerminalHistory>>,
6677 plain_input: Option<&'a PlainActorActivation>,
6678 canonical_activation: Option<&'a ActorActivationFence>,
6679 canonical_placement_ref: Option<&'a bamboo_domain::ActorPlacementRef>,
6680 actor_event_observer: Option<&'a dyn ActorEventObserver>,
6681 plain_run: Option<(&'a RunSpec, &'a AtomicU64)>,
6682 first_frame_timeout: Option<Duration>,
6683}
6684
6685#[derive(Debug, Clone, PartialEq, Eq)]
6686struct ExpectedPermissionPosture {
6687 policy_revision: u64,
6688 resolution: bamboo_domain::PermissionModeResolution,
6689 expected_audit_revision: Option<u64>,
6690 executor_mapping: String,
6691}
6692
6693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6694enum PermissionPostureHandshake {
6695 NotRequired,
6697 Awaiting,
6700 Confirmed,
6702}
6703
6704impl PermissionPostureHandshake {
6705 fn new(expected: Option<&ExpectedPermissionPosture>) -> Self {
6706 if expected.is_some() {
6707 Self::Awaiting
6708 } else {
6709 Self::NotRequired
6710 }
6711 }
6712
6713 fn is_awaiting(self) -> bool {
6714 self == Self::Awaiting
6715 }
6716
6717 fn posture_was_confirmed(self) -> bool {
6718 self == Self::Confirmed
6719 }
6720}
6721
6722fn permission_posture_seed_from_event(
6723 session: &Session,
6724 event: &AgentEvent,
6725) -> Result<Option<bamboo_domain::PermissionAuditSeed>, String> {
6726 let AgentEvent::PermissionPostureActivated {
6727 session_id,
6728 policy_revision,
6729 requested_mode,
6730 effective_mode,
6731 executor_mapping,
6732 } = event
6733 else {
6734 return Ok(None);
6735 };
6736 if session_id != &session.id {
6737 return Err("permission posture event targets a different logical session".to_string());
6738 }
6739 let requested = bamboo_domain::SessionPermissionMode::from_audit_str(requested_mode)
6740 .ok_or_else(|| "permission posture event has an invalid requested mode".to_string())?;
6741 let effective = bamboo_domain::PermissionMode::from_audit_str(effective_mode)
6742 .ok_or_else(|| "permission posture event has an invalid effective mode".to_string())?;
6743 let resolution = bamboo_domain::PermissionModeResolution {
6744 requested,
6745 effective,
6746 };
6747 if !resolution.is_consistent() {
6748 return Err("permission posture event has an inconsistent mode pair".to_string());
6749 }
6750 let current_requested = session
6751 .agent_runtime_state
6752 .as_ref()
6753 .map(|state| state.effective_permission_mode())
6754 .unwrap_or_default();
6755 if current_requested != requested {
6756 return Err("permission posture event is stale for the host typed mode".to_string());
6757 }
6758 let mapping_chars = executor_mapping.chars().count();
6759 if mapping_chars == 0 || mapping_chars > bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS {
6760 return Err("permission posture event has an invalid executor mapping".to_string());
6761 }
6762 Ok(Some(bamboo_domain::PermissionAuditSeed::new(
6763 *policy_revision,
6764 resolution,
6765 executor_mapping,
6766 )))
6767}
6768
6769fn actor_event_logical_identity(
6770 session: &Session,
6771 parent_session_id: &str,
6772 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6773) -> LogicalSessionIdentity {
6774 LogicalSessionIdentity {
6775 creation: creation.cloned(),
6776 session_id: session.id.clone(),
6777 parent_session_id: session
6778 .parent_session_id
6779 .clone()
6780 .or_else(|| Some(parent_session_id.to_string())),
6781 root_session_id: if session.root_session_id.trim().is_empty() {
6782 parent_session_id.to_string()
6783 } else {
6784 session.root_session_id.clone()
6785 },
6786 }
6787}
6788
6789fn validate_strict_history_terminal(
6790 watermark: Option<&bamboo_subagent::ActorEventWatermark>,
6791 session: &Session,
6792 parent_session_id: &str,
6793 activation_run_id: Option<&str>,
6794 execution_epoch: u64,
6795 creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6796 consumed_seq: u64,
6797) -> Result<(), AgentError> {
6798 let identity = actor_event_logical_identity(session, parent_session_id, creation);
6799 if watermark
6800 .zip(activation_run_id)
6801 .is_some_and(|(watermark, run_id)| {
6802 watermark.matches_consumed_run(&identity, run_id, execution_epoch, consumed_seq)
6803 })
6804 {
6805 Ok(())
6806 } else {
6807 Err(AgentError::LLM("actor_history_tail_incomplete".into()))
6808 }
6809}
6810
6811fn validate_actor_event_batch(
6812 batch: &ActorEventBatch,
6813 logical_session: &Session,
6814 parent_session_id: &str,
6815 activation_run_id: Option<&str>,
6816 execution_epoch: u64,
6817 expected_source_actor_id: &str,
6818 expected_creation: Option<&bamboo_subagent::proto::ChildCreationIdentity>,
6819) -> Result<(), AgentError> {
6820 batch
6821 .validate()
6822 .map_err(|error| AgentError::LLM(format!("actor emitted invalid event batch: {error}")))?;
6823 let identity = batch.logical_session.as_ref().ok_or_else(|| {
6824 AgentError::LLM("actor event batch omitted its logical session identity".to_string())
6825 })?;
6826 let expected_identity =
6831 actor_event_logical_identity(logical_session, parent_session_id, expected_creation);
6832 if identity != &expected_identity {
6833 return Err(AgentError::LLM(
6834 "actor event batch targets a different logical session".to_string(),
6835 ));
6836 }
6837 if batch.activation_id.as_deref() != activation_run_id {
6838 return Err(AgentError::LLM(
6839 "actor event batch belongs to a stale activation".to_string(),
6840 ));
6841 }
6842 if batch.execution_epoch != execution_epoch {
6843 return Err(AgentError::LLM(
6844 "actor event batch belongs to a stale execution epoch".to_string(),
6845 ));
6846 }
6847 if batch
6848 .source_actor_id
6849 .as_deref()
6850 .is_some_and(|source| source != expected_source_actor_id)
6851 {
6852 return Err(AgentError::LLM(
6853 "actor event batch declares a different physical actor".to_string(),
6854 ));
6855 }
6856 Ok(())
6857}
6858
6859async fn validate_directory_actor_event(
6863 store: &bamboo_storage::SessionStoreV2,
6864 fence: &ActorActivationFence,
6865 session: &Session,
6866 run_id: Option<&str>,
6867) -> Result<bamboo_domain::ActorDirectoryEntry, AgentError> {
6868 store
6869 .validate_fence(fence, chrono::Utc::now())
6870 .await
6871 .map_err(|_| AgentError::LLM("actor event owner is stale".into()))?;
6872 let entry = store
6873 .inspect_actor(&fence.actor_id)
6874 .await
6875 .map_err(|_| AgentError::LLM("actor event authority is unavailable".into()))?;
6876 if !entry.actor.matches_session(session)
6877 || entry.actor.project_id != project_id_for_actor_run(session)?.map(|id| id.to_string())
6878 || !entry.activation.as_ref().is_some_and(|current| {
6879 current.matches_fence(fence)
6880 && current.status == bamboo_domain::ActorActivationStatus::Running
6881 })
6882 || run_id.is_some_and(|id| id != fence.run_id)
6883 {
6884 return Err(AgentError::LLM("actor event authority changed".into()));
6885 }
6886 Ok(entry)
6887}
6888
6889async fn inspect_active_owned_tree(
6892 store: &bamboo_storage::SessionStoreV2,
6893 binding: &SessionInboxRuntimeBinding,
6894 run_id: &str,
6895 child_id: &str,
6896 parent_id: &str,
6897 logical_session: &Session,
6898 creation: &bamboo_subagent::proto::ChildCreationIdentity,
6899 plain_input: Option<&PlainActorActivation>,
6900 cursor: Option<&str>,
6901) -> Option<serde_json::Value> {
6902 use bamboo_agent_core::storage::Storage;
6903 let current = match store.load_session(child_id).await {
6904 Ok(Some(current)) => current,
6905 _ => {
6906 tracing::warn!("owned tree denied: canonical caller unavailable");
6907 return None;
6908 }
6909 };
6910 let current_project = match project_id_for_actor_run(¤t) {
6911 Ok(project) => project,
6912 Err(_) => {
6913 tracing::warn!("owned tree denied: invalid canonical Project");
6914 return None;
6915 }
6916 };
6917 let logical_project = match project_id_for_actor_run(logical_session) {
6918 Ok(project) => project,
6919 Err(_) => {
6920 tracing::warn!("owned tree denied: invalid logical Project");
6921 return None;
6922 }
6923 };
6924 if current.id != child_id
6925 || current.kind != bamboo_domain::SessionKind::Child
6926 || current.parent_session_id.as_deref() != Some(parent_id)
6927 || current.parent_session_id != logical_session.parent_session_id
6928 || current.root_session_id != logical_session.root_session_id
6929 || current.created_at != creation.created_at
6930 || current.created_at != logical_session.created_at
6931 || current.spawn_depth != creation.spawn_depth
6932 || current.spawn_depth != logical_session.spawn_depth
6933 || current_project != logical_project
6934 {
6935 tracing::warn!("owned tree denied: caller lifetime or Project changed");
6936 return None;
6937 }
6938 if let Some(activation) = plain_input {
6939 validate_directory_actor_event(
6940 activation.store.as_ref(),
6941 &activation.fence,
6942 ¤t,
6943 Some(run_id),
6944 )
6945 .await
6946 .ok()?;
6947 } else if !binding.router.owns_run(child_id, run_id).await {
6948 tracing::warn!("owned tree denied: activation owner changed");
6949 return None;
6950 }
6951 let page = match crate::session_app::child_session::owned_tree::inspect_owned_tree(
6952 store, child_id, cursor,
6953 )
6954 .await
6955 {
6956 Ok(page) => page,
6957 Err(error) => {
6958 tracing::warn!(?error, "owned tree denied: canonical tree read failed");
6959 return None;
6960 }
6961 };
6962 let again = match store.load_session(child_id).await {
6963 Ok(Some(again)) => again,
6964 _ => {
6965 tracing::warn!("owned tree denied: canonical caller disappeared");
6966 return None;
6967 }
6968 };
6969 if again.created_at != current.created_at
6970 || again.parent_session_id != current.parent_session_id
6971 || again.root_session_id != current.root_session_id
6972 || again.spawn_depth != current.spawn_depth
6973 || project_id_for_actor_run(&again).ok()? != current_project
6974 {
6975 tracing::warn!("owned tree denied: caller changed during inspection");
6976 return None;
6977 }
6978 if let Some(activation) = plain_input {
6979 validate_directory_actor_event(
6980 activation.store.as_ref(),
6981 &activation.fence,
6982 &again,
6983 Some(run_id),
6984 )
6985 .await
6986 .ok()?;
6987 } else if !binding.router.owns_run(child_id, run_id).await {
6988 tracing::warn!("owned tree denied: activation owner changed after inspection");
6989 return None;
6990 }
6991 serde_json::to_vec(&page)
6992 .ok()
6993 .filter(|bytes| bytes.len() <= 8192)?;
6994 Some(page)
6995}
6996
6997async fn load_active_subagent_caller(
7001 store: &bamboo_storage::SessionStoreV2,
7002 binding: &SessionInboxRuntimeBinding,
7003 run_id: &str,
7004 child_id: &str,
7005 parent_id: &str,
7006 logical_session: &Session,
7007 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7008 plain_input: Option<&PlainActorActivation>,
7009) -> Option<Session> {
7010 use bamboo_agent_core::storage::Storage;
7011 let current = store.load_session(child_id).await.ok().flatten()?;
7012 let current_project = project_id_for_actor_run(¤t).ok()?;
7013 let logical_project = project_id_for_actor_run(logical_session).ok()?;
7014 if current.id != child_id
7015 || current.kind != bamboo_domain::SessionKind::Child
7016 || current.parent_session_id.as_deref() != Some(parent_id)
7017 || current.parent_session_id != logical_session.parent_session_id
7018 || current.root_session_id != logical_session.root_session_id
7019 || current.created_at != creation.created_at
7020 || current.created_at != logical_session.created_at
7021 || current.spawn_depth != creation.spawn_depth
7022 || current.spawn_depth != logical_session.spawn_depth
7023 || current_project != logical_project
7024 || bamboo_domain::ActorSession::from_session(¤t).is_err()
7025 {
7026 return None;
7027 }
7028 if let Some(activation) = plain_input {
7029 validate_directory_actor_event(
7030 activation.store.as_ref(),
7031 &activation.fence,
7032 ¤t,
7033 Some(run_id),
7034 )
7035 .await
7036 .ok()?;
7037 } else if !binding.router.owns_run(child_id, run_id).await {
7038 return None;
7039 }
7040 Some(current)
7041}
7042
7043async fn invoke_active_subagent(
7044 tool: &dyn bamboo_agent_core::tools::Tool,
7045 caller: &Session,
7046 args: serde_json::Value,
7047 tool_call_id: &str,
7048) -> serde_json::Value {
7049 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7050 let mut ctx = ToolCtx::none(tool_call_id.to_owned());
7051 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7052 match tool.invoke(args, ctx).await {
7053 Ok(ToolOutcome::Completed(result))
7054 if result.images.is_empty()
7055 && serde_json::to_vec(&result).is_ok_and(|bytes| bytes.len() <= 16 * 1024) =>
7056 {
7057 serde_json::json!({"result":result})
7058 }
7059 _ => serde_json::json!({
7060 "error":"SubAgent operation failed or its result exceeded the limit; inspect logical child state before retrying"
7061 }),
7062 }
7063}
7064
7065#[allow(clippy::too_many_arguments)]
7068async fn checkpoint_active_parent_question(
7069 store: &bamboo_storage::SessionStoreV2,
7070 binding: &SessionInboxRuntimeBinding,
7071 run_id: &str,
7072 child_id: &str,
7073 parent_id: &str,
7074 logical_session: &mut Session,
7075 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7076 plain_input: Option<&PlainActorActivation>,
7077 observation: ParentQuestionCheckpointV1,
7078) -> Option<String> {
7079 use bamboo_agent_core::storage::Storage;
7080 if plain_input.is_some() || observation.validate_shape().is_err() {
7081 return None;
7082 }
7083 let _session_guard = binding
7087 .parent_question_lock
7088 .as_ref()?
7089 .acquire_lock(child_id)
7090 .await;
7091 let caller = load_active_subagent_caller(
7092 store,
7093 binding,
7094 run_id,
7095 child_id,
7096 parent_id,
7097 logical_session,
7098 creation,
7099 plain_input,
7100 )
7101 .await?;
7102 let parent = store.load_session(parent_id).await.ok().flatten()?;
7103 if parent.created_at > caller.created_at || !binding.router.owns_run(child_id, run_id).await {
7104 return None;
7105 }
7106 let (saved, question) = store
7107 .checkpoint_parent_question(&caller, &parent, &observation)
7108 .await
7109 .ok()
7110 .flatten()?;
7111 logical_session.messages = saved.messages;
7114 logical_session.pending_question = saved.pending_question;
7115 if let Some(reason) = saved.metadata.get("runtime.suspend_reason") {
7116 logical_session
7117 .metadata
7118 .insert("runtime.suspend_reason".into(), reason.clone());
7119 }
7120 if let Some(request) = saved
7121 .metadata
7122 .get(bamboo_domain::PARENT_QUESTION_REQUEST_KEY)
7123 {
7124 logical_session.metadata.insert(
7125 bamboo_domain::PARENT_QUESTION_REQUEST_KEY.into(),
7126 request.clone(),
7127 );
7128 }
7129 logical_session.metadata.insert(
7130 "runtime.actor_parent_question_handoff".into(),
7131 "true".into(),
7132 );
7133 Some(question.id.as_str().to_owned())
7134}
7135
7136async fn ensure_active_nested_wait(
7141 tool: &dyn bamboo_agent_core::tools::Tool,
7142 store: &bamboo_storage::SessionStoreV2,
7143 binding: &SessionInboxRuntimeBinding,
7144 run_id: &str,
7145 child_id: &str,
7146 parent_id: &str,
7147 logical_session: &Session,
7148 creation: &bamboo_subagent::proto::ChildCreationIdentity,
7149 plain_input: Option<&PlainActorActivation>,
7150) -> Result<bool, AgentError> {
7151 fn is_terminal_status(status: &str) -> bool {
7152 matches!(
7153 status,
7154 "completed" | "error" | "timeout" | "cancelled" | "skipped"
7155 )
7156 }
7157 use bamboo_agent_core::storage::Storage;
7158 use bamboo_agent_core::tools::{ToolCtx, ToolOutcome};
7159
7160 let caller = load_active_subagent_caller(
7161 store,
7162 binding,
7163 run_id,
7164 child_id,
7165 parent_id,
7166 logical_session,
7167 creation,
7168 plain_input,
7169 )
7170 .await
7171 .ok_or_else(|| AgentError::LLM("nested wait caller is no longer active".into()))?;
7172 if caller
7173 .agent_runtime_state
7174 .as_ref()
7175 .and_then(|state| state.waiting_for_children.as_ref())
7176 .is_some()
7177 {
7178 return Ok(true);
7179 }
7180
7181 let statuses = store
7182 .list_child_run_statuses(child_id)
7183 .await
7184 .map_err(|error| AgentError::LLM(format!("nested child status scan failed: {error}")))?;
7185 let mut active = Vec::new();
7186 for (id, status) in statuses {
7187 if !status.as_deref().is_some_and(is_terminal_status)
7188 && !store
7189 .is_supervisor_observer_child(&caller, &id)
7190 .await
7191 .map_err(|error| {
7192 AgentError::LLM(format!("nested Observer classification failed: {error}"))
7193 })?
7194 {
7195 active.push(id);
7196 }
7197 }
7198 if active.is_empty() {
7199 return Ok(false);
7200 }
7201
7202 let mut ctx = ToolCtx::none(format!("host-nested-orphan-wait:{run_id}"));
7203 ctx.session_id = Some(Arc::from(caller.id.as_str()));
7204 let outcome = tokio::time::timeout(
7205 Duration::from_secs(30),
7206 tool.invoke(
7207 serde_json::json!({"action":"wait", "child_session_ids":active}),
7208 ctx,
7209 ),
7210 )
7211 .await
7212 .map_err(|_| AgentError::LLM("nested wait registration timed out".into()))?
7213 .map_err(|error| AgentError::LLM(format!("nested wait registration failed: {error}")))?;
7214 let wait_registered = match outcome {
7215 ToolOutcome::Completed(result) if result.success => {
7216 let payload: serde_json::Value = serde_json::from_str(&result.result)
7217 .map_err(|_| AgentError::LLM("nested wait result is invalid".into()))?;
7218 payload["status"] == "waiting"
7219 }
7220 _ => {
7221 return Err(AgentError::LLM(
7222 "nested wait registration was not completed".into(),
7223 ))
7224 }
7225 };
7226
7227 let latest = load_active_subagent_caller(
7230 store,
7231 binding,
7232 run_id,
7233 child_id,
7234 parent_id,
7235 logical_session,
7236 creation,
7237 plain_input,
7238 )
7239 .await
7240 .ok_or_else(|| AgentError::LLM("nested wait caller changed during registration".into()))?;
7241 if latest
7242 .agent_runtime_state
7243 .as_ref()
7244 .and_then(|state| state.waiting_for_children.as_ref())
7245 .is_some()
7246 {
7247 return Ok(true);
7248 }
7249 let statuses = store
7250 .list_child_run_statuses(child_id)
7251 .await
7252 .map_err(|error| {
7253 AgentError::LLM(format!("nested child wait verification failed: {error}"))
7254 })?;
7255 let mut still_active = false;
7256 for (id, status) in statuses {
7257 if !status.as_deref().is_some_and(is_terminal_status)
7258 && !store
7259 .is_supervisor_observer_child(&latest, &id)
7260 .await
7261 .map_err(|error| {
7262 AgentError::LLM(format!("nested Observer classification failed: {error}"))
7263 })?
7264 {
7265 still_active = true;
7266 break;
7267 }
7268 }
7269 if still_active {
7270 return Err(AgentError::LLM(
7271 "nested child remains active without a durable parent wait".into(),
7272 ));
7273 }
7274 Ok(wait_registered)
7279}
7280
7281fn actor_event_route_error(error: ActorEventRouteError) -> AgentError {
7282 let message = match error {
7283 ActorEventRouteError::SequenceGap { .. } => {
7284 "actor event sequence gap; authoritative session snapshot is required"
7285 }
7286 ActorEventRouteError::LifecycleMissing => {
7287 "actor emitted live content before its activation lifecycle event"
7288 }
7289 ActorEventRouteError::ForgedSession => {
7290 "actor event payload targets a different canonical session"
7291 }
7292 ActorEventRouteError::ConflictingReplay => {
7293 "actor replay reused an event sequence with different content"
7294 }
7295 ActorEventRouteError::StaleAuthority => "actor event owner is stale",
7296 ActorEventRouteError::SequenceExhausted => "actor event sequence is exhausted",
7297 };
7298 AgentError::LLM(message.into())
7299}
7300
7301const MAX_DISPLAY_CALLS: usize = 4_096;
7302const MAX_NESTED_DISPLAY_DEPTH: usize = 8;
7303const MAX_DISPLAY_ID_BYTES: usize = 256;
7304const MAX_DISPLAY_HOOK_RESULT_DEPTH: usize = 16;
7305
7306fn hook_result_for_display(result: HookResult, depth: usize) -> Option<HookResult> {
7307 if depth >= MAX_DISPLAY_HOOK_RESULT_DEPTH {
7308 return None;
7309 }
7310 Some(match result {
7311 HookResult::Continue => HookResult::Continue,
7312 HookResult::Mutated => HookResult::Mutated,
7313 HookResult::Allow => HookResult::Allow,
7314 HookResult::Ask => HookResult::Ask,
7315 HookResult::Deny { .. } => HookResult::Deny {
7316 reason: "Hook denied".into(),
7317 },
7318 HookResult::InjectContext { .. } => HookResult::InjectContext {
7319 text: "Hook context hidden".into(),
7320 },
7321 HookResult::WithContext { result, .. } => HookResult::WithContext {
7322 result: Box::new(hook_result_for_display(*result, depth + 1)?),
7323 text: "Hook context hidden".into(),
7324 },
7325 HookResult::Suspend { .. } => HookResult::Suspend {
7326 reason: "Hook suspended".into(),
7327 },
7328 HookResult::Abort { .. } => HookResult::Abort {
7329 reason: "Hook aborted".into(),
7330 },
7331 })
7332}
7333
7334#[derive(Clone, Copy, PartialEq, Eq)]
7335enum DisplayCallKind {
7336 Ordinary,
7337 PrivateBrowser,
7338 Reused,
7339}
7340
7341#[derive(Clone, Copy)]
7342struct DisplayCall {
7343 kind: DisplayCallKind,
7344 started: bool,
7345}
7346
7347#[derive(Default)]
7348struct ActorEventDisplay {
7349 calls: HashMap<String, DisplayCall>,
7350 hook_names: HashMap<String, usize>,
7351 children: HashMap<(String, String), Box<ActorEventDisplay>>,
7352 identities: Arc<AtomicUsize>,
7355 overflowed: bool,
7356 nested_overflowed: bool,
7357}
7358
7359fn is_browser_tool_name(tool_name: &str) -> bool {
7360 tool_name
7361 .trim()
7362 .rsplit("::")
7363 .next()
7364 .is_some_and(|name| name.eq_ignore_ascii_case("browser"))
7365}
7366
7367fn known_browser_action(args: &serde_json::Value) -> bool {
7368 matches!(
7369 args.get("action").and_then(serde_json::Value::as_str),
7370 Some(
7371 "tabs"
7372 | "new_tab"
7373 | "activate_tab"
7374 | "close_tab"
7375 | "navigate"
7376 | "history"
7377 | "viewport"
7378 | "snapshot"
7379 | "click"
7380 | "click_at"
7381 | "hover"
7382 | "drag"
7383 | "fill"
7384 | "select_option"
7385 | "set_file_input"
7386 | "type"
7387 | "press"
7388 | "key"
7389 | "scroll"
7390 | "download"
7391 | "screenshot"
7392 | "dialog_respond"
7393 )
7394 )
7395}
7396
7397fn is_private_browser_call(tool_name: &str, args: &serde_json::Value) -> bool {
7398 is_browser_tool_name(tool_name)
7399 && (!known_browser_action(args)
7400 || args
7401 .get("action")
7402 .and_then(serde_json::Value::as_str)
7403 .is_some_and(|action| {
7404 action.eq_ignore_ascii_case("download") || action == "set_file_input"
7405 })
7406 || args.get("data_base64").is_some())
7407}
7408
7409fn actor_arguments_for_display(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
7410 if is_browser_tool_name(tool_name) {
7411 if !known_browser_action(args) {
7414 return serde_json::json!({"details":"[redacted]"});
7415 }
7416 }
7417 tool_start_arguments_for_display(tool_name, args)
7418}
7419
7420impl ActorEventDisplay {
7421 fn fail_closed(&mut self) {
7422 self.identities
7423 .store(MAX_DISPLAY_CALLS + 1, Ordering::Relaxed);
7424 self.overflowed = true;
7425 self.calls.clear();
7426 self.hook_names.clear();
7427 self.nested_overflowed = true;
7428 self.children.clear();
7429 }
7430
7431 fn reserve_identity(&mut self) -> bool {
7432 if self.identities.fetch_add(1, Ordering::Relaxed) < MAX_DISPLAY_CALLS {
7433 true
7434 } else {
7435 self.fail_closed();
7436 false
7437 }
7438 }
7439
7440 fn remember_start(&mut self, id: &str, private: bool) -> DisplayCallKind {
7441 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7442 return DisplayCallKind::Reused;
7443 }
7444 let kind = if private {
7445 DisplayCallKind::PrivateBrowser
7446 } else {
7447 DisplayCallKind::Ordinary
7448 };
7449 if let Some(call) = self.calls.get_mut(id) {
7450 if call.started || call.kind != kind {
7451 call.kind = DisplayCallKind::Reused;
7452 }
7453 call.started = true;
7454 return call.kind;
7455 }
7456 if !self.reserve_identity() {
7457 return DisplayCallKind::Reused;
7458 }
7459 self.calls.insert(
7460 id.to_string(),
7461 DisplayCall {
7462 kind,
7463 started: true,
7464 },
7465 );
7466 kind
7467 }
7468
7469 fn remember_approval(&mut self, id: &str, private: bool) -> DisplayCallKind {
7470 if self.overflowed || id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES {
7471 return DisplayCallKind::Reused;
7472 }
7473 let kind = if private {
7474 DisplayCallKind::PrivateBrowser
7475 } else {
7476 DisplayCallKind::Ordinary
7477 };
7478 if let Some(call) = self.calls.get_mut(id) {
7479 if call.kind != kind {
7480 call.kind = DisplayCallKind::Reused;
7481 }
7482 return call.kind;
7483 }
7484 if !self.reserve_identity() {
7485 return DisplayCallKind::Reused;
7486 }
7487 self.calls.insert(
7488 id.to_string(),
7489 DisplayCall {
7490 kind,
7491 started: false,
7492 },
7493 );
7494 kind
7495 }
7496
7497 fn known_kind(&self, id: &str) -> Option<DisplayCallKind> {
7498 if self.overflowed || self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7499 return None;
7500 }
7501 self.calls
7502 .get(id)
7503 .filter(|call| call.started)
7504 .map(|call| call.kind)
7505 }
7506
7507 fn display_hook_name(&mut self, hook_name: &str) -> Option<String> {
7508 if self.overflowed || hook_name.is_empty() || hook_name.len() > MAX_DISPLAY_ID_BYTES {
7509 self.fail_closed();
7510 return None;
7511 }
7512 if let Some(index) = self.hook_names.get(hook_name) {
7513 return Some(format!("actor-hook-{index}"));
7514 }
7515 if !self.reserve_identity() {
7516 return None;
7517 }
7518 let index = self.hook_names.len() + 1;
7519 self.hook_names.insert(hook_name.to_string(), index);
7520 Some(format!("actor-hook-{index}"))
7521 }
7522
7523 fn project(&mut self, event: AgentEvent, depth: usize) -> Option<AgentEvent> {
7524 let call_id = match &event {
7525 AgentEvent::ToolStart { tool_call_id, .. }
7526 | AgentEvent::ToolApprovalRequested { tool_call_id, .. }
7527 | AgentEvent::ToolToken { tool_call_id, .. }
7528 | AgentEvent::ToolComplete { tool_call_id, .. }
7529 | AgentEvent::ToolError { tool_call_id, .. }
7530 | AgentEvent::ToolLifecycle { tool_call_id, .. } => Some(tool_call_id),
7531 _ => None,
7532 };
7533 if call_id.is_some_and(|id| id.is_empty() || id.len() > MAX_DISPLAY_ID_BYTES) {
7534 self.fail_closed();
7535 return None;
7536 }
7537 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7538 self.fail_closed();
7539 }
7540 Some(match event {
7541 AgentEvent::ToolStart {
7542 tool_call_id,
7543 tool_name,
7544 arguments,
7545 } => {
7546 let private = is_private_browser_call(&tool_name, &arguments);
7547 let kind = self.remember_start(&tool_call_id, private);
7548 let arguments = if kind == DisplayCallKind::Reused {
7549 serde_json::json!({"details":"[redacted]"})
7550 } else {
7551 actor_arguments_for_display(&tool_name, &arguments)
7552 };
7553 AgentEvent::ToolStart {
7554 tool_call_id,
7555 tool_name: match kind {
7556 DisplayCallKind::Reused => "unknown".into(),
7557 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7558 _ => tool_name,
7559 },
7560 arguments,
7561 }
7562 }
7563 AgentEvent::ToolApprovalRequested {
7564 tool_call_id,
7565 tool_name,
7566 parameters,
7567 } => {
7568 let private = is_private_browser_call(&tool_name, ¶meters);
7569 let kind = self.remember_approval(&tool_call_id, private);
7570 let parameters = if kind == DisplayCallKind::Reused {
7571 serde_json::json!({"details":"[redacted]"})
7572 } else {
7573 actor_arguments_for_display(&tool_name, ¶meters)
7574 };
7575 AgentEvent::ToolApprovalRequested {
7576 tool_call_id,
7577 tool_name: match kind {
7578 DisplayCallKind::Reused => "unknown".into(),
7579 _ if is_browser_tool_name(&tool_name) => "browser".into(),
7580 _ => tool_name,
7581 },
7582 parameters,
7583 }
7584 }
7585 AgentEvent::ToolToken {
7586 tool_call_id,
7587 content,
7588 } => {
7589 let content = match self.known_kind(&tool_call_id) {
7590 Some(DisplayCallKind::Ordinary) => content,
7591 Some(DisplayCallKind::PrivateBrowser) => "Browser tool output hidden".into(),
7592 _ => "Tool output hidden".into(),
7593 };
7594 AgentEvent::ToolToken {
7595 tool_call_id,
7596 content,
7597 }
7598 }
7599 AgentEvent::ToolComplete {
7600 tool_call_id,
7601 result,
7602 } => {
7603 let result = match self.known_kind(&tool_call_id) {
7604 Some(DisplayCallKind::Ordinary) => result,
7605 Some(DisplayCallKind::PrivateBrowser) => {
7606 bamboo_agent_core::tools::ToolResult::text(
7607 result.success,
7608 "Browser tool result hidden",
7609 )
7610 }
7611 _ => bamboo_agent_core::tools::ToolResult::text(
7612 result.success,
7613 "Tool result hidden",
7614 ),
7615 };
7616 AgentEvent::ToolComplete {
7617 tool_call_id,
7618 result,
7619 }
7620 }
7621 AgentEvent::ToolError {
7622 tool_call_id,
7623 error,
7624 } => {
7625 let error = match self.known_kind(&tool_call_id) {
7626 Some(DisplayCallKind::Ordinary) => error,
7627 Some(DisplayCallKind::PrivateBrowser) => "Browser tool failed".into(),
7628 _ => "Tool failed".into(),
7629 };
7630 AgentEvent::ToolError {
7631 tool_call_id,
7632 error,
7633 }
7634 }
7635 AgentEvent::ToolLifecycle {
7636 tool_call_id,
7637 tool_name,
7638 phase,
7639 elapsed_ms,
7640 is_mutating,
7641 auto_approved,
7642 summary,
7643 error,
7644 } => {
7645 let kind = self.known_kind(&tool_call_id);
7646 let (summary, error) = match kind {
7647 Some(DisplayCallKind::Ordinary) => (summary, error),
7648 Some(DisplayCallKind::PrivateBrowser) => (
7649 summary.map(|_| "Browser tool activity hidden".into()),
7650 error.map(|_| "Browser tool failed".into()),
7651 ),
7652 _ => (
7653 summary.map(|_| "Tool activity hidden".into()),
7654 error.map(|_| "Tool failed".into()),
7655 ),
7656 };
7657 AgentEvent::ToolLifecycle {
7658 tool_call_id,
7659 tool_name: match kind {
7660 Some(DisplayCallKind::Ordinary) => tool_name,
7661 Some(DisplayCallKind::PrivateBrowser) => "browser".into(),
7662 _ => "unknown".into(),
7663 },
7664 phase: if kind == Some(DisplayCallKind::Ordinary)
7665 || matches!(phase.as_str(), "begin" | "finished" | "error" | "cancelled")
7666 {
7667 phase
7668 } else {
7669 "event".into()
7670 },
7671 elapsed_ms,
7672 is_mutating,
7673 auto_approved,
7674 summary,
7675 error,
7676 }
7677 }
7678 AgentEvent::HookLifecycle {
7679 hook_name,
7680 point,
7681 phase,
7682 duration_ms,
7683 decision,
7684 } => {
7685 let Some(decision) = hook_result_for_display(decision, 0) else {
7686 self.fail_closed();
7687 return None;
7688 };
7689 let hook_name = self.display_hook_name(&hook_name)?;
7690 let phase = match phase.as_str() {
7691 "completed" | "started" | "error" => phase,
7692 _ => "event".into(),
7693 };
7694 AgentEvent::HookLifecycle {
7695 hook_name,
7696 point,
7697 phase,
7698 duration_ms,
7699 decision,
7700 }
7701 }
7702 AgentEvent::ChildApprovalRequested {
7703 child_session_id,
7704 request_id,
7705 tool_name,
7706 permission,
7707 resource,
7708 } => {
7709 let (tool_name, permission, resource) = if is_browser_tool_name(&tool_name) {
7710 (
7711 "browser".into(),
7712 "Browser interaction approval".into(),
7713 "[redacted]".into(),
7714 )
7715 } else {
7716 (tool_name, permission, resource)
7717 };
7718 AgentEvent::ChildApprovalRequested {
7719 child_session_id,
7720 request_id,
7721 tool_name,
7722 permission,
7723 resource,
7724 }
7725 }
7726 AgentEvent::ChildApprovalChanged { tool_name, .. }
7727 if is_browser_tool_name(&tool_name) =>
7728 {
7729 return None;
7733 }
7734 AgentEvent::SubAgentEvent {
7735 parent_session_id,
7736 child_session_id,
7737 event,
7738 } => {
7739 if depth >= MAX_NESTED_DISPLAY_DEPTH || self.nested_overflowed {
7740 return None;
7741 }
7742 if parent_session_id.is_empty()
7743 || child_session_id.is_empty()
7744 || parent_session_id.len() > MAX_DISPLAY_ID_BYTES
7745 || child_session_id.len() > MAX_DISPLAY_ID_BYTES
7746 {
7747 self.fail_closed();
7748 return None;
7749 }
7750 let key = (parent_session_id.clone(), child_session_id.clone());
7751 if !self.children.contains_key(&key) && !self.reserve_identity() {
7752 return None;
7753 }
7754 let identities = self.identities.clone();
7755 let child = self.children.entry(key).or_insert_with(|| {
7756 Box::new(ActorEventDisplay {
7757 identities,
7758 ..Default::default()
7759 })
7760 });
7761 let event = child.project(*event, depth + 1)?;
7762 if self.identities.load(Ordering::Relaxed) > MAX_DISPLAY_CALLS {
7763 self.fail_closed();
7764 return None;
7765 }
7766 AgentEvent::SubAgentEvent {
7767 parent_session_id,
7768 child_session_id,
7769 event: Box::new(event),
7770 }
7771 }
7772 other => other,
7773 })
7774 }
7775}
7776
7777#[allow(clippy::too_many_arguments)]
7778async fn process_actor_event(
7779 event: serde_json::Value,
7780 strict_permission_events: bool,
7781 permission_handshake: &mut PermissionPostureHandshake,
7782 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7783 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7784 logical_session: &mut Session,
7785 event_tx: &mpsc::Sender<AgentEvent>,
7786 display: &mut ActorEventDisplay,
7787) -> crate::runtime::runner::Result<()> {
7788 let event = match serde_json::from_value::<AgentEvent>(event) {
7789 Ok(event) => event,
7790 Err(_) if strict_permission_events => {
7791 return Err(AgentError::LLM(
7795 "actor emitted malformed AgentEvent under a typed permission posture contract"
7796 .to_string(),
7797 ));
7798 }
7799 Err(_) => {
7800 display.fail_closed();
7804 return Ok(());
7805 }
7806 };
7807 process_typed_actor_event(
7808 event,
7809 permission_handshake,
7810 expected_permission_posture,
7811 session_inbox_runtime,
7812 logical_session,
7813 event_tx,
7814 display,
7815 )
7816 .await
7817}
7818
7819#[allow(clippy::too_many_arguments)]
7820async fn process_typed_actor_event(
7821 event: AgentEvent,
7822 permission_handshake: &mut PermissionPostureHandshake,
7823 expected_permission_posture: Option<&ExpectedPermissionPosture>,
7824 session_inbox_runtime: Option<&SessionInboxRuntimeBinding>,
7825 logical_session: &mut Session,
7826 event_tx: &mpsc::Sender<AgentEvent>,
7827 display: &mut ActorEventDisplay,
7828) -> crate::runtime::runner::Result<()> {
7829 if matches!(&event, AgentEvent::PermissionPostureActivated { .. }) {
7830 if permission_handshake.posture_was_confirmed() {
7831 return Err(AgentError::LLM(
7832 "actor emitted a duplicate permission posture activation".to_string(),
7833 ));
7834 }
7835 let seed = permission_posture_seed_from_event(logical_session, &event)
7836 .map_err(AgentError::LLM)?
7837 .ok_or_else(|| {
7838 AgentError::LLM(
7839 "actor permission posture event did not decode as a posture".to_string(),
7840 )
7841 })?;
7842 if let Some(expected) = expected_permission_posture {
7843 if seed.policy_revision != expected.policy_revision
7844 || seed.resolution != expected.resolution
7845 {
7846 return Err(AgentError::LLM(
7847 "permission posture event does not match the host-dispatched policy"
7848 .to_string(),
7849 ));
7850 }
7851 if seed.executor_mapping() != expected.executor_mapping {
7852 return Err(AgentError::LLM(
7853 "permission posture event does not match the host-dispatched executor mapping"
7854 .to_string(),
7855 ));
7856 }
7857 }
7858 if let Some(binding) = session_inbox_runtime {
7859 let saved = binding
7860 .persistence
7861 .record_permission_posture_activation(
7862 &logical_session.id,
7863 expected_permission_posture
7864 .and_then(|expected| expected.expected_audit_revision),
7865 &seed,
7866 )
7867 .await
7868 .map_err(|error| {
7869 AgentError::LLM(format!(
7870 "persist child permission posture bootstrap: {error}"
7871 ))
7872 })?
7873 .ok_or_else(|| {
7874 AgentError::LLM(
7875 "persist child permission posture bootstrap: session not found".to_string(),
7876 )
7877 })?;
7878 let snapshot = bamboo_domain::PermissionAuditSnapshot::from_metadata(&saved.metadata)
7879 .ok_or_else(|| {
7880 AgentError::LLM(
7881 "persisted child permission posture audit is incomplete".to_string(),
7882 )
7883 })?;
7884 snapshot.write_to(&mut logical_session.metadata);
7885 } else {
7886 bamboo_domain::record_permission_audit(&mut logical_session.metadata, &seed, None)
7889 .map_err(|error| {
7890 AgentError::LLM(format!(
7891 "record in-memory child permission posture: {error}"
7892 ))
7893 })?;
7894 }
7895 *permission_handshake = PermissionPostureHandshake::Confirmed;
7898 } else if permission_handshake.is_awaiting() {
7899 return Err(AgentError::LLM(
7900 "actor emitted an execution event before permission posture confirmation".to_string(),
7901 ));
7902 }
7903 if let Some(event) = display.project(event, 0) {
7904 let _ = event_tx.send(event).await;
7905 }
7906 Ok(())
7907}
7908
7909async fn drive(context: ActorDriveContext<'_>) -> crate::runtime::runner::Result<Option<String>> {
7920 let ActorDriveContext {
7921 client,
7922 parent_session_id,
7923 child_session_id,
7924 child_attempt,
7925 approval_registry,
7926 approval_decider,
7927 approval_reviewer,
7928 escalation_bridge,
7929 event_tx,
7930 cancel_token,
7931 live_rx,
7932 delivery_rx,
7933 logical_session,
7934 mut expected_permission_posture,
7935 expected_creation,
7936 session_inbox_runtime,
7937 actor_directory_store,
7938 canonical_subagent_tool,
7939 ticket_service,
7940 activation_run_id,
7941 execution_epoch,
7942 expected_source_actor_id,
7943 initial_inflight_claims,
7944 first_frame_timeout,
7945 plain_actor,
7946 remote_environment_lease,
7947 mut readonly_output,
7948 local_history_tools,
7949 local_history_read_only,
7950 mut strict_history_output,
7951 plain_input,
7952 canonical_activation,
7953 canonical_placement_ref,
7954 actor_event_observer,
7955 plain_run,
7956 } = context;
7957
7958 logical_session
7962 .metadata
7963 .remove("runtime.actor_nested_wait_handoff");
7964
7965 let mut got_first_frame = false;
7972 let mut first_frame_watch = first_frame_timeout.map(|d| Box::pin(tokio::time::sleep(d)));
7973 let mut inflight_claims = initial_inflight_claims;
7974 let mut owned_inputs = plain_input
7975 .map(|activation| VecDeque::from(activation.initial_inputs.clone()))
7976 .unwrap_or_default();
7977 let continuation_limit = if owned_inputs.is_empty() { 2 } else { 1 };
7980 let mut continuations = usize::from(!owned_inputs.is_empty());
7981 let mut released_inputs: Vec<(
7982 SessionInboxOwnedClaim,
7983 bamboo_subagent::proto::InitialInputRelease,
7984 )> = Vec::new();
7985 let mut current_epoch = execution_epoch;
7986 let approval_epoch = Arc::new(AtomicU64::new(execution_epoch));
7987 let approval_cancel = cancel_token.child_token();
7988 let _approval_lifetime = approval_cancel.clone().drop_guard();
7989 let mut approval_scopes: HashMap<String, (serde_json::Value, ChildApprovalScope)> =
7990 HashMap::new();
7991 let mut owned_tree_requests = 0usize;
7992 let mut canonical_subagent_requests = 0usize;
7993 let mut committed_plan_calls = HashMap::new();
7994 let mut nested_wait_registered_this_run = false;
7998 let mut parent_question_checkpoint_id: Option<String> = None;
7999 let strict_permission_events = expected_permission_posture.is_some();
8000 let mut permission_handshake =
8001 PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8002 let mut next_actor_event_seq = 1u64;
8003 let mut canonical_router: Option<ActorEventRouter> = None;
8004 let canonical_fence = plain_input
8005 .map(|activation| &activation.fence)
8006 .or(canonical_activation);
8007 let canonical_store = plain_input
8008 .map(|activation| activation.store.as_ref())
8009 .or(actor_directory_store);
8010 let remote_claim_fence = actor_directory_store.zip(canonical_activation);
8011 let workflow_usage_requested = logical_session
8012 .metadata
8013 .get(bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY)
8014 .is_some_and(|v| v == "true");
8015 if workflow_usage_requested {
8016 logical_session
8017 .metadata
8018 .remove(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY);
8019 logical_session
8020 .metadata
8021 .remove(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY);
8022 }
8023 let mut remote_cancel_deadline: Option<tokio::time::Instant> = None;
8024 let mut display = ActorEventDisplay::default();
8025 let mut readonly = readonly_output
8026 .as_ref()
8027 .map(|_| ReadOnlyActorCollector::default());
8028 let mut local_history = local_history_tools.map(|_| LocalToolCollector::default());
8029 loop {
8030 tokio::select! {
8031 _ = cancel_token.cancelled(), if remote_cancel_deadline.is_none() => {
8032 if remote_claim_fence.is_some() || workflow_usage_requested {
8033 let _ = client.send(ParentFrame::Cancel).await;
8039 remote_cancel_deadline = Some(
8040 tokio::time::Instant::now() + Duration::from_secs(10)
8041 );
8042 continue;
8043 }
8044 break;
8046 }
8047 _ = async {
8048 if let Some(deadline) = remote_cancel_deadline {
8049 tokio::time::sleep_until(deadline).await;
8050 } else {
8051 std::future::pending::<()>().await;
8052 }
8053 } => {
8054 return Err(AgentError::Cancelled);
8055 }
8056 _ = async {
8057 match first_frame_watch.as_mut() {
8058 Some(s) => s.as_mut().await,
8059 None => std::future::pending::<()>().await,
8060 }
8061 }, if !got_first_frame => {
8062 return Err(AgentError::WorkerUnresponsive(format!(
8063 "child {child_session_id} produced no frame within {:?}",
8064 first_frame_timeout.unwrap_or_default()
8065 )));
8066 }
8067 Some(_generation) = delivery_rx.recv(),
8068 if session_inbox_runtime.is_some() && activation_run_id.is_some() =>
8069 {
8070 if plain_input.is_some() {
8071 continue;
8074 }
8075 forward_next_canonical_claim(
8076 client,
8077 session_inbox_runtime.expect("guarded"),
8078 logical_session,
8079 activation_run_id.expect("guarded"),
8080 &mut inflight_claims,
8081 remote_claim_fence,
8082 )
8083 .await?;
8084 }
8085 Some(frame) = live_rx.recv() => {
8086 if plain_actor { return Err(plain_actor_unsupported()); }
8087 if client.send(frame).await.is_err() {
8089 tracing::warn!("live steering frame could not be sent; connection failing");
8090 }
8091 }
8092 frame = client.next_frame() => {
8093 got_first_frame = true;
8096 first_frame_watch = None;
8097 match frame {
8098 Ok(Some(ChildFrame::Event { event })) => {
8099 if canonical_activation.is_some() {
8100 return Err(AgentError::LLM("remote_actor_event_batch_required".into()));
8101 }
8102 if local_history.is_some() {
8103 return Err(local_tool_history_unsupported());
8104 }
8105 if event.get("initial_input_control").is_some() {
8106 let bamboo_subagent::proto::InitialInputControl::Request { request } =
8107 bamboo_subagent::proto::InitialInputControl::decode(event).map_err(|_| plain_actor_unsupported())?
8108 else { return Err(plain_actor_unsupported()); };
8109 if !permission_handshake.posture_was_confirmed() || cancel_token.is_cancelled() { return Err(plain_actor_unsupported()); }
8110 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8111 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8112 let (run, _) = plain_run.ok_or_else(|| plain_actor_unsupported())?;
8113 let previous = released_inputs.iter()
8117 .find(|(_, release)| release.request == request);
8118 let (claim, previous) = match previous {
8119 Some((claim, release)) => (claim.clone(), Some(release.clone())),
8120 None => (owned_inputs.front().cloned()
8121 .ok_or_else(|| plain_actor_unsupported())?, None),
8122 };
8123 let release = activation.release_initial_input(binding, logical_session, run, current_epoch, &claim,
8124 &request, &mut expected_permission_posture, previous.as_ref()).await?;
8125 if cancel_token.is_cancelled() { return Err(AgentError::Cancelled); }
8126 client.send(ParentFrame::InitialInputRelease { release: release.clone() }).await
8127 .map_err(|_| AgentError::LLM("Actor initial release unconfirmed; durable input is preserved".into()))?;
8128 if previous.is_none() {
8129 let _ = owned_inputs.pop_front();
8130 released_inputs.push((claim, release));
8131 }
8132 continue;
8133 }
8134 if expected_creation.is_some() {
8135 return Err(AgentError::LLM("worker omitted required Child creation event identity".into()));
8136 }
8137 if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE { continue; }
8138 if event["type"] == "owned_readonly_transcript" {
8139 if plain_actor { return Err(plain_actor_unsupported()); }
8140 continue;
8141 }
8142 if plain_actor && !plain_actor_event(&event)? { continue; }
8143 if let Some(activation) = plain_input {
8144 validate_directory_actor_event(
8145 activation.store.as_ref(),
8146 &activation.fence,
8147 logical_session,
8148 None,
8149 ).await?;
8150 }
8151 process_actor_event(
8155 event,
8156 strict_permission_events,
8157 &mut permission_handshake,
8158 expected_permission_posture.as_ref(),
8159 session_inbox_runtime,
8160 logical_session,
8161 event_tx,
8162 &mut display,
8163 )
8164 .await?;
8165 }
8166 Ok(Some(ChildFrame::EventBatch { batch })) => {
8167 validate_actor_event_batch(
8168 &batch,
8169 logical_session,
8170 parent_session_id,
8171 activation_run_id,
8172 current_epoch,
8173 expected_source_actor_id,
8174 expected_creation,
8175 )?;
8176 let directory_entry = if let (Some(store), Some(fence)) = (canonical_store, canonical_fence) {
8177 let entry = validate_directory_actor_event(
8178 store, fence, logical_session, batch.activation_id.as_deref(),
8179 ).await?;
8180 if canonical_placement_ref.is_some_and(|expected| {
8181 entry.activation.as_ref().and_then(|activation| activation.placement_ref.as_ref()) != Some(expected)
8182 }) {
8183 return Err(AgentError::LLM("remote_actor_placement_ref_changed".into()));
8184 }
8185 Some(entry)
8186 } else { None };
8187 if let (Some(entry), Some(fence)) = (&directory_entry, canonical_fence) {
8188 if canonical_router.is_none() {
8189 canonical_router = Some(
8190 ActorEventRouter::new(
8191 entry,
8192 fence,
8193 current_epoch,
8194 strict_permission_events && permission_handshake.is_awaiting(),
8195 )
8196 .map_err(actor_event_route_error)?,
8197 );
8198 }
8199 }
8200 if local_history_tools.is_some()
8201 && (batch.first_seq != next_actor_event_seq
8202 || batch.qos != bamboo_subagent::ActorEventQos::Durable
8203 || batch.last_seq == u64::MAX) {
8204 return Err(local_tool_history_unsupported());
8205 }
8206 if batch.last_seq < next_actor_event_seq {
8207 continue;
8208 }
8209 if batch.first_seq > next_actor_event_seq {
8210 if plain_actor {
8211 return Err(AgentError::LLM(
8212 "actor event sequence gap; authoritative session snapshot is required".into(),
8213 ));
8214 }
8215 tracing::warn!(
8216 child_session_id,
8217 expected_seq = next_actor_event_seq,
8218 received_seq = batch.first_seq,
8219 "actor event sequence gap; authoritative session snapshot is required"
8220 );
8221 display.fail_closed();
8225 }
8226 let skip = next_actor_event_seq
8227 .saturating_sub(batch.first_seq)
8228 .min(batch.events.len() as u64) as usize;
8229 for (offset, event) in batch.events.into_iter().enumerate().skip(skip) {
8230 let seq = batch.first_seq + offset as u64;
8231 let mut publish = true;
8232 if event["type"] == bamboo_subagent::proto::WorkflowAgentUsage::TYPE {
8233 if workflow_usage_requested {
8234 if permission_handshake.is_awaiting() { return Err(plain_actor_unsupported()); }
8235 let usage: bamboo_subagent::proto::WorkflowAgentUsage = serde_json::from_value(event.clone()).map_err(|_| plain_actor_unsupported())?;
8236 let activation = activation_run_id.ok_or_else(plain_actor_unsupported)?;
8237 if !usage.matches(logical_session, activation) { return Err(plain_actor_unsupported()); }
8238 if logical_session.metadata.contains_key(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY) { return Err(plain_actor_unsupported()); }
8239 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_USAGE_OBSERVATION_KEY.into(), serde_json::to_string(&usage).map_err(|_| plain_actor_unsupported())?);
8240 }
8241 publish = false;
8242 }
8243 if publish { if let Some(collector) = local_history.as_mut() {
8244 if permission_handshake.is_awaiting() && event["type"] != "permission_posture_activated" {
8245 return Err(local_tool_history_unsupported());
8246 }
8247 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8248 publish = collector.ticket_event(&event, &committed_plan_calls)?;
8249 }
8250 publish &= collector.event(&event)?;
8251 } else if event["type"] == bamboo_subagent::proto::LocalToolMessages::TYPE {
8252 publish = false;
8254 }}
8255 if publish { publish = if let Some(collector) = readonly.as_mut() {
8256 if permission_handshake.is_awaiting()
8257 && event["type"] != "permission_posture_activated" {
8258 return Err(plain_actor_unsupported());
8259 }
8260 collector.event(&event)?
8261 } else if event["type"] == "owned_readonly_transcript" {
8262 if plain_actor { return Err(plain_actor_unsupported()); }
8263 false } else if plain_actor { plain_actor_event(&event)? } else { true };
8265 }
8266 if let (Some(router), Some(entry), Some(fence)) =
8267 (canonical_router.as_mut(), directory_entry.as_ref(), canonical_fence)
8268 {
8269 let typed = if publish {
8270 Some(serde_json::from_value::<AgentEvent>(event.clone())
8271 .map_err(|_| plain_actor_unsupported())?)
8272 } else {
8273 None
8274 };
8275 match router.route(entry, fence, current_epoch, seq, typed)
8276 .map_err(actor_event_route_error)?
8277 {
8278 ActorEventRoute::Publish(envelope) => {
8279 let public_event = actor_event_observer
8280 .filter(|_| envelope.class != ActorEventClass::Ephemeral)
8281 .map(|_| PublicActorEvent::from(envelope.as_ref()));
8282 tracing::trace!(
8283 actor_id = %envelope.actor_id,
8284 event_id = %envelope.event_id,
8285 class = ?envelope.class,
8286 "publishing canonical Actor event"
8287 );
8288 process_typed_actor_event(
8289 envelope.payload,
8290 &mut permission_handshake,
8291 expected_permission_posture.as_ref(),
8292 session_inbox_runtime,
8293 logical_session,
8294 event_tx,
8295 &mut display,
8296 ).await?;
8297 if let (Some(observer), Some(public_event)) =
8298 (actor_event_observer, public_event)
8299 {
8300 observer.publish(public_event);
8301 }
8302 }
8303 ActorEventRoute::Duplicate | ActorEventRoute::Suppressed => {}
8304 }
8305 next_actor_event_seq = seq.saturating_add(1);
8306 continue;
8307 }
8308 if !publish {
8309 next_actor_event_seq = seq.saturating_add(1);
8310 continue;
8311 }
8312 process_actor_event(
8313 event,
8314 strict_permission_events,
8315 &mut permission_handshake,
8316 expected_permission_posture.as_ref(),
8317 session_inbox_runtime,
8318 logical_session,
8319 event_tx,
8320 &mut display,
8321 )
8322 .await?;
8323 next_actor_event_seq = seq.saturating_add(1);
8324 }
8325 }
8326 Ok(Some(ChildFrame::OwnedTreeRequest { id, cursor })) => {
8327 owned_tree_requests += 1;
8328 if owned_tree_requests > 16 {
8329 return Err(AgentError::LLM("owned tree request limit exceeded".into()));
8330 }
8331 let page = if id.len() <= 128
8332 && !logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8333 && cursor.as_ref().is_none_or(|value| !value.is_empty() && value.len() <= 128)
8334 && !cancel_token.is_cancelled()
8335 {
8336 match (actor_directory_store, session_inbox_runtime,
8337 activation_run_id, expected_creation) {
8338 (Some(store), Some(binding), Some(run_id), Some(creation)) => {
8339 tokio::time::timeout(
8340 Duration::from_secs(8),
8341 inspect_active_owned_tree(
8342 store, binding, run_id, child_session_id,
8343 parent_session_id, logical_session, creation,
8344 plain_input, cursor.as_deref(),
8345 ),
8346 ).await.ok().flatten()
8347 }
8348 _ => {
8349 tracing::warn!("owned tree denied: canonical Host scope unavailable");
8350 None
8351 },
8352 }
8353 } else {
8354 tracing::warn!("owned tree denied: request bounds or cancellation");
8355 None
8356 };
8357 if cancel_token.is_cancelled() {
8358 return Err(AgentError::Cancelled);
8359 }
8360 client.send(ParentFrame::OwnedTreeReply { id, page }).await
8361 .map_err(|_| AgentError::LLM("owned tree reply transport closed".into()))?;
8362 }
8363 Ok(Some(ChildFrame::SubAgentRequest { id, tool_call_id, args })) => {
8364 canonical_subagent_requests += 1;
8365 if canonical_subagent_requests > 64 {
8366 return Err(AgentError::LLM("canonical SubAgent request limit exceeded".into()));
8367 }
8368 let valid_shape = !id.is_empty() && id.len() <= 128
8369 && !tool_call_id.is_empty() && tool_call_id.len() <= 128
8370 && args.is_object()
8371 && serde_json::to_vec(&args)
8372 .is_ok_and(|bytes| bytes.len() <= 64 * 1024);
8373 let registers_wait = matches!(args.get("action").and_then(|value| value.as_str()),
8374 Some("wait"))
8375 || (args.get("action").and_then(|value| value.as_str()) == Some("create")
8376 && args.get("wait").and_then(|value| value.as_bool()) == Some(true));
8377 let checkpoint_payload = args.get(PARENT_QUESTION_CHECKPOINT_ACTION).cloned();
8378 let ticket_bootstrap_pending = valid_shape
8379 && logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8380 && args == serde_json::json!({(crate::ticket_worker_plan::TICKET_PLAN_ACTION): {"read":true}})
8381 && permission_handshake.is_awaiting()
8382 && !cancel_token.is_cancelled();
8383 let result = if ticket_bootstrap_pending {
8384 serde_json::json!({"error":crate::ticket_worker_plan::remote::TICKET_BOOTSTRAP_POSTURE_PENDING})
8388 } else if valid_shape
8389 && (!logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
8390 || args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some()
8391 || args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some())
8392 && !cancel_token.is_cancelled()
8393 && !permission_handshake.is_awaiting()
8394 {
8395 if args.get(crate::ticket_worker_plan::TICKET_PLAN_ACTION).is_some() {
8396 match (ticket_service.as_deref(), actor_directory_store,
8397 session_inbox_runtime, activation_run_id, expected_creation) {
8398 (Some(service), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8399 if let Some(caller) = load_active_subagent_caller(
8400 store, binding, run_id, child_session_id,
8401 parent_session_id, logical_session, creation, plain_input,
8402 ).await {
8403 match crate::ticket_worker_plan::remote::apply_host_plan_request(service, &caller, run_id, &args, &tool_call_id) {
8404 Ok(projection) => {
8405 logical_session.task_list = projection.task_list.clone();
8406 logical_session.set_task_list_version_meta(projection.plan_revision.to_string());
8407 if let Some(question) = &projection.question {
8408 let raw = serde_json::to_string(question).map_err(|_| local_tool_history_unsupported())?;
8409 logical_session.metadata.insert(crate::ticket_worker_plan::TICKET_QUESTION_YIELD_KEY.into(), raw);
8410 if let Some(collector) = local_history.as_mut() {
8411 collector.ticket_yield = Some((tool_call_id.clone(), question.clone()));
8412 }
8413 }
8414 if let Some(task) = args[crate::ticket_worker_plan::TICKET_PLAN_ACTION].get("task") {
8415 committed_plan_calls.insert(tool_call_id.clone(), task.clone());
8416 if let Some(task_list) = projection.task_list.clone() {
8417 let _ = event_tx.send(AgentEvent::TaskListUpdated {
8418 task_list,
8419 version: Some(projection.plan_revision),
8420 }).await;
8421 }
8422 }
8423 serde_json::json!({"result":projection})
8424 }
8425 Err(error) => serde_json::json!({"error":error.to_string()}),
8426 }
8427 } else { serde_json::json!({"error":"LocalPlan Child Run is no longer current"}) }
8428 }
8429 _ => serde_json::json!({"error":"LocalPlan Host authority unavailable"}),
8430 }
8431 } else if args.get(crate::ticket_worker_plan::files::TICKET_FILE_ACTION).is_some() {
8432 match (ticket_service.as_deref(), actor_directory_store, session_inbox_runtime,
8433 activation_run_id, expected_creation, local_history_tools) {
8434 (Some(service),Some(store),Some(binding),Some(run_id),Some(creation),Some(ceiling)) => {
8435 if let Some(caller)=load_active_subagent_caller(store,binding,run_id,child_session_id,parent_session_id,logical_session,creation,plain_input).await {
8436 match crate::ticket_worker_plan::files::apply_host_file_request(service,&caller,run_id,&args,&tool_call_id,ceiling) {
8437 Ok(reply)=>{
8438 let body=&args[crate::ticket_worker_plan::files::TICKET_FILE_ACTION];
8439 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())?}));
8440 serde_json::json!({"result":reply})
8441 },
8442 Err(error)=>serde_json::json!({"error":error.to_string()}),
8443 }
8444 } else { serde_json::json!({"error":"Ticket file Child Run is no longer current"}) }
8445 },
8446 _=>serde_json::json!({"error":"Ticket file Host authority unavailable"}),
8447 }
8448 } else if let Some(payload) = checkpoint_payload {
8449 let parsed = (args.as_object().is_some_and(|object| object.len() == 1))
8450 .then(|| serde_json::from_value::<ParentQuestionCheckpointV1>(payload).ok())
8451 .flatten()
8452 .filter(|observation| observation.tool_call_id == tool_call_id);
8453 match (parsed, actor_directory_store, session_inbox_runtime,
8454 activation_run_id, expected_creation) {
8455 (Some(observation), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8456 let receipt = tokio::time::timeout(Duration::from_secs(25),
8457 checkpoint_active_parent_question(
8458 store, binding, run_id, child_session_id,
8459 parent_session_id, logical_session, creation,
8460 plain_input, observation,
8461 )).await.ok().flatten();
8462 if let Some(request_id) = receipt {
8463 parent_question_checkpoint_id = Some(request_id.clone());
8464 serde_json::json!({"result":{"request_id":request_id}})
8465 } else {
8466 serde_json::json!({"error":"canonical Child question checkpoint rejected; persisted worker question is preserved"})
8467 }
8468 }
8469 _ => serde_json::json!({"error":"canonical Child question checkpoint unavailable"}),
8470 }
8471 } else {
8472 match (canonical_subagent_tool.as_deref(), actor_directory_store,
8473 session_inbox_runtime, activation_run_id, expected_creation) {
8474 (Some(tool), Some(store), Some(binding), Some(run_id), Some(creation)) => {
8475 if let Some(caller) = load_active_subagent_caller(
8476 store, binding, run_id, child_session_id,
8477 parent_session_id, logical_session, creation,
8478 plain_input,
8479 ).await {
8480 tokio::time::timeout(
8481 Duration::from_secs(30),
8482 invoke_active_subagent(tool, &caller, args, &tool_call_id),
8483 ).await.unwrap_or_else(|_| serde_json::json!({
8484 "error":"canonical SubAgent operation timed out; inspect logical child state before retrying"
8485 }))
8486 } else {
8487 serde_json::json!({"error":"canonical SubAgent caller is no longer active"})
8488 }
8489 }
8490 _ => serde_json::json!({"error":"canonical SubAgent Host authority unavailable"}),
8491 }
8492 }
8493 } else {
8494 serde_json::json!({"error":"canonical SubAgent request rejected"})
8495 };
8496 if registers_wait
8497 && result["result"]["success"] == true
8498 && result["result"]["result"]
8499 .as_str()
8500 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
8501 .is_some_and(|value| value["status"] == "waiting")
8502 {
8503 nested_wait_registered_this_run = true;
8504 }
8505 if cancel_token.is_cancelled() {
8506 return Err(AgentError::Cancelled);
8507 }
8508 client.send(ParentFrame::SubAgentReply { id, result }).await
8509 .map_err(|_| AgentError::LLM("canonical SubAgent reply transport closed".into()))?;
8510 }
8511 Ok(Some(ChildFrame::ApprovalRequest { id, body })) => {
8512 if logical_session.metadata.contains_key(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY) {
8513 return Err(AgentError::LLM("Ticket Task-only Worker cannot request unrelated action authority".into()));
8514 }
8515 if plain_actor { return Err(plain_actor_unsupported()); }
8516 if permission_handshake.is_awaiting() {
8517 return Err(AgentError::LLM(
8518 "actor requested approval before permission posture confirmation"
8519 .to_string(),
8520 ));
8521 }
8522 if let Some(reviewer) = approval_reviewer
8528 .cloned()
8529 .or_else(child_approval_reviewer)
8530 {
8531 let child = child_session_id.to_string();
8539 let parent = parent_session_id.to_string();
8540 let req_id = id.clone();
8541 let body = body.clone();
8542 let registry = approval_registry.cloned();
8543 let Some((binding, run)) = session_inbox_runtime.zip(activation_run_id) else {
8544 tokio::spawn(async move {
8547 let approved = tokio::time::timeout(CHILD_APPROVAL_TIMEOUT,
8548 reviewer.review(&parent, &child, &body)).await.unwrap_or(false);
8549 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8550 child_attempt, &req_id, approved);
8551 });
8552 continue;
8553 };
8554 let generation = body.pointer("/permission_request/request_generation")
8555 .and_then(serde_json::Value::as_str).unwrap_or("").to_string();
8556 let scope = if let Some((original, scope)) = approval_scopes.get(&generation) {
8557 if original != &body || scope.stamp()["reply"] != req_id {
8558 return Err(AgentError::LLM("approval generation changed in live scope".into()));
8559 }
8560 scope.clone()
8561 } else {
8562 if approval_scopes.len() >= 64 {
8563 return Err(AgentError::LLM("approval live scope limit exceeded".into()));
8564 }
8565 let scope = ChildApprovalScope::new(&parent, &child,
8566 (child_attempt, run, current_epoch, &req_id, chrono::Utc::now() + chrono::Duration::seconds(240)), binding.router.clone(),
8567 approval_cancel.clone(), approval_epoch.clone());
8568 approval_scopes.insert(generation, (body.clone(), scope.clone()));
8569 scope
8570 };
8571 tokio::spawn(async move {
8572 let result = tokio::time::timeout(
8573 CHILD_APPROVAL_TIMEOUT,
8574 reviewer.review_scoped(&parent, &child, &body, &scope),
8575 ).await.unwrap_or(ChildApprovalReview::Reply(false));
8576 if let ChildApprovalReview::Reply(approved) = result {
8577 if scope.is_current(&parent, &child).await {
8578 super::live::deliver_approval_scoped(registry.as_ref(), &child,
8579 child_attempt, &req_id, approved && chrono::Utc::now() < scope.deadline());
8580 }
8581 }
8582 });
8583 } else if approval_decider.is_some() {
8584 let approved =
8588 decide_child_approval(approval_decider, child_session_id, &body)
8589 .await;
8590 if client
8591 .send(ParentFrame::ApprovalReply { id, approved })
8592 .await
8593 .is_err()
8594 {
8595 tracing::warn!(
8596 "failed to answer approval_request; connection failing"
8597 );
8598 }
8599 } else if let Some(host) = escalation_bridge.clone() {
8600 let child = child_session_id.to_string();
8607 let req_id = id.clone();
8608 let body = body.clone();
8609 let registry = approval_registry.cloned();
8610 tokio::spawn(async move {
8611 let approved = match tokio::time::timeout(
8612 CHILD_APPROVAL_TIMEOUT,
8613 host.approval_call(body),
8614 )
8615 .await
8616 {
8617 Ok(Ok(reply)) => reply
8618 .get("approved")
8619 .and_then(|v| v.as_bool())
8620 .unwrap_or(false),
8621 _ => false,
8623 };
8624 super::live::deliver_approval_scoped(
8625 registry.as_ref(),
8626 &child,
8627 child_attempt,
8628 &req_id,
8629 approved,
8630 );
8631 });
8632 } else {
8633 tracing::warn!(
8637 parent_session_id,
8638 child_session_id,
8639 request_id = %id,
8640 "forced-ask request has no parent-agent reviewer; denying"
8641 );
8642 if client
8643 .send(ParentFrame::ApprovalReply {
8644 id,
8645 approved: false,
8646 })
8647 .await
8648 .is_err()
8649 {
8650 tracing::warn!(
8651 "failed to send fail-closed approval reply; connection failing"
8652 );
8653 }
8654 }
8655 }
8656 Ok(Some(ChildFrame::SessionMessageAdmitted { confirmation })) => {
8657 if let Some(activation) = plain_input {
8658 if activation.initial_release_required { return Err(plain_actor_unsupported()); }
8659 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8660 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8661 let claim = owned_inputs.front().ok_or_else(|| plain_actor_unsupported())?;
8662 activation.confirm_input(binding, logical_session, run_id, claim, &confirmation).await?;
8663 let _ = owned_inputs.pop_front();
8664 continue;
8665 }
8666 let Some(binding) = session_inbox_runtime else {
8667 tracing::warn!(
8668 child_session_id,
8669 "ignoring SessionInbox confirmation without a runtime binding"
8670 );
8671 continue;
8672 };
8673 let Some(bound_run_id) = activation_run_id else {
8674 tracing::warn!(
8675 child_session_id,
8676 "ignoring SessionInbox confirmation without an activation owner"
8677 );
8678 continue;
8679 };
8680 let Some(claim) = inflight_claims.front() else {
8681 tracing::warn!(
8682 child_session_id,
8683 envelope_id = %confirmation.envelope_id,
8684 "rejecting stale SessionInbox confirmation with no in-flight canonical claim"
8685 );
8686 continue;
8687 };
8688 let exact = confirmation.target_session_id == logical_session.id
8689 && confirmation.envelope_id == claim.envelope.id.as_str()
8690 && confirmation.canonical_claim_generation == claim.generation
8691 && confirmation.activation_run_id == bound_run_id;
8692 if !exact
8693 || !binding
8694 .router
8695 .owns_run(&logical_session.id, bound_run_id)
8696 .await
8697 {
8698 tracing::warn!(
8699 child_session_id,
8700 expected_target = %logical_session.id,
8701 received_target = %confirmation.target_session_id,
8702 expected_envelope_id = %claim.envelope.id,
8703 received_envelope_id = %confirmation.envelope_id,
8704 expected_generation = claim.generation,
8705 received_generation = confirmation.canonical_claim_generation,
8706 expected_run_id = bound_run_id,
8707 received_run_id = %confirmation.activation_run_id,
8708 "rejecting stale or mismatched SessionInbox admission confirmation"
8709 );
8710 continue;
8711 }
8712 let claim = inflight_claims
8713 .pop_front()
8714 .expect("validated in-flight canonical claim");
8715 checkpoint_and_ack_canonical_claim(
8718 binding,
8719 logical_session,
8720 &claim,
8721 remote_claim_fence,
8722 )
8723 .await?;
8724 if inflight_claims.is_empty() {
8728 forward_next_canonical_claim(
8729 client,
8730 binding,
8731 logical_session,
8732 bound_run_id,
8733 &mut inflight_claims,
8734 remote_claim_fence,
8735 )
8736 .await?;
8737 }
8738 }
8739 Ok(Some(ChildFrame::Terminal { status, result, error, final_event_watermark, .. })) => {
8740 if local_history_tools.is_some() {
8741 validate_strict_history_terminal(
8744 final_event_watermark.as_ref(), logical_session, parent_session_id,
8745 activation_run_id, current_epoch, expected_creation,
8746 next_actor_event_seq - 1,
8747 )?;
8748 }
8749 if remote_cancel_deadline.is_some() && status == TerminalStatus::Cancelled {
8750 if let Some(tools) = local_history_tools {
8751 if permission_handshake.is_awaiting()
8755 || !owned_inputs.is_empty()
8756 || !inflight_claims.is_empty()
8757 || parent_question_checkpoint_id.is_some()
8758 || nested_wait_registered_this_run
8759 || strict_history_output.is_none()
8760 {
8761 return Err(local_tool_history_unsupported());
8762 }
8763 commit_local_tool_history(
8764 local_history.take().ok_or_else(local_tool_history_unsupported)?,
8765 logical_session,
8766 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8767 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8768 (tools, local_history_read_only),
8769 LocalToolTerminal::Cancelled,
8770 cancel_token,
8771 ).await?;
8772 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8773 logical_session,
8774 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8775 current_epoch,
8776 next_actor_event_seq - 1,
8777 ).map_err(|_| local_tool_history_unsupported())?;
8778 *strict_history_output.as_deref_mut()
8779 .ok_or_else(local_tool_history_unsupported)? = Some(StrictTerminalHistory {
8780 proof,
8781 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8782 });
8783 }
8784 client.accept_durable_terminal(status);
8790 if workflow_usage_requested {
8791 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8792 }
8793 return Err(AgentError::Cancelled);
8794 }
8795 if parent_question_checkpoint_id.is_some()
8796 && status != TerminalStatus::Suspended
8797 {
8798 return Err(AgentError::LLM(
8799 "Child question checkpoint must end in a suspended Run".into(),
8800 ));
8801 }
8802 if permission_handshake.is_awaiting() {
8803 if remote_environment_lease && status == TerminalStatus::Error {
8804 if let Some(code) = error.as_deref().and_then(remote_environment_error_code) {
8805 client.accept_durable_terminal(status);
8812 if workflow_usage_requested {
8813 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8814 }
8815 return Err(AgentError::LLM(code.to_owned()));
8816 }
8817 }
8818 return Err(AgentError::LLM(
8819 "actor terminated before permission posture confirmation"
8820 .to_string(),
8821 ));
8822 }
8823 if !owned_inputs.is_empty() {
8824 return Err(AgentError::LLM("Actor terminated before confirming its durable correction; Inbox claim is retained".into()));
8825 }
8826 if let Some(claim) = inflight_claims.front() {
8827 return Err(AgentError::LLM(format!(
8828 "actor terminated before durably admitting SessionInbox message {}; canonical claim remains recoverable",
8829 claim.envelope.id
8830 )));
8831 }
8832 if status == TerminalStatus::Completed {
8833 if let Some(collector) = local_history.take() {
8834 commit_local_tool_history(
8835 collector, logical_session,
8836 session_inbox_runtime.ok_or_else(local_tool_history_unsupported)?,
8837 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8838 (local_history_tools.ok_or_else(local_tool_history_unsupported)?, local_history_read_only),
8839 LocalToolTerminal::Completed(result.as_deref()), cancel_token,
8840 ).await?;
8841 }
8842 }
8843 if let Some(collector) = readonly.take() {
8844 if status == TerminalStatus::Completed {
8845 let activation = plain_input.ok_or_else(|| plain_actor_unsupported())?;
8846 let backlog = activation.input_inbox.inspect(&logical_session.id).await
8847 .map_err(|_| plain_actor_unsupported())?;
8848 if backlog.pending != 0 || backlog.claimed != 0 || backlog.activation_pending() {
8849 return Err(AgentError::LLM("Glob Actor continuation is unsupported; pending Inbox input is preserved".into()));
8850 }
8851 *readonly_output.as_deref_mut().ok_or_else(|| plain_actor_unsupported())? =
8852 collector.finish(result.as_deref())?;
8853 }
8854 } else if let Some(activation) = plain_input {
8855 if status == TerminalStatus::Completed {
8856 let binding = session_inbox_runtime.ok_or_else(|| plain_actor_unsupported())?;
8857 let run_id = activation_run_id.ok_or_else(|| plain_actor_unsupported())?;
8858 if continuations < continuation_limit {
8859 if let Some((claim, epoch)) = activation.continue_input(
8860 client, binding, logical_session, run_id, plain_run, &mut expected_permission_posture,
8861 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?, event_tx,
8862 ).await? {
8863 owned_inputs = VecDeque::from([claim]);
8864 released_inputs.clear();
8865 continuations += 1;
8866 current_epoch = epoch;
8867 approval_epoch.store(epoch, Ordering::Release);
8868 next_actor_event_seq = 1;
8871 canonical_router = None;
8872 permission_handshake = PermissionPostureHandshake::new(expected_permission_posture.as_ref());
8873 display = ActorEventDisplay::default();
8874 continue;
8875 }
8876 } else if activation.input_inbox.inspect(&logical_session.id).await
8877 .map_err(|_| plain_actor_unsupported())?.activation_pending() {
8878 if continuation_limit == 1 {
8879 return Err(AgentError::LLM("Only one Actor correction continuation is supported; remaining Inbox input is preserved".into()));
8880 }
8881 activation.append_reply(logical_session,
8886 result.clone().filter(|text| !text.is_empty()).ok_or_else(|| plain_actor_unsupported())?,
8887 event_tx).await?;
8888 return Err(AgentError::LLM("Actor correction continuation limit reached; completed reply committed and remaining Inbox input preserved".into()));
8889 }
8890 }
8891 }
8892 if status == TerminalStatus::Completed {
8893 if let (Some(tool), Some(creation)) =
8898 (canonical_subagent_tool.as_deref(), expected_creation)
8899 {
8900 let (Some(store), Some(binding), Some(run_id)) =
8901 (actor_directory_store, session_inbox_runtime,
8902 activation_run_id)
8903 else {
8904 return Err(AgentError::LLM(
8905 "nested SubAgent Host authority unavailable at Child terminal"
8906 .into(),
8907 ));
8908 };
8909 let must_suspend = ensure_active_nested_wait(
8910 tool, store, binding, run_id, child_session_id,
8911 parent_session_id, logical_session, creation, plain_input,
8912 ).await?;
8913 if must_suspend || nested_wait_registered_this_run {
8914 logical_session.metadata.insert(
8921 "runtime.actor_nested_wait_handoff".into(),
8922 "true".into(),
8923 );
8924 }
8925 }
8926 }
8927 let terminal_result = match status {
8928 TerminalStatus::Completed => Ok(result),
8929 TerminalStatus::Cancelled => Err(AgentError::Cancelled),
8930 TerminalStatus::Error => Err(AgentError::LLM(
8931 error.unwrap_or_else(|| "actor child errored".to_string()),
8932 )),
8933 TerminalStatus::Suspended => {
8934 use bamboo_agent_core::storage::Storage;
8935 let (Some(request_id), Some(store), Some(binding), Some(run_id), Some(creation)) =
8936 (parent_question_checkpoint_id.as_ref(), actor_directory_store,
8937 session_inbox_runtime, activation_run_id, expected_creation)
8938 else {
8939 return Err(AgentError::LLM("uncheckpointed Child suspension rejected".into()));
8940 };
8941 if load_active_subagent_caller(
8942 store, binding, run_id, child_session_id,
8943 parent_session_id, logical_session, creation, plain_input,
8944 ).await.is_none() {
8945 return Err(AgentError::LLM("stale Child question suspension rejected".into()));
8946 }
8947 let id = bamboo_domain::SessionMessageId::parse(request_id.clone())
8948 .map_err(|_| AgentError::LLM("invalid Child question receipt".into()))?;
8949 let child = store.load_session(child_session_id).await
8950 .map_err(|_| AgentError::LLM("canonical Child question unavailable".into()))?
8951 .ok_or_else(|| AgentError::LLM("canonical Child question missing".into()))?;
8952 let parent = store.load_session(parent_session_id).await
8953 .map_err(|_| AgentError::LLM("canonical direct parent unavailable".into()))?
8954 .ok_or_else(|| AgentError::LLM("canonical direct parent missing".into()))?;
8955 let pending = ParentQuestion::for_pending(&parent, &child)
8956 .is_some_and(|question| question.id == id);
8957 let answered = bamboo_domain::ParentQuestionResolution::from_child(
8958 &parent, &child, &id,
8959 ).is_some();
8960 if !pending && !answered {
8961 return Err(AgentError::LLM("canonical Child question proof changed".into()));
8962 }
8963 logical_session.metadata.insert(
8964 "runtime.actor_parent_question_handoff".into(), "true".into(),
8965 );
8966 Ok(None)
8967 }
8968 };
8969 if local_history_tools.is_some() {
8970 if status != TerminalStatus::Completed {
8974 return terminal_result;
8975 }
8976 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
8977 logical_session,
8978 activation_run_id.ok_or_else(local_tool_history_unsupported)?,
8979 current_epoch,
8980 next_actor_event_seq - 1,
8981 ).map_err(|_| local_tool_history_unsupported())?;
8982 let history = StrictTerminalHistory {
8983 proof,
8984 watermark: final_event_watermark.ok_or_else(local_tool_history_unsupported)?,
8985 };
8986 if let Some(output) = strict_history_output.as_deref_mut() {
8987 *output = Some(history);
8988 }
8989 }
8990 client.accept_durable_terminal(status);
8997 if workflow_usage_requested {
8998 logical_session.metadata.insert(bamboo_subagent::proto::WORKFLOW_TERMINAL_OBSERVATION_KEY.into(), activation_run_id.ok_or_else(plain_actor_unsupported)?.into());
8999 }
9000 if status == TerminalStatus::Completed {
9001 if let Some(activation) = plain_input {
9002 *activation.completion_router.lock().recover_poison() =
9005 canonical_router.take();
9006 }
9007 }
9008 return terminal_result;
9009 }
9010 Ok(None) => {
9011 return Err(AgentError::LLM(
9012 "actor child closed before terminal".to_string(),
9013 ));
9014 }
9015 Err(e) => {
9016 return Err(AgentError::LLM(format!("actor transport error: {e}")));
9017 }
9018 }
9019 }
9020 }
9021 }
9022
9023 let _ = client.send(ParentFrame::Cancel).await;
9025 Err(AgentError::Cancelled)
9026}
9027
9028fn project_id_for_actor_run(
9030 session: &Session,
9031) -> Result<Option<bamboo_domain::ProjectId>, AgentError> {
9032 match crate::project_context::ProjectContextResolver::session_project_identity(session) {
9033 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
9034 Ok(Some(project_id))
9035 }
9036 crate::project_context::SessionProjectIdentity::Unassigned => Ok(None),
9037 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
9038 Err(AgentError::LLM(format!(
9039 "child session carries an invalid Project identity '{raw}': {message}"
9040 )))
9041 }
9042 }
9043}
9044
9045fn remote_broker_unavailable() -> AgentError {
9046 AgentError::LLM("remote_broker_activation_unavailable".into())
9047}
9048
9049fn remote_environment_error_code(value: &str) -> Option<&'static str> {
9053 const CODES: &[&str] = &[
9054 "remote_environment_lease_invalid",
9055 "remote_environment_snapshot_mismatch",
9056 "remote_environment_checkout_not_clean",
9057 "remote_environment_checkout_unsupported",
9058 "remote_environment_workspace_unavailable",
9059 "remote_environment_workspace_outside_git_root",
9060 "remote_environment_path_unsupported",
9061 "remote_environment_git_unavailable",
9062 "remote_environment_git_inspection_failed",
9063 "remote_environment_git_inspection_timed_out",
9064 "remote_environment_git_output_too_large",
9065 "remote_environment_snapshot_too_large",
9066 "remote_environment_snapshot_changed",
9067 "remote_environment_index_invalid",
9068 "remote_environment_index_unsupported",
9069 "remote_environment_host_path_or_grants_rejected",
9070 "remote_environment_worker_workspace_missing",
9071 "remote_environment_runspec_invalid",
9072 "remote_environment_lease_missing_or_host_path_present",
9073 ];
9074 CODES.iter().copied().find(|code| *code == value)
9075}
9076
9077async fn remote_canonical_lineage(
9080 binding: &SessionInboxRuntimeBinding,
9081 session: &Session,
9082) -> Result<Vec<bamboo_domain::ActorSession>, AgentError> {
9083 let mut current = bamboo_domain::ActorSession::from_session(session)
9084 .map_err(|_| remote_broker_unavailable())?;
9085 let mut chain = Vec::new();
9086 loop {
9087 if chain.len() > session.spawn_depth as usize {
9088 return Err(remote_broker_unavailable());
9089 }
9090 let saved = binding
9091 .storage
9092 .load_session(¤t.actor_id)
9093 .await
9094 .map_err(|_| remote_broker_unavailable())?
9095 .ok_or_else(remote_broker_unavailable)?;
9096 let observed = bamboo_domain::ActorSession::from_session(&saved)
9097 .map_err(|_| remote_broker_unavailable())?;
9098 if observed.actor_id != current.actor_id
9099 || observed.session_created_at != current.session_created_at
9100 || observed.parent_actor_id != current.parent_actor_id
9101 || observed.root_actor_id != current.root_actor_id
9102 || observed.spawn_depth != current.spawn_depth
9103 || observed.project_id != current.project_id
9104 {
9105 return Err(remote_broker_unavailable());
9106 }
9107 chain.push(observed.clone());
9108 let Some(parent_id) = observed.parent_actor_id.as_ref() else {
9109 if observed.actor_id != session.root_session_id || observed.spawn_depth != 0 {
9110 return Err(remote_broker_unavailable());
9111 }
9112 return Ok(chain);
9113 };
9114 let parent = binding
9115 .storage
9116 .load_session(parent_id)
9117 .await
9118 .map_err(|_| remote_broker_unavailable())?
9119 .ok_or_else(remote_broker_unavailable)?;
9120 let parent = bamboo_domain::ActorSession::from_session(&parent)
9121 .map_err(|_| remote_broker_unavailable())?;
9122 if parent.root_actor_id != observed.root_actor_id
9123 || parent.project_id != observed.project_id
9124 || parent.spawn_depth.checked_add(1) != Some(observed.spawn_depth)
9125 || parent.session_created_at > observed.session_created_at
9126 {
9127 return Err(remote_broker_unavailable());
9128 }
9129 current = parent;
9130 }
9131}
9132
9133async fn canonical_child_creation(
9134 binding: &SessionInboxRuntimeBinding,
9135 session: &Session,
9136 job: &SpawnJob,
9137) -> Result<bamboo_subagent::proto::ChildCreationIdentity, AgentError> {
9138 let conflict = || {
9139 AgentError::LLM(
9140 "child creation identity unavailable or changed in canonical host Storage".into(),
9141 )
9142 };
9143 let current = binding
9144 .storage
9145 .load_session(&job.child_session_id)
9146 .await
9147 .map_err(|_| conflict())?
9148 .ok_or_else(conflict)?;
9149 if bamboo_domain::ActorSession::from_session(¤t).is_err()
9150 || bamboo_domain::ActorSession::from_session(session).is_err()
9151 || current.kind != bamboo_domain::SessionKind::Child
9152 || session.kind != current.kind
9153 || current.id != session.id
9154 || current.id != job.child_session_id
9155 || current.parent_session_id.as_deref() != Some(job.parent_session_id.as_str())
9156 || current.parent_session_id != session.parent_session_id
9157 || current.root_session_id.trim().is_empty()
9158 || current.root_session_id != session.root_session_id
9159 || current.created_at != session.created_at
9160 || current.spawn_depth != session.spawn_depth
9161 || current.spawn_depth == 0
9162 || project_id_for_actor_run(¤t)? != project_id_for_actor_run(session)?
9163 {
9164 return Err(conflict());
9165 }
9166 Ok(bamboo_subagent::proto::ChildCreationIdentity {
9167 created_at: current.created_at,
9168 spawn_depth: current.spawn_depth,
9169 })
9170}
9171
9172fn logical_identity_for_actor_run(session: &Session, job: &SpawnJob) -> LogicalSessionIdentity {
9173 LogicalSessionIdentity {
9174 creation: None,
9175 session_id: session.id.clone(),
9176 parent_session_id: session
9177 .parent_session_id
9178 .clone()
9179 .or_else(|| Some(job.parent_session_id.clone())),
9180 root_session_id: if session.root_session_id.trim().is_empty() {
9181 job.parent_session_id.clone()
9182 } else {
9183 session.root_session_id.clone()
9184 },
9185 }
9186}
9187
9188fn extract_assignment(session: &Session) -> String {
9189 session
9190 .messages
9191 .iter()
9192 .rev()
9193 .find(|m| matches!(m.role, Role::User))
9194 .map(|m| m.content.clone())
9195 .unwrap_or_else(|| {
9196 session
9197 .metadata
9198 .get("title")
9199 .cloned()
9200 .unwrap_or_else(|| "Execute task".to_string())
9201 })
9202}
9203
9204#[cfg(test)]
9205mod tests {
9206 use super::*;
9207 use crate::SessionActivationRouter;
9208 use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort, Storage};
9209
9210 include!("expired_plain_owner_tests.rs");
9211
9212 include!("owned_cold_input_tests.rs");
9213
9214 #[test]
9215 fn plain_rejection_logs_branch_and_run_without_changing_error() {
9216 let capture_interest_guard = tracing::Dispatch::new(
9218 tracing_subscriber::fmt()
9219 .with_max_level(tracing_subscriber::filter::LevelFilter::OFF)
9220 .with_writer(std::io::sink)
9221 .finish(),
9222 );
9223 let output = tempfile::NamedTempFile::new().unwrap();
9224 let subscriber = tracing_subscriber::fmt()
9225 .with_ansi(false)
9226 .without_time()
9227 .with_writer(output.reopen().unwrap())
9228 .finish();
9229 let branch_line = std::cell::Cell::new(0);
9230 let error = tracing::subscriber::with_default(subscriber, || {
9231 std::thread::spawn(|| drop(plain_actor_unsupported()))
9233 .join()
9234 .expect("unscoped rejection warning thread panicked");
9235 let span = tracing::warn_span!(
9236 "actor_child_run",
9237 child_session_id = %"diagnostic-child",
9238 activation_run_id = tracing::field::Empty
9239 );
9240 let _entered = span.enter();
9241 span.record("activation_run_id", "diagnostic-run");
9242 None::<()>
9243 .ok_or_else(|| {
9244 let caller_line = line!() + 1;
9245 let error = plain_actor_unsupported();
9246 branch_line.set(caller_line);
9247 error
9248 })
9249 .unwrap_err()
9250 });
9251 assert!(matches!(&error, AgentError::LLM(message)
9252 if message.starts_with("This Actor Child supports a fresh plain activation,")
9253 && message.ends_with("Other continuation is unsupported; durable history is preserved.")));
9254 let log = std::fs::read_to_string(output.path()).unwrap();
9255 assert!(log.contains("child_session_id=diagnostic-child"), "{log}");
9256 assert!(
9257 log.contains("activation_run_id=\"diagnostic-run\""),
9258 "{log}"
9259 );
9260 assert!(
9261 log.contains(&format!("process_id={}", std::process::id())),
9262 "{log}"
9263 );
9264 assert!(log.contains(&format!("source_file={:?}", file!())), "{log}");
9265 assert!(
9266 log.contains(&format!("source_line={}", branch_line.get())),
9267 "{log}"
9268 );
9269 assert!(!error.to_string().contains("diagnostic-run"));
9270 drop(capture_interest_guard);
9271 }
9272
9273 #[tokio::test]
9274 async fn completed_plain_continuation_requires_bounded_inputs_and_current_identity() {
9275 for case in [
9276 "valid",
9277 "over-batch-limit",
9278 "tool-history",
9279 "stale-lineage",
9280 "no-release",
9281 ] {
9282 let temp = tempfile::tempdir().unwrap();
9283 let store = Arc::new(
9284 bamboo_storage::SessionStoreV2::new(temp.path().into())
9285 .await
9286 .unwrap(),
9287 );
9288 let parent = Session::new("continuation-parent", "model");
9289 store.save_session(&parent).await.unwrap();
9290 let mut child =
9291 Session::new_child_of("continuation-child", &parent, "model", "first task");
9292 child.add_message(bamboo_agent_core::Message::assistant("first result", None));
9293 if case == "tool-history" {
9294 let mut tool = bamboo_agent_core::Message::assistant("old tool result", None);
9295 tool.role = Role::Tool;
9296 child.add_message(tool);
9297 }
9298 child.set_last_run_status("completed");
9299 store.save_session(&child).await.unwrap();
9300 store.ensure_actor(&child.id).await.unwrap();
9301 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
9302 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
9303 let now = chrono::Utc::now();
9304 let old = store
9305 .claim_activation(&ActorActivationClaim {
9306 actor_id: child.id.clone(),
9307 run_id: "first-run".into(),
9308 lease_owner: "first-owner".into(),
9309 lease_expires_at: now + chrono::Duration::minutes(1),
9310 inbox_generation: 0,
9311 placement_ref: None,
9312 now,
9313 })
9314 .await
9315 .unwrap();
9316 store.start_activation(&old.fence(), now).await.unwrap();
9317 store
9318 .finish_activation(&old.fence(), now, ActorActivationFinish::Succeeded)
9319 .await
9320 .unwrap();
9321 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
9322 store.clone(),
9323 Default::default(),
9324 ));
9325 for slot in 0..if case == "over-batch-limit" {
9326 bamboo_domain::SessionInboxLimits::default().max_claim_batch + 1
9327 } else {
9328 1
9329 } {
9330 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
9331 &child.id,
9332 format!("new task {slot}"),
9333 );
9334 let receipt = inbox.deliver(&envelope).await.unwrap();
9335 inbox
9336 .mark_activation_eligible(
9337 &child.id,
9338 receipt.generation,
9339 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
9340 )
9341 .await
9342 .unwrap();
9343 }
9344 let storage: Arc<dyn Storage> = store.clone();
9345 let persistence = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
9346 let binding = actor_binding(store.clone(), inbox, persistence);
9347 let _registration = binding
9348 .router
9349 .register_run(&child.id, "second-run")
9350 .await
9351 .unwrap();
9352 if case == "stale-lineage" {
9353 child.parent_session_id = Some("foreign-parent".into());
9354 }
9355 let result = PlainActorActivation::start(
9356 store.clone(),
9357 &child,
9358 &binding,
9359 Some("second-run"),
9360 Some(policy),
9361 true,
9362 (case != "no-release").then_some("second-worker"),
9363 )
9364 .await;
9365 if case == "valid" {
9366 let activation = result.expect("one new input on a completed plain Child");
9367 assert_eq!(activation.fence.attempt, old.attempt + 1);
9368 assert_eq!(activation.fence.actor_id, child.id);
9369 assert_eq!(activation.inbox_generation, 1);
9370 activation
9371 .finish(ActorActivationFinish::Failed)
9372 .await
9373 .unwrap();
9374 } else {
9375 assert!(
9376 result.is_err(),
9377 "unsupported continuation {case} must fail before claim"
9378 );
9379 assert_eq!(
9380 store
9381 .inspect_actor(&child.id)
9382 .await
9383 .unwrap()
9384 .actor
9385 .current_attempt,
9386 old.attempt
9387 );
9388 }
9389 let canonical = store.load_session(&child.id).await.unwrap().unwrap();
9390 assert_eq!(
9391 canonical.parent_session_id.as_deref(),
9392 Some(parent.id.as_str())
9393 );
9394 assert!(canonical
9395 .messages
9396 .iter()
9397 .any(|message| message.content == "first result"));
9398 }
9399 }
9400
9401 #[tokio::test]
9402 async fn pre_dispatch_placement_failure_fails_only_claimed_activation() {
9403 let temp = tempfile::tempdir().unwrap();
9404 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9405 .await
9406 .unwrap();
9407 let parent = Session::new("placement-parent", "model");
9408 let child = Session::new_child_of("placement-child", &parent, "model", "task");
9409 store.save_session(&parent).await.unwrap();
9410 store.save_session(&child).await.unwrap();
9411 store.ensure_actor(&child.id).await.unwrap();
9412 store
9413 .bind_control_plane(
9414 &child.id,
9415 bamboo_domain::ActorControlPlaneBinding {
9416 policy_revision: None,
9417 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9418 pool_id: "remote-worker".into(),
9419 }),
9420 },
9421 )
9422 .await
9423 .unwrap();
9424
9425 let claim = |run_id: &str| ActorActivationClaim {
9426 actor_id: child.id.clone(),
9427 run_id: run_id.into(),
9428 lease_owner: format!("host-{run_id}"),
9429 lease_expires_at: chrono::Utc::now() + chrono::Duration::seconds(80),
9430 inbox_generation: 0,
9431 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9432 class: bamboo_domain::ActorPlacementClass::Remote,
9433 lease_id: format!("slot-{run_id}"),
9434 slot_epoch: Some(1),
9435 }),
9436 now: chrono::Utc::now(),
9437 };
9438 let first = store.claim_activation(&claim("first")).await.unwrap();
9439 store
9440 .start_activation(&first.fence(), chrono::Utc::now())
9441 .await
9442 .unwrap();
9443 assert!(store.claim_activation(&claim("retry")).await.is_err());
9444
9445 fail_pre_dispatch_activation(&store, &first.fence())
9448 .await
9449 .unwrap();
9450 let failed = store.inspect_actor(&child.id).await.unwrap();
9451 assert_eq!(
9452 failed.activation.unwrap().status,
9453 bamboo_domain::ActorActivationStatus::Failed
9454 );
9455 let retry = store.claim_activation(&claim("retry")).await.unwrap();
9456 assert_eq!(retry.attempt, first.attempt + 1);
9457 assert!(fail_pre_dispatch_activation(&store, &first.fence())
9458 .await
9459 .is_err());
9460 assert_eq!(
9461 store.inspect_actor(&child.id).await.unwrap().activation,
9462 Some(retry)
9463 );
9464 }
9465
9466 #[tokio::test]
9467 async fn lost_run_delivery_receipt_keeps_actor_activation_live() {
9468 struct AcceptedWithoutReceipt {
9469 published: bool,
9470 }
9471
9472 #[async_trait]
9473 impl bamboo_subagent::ChildLink for AcceptedWithoutReceipt {
9474 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9475 assert!(matches!(frame, ParentFrame::Run(_)));
9476 self.published = true;
9477 Err(bamboo_subagent::TransportError::Protocol(
9478 "broker delivery receipt lost after publication".into(),
9479 ))
9480 }
9481
9482 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9483 std::future::pending().await
9484 }
9485 }
9486
9487 let temp = tempfile::tempdir().unwrap();
9488 let store = Arc::new(
9489 bamboo_storage::SessionStoreV2::new(temp.path().into())
9490 .await
9491 .unwrap(),
9492 );
9493 let parent = Session::new("dispatch-parent", "model");
9494 let child = Session::new_child_of("dispatch-child", &parent, "model", "task");
9495 store.save_session(&parent).await.unwrap();
9496 store.save_session(&child).await.unwrap();
9497 store.ensure_actor(&child.id).await.unwrap();
9498 let now = chrono::Utc::now();
9499 let claim = ActorActivationClaim {
9500 actor_id: child.id.clone(),
9501 run_id: "dispatch-run".into(),
9502 lease_owner: "dispatch-host".into(),
9503 lease_expires_at: now + chrono::Duration::seconds(80),
9504 inbox_generation: 0,
9505 placement_ref: None,
9506 now,
9507 };
9508 let activation = store.claim_activation(&claim).await.unwrap();
9509 store
9510 .start_activation(&activation.fence(), chrono::Utc::now())
9511 .await
9512 .unwrap();
9513 let mut link = AcceptedWithoutReceipt { published: false };
9514 let mut send_invoked = false;
9515 let run = RunSpec {
9516 assignment: "task".into(),
9517 logical_session: None,
9518 project_id: None,
9519 reasoning_effort: None,
9520 permission_policy: None,
9521 messages: Vec::new(),
9522 activation_run_id: Some(claim.run_id.clone()),
9523 execution_epoch: 1,
9524 initial_session_messages: Vec::new(),
9525 secrets: Default::default(),
9526 };
9527 assert!(send_actor_run(&mut link, run, &mut send_invoked)
9528 .await
9529 .is_err());
9530 assert!(send_invoked && link.published);
9531 let slot = WorkerSlotLease {
9532 lease_id: "dispatch-slot".into(),
9533 host_ref: "dispatch-worker".into(),
9534 connection_generation: "dispatch-generation".into(),
9535 slot: 0,
9536 epoch: 1,
9537 actor_id: child.id.clone(),
9538 run_id: claim.run_id.clone(),
9539 expires_at: claim.lease_expires_at,
9540 };
9541 let mut lease = ActivePlacementLease {
9542 registry: bamboo_storage::v2::FileHostRegistry::new(temp.path().into())
9543 .await
9544 .unwrap(),
9545 actor_store: store.clone(),
9546 route: ScopedPlacementRoute {
9547 endpoint: String::new(),
9548 token: String::new(),
9549 ca_cert_file: PathBuf::new(),
9550 parent: bamboo_subagent::AgentRef {
9551 session_id: "dispatch-parent".into(),
9552 role: Some("host".into()),
9553 },
9554 worker_role: "worker".into(),
9555 pinned_mailbox: None,
9556 requirements: Default::default(),
9557 },
9558 mailbox: "dispatch-worker".into(),
9559 host_ref: "dispatch-worker".into(),
9560 fence: activation.fence(),
9561 placement_ref: bamboo_domain::ActorPlacementRef {
9562 class: bamboo_domain::ActorPlacementClass::Remote,
9563 lease_id: slot.lease_id.clone(),
9564 slot_epoch: Some(slot.epoch),
9565 },
9566 lease: Arc::new(tokio::sync::Mutex::new(slot)),
9567 heartbeat: tokio::spawn(std::future::pending()),
9568 run_cancel: CancellationToken::new(),
9569 lost: Arc::new(AtomicBool::new(false)),
9570 release_on_drop: AtomicBool::new(false),
9571 };
9572 assert!(!lease.abort_if_run_unsent(send_invoked).await.unwrap());
9573 assert_eq!(
9574 store
9575 .inspect_actor(&child.id)
9576 .await
9577 .unwrap()
9578 .activation
9579 .unwrap()
9580 .status,
9581 bamboo_domain::ActorActivationStatus::Running
9582 );
9583 let mut retry = claim.clone();
9584 retry.run_id = "dispatch-retry".into();
9585 retry.now = chrono::Utc::now();
9586 retry.lease_expires_at = retry.now + chrono::Duration::seconds(80);
9587 assert!(matches!(
9588 store.claim_activation(&retry).await,
9589 Err(bamboo_domain::ActorDirectoryError::Busy)
9590 ));
9591 }
9592
9593 #[test]
9594 fn accepted_cancelled_terminal_maps_to_cancelled_actor_activation() {
9595 let cancelled: Result<Option<String>, AgentError> = Err(AgentError::Cancelled);
9596 assert_eq!(
9597 remote_activation_finish(&cancelled, Some(TerminalStatus::Cancelled)),
9598 ActorActivationFinish::Cancelled
9599 );
9600 assert_eq!(
9601 remote_activation_finish(&cancelled, None),
9602 ActorActivationFinish::Failed
9603 );
9604 assert_eq!(
9605 remote_activation_finish(&cancelled, Some(TerminalStatus::Completed)),
9606 ActorActivationFinish::Failed
9607 );
9608 }
9609
9610 #[tokio::test]
9611 async fn remote_reply_is_fenced_before_terminal_status_save() {
9612 let temp = tempfile::tempdir().unwrap();
9613 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
9614 .await
9615 .unwrap();
9616 let parent = Session::new("remote-reply-parent", "model");
9617 let mut child = Session::new_child_of("remote-reply-child", &parent, "model", "task");
9618 child.add_message(bamboo_agent_core::Message::user("task"));
9619 store.save_session(&parent).await.unwrap();
9620 store.save_session(&child).await.unwrap();
9621 store.ensure_actor(&child.id).await.unwrap();
9622 store
9623 .bind_control_plane(
9624 &child.id,
9625 bamboo_domain::ActorControlPlaneBinding {
9626 policy_revision: None,
9627 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Pool {
9628 pool_id: "remote-worker".into(),
9629 }),
9630 },
9631 )
9632 .await
9633 .unwrap();
9634 let now = chrono::Utc::now();
9635 let activation = store
9636 .claim_activation(&ActorActivationClaim {
9637 actor_id: child.id.clone(),
9638 run_id: "remote-reply-run".into(),
9639 lease_owner: "remote-worker".into(),
9640 lease_expires_at: now + chrono::Duration::seconds(80),
9641 inbox_generation: 0,
9642 placement_ref: Some(bamboo_domain::ActorPlacementRef {
9643 class: bamboo_domain::ActorPlacementClass::Remote,
9644 lease_id: "remote-slot".into(),
9645 slot_epoch: Some(1),
9646 }),
9647 now,
9648 })
9649 .await
9650 .unwrap();
9651 let fence = activation.fence();
9652 store.start_activation(&fence, now).await.unwrap();
9653
9654 append_fenced_remote_reply(&store, &fence, &mut child, "done")
9655 .await
9656 .unwrap();
9657 store
9658 .finish_activation(&fence, chrono::Utc::now(), ActorActivationFinish::Succeeded)
9659 .await
9660 .unwrap();
9661 child.set_last_run_status("completed");
9662 store.save_session(&child).await.unwrap();
9663 let saved = store.load_session(&child.id).await.unwrap().unwrap();
9664 assert_eq!(saved.last_run_status().as_deref(), Some("completed"));
9665 assert_eq!(saved.messages.len(), 2);
9666 assert_eq!(saved.messages.last().unwrap().content, "done");
9667 assert!(
9668 append_fenced_remote_reply(&store, &fence, &mut child, "duplicate")
9669 .await
9670 .is_err()
9671 );
9672 assert_eq!(
9673 store
9674 .load_session(&child.id)
9675 .await
9676 .unwrap()
9677 .unwrap()
9678 .messages
9679 .len(),
9680 2
9681 );
9682 }
9683
9684 async fn start_bus() -> (String, tempfile::TempDir) {
9685 let dir = tempfile::tempdir().unwrap();
9686 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
9687 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
9688 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9689 let addr = listener.local_addr().unwrap();
9690 tokio::spawn(async move {
9691 let _ = server.serve(listener).await;
9692 });
9693 (format!("ws://{addr}"), dir)
9694 }
9695
9696 struct DurableAckProbe {
9697 calls: Arc<AtomicUsize>,
9698 fail: bool,
9699 terminal_status: TerminalStatus,
9700 parent_mailbox: String,
9701 }
9702
9703 #[async_trait]
9704 impl bamboo_subagent::ChildLink for DurableAckProbe {
9705 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
9706 Ok(())
9707 }
9708
9709 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
9710 Ok(None)
9711 }
9712
9713 fn has_pending_durable_terminal(&self) -> bool {
9714 true
9715 }
9716
9717 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
9718 Some(bamboo_subagent::DurableChildDeliveryReceipt {
9719 broker_identity: "00000000-0000-4000-8000-000000000001".into(),
9720 parent_mailbox: self.parent_mailbox.clone(),
9721 correlation_id: "broker-run-1".into(),
9722 message_ids: vec!["broker-outcome-1".into()],
9723 terminal_status: self.terminal_status,
9724 })
9725 }
9726
9727 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
9728 self.calls.fetch_add(1, Ordering::SeqCst);
9729 if self.fail {
9730 Err(bamboo_subagent::TransportError::Protocol(
9731 "injected ACK delivery failure".into(),
9732 ))
9733 } else {
9734 Ok(())
9735 }
9736 }
9737 }
9738
9739 async fn insert_ack_probe(
9740 runner: &ActorChildRunner,
9741 session: &Session,
9742 calls: &Arc<AtomicUsize>,
9743 terminal_status: TerminalStatus,
9744 ) {
9745 insert_ack_probe_with_nested_wait(runner, session, calls, terminal_status, false).await;
9746 }
9747
9748 async fn insert_ack_probe_with_nested_wait(
9749 runner: &ActorChildRunner,
9750 session: &Session,
9751 calls: &Arc<AtomicUsize>,
9752 terminal_status: TerminalStatus,
9753 host_nested_wait_handoff: bool,
9754 ) {
9755 runner.pending_durable_links.lock().await.insert(
9756 (session.id.clone(), session.created_at, "run-1".into()),
9757 PendingDurableChildLink {
9758 link: Box::new(DurableAckProbe {
9759 calls: calls.clone(),
9760 fail: false,
9761 terminal_status,
9762 parent_mailbox: format!("p-{}", session.id),
9763 }),
9764 host_nested_wait_handoff,
9765 strict_history: None,
9766 strict_history_required: false,
9767 strict_history_committed: false,
9768 },
9769 );
9770 }
9771
9772 #[tokio::test]
9773 async fn broker_receipt_reconciler_repairs_only_the_proven_local_mailbox() {
9774 let (endpoint, broker_dir) = start_bus().await;
9775 let home = tempfile::tempdir().unwrap();
9776 let store = Arc::new(
9777 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9778 .await
9779 .unwrap(),
9780 );
9781 let root = Session::new("receipt-root", "model");
9782 store.save_session(&root).await.unwrap();
9783 let mut sender = bamboo_broker::BrokerClient::connect(
9784 &endpoint,
9785 bamboo_subagent::AgentRef {
9786 session_id: "receipt-worker".into(),
9787 role: None,
9788 },
9789 "t",
9790 )
9791 .await
9792 .unwrap();
9793 let broker_identity = sender.durable_broker_identity().await.unwrap();
9794 let wrong_broker_identity = if broker_identity == "00000000-0000-4000-8000-000000000001" {
9795 "00000000-0000-4000-8000-000000000002"
9796 } else {
9797 "00000000-0000-4000-8000-000000000001"
9798 };
9799
9800 for (id, receipt_mailbox, receipt_broker) in [
9801 ("receipt-good", "p-receipt-good", broker_identity.as_str()),
9802 (
9803 "receipt-wrong-mailbox",
9804 "p-another-child",
9805 broker_identity.as_str(),
9806 ),
9807 (
9808 "receipt-wrong-broker",
9809 "p-receipt-wrong-broker",
9810 wrong_broker_identity,
9811 ),
9812 (
9813 "receipt-local-role-changed",
9814 "p-receipt-local-role-changed",
9815 broker_identity.as_str(),
9816 ),
9817 (
9818 "receipt-remote-unproven",
9819 "remote-parent-unproven",
9820 broker_identity.as_str(),
9821 ),
9822 ] {
9823 let mut child = Session::new_child_of(id, &root, "model", "task");
9824 child
9825 .metadata
9826 .insert("runtime.kind".into(), "external".into());
9827 child
9828 .metadata
9829 .insert("external.protocol".into(), "actor".into());
9830 child.metadata.insert(
9831 "external.agent_id".into(),
9832 crate::external_agents::config::LOCAL_ACTOR_AGENT_ID.into(),
9833 );
9834 if id == "receipt-local-role-changed" || id == "receipt-remote-unproven" {
9835 child
9836 .metadata
9837 .insert("subagent_type".into(), "remote-worker".into());
9838 }
9839 child.add_message(bamboo_agent_core::Message::user("work"));
9840 store.save_session(&child).await.unwrap();
9841 let message_id = bamboo_subagent::MsgId::new();
9842 sender
9843 .deliver(
9844 &format!("p-{id}"),
9845 bamboo_subagent::InboxMessage {
9846 id: message_id.clone(),
9847 from: bamboo_subagent::AgentRef {
9848 session_id: "receipt-worker".into(),
9849 role: None,
9850 },
9851 kind: bamboo_subagent::InboxKind::Outcome,
9852 body: serde_json::to_value(bamboo_subagent::ChildOutcome::completed(
9853 "done",
9854 ))
9855 .unwrap(),
9856 created_at: chrono::Utc::now(),
9857 correlation_id: Some(bamboo_subagent::MsgId::new()),
9858 },
9859 )
9860 .await
9861 .unwrap();
9862 let mailbox =
9865 bamboo_subagent::Mailbox::at(broker_dir.path().join(format!("mailboxes/p-{id}")));
9866 assert_eq!(mailbox.drain().await.unwrap().len(), 1);
9867 child.add_message(bamboo_agent_core::Message::assistant("done", None));
9868 child.set_last_run_status("completed");
9869 if id == "receipt-good" {
9870 let prior_ids = child.messages[..child.messages.len() - 1]
9871 .iter()
9872 .map(|message| message.id.clone())
9873 .collect();
9874 crate::execution::ChildCompletionSource::prepare(&mut child, "run-1", &prior_ids);
9875 assert!(
9876 crate::execution::ChildCompletionSource::defer_for_durable_delivery(
9877 &mut child, "run-1"
9878 )
9879 .unwrap()
9880 );
9881 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
9882 &child, "run-1", 7, 1,
9883 )
9884 .unwrap();
9885 store
9886 .prepare_broker_terminal_receipt_with_completeness(
9887 &child,
9888 bamboo_storage::v2::BrokerTerminalRoute {
9889 activation_run_id: "run-1",
9890 broker_identity: receipt_broker,
9891 parent_mailbox: receipt_mailbox,
9892 broker_correlation_id: "correlation-1",
9893 message_ids: &[message_id.as_str().to_owned()],
9894 },
9895 &proof,
9896 )
9897 .await
9898 .unwrap();
9899 let mut staged = child.clone();
9900 staged.set_last_run_status("running");
9901 staged.clear_last_run_error();
9902 store.save_session(&staged).await.unwrap();
9903 store
9904 .commit_broker_terminal_receipt_with_completeness(&child, "run-1", &proof)
9905 .await
9906 .unwrap();
9907 } else {
9908 store
9909 .prepare_broker_terminal_receipt(
9910 &child,
9911 "run-1",
9912 receipt_broker,
9913 receipt_mailbox,
9914 "correlation-1",
9915 &[message_id.as_str().to_owned()],
9916 )
9917 .await
9918 .unwrap();
9919 store.save_session(&child).await.unwrap();
9920 }
9921 }
9922
9923 let mut config = bamboo_config::Config::default();
9924 config.subagents_mut().broker = Some(bamboo_config::BrokerClientConfig {
9925 endpoint,
9926 token: "t".into(),
9927 ..Default::default()
9928 });
9929 config
9930 .subagents_mut()
9931 .remote_placements
9932 .push(bamboo_config::RemoteActorPlacement {
9933 role: "remote-worker".into(),
9934 endpoint: "ws://unscoped.example.invalid".into(),
9935 token_env: None,
9936 ca_cert_file: None,
9937 broker_peer: None,
9938 placement_requirements: None,
9939 });
9940 let cold_store = Arc::new(
9943 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
9944 .await
9945 .unwrap(),
9946 );
9947 let before = cold_store
9948 .load_session("receipt-good")
9949 .await
9950 .unwrap()
9951 .unwrap();
9952 assert!(crate::execution::ChildCompletionSource::has_source_record(
9953 &before
9954 ));
9955 assert_eq!(before.last_run_status().as_deref(), Some("running"));
9956 assert!(crate::execution::ChildCompletionSource::from_committed_session(&before).is_none());
9957 let repair = BrokerTerminalReceiptReconciler::new(cold_store.clone(), &config);
9958 let report = repair.reconcile_once().await;
9959 assert_eq!(report.candidates, 5);
9960 assert_eq!(report.repaired, 2);
9961 assert_eq!(report.blocked, 3);
9962 let after = cold_store
9963 .load_session("receipt-good")
9964 .await
9965 .unwrap()
9966 .unwrap();
9967 assert!(crate::execution::ChildCompletionSource::from_committed_session(&after).is_some());
9968 assert_eq!(
9969 serde_json::to_value(before.messages).unwrap(),
9970 serde_json::to_value(after.messages).unwrap()
9971 );
9972 let retry = repair.reconcile_once().await;
9973 assert_eq!(retry.repaired, 0, "committed strict recovery is idempotent");
9974
9975 let good = bamboo_subagent::Mailbox::at(broker_dir.path().join("mailboxes/p-receipt-good"));
9976 let wrong = bamboo_subagent::Mailbox::at(
9977 broker_dir.path().join("mailboxes/p-receipt-wrong-mailbox"),
9978 );
9979 let wrong_broker = bamboo_subagent::Mailbox::at(
9980 broker_dir.path().join("mailboxes/p-receipt-wrong-broker"),
9981 );
9982 let local_role_changed = bamboo_subagent::Mailbox::at(
9983 broker_dir
9984 .path()
9985 .join("mailboxes/p-receipt-local-role-changed"),
9986 );
9987 let remote_unproven = bamboo_subagent::Mailbox::at(
9988 broker_dir
9989 .path()
9990 .join("mailboxes/p-receipt-remote-unproven"),
9991 );
9992 assert_eq!(good.pending_count().await.unwrap(), 0);
9993 assert_eq!(wrong.pending_count().await.unwrap(), 1);
9994 assert_eq!(wrong_broker.pending_count().await.unwrap(), 1);
9995 assert_eq!(local_role_changed.pending_count().await.unwrap(), 0);
9996 assert_eq!(remote_unproven.pending_count().await.unwrap(), 1);
9997 let good_child = store.load_session("receipt-good").await.unwrap().unwrap();
9998 let wrong_child = store
9999 .load_session("receipt-wrong-mailbox")
10000 .await
10001 .unwrap()
10002 .unwrap();
10003 let wrong_broker_child = store
10004 .load_session("receipt-wrong-broker")
10005 .await
10006 .unwrap()
10007 .unwrap();
10008 let local_role_changed_child = store
10009 .load_session("receipt-local-role-changed")
10010 .await
10011 .unwrap()
10012 .unwrap();
10013 let remote_unproven_child = store
10014 .load_session("receipt-remote-unproven")
10015 .await
10016 .unwrap()
10017 .unwrap();
10018 assert!(store
10019 .recover_broker_terminal_receipts(&good_child)
10020 .await
10021 .unwrap()
10022 .is_empty());
10023 assert_eq!(
10024 store
10025 .recover_broker_terminal_receipts(&wrong_child)
10026 .await
10027 .unwrap()
10028 .len(),
10029 1
10030 );
10031 assert_eq!(
10032 store
10033 .recover_broker_terminal_receipts(&wrong_broker_child)
10034 .await
10035 .unwrap()
10036 .len(),
10037 1
10038 );
10039 assert!(store
10040 .recover_broker_terminal_receipts(&local_role_changed_child)
10041 .await
10042 .unwrap()
10043 .is_empty());
10044 assert_eq!(
10045 store
10046 .recover_broker_terminal_receipts(&remote_unproven_child)
10047 .await
10048 .unwrap()
10049 .len(),
10050 1
10051 );
10052 }
10053
10054 #[tokio::test]
10055 async fn broker_receipt_confirmation_requires_exact_birth_run_and_committed_status() {
10056 let runner = ActorChildRunner::new(
10057 "test".into(),
10058 PathBuf::new(),
10059 vec![],
10060 PathBuf::new(),
10061 ExecutorSpec::BambooRuntime,
10062 vec![],
10063 "test".into(),
10064 1,
10065 );
10066 let home = tempfile::tempdir().unwrap();
10067 let store = Arc::new(
10068 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10069 .await
10070 .unwrap(),
10071 );
10072 let root = Session::new("ack-parent", "model");
10073 store.save_session(&root).await.unwrap();
10074 let mut child = Session::new_child_of("ack-child", &root, "model", "task");
10075 child.add_message(bamboo_agent_core::Message::user("work"));
10076 store.save_session(&child).await.unwrap();
10077 child.add_message(bamboo_agent_core::Message::assistant("done", None));
10078 child.set_last_run_status("completed");
10079 store
10080 .prepare_broker_terminal_receipt(
10081 &child,
10082 "run-1",
10083 "00000000-0000-4000-8000-000000000001",
10084 "p-ack-child",
10085 "broker-run-1",
10086 &["broker-outcome-1".into()],
10087 )
10088 .await
10089 .unwrap();
10090 store.save_session(&child).await.unwrap();
10091 runner.set_actor_directory_store(Some(store));
10092 let calls = Arc::new(AtomicUsize::new(0));
10093 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10094 runner
10095 .confirm_durable_child_delivery(&child, "wrong-run", true)
10096 .await
10097 .unwrap();
10098 let mut wrong_birth = child.clone();
10099 wrong_birth.created_at += chrono::Duration::seconds(1);
10100 runner
10101 .confirm_durable_child_delivery(&wrong_birth, "run-1", true)
10102 .await
10103 .unwrap();
10104 assert_eq!(runner.pending_durable_links.lock().await.len(), 1);
10105 child.set_last_run_status("completed");
10106 runner
10107 .confirm_durable_child_delivery(&child, "run-1", false)
10108 .await
10109 .unwrap();
10110 assert_eq!(calls.load(Ordering::SeqCst), 0);
10111
10112 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10113 child.set_last_run_status("error");
10114 runner
10115 .confirm_durable_child_delivery(&child, "run-1", true)
10116 .await
10117 .unwrap();
10118 assert_eq!(calls.load(Ordering::SeqCst), 0);
10119
10120 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10121 child.set_last_run_status("completed");
10122 runner
10123 .confirm_durable_child_delivery(&child, "run-1", true)
10124 .await
10125 .unwrap();
10126 assert_eq!(calls.load(Ordering::SeqCst), 1);
10127 }
10128
10129 #[tokio::test]
10130 async fn strict_completeness_gate_precedes_ack_and_ack_failure_preserves_cold_proof() {
10131 for commit in [false, true] {
10132 let runner = ActorChildRunner::new(
10133 "test".into(),
10134 PathBuf::new(),
10135 vec![],
10136 PathBuf::new(),
10137 ExecutorSpec::BambooRuntime,
10138 vec![],
10139 "test".into(),
10140 1,
10141 );
10142 let home = tempfile::tempdir().unwrap();
10143 let store = Arc::new(
10144 bamboo_storage::SessionStoreV2::new(home.path().into())
10145 .await
10146 .unwrap(),
10147 );
10148 let root = Session::new("strict-ack-parent", "model");
10149 store.save_session(&root).await.unwrap();
10150 let mut child = Session::new_child_of("strict-ack-child", &root, "model", "work");
10151 child.add_message(bamboo_agent_core::Message::user("work"));
10152 store.save_session(&child).await.unwrap();
10153 child.add_message(bamboo_agent_core::Message::assistant("finished", None));
10154 child.set_last_run_status("completed");
10155 let proof = bamboo_storage::v2::HostTerminalCompleteness::from_verified_host(
10156 &child, "run-1", 7, 2,
10157 )
10158 .unwrap();
10159 let creation = bamboo_subagent::proto::ChildCreationIdentity {
10160 created_at: child.created_at,
10161 spawn_depth: child.spawn_depth,
10162 };
10163 let history = StrictTerminalHistory {
10164 proof: proof.clone(),
10165 watermark: bamboo_subagent::ActorEventWatermark {
10166 version: 1,
10167 logical_session: Some(actor_event_logical_identity(
10168 &child,
10169 &root.id,
10170 Some(&creation),
10171 )),
10172 activation_id: Some("run-1".into()),
10173 execution_epoch: 7,
10174 final_seq: 2,
10175 },
10176 };
10177 let parent_mailbox = "p-strict-ack-child";
10178 store
10179 .prepare_broker_terminal_receipt_with_completeness(
10180 &child,
10181 bamboo_storage::v2::BrokerTerminalRoute {
10182 activation_run_id: "run-1",
10183 broker_identity: "00000000-0000-4000-8000-000000000001",
10184 parent_mailbox,
10185 broker_correlation_id: "broker-run-1",
10186 message_ids: &["broker-outcome-1".into()],
10187 },
10188 &proof,
10189 )
10190 .await
10191 .unwrap();
10192 store.save_session(&child).await.unwrap();
10193 runner.set_actor_directory_store(Some(store.clone()));
10194 let calls = Arc::new(AtomicUsize::new(0));
10195 runner.pending_durable_links.lock().await.insert(
10196 (child.id.clone(), child.created_at, "run-1".into()),
10197 PendingDurableChildLink {
10198 link: Box::new(DurableAckProbe {
10199 calls: calls.clone(),
10200 fail: true,
10201 terminal_status: TerminalStatus::Completed,
10202 parent_mailbox: parent_mailbox.into(),
10203 }),
10204 host_nested_wait_handoff: false,
10205 strict_history: Some(history),
10206 strict_history_required: true,
10207 strict_history_committed: false,
10208 },
10209 );
10210 if commit {
10211 runner
10212 .commit_durable_child_delivery(&child, "run-1")
10213 .await
10214 .unwrap();
10215 assert_eq!(calls.load(Ordering::SeqCst), 0, "proof commit cannot ACK");
10216 }
10217 assert!(runner
10218 .confirm_durable_child_delivery(&child, "run-1", true)
10219 .await
10220 .is_err());
10221 assert_eq!(calls.load(Ordering::SeqCst), usize::from(commit));
10222 let cold = bamboo_storage::SessionStoreV2::new(home.path().into())
10223 .await
10224 .unwrap();
10225 let recovered = cold.recover_broker_terminal_receipts(&child).await;
10226 if commit {
10227 let receipts = recovered.unwrap();
10228 assert_eq!(receipts.len(), 1);
10229 assert_eq!(
10230 receipts[0]
10231 .terminal_completeness
10232 .as_ref()
10233 .unwrap()
10234 .contiguous_applied_seq,
10235 2
10236 );
10237 assert_eq!(
10238 cold.load_session(&child.id)
10239 .await
10240 .unwrap()
10241 .unwrap()
10242 .last_run_status()
10243 .as_deref(),
10244 Some("completed")
10245 );
10246 } else {
10247 assert!(
10248 recovered.is_err(),
10249 "matching canonical history is not proof commitment"
10250 );
10251 }
10252 assert_eq!(
10253 serde_json::to_value(
10254 cold.load_session(&child.id)
10255 .await
10256 .unwrap()
10257 .unwrap()
10258 .messages
10259 )
10260 .unwrap(),
10261 serde_json::to_value(&child.messages).unwrap(),
10262 );
10263 }
10264 }
10265
10266 #[tokio::test]
10267 async fn broker_receipt_confirms_canonical_worker_error_and_cancelled_status() {
10268 for (terminal_status, status) in [
10269 (TerminalStatus::Error, "error"),
10270 (TerminalStatus::Cancelled, "cancelled"),
10271 ] {
10272 let runner = ActorChildRunner::new(
10273 "test".into(),
10274 PathBuf::new(),
10275 vec![],
10276 PathBuf::new(),
10277 ExecutorSpec::BambooRuntime,
10278 vec![],
10279 "test".into(),
10280 1,
10281 );
10282 let home = tempfile::tempdir().unwrap();
10283 let store = Arc::new(
10284 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10285 .await
10286 .unwrap(),
10287 );
10288 let root = Session::new("failed-ack-parent", "model");
10289 store.save_session(&root).await.unwrap();
10290 let mut child = Session::new_child_of("failed-ack-child", &root, "model", "task");
10291 child.add_message(bamboo_agent_core::Message::user("work"));
10292 store.save_session(&child).await.unwrap();
10293 child.set_last_run_status(status);
10294 child.set_last_run_error(format!("worker {status}"));
10295 store
10296 .prepare_broker_terminal_receipt(
10297 &child,
10298 "run-1",
10299 "00000000-0000-4000-8000-000000000001",
10300 "p-failed-ack-child",
10301 "broker-run-1",
10302 &["broker-outcome-1".into()],
10303 )
10304 .await
10305 .unwrap();
10306 store.save_session(&child).await.unwrap();
10307 runner.set_actor_directory_store(Some(store));
10308 let calls = Arc::new(AtomicUsize::new(0));
10309 insert_ack_probe(&runner, &child, &calls, terminal_status).await;
10310 runner
10311 .confirm_durable_child_delivery(&child, "run-1", true)
10312 .await
10313 .unwrap();
10314 assert_eq!(calls.load(Ordering::SeqCst), 1, "{status}");
10315 }
10316 }
10317
10318 #[tokio::test]
10319 async fn broker_completed_terminal_projects_only_host_verified_nested_wait() {
10320 assert_eq!(
10321 effective_broker_terminal_status(TerminalStatus::Completed, true).unwrap(),
10322 "suspended"
10323 );
10324 for raw in [TerminalStatus::Error, TerminalStatus::Cancelled] {
10325 assert!(effective_broker_terminal_status(raw, true).is_err());
10326 }
10327 let runner = ActorChildRunner::new(
10328 "test".into(),
10329 PathBuf::new(),
10330 vec![],
10331 PathBuf::new(),
10332 ExecutorSpec::BambooRuntime,
10333 vec![],
10334 "test".into(),
10335 1,
10336 );
10337 let home = tempfile::tempdir().unwrap();
10338 let store = Arc::new(
10339 bamboo_storage::SessionStoreV2::new(home.path().to_path_buf())
10340 .await
10341 .unwrap(),
10342 );
10343 let root = Session::new("nested-ack-parent", "model");
10344 store.save_session(&root).await.unwrap();
10345 let mut child = Session::new_child_of("nested-ack-child", &root, "model", "task");
10346 child.add_message(bamboo_agent_core::Message::user("spawn a grandchild"));
10347 store.save_session(&child).await.unwrap();
10348 child.set_last_run_status("suspended");
10349 store
10350 .prepare_broker_terminal_receipt(
10351 &child,
10352 "run-1",
10353 "00000000-0000-4000-8000-000000000001",
10354 "p-nested-ack-child",
10355 "broker-run-1",
10356 &["broker-outcome-1".into()],
10357 )
10358 .await
10359 .unwrap();
10360 store.save_session(&child).await.unwrap();
10361 runner.set_actor_directory_store(Some(store.clone()));
10362 let calls = Arc::new(AtomicUsize::new(0));
10363
10364 insert_ack_probe(&runner, &child, &calls, TerminalStatus::Completed).await;
10365 runner
10366 .confirm_durable_child_delivery(&child, "run-1", true)
10367 .await
10368 .unwrap();
10369 assert_eq!(calls.load(Ordering::SeqCst), 0);
10370
10371 insert_ack_probe_with_nested_wait(&runner, &child, &calls, TerminalStatus::Completed, true)
10372 .await;
10373 runner
10374 .confirm_durable_child_delivery(&child, "run-1", true)
10375 .await
10376 .unwrap();
10377 assert_eq!(calls.load(Ordering::SeqCst), 1);
10378 assert_eq!(
10379 store
10380 .load_session(&child.id)
10381 .await
10382 .unwrap()
10383 .unwrap()
10384 .last_run_status()
10385 .as_deref(),
10386 Some("suspended")
10387 );
10388 assert!(store
10389 .recover_broker_terminal_receipts(&child)
10390 .await
10391 .unwrap()
10392 .is_empty());
10393 }
10394
10395 #[tokio::test]
10396 async fn host_completion_notifies_once_only_after_reply_and_finish_commit() {
10397 struct CommittedObserver {
10398 authority: PathBuf,
10399 events: std::sync::Mutex<Vec<PublicActorEvent>>,
10400 }
10401 impl ActorEventObserver for CommittedObserver {
10402 fn publish(&self, event: PublicActorEvent) {
10403 let entry: bamboo_domain::ActorDirectoryEntry =
10405 serde_json::from_slice(&std::fs::read(&self.authority).unwrap()).unwrap();
10406 let committed = entry.activation.unwrap();
10407 assert_eq!(
10408 committed.status,
10409 bamboo_domain::ActorActivationStatus::Succeeded
10410 );
10411 assert_eq!(committed.activation_id, event.activation_id);
10412 assert!(committed.finished_at.is_some());
10413 self.events.lock().unwrap().push(event);
10414 }
10415 }
10416 struct CompletionLink(VecDeque<ChildFrame>);
10417 #[async_trait]
10418 impl bamboo_subagent::ChildLink for CompletionLink {
10419 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
10420 Ok(())
10421 }
10422 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
10423 Ok(self.0.pop_front())
10424 }
10425 }
10426 for cut in [
10427 "success",
10428 "no_terminal",
10429 "invalid_terminal",
10430 "stale",
10431 "storage_failure",
10432 "append_failure",
10433 ] {
10434 let temp = tempfile::tempdir().unwrap();
10435 let store = Arc::new(
10436 bamboo_storage::SessionStoreV2::new(temp.path().into())
10437 .await
10438 .unwrap(),
10439 );
10440 let root = Session::new("notify-root", "model");
10441 store.save_session(&root).await.unwrap();
10442 let mut child = Session::new_child_of("notify-child", &root, "model", "assignment");
10443 store.save_session(&child).await.unwrap();
10444 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
10445 store.clone(),
10446 bamboo_domain::SessionInboxLimits::default(),
10447 ));
10448 let binding = actor_binding(
10449 store.clone(),
10450 inbox,
10451 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
10452 );
10453 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
10454 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
10455 let activation = PlainActorActivation::start(
10456 store.clone(),
10457 &child,
10458 &binding,
10459 Some("notify-run"),
10460 Some(policy),
10461 false,
10462 None,
10463 )
10464 .await
10465 .unwrap();
10466 let _owner = binding
10467 .router
10468 .register_run(&child.id, "notify-run")
10469 .await
10470 .unwrap();
10471 let batch = ActorEventBatch {
10472 logical_session: Some(LogicalSessionIdentity {
10473 session_id: child.id.clone(),
10474 parent_session_id: child.parent_session_id.clone(),
10475 root_session_id: child.root_session_id.clone(),
10476 creation: None,
10477 }),
10478 activation_id: Some("notify-run".into()),
10479 execution_epoch: 9,
10480 source_node_id: None,
10481 source_actor_id: Some("notify-worker".into()),
10482 first_seq: 1,
10483 last_seq: 2,
10484 qos: bamboo_subagent::ActorEventQos::Durable,
10485 events: vec![
10486 serde_json::to_value(AgentEvent::Token {
10487 content: "PRIVATE_PROVIDER_TOKEN".into(),
10488 })
10489 .unwrap(),
10490 serde_json::to_value(AgentEvent::message_appended(
10491 &child.id,
10492 &bamboo_agent_core::Message::assistant("PRIVATE_WORKER_CACHE", None),
10493 ))
10494 .unwrap(),
10495 ],
10496 };
10497 let mut frames = VecDeque::from([ChildFrame::EventBatch { batch }]);
10498 if cut != "no_terminal" {
10499 let mut terminal = completed_actor_frame();
10500 if cut == "invalid_terminal" {
10501 if let ChildFrame::Terminal { status, .. } = &mut terminal {
10502 *status = TerminalStatus::Suspended;
10503 }
10504 }
10505 frames.push_back(terminal);
10506 }
10507 let mut link = CompletionLink(frames);
10508 let (tx, mut rx) = mpsc::channel(8);
10509 let (_live, mut live_rx) = mpsc::unbounded_channel();
10510 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
10511 let cancel = CancellationToken::new();
10512 let outcome = drive(ActorDriveContext {
10513 client: &mut link,
10514 parent_session_id: "notify-root",
10515 child_session_id: "notify-child",
10516 child_attempt: 0,
10517 approval_registry: None,
10518 approval_decider: None,
10519 approval_reviewer: None,
10520 escalation_bridge: None,
10521 event_tx: &tx,
10522 cancel_token: &cancel,
10523 live_rx: &mut live_rx,
10524 delivery_rx: &mut delivery_rx,
10525 logical_session: &mut child,
10526 expected_permission_posture: None,
10527 expected_creation: None,
10528 session_inbox_runtime: Some(&binding),
10529 actor_directory_store: Some(store.as_ref()),
10530 canonical_subagent_tool: None,
10531 ticket_service: None,
10532 activation_run_id: Some("notify-run"),
10533 execution_epoch: 9,
10534 expected_source_actor_id: "notify-worker",
10535 initial_inflight_claims: VecDeque::new(),
10536 plain_actor: true,
10537 remote_environment_lease: false,
10538 readonly_output: None,
10539 local_history_tools: None,
10540 local_history_read_only: false,
10541 strict_history_output: None,
10542 plain_input: Some(&activation),
10543 canonical_activation: None,
10544 canonical_placement_ref: None,
10545 actor_event_observer: None,
10546 plain_run: None,
10547 first_frame_timeout: Some(Duration::from_secs(1)),
10548 })
10549 .await;
10550 let admitted = !matches!(cut, "no_terminal" | "invalid_terminal");
10551 assert_eq!(outcome.is_ok(), admitted, "{cut}: {outcome:?}");
10552 assert_eq!(
10553 activation.completion_router.lock().unwrap().is_some(),
10554 admitted,
10555 "{cut}"
10556 );
10557 while rx.try_recv().is_ok() {}
10558 let authority = temp
10559 .path()
10560 .join(store.resolve_rel_path(&child.id).await.unwrap())
10561 .join("actor-authority.json");
10562 let observer = CommittedObserver {
10563 authority: authority.clone(),
10564 events: Default::default(),
10565 };
10566 if cut == "append_failure" {
10567 child.add_message(bamboo_agent_core::Message::user("uncommitted-prefix"));
10568 assert!(activation
10569 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10570 .await
10571 .is_err());
10572 activation
10573 .finish(ActorActivationFinish::Failed)
10574 .await
10575 .unwrap();
10576 assert!(observer.events.lock().unwrap().is_empty());
10577 continue;
10578 }
10579 activation
10580 .append_reply(&mut child, "PRIVATE_REPLY".into(), &tx)
10581 .await
10582 .unwrap();
10583 assert!(
10584 rx.try_recv().is_ok(),
10585 "legacy content notification retained"
10586 );
10587 assert!(
10588 observer.events.lock().unwrap().is_empty(),
10589 "reply alone must not publish"
10590 );
10591 if cut == "stale" {
10592 store
10593 .retire_actor(&child.id, chrono::Utc::now())
10594 .await
10595 .unwrap();
10596 } else if cut == "storage_failure" {
10597 std::fs::remove_file(&authority).unwrap();
10600 std::fs::create_dir(&authority).unwrap();
10601 }
10602 let result = activation
10603 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10604 .await;
10605 assert_eq!(
10606 result.is_ok(),
10607 matches!(cut, "success" | "no_terminal" | "invalid_terminal"),
10608 "{cut}"
10609 );
10610 let events = observer.events.lock().unwrap().clone();
10611 assert_eq!(events.len(), usize::from(cut == "success"), "{cut}");
10612 if let Some(event) = events.first() {
10613 assert_eq!(event.actor_id, child.id);
10614 assert_eq!(event.root_actor_id, root.id);
10615 assert_eq!(event.parent_actor_id, Some(root.id.clone()));
10616 assert_eq!(event.source_order.sequence, 3);
10617 let json = serde_json::to_string(event).unwrap();
10618 assert!(!json.contains("PRIVATE_") && !json.contains("notify-run"));
10619 let persisted = store.load_session(&child.id).await.unwrap().unwrap();
10620 assert_eq!(persisted.messages.last().unwrap().content, "PRIVATE_REPLY");
10621 }
10622 assert!(activation
10623 .finish_with_observer(ActorActivationFinish::Succeeded, Some(&observer))
10624 .await
10625 .is_err());
10626 assert_eq!(
10627 observer.events.lock().unwrap().len(),
10628 events.len(),
10629 "repeat finish cannot publish"
10630 );
10631 }
10632 }
10633
10634 async fn bind_local_control_plane(
10635 store: &bamboo_storage::SessionStoreV2,
10636 actor_id: &str,
10637 policy: &bamboo_tools::permission::PermissionConfig,
10638 ) {
10639 store
10640 .bind_control_plane(
10641 actor_id,
10642 bamboo_domain::ActorControlPlaneBinding {
10643 policy_revision: Some(policy.policy_revision()),
10644 placement_intent: Some(bamboo_domain::ActorPlacementIntent::Local),
10645 },
10646 )
10647 .await
10648 .unwrap();
10649 }
10650
10651 fn local_tool_sample(
10652 host: &Session,
10653 denied_write: bool,
10654 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10655 local_tool_sample_with_reasoning(host, denied_write, None)
10656 }
10657
10658 fn local_tool_sample_with_reasoning(
10659 host: &Session,
10660 denied_write: bool,
10661 reasoning: Option<&str>,
10662 ) -> (LocalToolCollector, Vec<bamboo_agent_core::Message>) {
10663 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10664 let calls = ["Read", "Write"]
10665 .map(|name| ToolCall {
10666 id: format!("local-{name}"),
10667 tool_type: "function".into(),
10668 function: FunctionCall {
10669 name: name.into(),
10670 arguments: "{\"file_path\":\"answer.txt\"}".into(),
10671 },
10672 })
10673 .to_vec();
10674 let mut rows = vec![Message::assistant("checking", Some(calls))];
10675 rows[0].reasoning = reasoning.map(str::to_owned);
10676 let mut collector = LocalToolCollector::default();
10677 if let Some(reasoning) = reasoning {
10678 assert!(collector
10679 .event(
10680 &serde_json::to_value(AgentEvent::ReasoningToken {
10681 content: reasoning.into(),
10682 })
10683 .unwrap()
10684 )
10685 .unwrap());
10686 }
10687 for name in ["Read", "Write"] {
10688 let denied = name == "Write" && denied_write;
10689 let id = format!("local-{name}");
10690 if !denied {
10691 collector
10692 .event(
10693 &serde_json::to_value(AgentEvent::ToolStart {
10694 tool_call_id: id.clone(),
10695 tool_name: name.into(),
10696 arguments: serde_json::json!({"file_path":"answer.txt"}),
10697 })
10698 .unwrap(),
10699 )
10700 .unwrap();
10701 }
10702 let text = if denied {
10703 "Error: native_tool_ceiling_denied: Write"
10704 } else {
10705 "actual file result"
10706 };
10707 let mut message = Message::tool_result_with_status(id.clone(), text, !denied);
10708 message.metadata = Some(serde_json::json!({"elapsed_ms":7,"tool_name":name,
10709 "is_mutating":name == "Write","auto_approved":name != "Write","success":!denied}));
10710 rows.push(message);
10711 let event = if denied {
10712 AgentEvent::ToolError {
10713 tool_call_id: id,
10714 error: "native_tool_ceiling_denied: Write".into(),
10715 }
10716 } else {
10717 AgentEvent::ToolComplete {
10718 tool_call_id: id,
10719 result: bamboo_agent_core::tools::ToolResult::text(true, text),
10720 }
10721 };
10722 collector
10723 .event(&serde_json::to_value(event).unwrap())
10724 .unwrap();
10725 }
10726 rows.push(Message::assistant("complete exact report", None));
10727 rows.last_mut().unwrap().reasoning = reasoning.map(str::to_owned);
10728 if let Some(reasoning) = reasoning {
10729 assert!(collector
10730 .event(
10731 &serde_json::to_value(AgentEvent::ReasoningToken {
10732 content: reasoning.into(),
10733 })
10734 .unwrap()
10735 )
10736 .unwrap());
10737 }
10738 let mut messages = host.messages.clone();
10739 if let Some(system) = messages
10740 .iter_mut()
10741 .find(|message| message.role == Role::System)
10742 {
10743 system.content = "worker-private system never imported".into();
10744 }
10745 messages.extend(rows.iter().cloned());
10746 let data = bamboo_subagent::proto::LocalToolMessages::Complete {
10747 version: 1,
10748 messages: messages
10749 .iter()
10750 .map(|message| serde_json::to_value(message).unwrap())
10751 .collect(),
10752 };
10753 assert!(!collector
10754 .event(&serde_json::to_value(data).unwrap())
10755 .unwrap());
10756 (collector, rows)
10757 }
10758
10759 #[test]
10760 fn ticket_task_event_requires_exact_host_callback_receipt() {
10761 let mut collector = LocalToolCollector::default();
10762 let args = serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]});
10763 let start = serde_json::to_value(AgentEvent::ToolStart {
10764 tool_call_id: "task-call".into(),
10765 tool_name: "Task".into(),
10766 arguments: args.clone(),
10767 })
10768 .unwrap();
10769 let mut receipts = HashMap::new();
10770 assert!(collector.ticket_event(&start, &receipts).unwrap());
10771 collector.event(&start).unwrap();
10772 let complete = serde_json::to_value(AgentEvent::ToolComplete {
10773 tool_call_id: "task-call".into(),
10774 result: bamboo_agent_core::tools::ToolResult::text(true, "done"),
10775 })
10776 .unwrap();
10777 assert!(collector.ticket_event(&complete, &receipts).is_err());
10778 receipts.insert("other-call".into(), args.clone());
10779 assert!(collector.ticket_event(&complete, &receipts).is_err());
10780 receipts.insert("task-call".into(), serde_json::json!({"tasks":[]}));
10781 assert!(collector.ticket_event(&complete, &receipts).is_err());
10782 receipts.insert("task-call".into(), args);
10783 assert!(collector.ticket_event(&complete, &receipts).unwrap());
10784 let forged_root = serde_json::to_value(AgentEvent::TaskListUpdated {
10785 task_list: bamboo_tools::TaskTool::task_list_from_args(
10786 &serde_json::json!({"tasks":[{"content":"forged root","status":"completed"}]}),
10787 "root",
10788 )
10789 .unwrap(),
10790 version: Some(999),
10791 })
10792 .unwrap();
10793 assert!(!collector.ticket_event(&forged_root, &receipts).unwrap());
10794 }
10795
10796 #[test]
10797 fn ticket_question_terminal_requires_exact_host_receipt_and_tool_result() {
10798 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
10799 let mut host = Session::new("question-child", "model");
10800 host.messages.push(Message::user("own assignment"));
10801 let question: bamboo_tickets::PendingRequest = serde_json::from_value(serde_json::json!({
10802 "id":"own-question", "work_id":"own-work", "assignment_id":"own-assignment",
10803 "generation":1, "contract_revision":1, "prompt_revision":1,
10804 "kind":{"kind":"question"}, "prompt":"精确问题", "status":"open",
10805 "answer":null, "consumed_attempt":null, "worker_packet_hash":"a".repeat(64), "updated_seq":3
10806 })).unwrap();
10807 let args = serde_json::json!({"tasks":[{"content":"own step","status":"blocked"}], "question":{"prompt":"精确问题"}});
10808 for case in ["exact", "no_host_receipt", "forged_request", "fake_report"] {
10809 let mut returned =
10810 serde_json::json!({"status":"waiting_for_answer", "request":question});
10811 if case == "forged_request" {
10812 returned["request"]["id"] = serde_json::json!("sibling-question");
10813 }
10814 let text = returned.to_string();
10815 let rows = [
10816 Message::assistant(
10817 "",
10818 Some(vec![ToolCall {
10819 id: "own-call".into(),
10820 tool_type: "function".into(),
10821 function: FunctionCall {
10822 name: "Task".into(),
10823 arguments: args.to_string(),
10824 },
10825 }]),
10826 ),
10827 Message::tool_result_with_status("own-call", &text, true),
10828 ];
10829 let collector = LocalToolCollector {
10830 messages: Some(host.messages.iter().cloned().chain(rows).collect()),
10831 starts: HashMap::from([("own-call".into(), ("Task".into(), args.clone()))]),
10832 outcomes: HashMap::from([("own-call".into(), (true, text))]),
10833 ticket_yield: (case != "no_host_receipt")
10834 .then(|| ("own-call".into(), question.clone())),
10835 };
10836 let terminal = if case == "fake_report" {
10837 Some("fake delivery")
10838 } else {
10839 Some("")
10840 };
10841 assert_eq!(
10842 collector
10843 .suffix(&host, &["Task".into()], false, terminal)
10844 .is_ok(),
10845 case == "exact",
10846 "{case}"
10847 );
10848 }
10849 }
10850
10851 #[test]
10852 fn local_tool_suffix_preserves_full_current_prefix_and_rejects_authority_or_collisions() {
10853 let mut host = Session::new("tool-history-prefix", "model");
10854 host.messages
10855 .push(bamboo_agent_core::Message::user("assignment"));
10856 let mut confirmed = bamboo_agent_core::Message::user("confirmed during Run");
10857 confirmed.metadata = Some(
10858 serde_json::json!({"session_message":{"id":confirmed.id,"target_session_id":host.id}}),
10859 );
10860 host.messages.push(confirmed);
10861 let tools = vec!["Read".into(), "Write".into()];
10862 let (good, rows) = local_tool_sample(&host, false);
10863 assert_eq!(
10864 serde_json::to_value(
10865 good.suffix(&host, &tools, false, Some("complete exact report"))
10866 .unwrap()
10867 )
10868 .unwrap(),
10869 serde_json::to_value(&rows).unwrap()
10870 );
10871 for case in [
10872 "missing",
10873 "changed",
10874 "metadata",
10875 "system",
10876 "user",
10877 "collision",
10878 "dangling",
10879 "result",
10880 "terminal",
10881 "tool_metadata",
10882 "grant",
10883 "start_arguments",
10884 "ledger",
10885 ] {
10886 let (mut collector, _) = local_tool_sample(&host, false);
10887 let mut current = host.clone();
10888 let messages = collector.messages.as_mut().unwrap();
10889 match case {
10890 "missing" => {
10891 messages.remove(host.messages.len() - 1);
10892 }
10893 "changed" => messages[0].content.push('!'),
10894 "metadata" => messages[1].metadata = None,
10895 "system" => messages.last_mut().unwrap().role = Role::System,
10896 "user" => messages.last_mut().unwrap().role = Role::User,
10897 "collision" => messages.last_mut().unwrap().id = host.messages[0].id.clone(),
10898 "dangling" => {
10899 messages[host.messages.len() + 1].tool_call_id = Some("foreign".into())
10900 }
10901 "result" => messages[host.messages.len() + 1].content.push('!'),
10902 "tool_metadata" => {
10903 messages[host.messages.len() + 1].metadata.as_mut().unwrap()["permission"] =
10904 true.into()
10905 }
10906 "grant" => {
10907 messages[host.messages.len()].tool_calls.as_mut().unwrap()[0]
10908 .function
10909 .name = "Bash".into()
10910 }
10911 "start_arguments" => {
10912 collector.starts.get_mut("local-Read").unwrap().1 =
10913 serde_json::json!({"file_path":"different.txt"})
10914 }
10915 "ledger" => {
10916 current.append_provider_transcript_group(current.messages[0].id.clone(), None, vec![
10917 bamboo_domain::session::provider_transcript::ProviderTranscriptItem::try_from_payload(
10918 bamboo_domain::ProviderFamily::OpenAi,
10919 bamboo_domain::ProviderProtocol::OpenAiResponsesV1,
10920 bamboo_domain::session::provider_transcript::ProviderTranscriptOrigin::HostToolSearch,
10921 bamboo_domain::session::provider_transcript::ProviderTranscriptAuthor::ToolResult,
10922 serde_json::json!({"type":"tool_search_output","execution":"client","call_id":"search","status":"completed","tools":[]}),
10923 ).unwrap(),
10924 ]).unwrap();
10925 }
10926 "terminal" => {}
10927 _ => unreachable!(),
10928 }
10929 let terminal = if case == "terminal" {
10930 "different"
10931 } else {
10932 "complete exact report"
10933 };
10934 assert!(
10935 collector
10936 .suffix(¤t, &tools, false, Some(terminal))
10937 .is_err(),
10938 "{case}"
10939 );
10940 }
10941 let (denied, _) = local_tool_sample(&host, true);
10942 assert!(denied
10943 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10944 .is_ok());
10945 let (mut contradicted, _) = local_tool_sample(&host, true);
10946 contradicted.starts.insert(
10947 "local-Write".into(),
10948 ("Read".into(), serde_json::json!({"file_path":"answer.txt"})),
10949 );
10950 assert!(contradicted
10951 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10952 .is_err());
10953 let (successful, _) = local_tool_sample(&host, false);
10954 assert!(successful
10955 .suffix(&host, &["Read".into()], true, Some("complete exact report"))
10956 .is_err());
10957 let mut empty = LocalToolCollector::default();
10958 assert!(empty
10959 .event(
10960 &serde_json::to_value(AgentEvent::ReasoningToken {
10961 content: "private".into()
10962 })
10963 .unwrap()
10964 )
10965 .unwrap());
10966 assert!(
10967 empty
10968 .suffix(&host, &tools, false, Some("complete exact report"))
10969 .is_err(),
10970 "stream observations alone cannot commit history"
10971 );
10972 let (mut completed, _) = local_tool_sample(&host, false);
10973 assert!(completed.event(&serde_json::json!({"type":"local_client_tool_messages_v1","version":1,"messages":[]})).is_err());
10974 }
10975
10976 struct LocalToolConcurrentCheckpoint {
10977 real: SessionInboxRuntimeBinding,
10978 claim: SessionInboxClaim,
10979 }
10980 #[async_trait]
10981 impl RuntimeSessionPersistence for LocalToolConcurrentCheckpoint {
10982 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10983 self.real.persistence.save_runtime_session(session).await
10984 }
10985 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
10986 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
10989 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &self.claim, None)
10990 .await
10991 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
10992 self.real
10993 .persistence
10994 .checkpoint_runtime_session(session)
10995 .await
10996 }
10997 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
10998 self.real.storage.load_session(id).await
10999 }
11000 }
11001
11002 #[tokio::test]
11003 async fn local_tool_history_checkpoint_preserves_confirmed_input_and_cold_pairs_or_refuses() {
11004 for case in [
11005 "success",
11006 "reasoning",
11007 "concurrent_input",
11008 "checkpoint_error",
11009 "stale_prefix",
11010 "cancelled",
11011 "lost_owner",
11012 ] {
11013 let temp = tempfile::tempdir().unwrap();
11014 let store = Arc::new(
11015 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11016 .await
11017 .unwrap(),
11018 );
11019 let root = Session::new("local-tool-root", "model");
11020 store.save_session(&root).await.unwrap();
11021 let mut child = Session::new_child("local-tool-child", &root.id, "model", "child");
11022 let mut system = bamboo_agent_core::Message::user("Host-owned immutable System");
11023 system.role = Role::System;
11024 child.messages.push(system);
11025 child
11026 .messages
11027 .push(bamboo_agent_core::Message::user("assignment"));
11028 store.save_session(&child).await.unwrap();
11029 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11030 store.clone(),
11031 bamboo_domain::SessionInboxLimits::default(),
11032 ));
11033 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11034 let binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11035 let mut owner = Some(
11036 binding
11037 .router
11038 .register_run(&child.id, "current-run")
11039 .await
11040 .unwrap(),
11041 );
11042 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
11043 &child.id,
11044 "current confirmed User",
11045 );
11046 let receipt = inbox.deliver(&envelope).await.unwrap();
11047 inbox
11048 .mark_activation_eligible(
11049 &child.id,
11050 receipt.generation,
11051 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11052 )
11053 .await
11054 .unwrap();
11055 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11056 checkpoint_and_ack_canonical_claim(&binding, &mut child, &claim, None)
11057 .await
11058 .unwrap();
11059 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
11060 let before = serde_json::to_value(&child).unwrap();
11061 let reasoning = (case == "reasoning")
11062 .then_some("Complete plain reasoning before Read and the final reply 🪷.");
11063 let (collector, rows) = local_tool_sample_with_reasoning(&child, false, reasoning);
11064 let mut binding = binding;
11065 let late = bamboo_domain::SessionMessageEnvelope::user_input(
11066 &child.id,
11067 "concurrent confirmed User",
11068 );
11069 if case == "concurrent_input" {
11070 let receipt = inbox.deliver(&late).await.unwrap();
11071 inbox
11072 .mark_activation_eligible(
11073 &child.id,
11074 receipt.generation,
11075 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11076 )
11077 .await
11078 .unwrap();
11079 let late_claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11080 binding.persistence = Arc::new(LocalToolConcurrentCheckpoint {
11081 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11082 claim: late_claim,
11083 });
11084 }
11085 if case == "checkpoint_error" {
11086 binding.persistence = Arc::new(ActorFaultingPersistence {
11087 inner: locked.clone(),
11088 fail_checkpoint_once: AtomicBool::new(true),
11089 });
11090 }
11091 if case == "stale_prefix" {
11092 let mut latest = child.clone();
11093 latest
11094 .messages
11095 .push(bamboo_agent_core::Message::user("new confirmed prefix"));
11096 store.save_session(&latest).await.unwrap();
11097 }
11098 let cancel = CancellationToken::new();
11099 if case == "cancelled" {
11100 cancel.cancel();
11101 }
11102 if case == "lost_owner" {
11103 owner.take().unwrap().abandon().await;
11104 }
11105 let durable_before =
11106 serde_json::to_value(store.load_session(&child.id).await.unwrap()).unwrap();
11107 let result = commit_local_tool_history(
11108 collector,
11109 &mut child,
11110 &binding,
11111 "current-run",
11112 (&["Read".into(), "Write".into()], false),
11113 LocalToolTerminal::Completed(Some("complete exact report")),
11114 &cancel,
11115 )
11116 .await;
11117 let cold = bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
11118 .await
11119 .unwrap()
11120 .load_session(&child.id)
11121 .await
11122 .unwrap()
11123 .unwrap();
11124 if !matches!(case, "success" | "reasoning" | "concurrent_input") {
11125 assert!(result.is_err(), "{case}");
11126 assert_eq!(
11127 serde_json::to_value(&child).unwrap(),
11128 before,
11129 "{case}: no partial mutable success"
11130 );
11131 assert_eq!(
11132 serde_json::to_value(Some(&cold)).unwrap(),
11133 durable_before,
11134 "{case}: no write"
11135 );
11136 continue;
11137 }
11138 result.unwrap();
11139 assert_eq!(
11140 serde_json::to_value(&cold.messages[..3]).unwrap(),
11141 before["messages"]
11142 );
11143 let suffix_start = if case == "concurrent_input" {
11144 assert!(inbox.was_admitted(&child.id, &late.id).await.unwrap());
11145 assert_eq!(
11146 serde_json::to_value(&cold.messages[3]).unwrap(),
11147 serde_json::to_value(late.to_provider_message().unwrap()).unwrap()
11148 );
11149 assert!(child
11150 .messages
11151 .iter()
11152 .any(|message| message.id == late.id.as_str()));
11153 4
11154 } else {
11155 3
11156 };
11157 assert_eq!(
11158 serde_json::to_value(&cold.messages[suffix_start..]).unwrap(),
11159 serde_json::to_value(rows).unwrap()
11160 );
11161 assert_eq!(
11162 cold.messages
11163 .iter()
11164 .filter(|message| message.id == envelope.id.as_str())
11165 .count(),
11166 1
11167 );
11168 assert_eq!(
11169 cold.messages
11170 .iter()
11171 .filter(|message| message.content == "complete exact report")
11172 .count(),
11173 1
11174 );
11175 assert_eq!(cold.metadata, child.metadata);
11176 assert!(cold.provider_transcript.is_empty());
11177 }
11178 }
11179
11180 #[tokio::test]
11181 async fn local_tool_completion_requires_current_ordered_batch_and_never_falls_back_to_text() {
11182 for case in [
11183 "legacy",
11184 "missing",
11185 "gap",
11186 "duplicate",
11187 "epoch",
11188 "source",
11189 "birth",
11190 "qos",
11191 "ticket_forged_success",
11192 ] {
11193 let mut session = Session::new_child("frame-child", "frame-root", "model", "child");
11194 session
11195 .messages
11196 .push(bamboo_agent_core::Message::user("original"));
11197 if case == "ticket_forged_success" {
11198 session.metadata.insert(
11199 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY.into(),
11200 "assignment".into(),
11201 );
11202 }
11203 let before = serde_json::to_value(&session).unwrap();
11204 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11205 created_at: session.created_at,
11206 spawn_depth: session.spawn_depth,
11207 };
11208 let mut messages = session.messages.clone();
11209 messages.push(bamboo_agent_core::Message::assistant("complete", None));
11210 let event = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11211 version: 1,
11212 messages: messages
11213 .iter()
11214 .map(|message| serde_json::to_value(message).unwrap())
11215 .collect(),
11216 })
11217 .unwrap();
11218 let mut batch = ActorEventBatch {
11219 logical_session: Some(LogicalSessionIdentity {
11220 session_id: session.id.clone(),
11221 parent_session_id: session.parent_session_id.clone(),
11222 root_session_id: session.root_session_id.clone(),
11223 creation: Some(creation.clone()),
11224 }),
11225 activation_id: Some("current".into()),
11226 execution_epoch: 7,
11227 source_node_id: None,
11228 source_actor_id: Some("selected".into()),
11229 first_seq: 1,
11230 last_seq: 1,
11231 qos: bamboo_subagent::proto::ActorEventQos::Durable,
11232 events: vec![event.clone()],
11233 };
11234 match case {
11235 "gap" => {
11236 batch.first_seq = 2;
11237 batch.last_seq = 2;
11238 }
11239 "epoch" => batch.execution_epoch = 6,
11240 "source" => batch.source_actor_id = Some("replaced".into()),
11241 "birth" => {
11242 batch
11243 .logical_session
11244 .as_mut()
11245 .unwrap()
11246 .creation
11247 .as_mut()
11248 .unwrap()
11249 .created_at += chrono::Duration::nanoseconds(1)
11250 }
11251 "qos" => batch.qos = bamboo_subagent::proto::ActorEventQos::Ephemeral,
11252 "ticket_forged_success" => {
11253 batch.last_seq = 2;
11254 batch.events = vec![
11255 serde_json::to_value(AgentEvent::ToolStart {
11256 tool_call_id: "forged".into(), tool_name: "Task".into(),
11257 arguments: serde_json::json!({"tasks":[{"content":"own step","status":"pending"}]}),
11258 }).unwrap(),
11259 serde_json::to_value(AgentEvent::ToolComplete {
11260 tool_call_id: "forged".into(),
11261 result: bamboo_agent_core::tools::ToolResult::text(true, "uncommitted"),
11262 }).unwrap(),
11263 ];
11264 }
11265 _ => {}
11266 }
11267 let terminal = ChildFrame::Terminal {
11268 status: TerminalStatus::Completed,
11269 result: Some("complete".into()),
11270 error: None,
11271 final_event_watermark: None,
11272 transcript: vec![],
11273 };
11274 let frames = match case {
11275 "legacy" => vec![ChildFrame::Event { event }, terminal],
11276 "missing" => vec![terminal],
11277 "duplicate" => vec![
11278 ChildFrame::EventBatch {
11279 batch: batch.clone(),
11280 },
11281 ChildFrame::EventBatch { batch },
11282 terminal,
11283 ],
11284 _ => vec![ChildFrame::EventBatch { batch }, terminal],
11285 };
11286 let mut link = ConfirmationSequenceLink {
11287 frames: frames.into(),
11288 sent: vec![],
11289 };
11290 let (tx, mut rx) = mpsc::channel(32);
11291 let (_live, mut live_rx) = mpsc::unbounded_channel();
11292 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11293 let cancel = CancellationToken::new();
11294 let tools = vec![if case == "ticket_forged_success" {
11295 "Task"
11296 } else {
11297 "Read"
11298 }
11299 .into()];
11300 let outcome = drive(ActorDriveContext {
11301 client: &mut link,
11302 parent_session_id: "frame-root",
11303 child_session_id: "frame-child",
11304 child_attempt: 0,
11305 approval_registry: None,
11306 approval_decider: None,
11307 approval_reviewer: None,
11308 escalation_bridge: None,
11309 event_tx: &tx,
11310 cancel_token: &cancel,
11311 live_rx: &mut live_rx,
11312 delivery_rx: &mut delivery_rx,
11313 logical_session: &mut session,
11314 expected_permission_posture: None,
11315 expected_creation: Some(&creation),
11316 session_inbox_runtime: None,
11317 actor_directory_store: None,
11318 canonical_subagent_tool: None,
11319 ticket_service: None,
11320 activation_run_id: Some("current"),
11321 execution_epoch: 7,
11322 expected_source_actor_id: "selected",
11323 initial_inflight_claims: vec![].into(),
11324 plain_actor: false,
11325 remote_environment_lease: false,
11326 readonly_output: None,
11327 local_history_tools: Some(&tools),
11328 local_history_read_only: true,
11329 strict_history_output: None,
11330 plain_input: None,
11331 canonical_activation: None,
11332 canonical_placement_ref: None,
11333 actor_event_observer: None,
11334 plain_run: None,
11335 first_frame_timeout: Some(Duration::from_secs(1)),
11336 })
11337 .await;
11338 assert!(outcome.is_err(), "{case}: no last-text success fallback");
11339 if case == "ticket_forged_success" {
11340 assert!(outcome
11341 .unwrap_err()
11342 .to_string()
11343 .contains("no exact Host receipt"));
11344 assert!(matches!(
11345 rx.try_recv().unwrap(),
11346 AgentEvent::ToolStart { .. }
11347 ));
11348 }
11349 assert_eq!(serde_json::to_value(&session).unwrap(), before, "{case}");
11350 assert!(link.sent.is_empty());
11351 assert!(rx.try_recv().is_err(), "DATA must never enter public feed");
11352 }
11353 }
11354
11355 struct HistoryTerminalProbe {
11356 frames: VecDeque<ChildFrame>,
11357 accepted: Option<TerminalStatus>,
11358 acks: usize,
11359 }
11360
11361 #[async_trait]
11362 impl bamboo_subagent::ChildLink for HistoryTerminalProbe {
11363 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11364 Ok(())
11365 }
11366 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11367 Ok(self.frames.pop_front())
11368 }
11369 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11370 self.accepted = Some(status);
11371 }
11372 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11373 Some(bamboo_subagent::DurableChildDeliveryReceipt {
11374 broker_identity: "fixture".into(),
11375 parent_mailbox: "parent".into(),
11376 correlation_id: "current".into(),
11377 message_ids: vec!["tail".into(), "terminal".into()],
11378 terminal_status: self.accepted?,
11379 })
11380 }
11381 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11382 self.acks += 1;
11383 Ok(())
11384 }
11385 }
11386
11387 struct CancelConcurrentInputCheckpoint {
11388 real: SessionInboxRuntimeBinding,
11389 envelope: bamboo_domain::SessionMessageEnvelope,
11390 }
11391 #[async_trait]
11392 impl RuntimeSessionPersistence for CancelConcurrentInputCheckpoint {
11393 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11394 self.real.persistence.save_runtime_session(session).await
11395 }
11396 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
11397 let receipt = self
11398 .real
11399 .inbox
11400 .deliver(&self.envelope)
11401 .await
11402 .map_err(|_| std::io::Error::other("concurrent delivery failed"))?;
11403 self.real
11404 .inbox
11405 .mark_activation_eligible(
11406 &session.id,
11407 receipt.generation,
11408 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11409 )
11410 .await
11411 .map_err(|_| std::io::Error::other("concurrent activation failed"))?;
11412 let claim = self
11413 .real
11414 .inbox
11415 .claim(&session.id, 1)
11416 .await
11417 .map_err(|_| std::io::Error::other("concurrent claim failed"))?
11418 .remove(0);
11419 let mut latest = self.real.storage.load_session(&session.id).await?.unwrap();
11420 checkpoint_and_ack_canonical_claim(&self.real, &mut latest, &claim, None)
11421 .await
11422 .map_err(|_| std::io::Error::other("concurrent admission failed"))?;
11423 self.real
11424 .persistence
11425 .checkpoint_runtime_session(session)
11426 .await
11427 }
11428 async fn load_runtime_session(&self, id: &str) -> std::io::Result<Option<Session>> {
11429 self.real.storage.load_session(id).await
11430 }
11431 }
11432
11433 struct CancelHistoryProbe {
11434 inner: HistoryTerminalProbe,
11435 cancel_requested: bool,
11436 }
11437
11438 #[async_trait]
11439 impl bamboo_subagent::ChildLink for CancelHistoryProbe {
11440 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
11441 self.cancel_requested |= matches!(frame, ParentFrame::Cancel);
11442 Ok(())
11443 }
11444 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
11445 if !self.cancel_requested {
11446 std::future::pending().await
11447 } else {
11448 self.inner.next_frame().await
11449 }
11450 }
11451 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
11452 self.inner.accept_durable_terminal(status);
11453 }
11454 fn durable_delivery_receipt(&self) -> Option<bamboo_subagent::DurableChildDeliveryReceipt> {
11455 self.inner.durable_delivery_receipt()
11456 }
11457 async fn acknowledge_durable_frames(&mut self) -> bamboo_subagent::TransportResult<()> {
11458 self.inner.acknowledge_durable_frames().await
11459 }
11460 }
11461
11462 #[tokio::test]
11463 async fn selected_cancelled_read_history_requires_checkpointed_complete_prefix() {
11464 use bamboo_subagent::ChildLink;
11465 for case in [
11466 "success",
11467 "empty",
11468 "missing",
11469 "unclosed",
11470 "unobserved",
11471 "prefix",
11472 "metadata",
11473 "write",
11474 "not_read_only",
11475 "watermark",
11476 "epoch",
11477 "birth",
11478 "permission",
11479 "inbox",
11480 "pending",
11481 "pending_eligible",
11482 "claimed",
11483 "inspect_error",
11484 "checkpoint_error",
11485 "lost_owner",
11486 "concurrent_input",
11487 ] {
11488 let temp = tempfile::tempdir().unwrap();
11489 let store = Arc::new(
11490 bamboo_storage::SessionStoreV2::new(temp.path().into())
11491 .await
11492 .unwrap(),
11493 );
11494 let root = Session::new("cancel-history-root", "model");
11495 store.save_session(&root).await.unwrap();
11496 let mut child = Session::new_child("cancel-history-child", &root.id, "model", "child");
11497 child
11498 .messages
11499 .push(bamboo_agent_core::Message::user("exact assignment"));
11500 child.metadata.insert(
11501 bamboo_subagent::proto::WORKFLOW_USAGE_REQUESTED_KEY.into(),
11502 "true".into(),
11503 );
11504 store.save_session(&child).await.unwrap();
11505 let before = serde_json::to_value(&child.messages).unwrap();
11506 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11507 store.clone(),
11508 bamboo_domain::SessionInboxLimits::default(),
11509 ));
11510 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11511 let mut binding = actor_binding(store.clone(), inbox.clone(), locked.clone());
11512 let mut owner = Some(
11513 binding
11514 .router
11515 .register_run(&child.id, "current")
11516 .await
11517 .unwrap(),
11518 );
11519 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11520 created_at: child.created_at,
11521 spawn_depth: child.spawn_depth,
11522 };
11523 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11524 let tool = if case == "write" { "Write" } else { "Read" };
11525 let arguments = serde_json::json!({"file_path":"assigned.txt"});
11526 let call = bamboo_agent_core::ToolCall {
11527 id: "cancel-read".into(),
11528 tool_type: "function".into(),
11529 function: bamboo_agent_core::FunctionCall {
11530 name: tool.into(),
11531 arguments: arguments.to_string(),
11532 },
11533 };
11534 let mut rows = child.messages.clone();
11535 let mut events = Vec::new();
11536 if case != "empty" {
11537 rows.push(bamboo_agent_core::Message::assistant("", Some(vec![call])));
11538 events.push(
11539 serde_json::to_value(AgentEvent::ToolStart {
11540 tool_call_id: "cancel-read".into(),
11541 tool_name: tool.into(),
11542 arguments,
11543 })
11544 .unwrap(),
11545 );
11546 if case != "unclosed" {
11547 let mut result = bamboo_agent_core::Message::tool_result_with_status(
11548 "cancel-read",
11549 "exact Read result",
11550 true,
11551 );
11552 if case == "metadata" {
11553 result.metadata = Some(serde_json::json!({"permission":true}));
11554 }
11555 rows.push(result);
11556 events.push(
11557 serde_json::to_value(AgentEvent::ToolComplete {
11558 tool_call_id: "cancel-read".into(),
11559 result: bamboo_agent_core::tools::ToolResult::text(
11560 true,
11561 "exact Read result",
11562 ),
11563 })
11564 .unwrap(),
11565 );
11566 }
11567 }
11568 if case == "unobserved" {
11569 events.clear();
11570 }
11571 if case == "prefix" {
11572 rows[0].content.push('!');
11573 }
11574 events.push(
11575 serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11576 version: 1,
11577 messages: rows
11578 .iter()
11579 .map(|row| serde_json::to_value(row).unwrap())
11580 .collect(),
11581 })
11582 .unwrap(),
11583 );
11584 if case == "missing" {
11585 events.clear();
11586 }
11587 let final_seq = events.len() as u64;
11588 let mut watermark = bamboo_subagent::ActorEventWatermark {
11589 version: 1,
11590 logical_session: Some(identity.clone()),
11591 activation_id: Some("current".into()),
11592 execution_epoch: 7,
11593 final_seq,
11594 };
11595 match case {
11596 "watermark" => watermark.final_seq += 1,
11597 "epoch" => watermark.execution_epoch -= 1,
11598 "birth" => {
11599 watermark
11600 .logical_session
11601 .as_mut()
11602 .unwrap()
11603 .creation
11604 .as_mut()
11605 .unwrap()
11606 .created_at += chrono::Duration::nanoseconds(1)
11607 }
11608 _ => {}
11609 }
11610 let mut frames = VecDeque::new();
11611 if !events.is_empty() {
11612 frames.push_back(ChildFrame::EventBatch {
11613 batch: ActorEventBatch {
11614 logical_session: Some(identity),
11615 activation_id: Some("current".into()),
11616 execution_epoch: 7,
11617 source_node_id: None,
11618 source_actor_id: Some("selected".into()),
11619 first_seq: 1,
11620 last_seq: final_seq,
11621 qos: bamboo_subagent::ActorEventQos::Durable,
11622 events,
11623 },
11624 });
11625 }
11626 frames.push_back(ChildFrame::Terminal {
11627 status: TerminalStatus::Cancelled,
11628 result: None,
11629 error: None,
11630 transcript: vec![],
11631 final_event_watermark: Some(watermark),
11632 });
11633 let mut claims = VecDeque::new();
11634 let pending_input =
11635 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "new canonical input");
11636 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11637 let receipt = inbox.deliver(&pending_input).await.unwrap();
11638 if case != "pending" {
11639 inbox
11640 .mark_activation_eligible(
11641 &child.id,
11642 receipt.generation,
11643 bamboo_domain::SessionActivationPolicy::RespectSpecificWait,
11644 )
11645 .await
11646 .unwrap();
11647 }
11648 if matches!(case, "inbox" | "claimed") {
11649 let claim = inbox.claim(&child.id, 1).await.unwrap().remove(0);
11650 if case == "inbox" {
11651 claims.push_back(claim);
11652 }
11653 }
11654 }
11655 if case == "concurrent_input" {
11656 binding.persistence = Arc::new(CancelConcurrentInputCheckpoint {
11657 real: actor_binding(store.clone(), inbox.clone(), locked.clone()),
11658 envelope: pending_input.clone(),
11659 });
11660 }
11661 if case == "inspect_error" {
11662 let rel = store.resolve_rel_path(&child.id).await.unwrap();
11663 let dir = store.bamboo_home_dir().join(rel).join("inbox");
11664 std::fs::create_dir_all(&dir).unwrap();
11665 std::fs::create_dir(dir.join(".session-inbox.lock")).unwrap();
11666 assert!(inbox.inspect(&child.id).await.is_err());
11667 }
11668 let backlog_before =
11669 if matches!(case, "inbox" | "pending" | "pending_eligible" | "claimed") {
11670 Some(inbox.inspect(&child.id).await.unwrap())
11671 } else {
11672 None
11673 };
11674 if case == "checkpoint_error" {
11675 binding.persistence = Arc::new(ActorFaultingPersistence {
11676 inner: locked.clone(),
11677 fail_checkpoint_once: AtomicBool::new(true),
11678 });
11679 }
11680 if case == "lost_owner" {
11681 owner.take().unwrap().abandon().await;
11682 }
11683 let mut link = CancelHistoryProbe {
11684 inner: HistoryTerminalProbe {
11685 frames,
11686 accepted: None,
11687 acks: 0,
11688 },
11689 cancel_requested: false,
11690 };
11691 let (tx, mut rx) = mpsc::channel(32);
11692 let (_live, mut live_rx) = mpsc::unbounded_channel();
11693 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11694 let cancel = CancellationToken::new();
11695 cancel.cancel();
11696 let tools = vec!["Read".into(), "Glob".into()];
11697 let mut strict_history = None;
11698 let outcome = drive(ActorDriveContext {
11699 client: &mut link,
11700 parent_session_id: &root.id,
11701 child_session_id: &child.id.clone(),
11702 child_attempt: 0,
11703 approval_registry: None,
11704 approval_decider: None,
11705 approval_reviewer: None,
11706 escalation_bridge: None,
11707 event_tx: &tx,
11708 cancel_token: &cancel,
11709 live_rx: &mut live_rx,
11710 delivery_rx: &mut delivery_rx,
11711 logical_session: &mut child,
11712 expected_permission_posture: (case == "permission")
11713 .then(|| expected_default_permission_posture(7)),
11714 expected_creation: Some(&creation),
11715 session_inbox_runtime: Some(&binding),
11716 actor_directory_store: None,
11717 canonical_subagent_tool: None,
11718 ticket_service: None,
11719 activation_run_id: Some("current"),
11720 execution_epoch: 7,
11721 expected_source_actor_id: "selected",
11722 initial_inflight_claims: claims,
11723 plain_actor: false,
11724 remote_environment_lease: false,
11725 readonly_output: None,
11726 local_history_tools: Some(&tools),
11727 local_history_read_only: case != "not_read_only",
11728 strict_history_output: Some(&mut strict_history),
11729 plain_input: None,
11730 canonical_activation: None,
11731 canonical_placement_ref: None,
11732 actor_event_observer: None,
11733 plain_run: None,
11734 first_frame_timeout: None,
11735 })
11736 .await;
11737 let proven = matches!(case, "success" | "empty");
11738 assert_eq!(strict_history.is_some(), proven, "{case}");
11739 assert_eq!(link.durable_delivery_receipt().is_some(), proven, "{case}");
11740 assert_eq!(
11741 link.inner.acks, 0,
11742 "{case}: pump cannot ACK before SDK save/proof"
11743 );
11744 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
11745 .await
11746 .unwrap()
11747 .load_session(&child.id)
11748 .await
11749 .unwrap()
11750 .unwrap();
11751 if proven {
11752 assert!(matches!(outcome, Err(AgentError::Cancelled)), "{case}");
11753 assert_eq!(link.inner.accepted, Some(TerminalStatus::Cancelled));
11754 let history = strict_history.unwrap();
11755 assert_eq!(history.proof.execution_epoch(), 7);
11756 assert_eq!(history.proof.contiguous_applied_seq(), final_seq);
11757 assert_eq!(
11758 serde_json::to_value(&cold.messages).unwrap(),
11759 serde_json::to_value(&rows).unwrap()
11760 );
11761 } else {
11762 assert!(outcome.is_err(), "{case}");
11763 if case == "concurrent_input" {
11764 assert!(
11765 cold.messages
11766 .iter()
11767 .any(|message| message.id == pending_input.id.as_str()),
11768 "new input must survive refusal"
11769 );
11770 } else {
11771 assert_eq!(
11772 serde_json::to_value(&cold.messages).unwrap(),
11773 before,
11774 "{case}: no unvalidated history write"
11775 );
11776 }
11777 }
11778 if let Some(backlog) = backlog_before {
11779 assert!(!inbox
11780 .was_admitted(&child.id, &pending_input.id)
11781 .await
11782 .unwrap());
11783 assert_eq!(
11784 inbox.inspect(&child.id).await.unwrap(),
11785 backlog,
11786 "{case}: proof refusal must neither claim nor ACK the input"
11787 );
11788 }
11789 while let Ok(event) = rx.try_recv() {
11790 assert!(
11791 !matches!(event, AgentEvent::SessionHistoryCommitted { .. }),
11792 "private DATA is not a canonical public barrier"
11793 );
11794 }
11795 }
11796 }
11797
11798 #[tokio::test]
11799 async fn strict_terminal_watermark_gates_history_commit_and_durable_acceptance() {
11800 use bamboo_subagent::ChildLink;
11801 for case in [
11802 "success",
11803 "tail",
11804 "short",
11805 "missing",
11806 "version",
11807 "activation",
11808 "epoch",
11809 "birth",
11810 "duplicate",
11811 "overlap",
11812 "reorder",
11813 "midgap",
11814 "qos",
11815 "zero_missing",
11816 "zero_valid",
11817 "error_tail",
11818 "cancelled_tail",
11819 "cancelled_valid",
11820 "cancelled_zero_valid",
11821 "error_valid",
11822 "error_zero_valid",
11823 "ordinary_lossy",
11824 ] {
11825 let temp = tempfile::tempdir().unwrap();
11826 let store = Arc::new(
11827 bamboo_storage::SessionStoreV2::new(temp.path().into())
11828 .await
11829 .unwrap(),
11830 );
11831 let root = Session::new("watermark-root", "model");
11832 store.save_session(&root).await.unwrap();
11833 let mut child = Session::new_child("watermark-child", &root.id, "model", "child");
11834 child
11835 .messages
11836 .push(bamboo_agent_core::Message::user("assignment"));
11837 store.save_session(&child).await.unwrap();
11838 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
11839 store.clone(),
11840 bamboo_domain::SessionInboxLimits::default(),
11841 ));
11842 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
11843 let binding = actor_binding(store.clone(), inbox, locked);
11844 let _owner = binding
11845 .router
11846 .register_run(&child.id, "current")
11847 .await
11848 .unwrap();
11849 let before = serde_json::to_value(&child).unwrap();
11850 let creation = bamboo_subagent::proto::ChildCreationIdentity {
11851 created_at: child.created_at,
11852 spawn_depth: child.spawn_depth,
11853 };
11854 let identity = actor_event_logical_identity(&child, &root.id, Some(&creation));
11855 let mut rows = child.messages.clone();
11856 rows.push(bamboo_agent_core::Message::assistant("complete", None));
11857 let data = serde_json::to_value(bamboo_subagent::proto::LocalToolMessages::Complete {
11858 version: 1,
11859 messages: rows
11860 .iter()
11861 .map(|row| serde_json::to_value(row).unwrap())
11862 .collect(),
11863 })
11864 .unwrap();
11865 let batch = |seq, event, qos| ActorEventBatch {
11866 logical_session: Some(identity.clone()),
11867 activation_id: Some("current".into()),
11868 execution_epoch: 7,
11869 source_node_id: None,
11870 source_actor_id: Some("selected".into()),
11871 first_seq: seq,
11872 last_seq: seq,
11873 qos,
11874 events: vec![event],
11875 };
11876 let durable = bamboo_subagent::ActorEventQos::Durable;
11877 let token = serde_json::json!({"type":"token", "content":"observation"});
11878 let mut frames = vec![ChildFrame::EventBatch {
11879 batch: batch(1, data.clone(), durable),
11880 }];
11881 let mut marker = bamboo_subagent::ActorEventWatermark {
11882 version: 1,
11883 logical_session: Some(identity.clone()),
11884 activation_id: Some("current".into()),
11885 execution_epoch: 7,
11886 final_seq: 1,
11887 };
11888 match case {
11889 "tail" | "error_tail" | "cancelled_tail" => marker.final_seq = 2,
11890 "short" => marker.final_seq = 0,
11891 "version" => marker.version = 99,
11892 "activation" => marker.activation_id = Some("old".into()),
11893 "epoch" => marker.execution_epoch = 6,
11894 "birth" => {
11895 marker
11896 .logical_session
11897 .as_mut()
11898 .unwrap()
11899 .creation
11900 .as_mut()
11901 .unwrap()
11902 .created_at += chrono::Duration::nanoseconds(1)
11903 }
11904 "duplicate" => frames.push(frames[0].clone()),
11905 "overlap" | "midgap" | "qos" => {
11906 frames = vec![
11907 ChildFrame::EventBatch {
11908 batch: batch(
11909 1,
11910 token.clone(),
11911 if case == "qos" {
11912 bamboo_subagent::ActorEventQos::Ephemeral
11913 } else {
11914 durable
11915 },
11916 ),
11917 },
11918 ChildFrame::EventBatch {
11919 batch: batch(
11920 if case == "overlap" {
11921 1
11922 } else if case == "midgap" {
11923 3
11924 } else {
11925 2
11926 },
11927 data,
11928 durable,
11929 ),
11930 },
11931 ];
11932 marker.final_seq = 2;
11933 }
11934 "reorder" => {
11935 frames = vec![
11936 ChildFrame::EventBatch {
11937 batch: batch(2, data, durable),
11938 },
11939 ChildFrame::EventBatch {
11940 batch: batch(1, token, durable),
11941 },
11942 ]
11943 }
11944 "zero_missing" | "zero_valid" | "error_zero_valid" | "cancelled_zero_valid" => {
11945 frames.clear();
11946 marker.final_seq = 0;
11947 }
11948 "ordinary_lossy" => {
11949 frames = vec![ChildFrame::EventBatch {
11950 batch: batch(3, token, bamboo_subagent::ActorEventQos::Ephemeral),
11951 }]
11952 }
11953 _ => {}
11954 }
11955 let status = match case {
11956 "error_tail" | "error_valid" | "error_zero_valid" => TerminalStatus::Error,
11957 "cancelled_tail" | "cancelled_valid" | "cancelled_zero_valid" => {
11958 TerminalStatus::Cancelled
11959 }
11960 _ => TerminalStatus::Completed,
11961 };
11962 frames.push(ChildFrame::Terminal {
11963 status,
11964 result: Some("complete".into()),
11965 error: Some("worker failed".into()),
11966 transcript: vec![],
11967 final_event_watermark: (!matches!(
11968 case,
11969 "missing" | "zero_missing" | "ordinary_lossy"
11970 ))
11971 .then_some(marker),
11972 });
11973 let mut link = HistoryTerminalProbe {
11974 frames: frames.into(),
11975 accepted: None,
11976 acks: 0,
11977 };
11978 let (tx, mut rx) = mpsc::channel(32);
11979 let (_live, mut live_rx) = mpsc::unbounded_channel();
11980 let (_delivery, mut delivery_rx) = mpsc::unbounded_channel();
11981 let cancel = CancellationToken::new();
11982 let tools = vec!["Read".into()];
11983 let mut strict_history = None;
11984 let outcome = drive(ActorDriveContext {
11985 client: &mut link,
11986 parent_session_id: &root.id,
11987 child_session_id: "watermark-child",
11988 child_attempt: 0,
11989 approval_registry: None,
11990 approval_decider: None,
11991 approval_reviewer: None,
11992 escalation_bridge: None,
11993 event_tx: &tx,
11994 cancel_token: &cancel,
11995 live_rx: &mut live_rx,
11996 delivery_rx: &mut delivery_rx,
11997 logical_session: &mut child,
11998 expected_permission_posture: None,
11999 expected_creation: Some(&creation),
12000 session_inbox_runtime: Some(&binding),
12001 actor_directory_store: None,
12002 canonical_subagent_tool: None,
12003 ticket_service: None,
12004 activation_run_id: Some("current"),
12005 execution_epoch: 7,
12006 expected_source_actor_id: "selected",
12007 initial_inflight_claims: vec![].into(),
12008 plain_actor: false,
12009 remote_environment_lease: false,
12010 readonly_output: None,
12011 local_history_tools: (case != "ordinary_lossy").then_some(&tools),
12012 local_history_read_only: true,
12013 strict_history_output: Some(&mut strict_history),
12014 plain_input: None,
12015 canonical_activation: None,
12016 canonical_placement_ref: None,
12017 actor_event_observer: None,
12018 plain_run: None,
12019 first_frame_timeout: Some(Duration::from_secs(1)),
12020 })
12021 .await;
12022 assert_eq!(strict_history.is_some(), case == "success", "{case}");
12023 if let Some(history) = strict_history {
12024 assert_eq!(history.proof.execution_epoch(), 7);
12025 assert_eq!(history.proof.contiguous_applied_seq(), 1);
12026 assert_eq!(history.watermark.final_seq, 1);
12027 }
12028 let cold = store.load_session(&child.id).await.unwrap().unwrap();
12029 if matches!(case, "success" | "ordinary_lossy") {
12030 assert_eq!(outcome.unwrap().as_deref(), Some("complete"), "{case}");
12031 } else {
12032 let error = outcome.unwrap_err();
12033 if matches!(case, "cancelled_valid" | "cancelled_zero_valid") {
12034 assert!(matches!(&error, AgentError::Cancelled), "{case}: {error}");
12035 }
12036 let error = error.to_string();
12037 if !matches!(
12038 case,
12039 "duplicate"
12040 | "overlap"
12041 | "reorder"
12042 | "midgap"
12043 | "qos"
12044 | "zero_valid"
12045 | "error_valid"
12046 | "error_zero_valid"
12047 | "cancelled_valid"
12048 | "cancelled_zero_valid"
12049 ) {
12050 assert!(
12051 error.contains("actor_history_tail_incomplete"),
12052 "{case}: {error}"
12053 );
12054 }
12055 }
12056 if case == "success" {
12057 assert_eq!(
12058 serde_json::to_value(&cold.messages).unwrap(),
12059 serde_json::to_value(rows).unwrap()
12060 );
12061 } else {
12062 assert_eq!(
12063 serde_json::to_value(&cold).unwrap(),
12064 before,
12065 "{case}: no cold history commit"
12066 );
12067 assert_eq!(
12068 serde_json::to_value(&child).unwrap(),
12069 before,
12070 "{case}: no mutable history commit"
12071 );
12072 }
12073 let accepted = matches!(case, "success" | "ordinary_lossy");
12074 assert_eq!(link.accepted.is_some(), accepted, "{case}");
12075 assert_eq!(
12076 link.durable_delivery_receipt().is_some(),
12077 accepted,
12078 "{case}"
12079 );
12080 assert_eq!(link.acks, 0, "frame pump never ACKs uncheckpointed history");
12081 while let Ok(event) = rx.try_recv() {
12082 assert!(
12083 matches!(event, AgentEvent::Token { .. }),
12084 "typed DATA cannot leak"
12085 );
12086 }
12087 }
12088 }
12089
12090 fn processless_pool_worker(mailbox_id: &str) -> PooledWorker {
12091 PooledWorker {
12092 worker: SpawnedChild::remote(AgentRecord {
12093 agent_id: mailbox_id.to_string(),
12094 role: "test".to_string(),
12095 labels: Vec::new(),
12096 endpoint: "ws://127.0.0.1:1".to_string(),
12097 pid: 0,
12098 version: String::new(),
12099 started_at: chrono::Utc::now(),
12100 lease_expires_at: chrono::Utc::now(),
12101 }),
12102 mailbox_id: mailbox_id.to_string(),
12103 parked_at: None,
12104 }
12105 }
12106
12107 #[test]
12108 fn parked_worker_deadline_is_inclusive_and_ignores_checked_out_workers() {
12109 let now = Instant::now();
12110 let timeout = Duration::from_secs(300);
12111 assert!(!parked_worker_expired(None, now, timeout));
12112 assert!(!parked_worker_expired(
12113 Some(now - timeout + Duration::from_millis(1)),
12114 now,
12115 timeout
12116 ));
12117 assert!(parked_worker_expired(Some(now - timeout), now, timeout));
12118 }
12119
12120 #[test]
12121 fn idle_pool_global_cap_bounds_distinct_fingerprints() {
12122 let mut pool = WorkerPool::new();
12123 pool.insert(
12124 "fingerprint-a".to_string(),
12125 vec![processless_pool_worker("a")],
12126 );
12127 pool.insert(
12128 "fingerprint-b".to_string(),
12129 vec![processless_pool_worker("b")],
12130 );
12131
12132 assert_eq!(
12133 idle_pool_limit_reason(&pool, "fingerprint-c", 4, 2),
12134 Some("pool_global_limit")
12135 );
12136 assert_eq!(idle_pool_limit_reason(&pool, "fingerprint-c", 4, 3), None);
12137 assert_eq!(
12138 idle_pool_limit_reason(&pool, "fingerprint-a", 1, 8),
12139 Some("pool_per_key_limit")
12140 );
12141 }
12142
12143 #[test]
12144 fn pool_sweep_removes_dead_workers_and_empty_fingerprint_buckets() {
12145 let mut pool = WorkerPool::new();
12146 pool.insert(
12147 "fingerprint-a".to_string(),
12148 vec![processless_pool_worker("dead")],
12149 );
12150
12151 let reaped = take_reapable_workers(
12152 &mut pool,
12153 Instant::now(),
12154 Duration::from_secs(POOLED_IDLE_TIMEOUT_SECS),
12155 );
12156
12157 assert!(pool.is_empty());
12158 assert_eq!(reaped.len(), 1);
12159 assert_eq!(reaped[0].1, PoolReapReason::ProcessExited);
12160 }
12161
12162 #[test]
12163 fn actor_preflight_counts_only_current_session_scoped_denies() {
12164 let config = bamboo_tools::permission::PermissionConfig::new();
12165 let secret_matcher = "TOP_SECRET_ACTOR_DENY_MATCHER";
12166 config.deny_scoped_session_permission(
12167 "target-session",
12168 bamboo_tools::permission::PermissionType::ExecuteCommand,
12169 secret_matcher,
12170 );
12171 config.deny_scoped_session_permission(
12172 "other-session",
12173 bamboo_tools::permission::PermissionType::WriteFile,
12174 "/other/**",
12175 );
12176
12177 assert_eq!(
12178 active_scoped_session_deny_count(&config, "target-session"),
12179 1
12180 );
12181 assert_eq!(
12182 active_scoped_session_deny_count(&config, "clean-session"),
12183 0
12184 );
12185 let error = ensure_no_active_scoped_session_denies(&config, "target-session")
12186 .unwrap_err()
12187 .to_string();
12188 assert!(!error.contains(secret_matcher));
12189 ensure_no_active_scoped_session_denies(&config, "clean-session")
12190 .expect("another session's deny must not block this activation");
12191 }
12192
12193 #[test]
12194 fn permission_posture_event_rejects_oversized_executor_mapping() {
12195 let session = Session::new("mapping-bound", "model");
12196 let event = AgentEvent::PermissionPostureActivated {
12197 session_id: session.id.clone(),
12198 policy_revision: 1,
12199 requested_mode: "default".to_string(),
12200 effective_mode: "default".to_string(),
12201 executor_mapping: "x".repeat(bamboo_domain::MAX_PERMISSION_EXECUTOR_MAPPING_CHARS + 1),
12202 };
12203
12204 assert!(permission_posture_seed_from_event(&session, &event)
12205 .unwrap_err()
12206 .contains("executor mapping"));
12207 }
12208
12209 #[test]
12210 fn remote_audit_revision_and_timestamp_fields_cannot_poison_host_audit() {
12211 let mut session = Session::new("host-resigns-audit", "model");
12212 let hostile_timestamp = "9".repeat(1024);
12213 let event: AgentEvent = serde_json::from_value(serde_json::json!({
12214 "type": "permission_posture_activated",
12215 "session_id": session.id.clone(),
12216 "policy_revision": 31,
12217 "requested_mode": "default",
12218 "effective_mode": "default",
12219 "executor_mapping": "codex_exec:approval_policy=never",
12220 "audit_revision": u64::MAX,
12221 "transitioned_at": hostile_timestamp,
12222 }))
12223 .expect("unknown remote audit fields are ignored by the typed event");
12224 let seed = permission_posture_seed_from_event(&session, &event)
12225 .unwrap()
12226 .expect("permission event");
12227
12228 let host_revision =
12229 bamboo_domain::record_permission_audit(&mut session.metadata, &seed, None).unwrap();
12230 let host_audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
12231 .expect("host-generated complete audit");
12232 assert_eq!(host_audit.audit_revision, host_revision);
12233 assert!(host_audit.audit_revision < bamboo_domain::MAX_PERMISSION_AUDIT_REVISION);
12234 assert_ne!(host_audit.transitioned_at, hostile_timestamp);
12235 assert!(chrono::DateTime::parse_from_rfc3339(&host_audit.transitioned_at).is_ok());
12236 }
12237
12238 struct ActorFaultingPersistence {
12239 inner: Arc<bamboo_storage::LockedSessionStore>,
12240 fail_checkpoint_once: std::sync::atomic::AtomicBool,
12241 }
12242
12243 #[async_trait]
12244 impl RuntimeSessionPersistence for ActorFaultingPersistence {
12245 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12246 self.inner.merge_save_runtime(session).await
12247 }
12248
12249 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
12250 if self
12251 .fail_checkpoint_once
12252 .swap(false, std::sync::atomic::Ordering::SeqCst)
12253 {
12254 return Err(std::io::Error::other("injected actor checkpoint failure"));
12255 }
12256 self.inner.checkpoint_runtime_session(session).await
12257 }
12258
12259 async fn load_runtime_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
12260 self.inner.storage().load_session(session_id).await
12261 }
12262 }
12263
12264 struct ActorFailBeforeAckInbox {
12265 inner: Arc<dyn SessionInboxPort>,
12266 fail_once: std::sync::atomic::AtomicBool,
12267 }
12268
12269 #[async_trait]
12270 impl SessionInboxPort for ActorFailBeforeAckInbox {
12271 async fn deliver(
12272 &self,
12273 envelope: &bamboo_domain::SessionMessageEnvelope,
12274 ) -> Result<bamboo_domain::SessionInboxReceipt, bamboo_domain::SessionInboxError> {
12275 self.inner.deliver(envelope).await
12276 }
12277
12278 async fn mark_activation_eligible(
12279 &self,
12280 target_session_id: &str,
12281 generation: u64,
12282 policy: bamboo_domain::SessionActivationPolicy,
12283 ) -> Result<(), bamboo_domain::SessionInboxError> {
12284 self.inner
12285 .mark_activation_eligible(target_session_id, generation, policy)
12286 .await
12287 }
12288
12289 async fn claim(
12290 &self,
12291 target_session_id: &str,
12292 limit: usize,
12293 ) -> Result<Vec<SessionInboxClaim>, bamboo_domain::SessionInboxError> {
12294 self.inner.claim(target_session_id, limit).await
12295 }
12296
12297 async fn was_admitted(
12298 &self,
12299 target_session_id: &str,
12300 id: &bamboo_domain::SessionMessageId,
12301 ) -> Result<bool, bamboo_domain::SessionInboxError> {
12302 self.inner.was_admitted(target_session_id, id).await
12303 }
12304
12305 async fn ack(
12306 &self,
12307 target_session_id: &str,
12308 claim: &SessionInboxClaim,
12309 ) -> Result<(), bamboo_domain::SessionInboxError> {
12310 if self
12311 .fail_once
12312 .swap(false, std::sync::atomic::Ordering::SeqCst)
12313 {
12314 return Err(bamboo_domain::SessionInboxError::Storage(
12315 "injected actor pre-ack failure".to_string(),
12316 ));
12317 }
12318 self.inner.ack(target_session_id, claim).await
12319 }
12320
12321 async fn inspect(
12322 &self,
12323 target_session_id: &str,
12324 ) -> Result<bamboo_domain::SessionInboxBacklog, bamboo_domain::SessionInboxError> {
12325 self.inner.inspect(target_session_id).await
12326 }
12327 }
12328
12329 async fn actor_inbox_fixture(
12330 session_id: &str,
12331 ) -> (
12332 tempfile::TempDir,
12333 Arc<bamboo_storage::SessionStoreV2>,
12334 Arc<bamboo_storage::LockedSessionStore>,
12335 Arc<dyn SessionInboxPort>,
12336 Session,
12337 SessionInboxClaim,
12338 ) {
12339 let temp = tempfile::tempdir().unwrap();
12340 let store = Arc::new(
12341 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
12342 .await
12343 .unwrap(),
12344 );
12345 let storage: Arc<dyn Storage> = store.clone();
12346 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage));
12347 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12348 store.clone(),
12349 bamboo_domain::SessionInboxLimits::default(),
12350 ));
12351 let session = Session::new(session_id, "model");
12352 store.save_session(&session).await.unwrap();
12353 let mut envelope =
12354 bamboo_domain::SessionMessageEnvelope::user_input(session_id, "actor follow-up");
12355 envelope.id =
12356 bamboo_domain::SessionMessageId::parse(format!("{session_id}-message")).unwrap();
12357 let receipt = inbox.deliver(&envelope).await.unwrap();
12358 inbox
12359 .mark_activation_eligible(
12360 session_id,
12361 receipt.generation,
12362 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12363 )
12364 .await
12365 .unwrap();
12366 let claim = inbox.claim(session_id, 1).await.unwrap().remove(0);
12367 (temp, store, locked, inbox, session, claim)
12368 }
12369
12370 fn actor_binding(
12371 store: Arc<bamboo_storage::SessionStoreV2>,
12372 inbox: Arc<dyn SessionInboxPort>,
12373 persistence: Arc<dyn RuntimeSessionPersistence>,
12374 ) -> SessionInboxRuntimeBinding {
12375 let storage: Arc<dyn Storage> = store;
12376 SessionInboxRuntimeBinding {
12377 router: SessionActivationRouter::new(),
12378 inbox,
12379 storage,
12380 persistence,
12381 parent_question_lock: None,
12382 }
12383 }
12384
12385 #[tokio::test]
12386 async fn remote_claim_seed_rejects_unsupported_inbox_before_dispatch() {
12387 let (_temp, store, locked, inbox, mut session, claim) =
12388 actor_inbox_fixture("unsupported-remote-inbox").await;
12389 let wrapped: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12390 inner: inbox.clone(),
12391 fail_once: std::sync::atomic::AtomicBool::new(false),
12392 });
12393 let binding = actor_binding(store.clone(), wrapped, locked);
12394 let fence = ActorActivationFence {
12395 schema_version: 1,
12396 actor_id: session.id.clone(),
12397 activation_id: "unsupported-inbox-activation".into(),
12398 attempt: 1,
12399 run_id: "unsupported-inbox-run".into(),
12400 lease_owner: "unsupported-inbox-host".into(),
12401 lease_epoch: 1,
12402 };
12403 let before = session.clone();
12404
12405 let error = checkpoint_claim_context_before_dispatch(
12406 &binding,
12407 &mut session,
12408 &[claim],
12409 Some((store.as_ref(), &fence)),
12410 )
12411 .await
12412 .unwrap_err();
12413 assert!(error
12414 .to_string()
12415 .contains("requires canonical FileSessionInbox"));
12416 assert_eq!(
12417 serde_json::to_value(&session.messages).unwrap(),
12418 serde_json::to_value(&before.messages).unwrap()
12419 );
12420 assert_eq!(
12421 serde_json::to_value(
12422 &store
12423 .load_session(&session.id)
12424 .await
12425 .unwrap()
12426 .unwrap()
12427 .messages
12428 )
12429 .unwrap(),
12430 serde_json::to_value(&before.messages).unwrap()
12431 );
12432 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12433 }
12434
12435 #[tokio::test]
12436 async fn remote_broker_lineage_rejects_missing_parent_and_project_drift() {
12437 let (_temp, store, locked, inbox, parent, _claim) =
12438 actor_inbox_fixture("remote-parent").await;
12439 let child = Session::new_child_of("remote-child", &parent, "model", "child");
12440 store.save_session(&child).await.unwrap();
12441 let binding = actor_binding(store.clone(), inbox, locked);
12442 let first = remote_canonical_lineage(&binding, &child).await.unwrap();
12443 assert_eq!(first.len(), 2);
12444 let mut changed = child.clone();
12445 changed.set_project_id_meta("different-project");
12446 assert!(remote_canonical_lineage(&binding, &changed).await.is_err());
12447 store.delete_session(&parent.id).await.unwrap();
12448 assert!(remote_canonical_lineage(&binding, &child).await.is_err());
12449 }
12450
12451 #[tokio::test]
12452 async fn spawn_depth_remote_lineage_accepts_existing_tree_above_default() {
12453 let (_temp, store, locked, inbox, root, _claim) =
12454 actor_inbox_fixture("deep-remote-root").await;
12455 let mut parent = root;
12456 for depth in 1..=6 {
12457 let child =
12458 Session::new_child_of(format!("remote-depth-{depth}"), &parent, "model", "child");
12459 store.save_session(&child).await.unwrap();
12460 parent = child;
12461 }
12462 let binding = actor_binding(store, inbox, locked);
12463 let chain = remote_canonical_lineage(&binding, &parent).await.unwrap();
12464 assert_eq!(chain.len(), 7);
12465 assert_eq!(chain.last().unwrap().spawn_depth, 0);
12466 }
12467
12468 #[tokio::test]
12469 async fn child_creation_requires_canonical_host_lifetime_not_cached_candidate() {
12470 let (_temp, store, locked, inbox, parent, _claim) =
12471 actor_inbox_fixture("creation-parent").await;
12472 let mut child = Session::new_child_of("creation-child", &parent, "model", "child");
12473 child.set_project_id_meta("creation-project");
12474 store.save_session(&child).await.unwrap();
12475 let binding = actor_binding(store.clone(), inbox, locked);
12476 let job = SpawnJob {
12477 parent_session_id: parent.id.clone(),
12478 child_session_id: child.id.clone(),
12479 model: "model".into(),
12480 disabled_tools: None,
12481 };
12482 let creation = canonical_child_creation(&binding, &child, &job)
12483 .await
12484 .unwrap();
12485 assert_eq!(creation.created_at, child.created_at);
12486 assert_eq!(creation.spawn_depth, child.spawn_depth);
12487 for damage in 0..7 {
12488 let mut held = child.clone();
12489 match damage {
12490 0 => held.created_at += chrono::Duration::nanoseconds(1),
12491 1 => held.parent_session_id = Some("foreign-parent".into()),
12492 2 => held.root_session_id = "foreign-root".into(),
12493 3 => held.spawn_depth += 1,
12494 4 => held.set_project_id_meta("foreign-project"),
12495 5 => held.kind = bamboo_domain::SessionKind::Root,
12496 _ => held.id = " creation-child ".into(),
12497 }
12498 assert!(
12499 canonical_child_creation(&binding, &held, &job)
12500 .await
12501 .is_err(),
12502 "damage {damage}"
12503 );
12504 }
12505 store.delete_session(&child.id).await.unwrap();
12506 assert!(canonical_child_creation(&binding, &child, &job)
12507 .await
12508 .is_err());
12509 let mut replacement = Session::new_child_of(&child.id, &parent, "model", "replacement");
12510 replacement.set_project_id_meta("creation-project");
12511 assert_ne!(replacement.created_at, child.created_at);
12512 store.save_session(&replacement).await.unwrap();
12513 assert!(
12514 canonical_child_creation(&binding, &child, &job)
12515 .await
12516 .is_err(),
12517 "old held spec cannot adopt replacement birth"
12518 );
12519 canonical_child_creation(&binding, &replacement, &job)
12520 .await
12521 .unwrap();
12522 }
12523
12524 #[tokio::test]
12525 async fn actor_immediate_claims_keep_exact_policy_across_mixed_order_ack_and_restart() {
12526 use bamboo_domain::{SessionActivationPolicy, SessionInboxLimits, SessionMessageEnvelope};
12527 for policies in [
12528 [
12529 SessionActivationPolicy::RespectSpecificWait,
12530 SessionActivationPolicy::InterruptSpecificWait,
12531 ],
12532 [
12533 SessionActivationPolicy::InterruptSpecificWait,
12534 SessionActivationPolicy::RespectSpecificWait,
12535 ],
12536 ] {
12537 let temp = tempfile::tempdir().unwrap();
12538 let store = Arc::new(
12539 bamboo_storage::SessionStoreV2::new(temp.path().into())
12540 .await
12541 .unwrap(),
12542 );
12543 let session_id = "actor-mixed-intent-policy";
12544 store
12545 .save_session(&Session::new(session_id, "model"))
12546 .await
12547 .unwrap();
12548 let inbox = bamboo_storage::FileSessionInbox::new(store, SessionInboxLimits::default());
12549 let staged = SessionMessageEnvelope::user_input(session_id, "staged sibling");
12550 let staged_receipt = inbox.deliver(&staged).await.unwrap();
12551 let mut sent = Vec::new();
12552 for policy in policies {
12553 let envelope = SessionMessageEnvelope::user_input(session_id, "immediate");
12554 let receipt = inbox
12555 .deliver_with_activation_intent(&envelope, policy, None)
12556 .await
12557 .unwrap();
12558 sent.push((envelope, policy, receipt));
12559 }
12560 let restarted = Arc::new(
12561 bamboo_storage::SessionStoreV2::new(temp.path().into())
12562 .await
12563 .unwrap(),
12564 );
12565 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12566 restarted.clone(),
12567 SessionInboxLimits::default(),
12568 ));
12569 let binding = actor_binding(
12570 restarted.clone(),
12571 inbox.clone(),
12572 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12573 );
12574 let mut session = restarted.load_session(session_id).await.unwrap().unwrap();
12575 let pairs =
12576 claim_canonical_deliveries(&binding, &mut session, "run-1", usize::MAX, None)
12577 .await
12578 .unwrap();
12579 assert_eq!(pairs.len(), 2, "the staged sibling has no permission");
12580 for ((claim, delivery), (envelope, policy, receipt)) in pairs.iter().zip(&sent) {
12581 assert_eq!(claim.envelope.id, envelope.id);
12582 assert_eq!(claim.generation, receipt.generation);
12583 assert_eq!(claim.activation_policy, *policy);
12584 assert_eq!(delivery.activation_policy, *policy);
12585 checkpoint_and_ack_canonical_claim(&binding, &mut session, claim, None)
12586 .await
12587 .unwrap();
12588 }
12589 let restarted = Arc::new(
12590 bamboo_storage::SessionStoreV2::new(temp.path().into())
12591 .await
12592 .unwrap(),
12593 );
12594 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12595 restarted.clone(),
12596 SessionInboxLimits::default(),
12597 ));
12598 let binding = actor_binding(
12599 restarted.clone(),
12600 inbox.clone(),
12601 Arc::new(bamboo_storage::LockedSessionStore::new(restarted.clone())),
12602 );
12603 session = restarted.load_session(session_id).await.unwrap().unwrap();
12604 for (envelope, policy, receipt) in &sent {
12605 assert_eq!(
12606 inbox
12607 .deliver_with_activation_intent(envelope, *policy, None)
12608 .await
12609 .unwrap(),
12610 *receipt
12611 );
12612 }
12613 assert!(
12614 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12615 .await
12616 .unwrap()
12617 .is_empty()
12618 );
12619 inbox
12620 .mark_activation_eligible(
12621 session_id,
12622 staged_receipt.generation,
12623 SessionActivationPolicy::RespectSpecificWait,
12624 )
12625 .await
12626 .unwrap();
12627 let pairs =
12628 claim_canonical_deliveries(&binding, &mut session, "run-2", usize::MAX, None)
12629 .await
12630 .unwrap();
12631 assert_eq!(pairs.len(), 1);
12632 assert_eq!(pairs[0].0.envelope.id, staged.id);
12633 assert_eq!(
12634 pairs[0].1.activation_policy,
12635 SessionActivationPolicy::RespectSpecificWait
12636 );
12637 }
12638 }
12639
12640 #[tokio::test]
12641 async fn actor_mismatched_typed_marker_id_collision_never_acks() {
12642 let (_temp, store, locked, inbox, mut session, claim) =
12643 actor_inbox_fixture("actor-live-id-collision").await;
12644 let mut forged = claim.envelope.to_provider_message().unwrap();
12645 forged.metadata = Some(serde_json::json!({
12646 "session_message": {
12647 "id": claim.envelope.id,
12648 "target_session_id": "different-session"
12649 }
12650 }));
12651 session.add_message(forged);
12652 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12653 let binding = actor_binding(store, inbox.clone(), persistence);
12654
12655 assert!(
12656 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12657 .await
12658 .is_err()
12659 );
12660 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12661 assert!(!inbox
12662 .was_admitted(&session.id, &claim.envelope.id)
12663 .await
12664 .unwrap());
12665 }
12666
12667 #[tokio::test]
12668 async fn actor_concurrent_durable_id_collision_after_claim_never_acks() {
12669 let (_temp, store, locked, inbox, mut session, claim) =
12670 actor_inbox_fixture("actor-durable-id-collision").await;
12671 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12672 let mut forged = bamboo_agent_core::Message::user("concurrent actor collision");
12673 forged.id = claim.envelope.id.to_string();
12674 concurrent.add_message(forged);
12675 store.save_session(&concurrent).await.unwrap();
12676 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12677 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12678
12679 assert!(
12680 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12681 .await
12682 .is_err()
12683 );
12684 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12685 assert!(!inbox
12686 .was_admitted(&session.id, &claim.envelope.id)
12687 .await
12688 .unwrap());
12689 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12690 assert!(!durable.messages.iter().any(|message| {
12691 bamboo_domain::is_matching_session_message(message, &claim.envelope)
12692 }));
12693 }
12694
12695 #[tokio::test]
12696 async fn actor_concurrent_durable_typed_body_mismatch_never_acks() {
12697 let (_temp, store, locked, inbox, mut session, claim) =
12698 actor_inbox_fixture("actor-durable-typed-body-collision").await;
12699 let mut different = claim.envelope.clone();
12700 different.body = bamboo_domain::SessionMessageBody::Content(
12701 bamboo_domain::SessionMessageContent::text("forged actor body"),
12702 );
12703 let mut concurrent = store.load_session(&session.id).await.unwrap().unwrap();
12704 concurrent.add_message(different.to_provider_message().unwrap());
12705 store.save_session(&concurrent).await.unwrap();
12706 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12707 let binding = actor_binding(store.clone(), inbox.clone(), persistence);
12708
12709 assert!(
12710 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12711 .await
12712 .is_err()
12713 );
12714 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12715 assert!(!inbox
12716 .was_admitted(&session.id, &claim.envelope.id)
12717 .await
12718 .unwrap());
12719 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12720 assert!(!durable
12721 .messages
12722 .iter()
12723 .any(|message| bamboo_domain::is_matching_session_message(message, &claim.envelope)));
12724 }
12725
12726 #[tokio::test]
12727 async fn actor_checkpoint_failure_rolls_back_and_restart_admits_once() {
12728 let (_temp, store, locked, inbox, mut session, claim) =
12729 actor_inbox_fixture("actor-checkpoint-failure").await;
12730 let envelope_id = claim.envelope.id.clone();
12731 let fault: Arc<dyn RuntimeSessionPersistence> = Arc::new(ActorFaultingPersistence {
12732 inner: locked.clone(),
12733 fail_checkpoint_once: std::sync::atomic::AtomicBool::new(true),
12734 });
12735 let binding = actor_binding(store.clone(), inbox.clone(), fault);
12736
12737 assert!(
12738 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12739 .await
12740 .is_err()
12741 );
12742 assert!(!session
12743 .messages
12744 .iter()
12745 .any(|message| message.id == envelope_id.as_str()));
12746 assert_eq!(inbox.inspect(&session.id).await.unwrap().claimed, 1);
12747 assert!(!inbox.was_admitted(&session.id, &envelope_id).await.unwrap());
12748
12749 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12750 store.clone(),
12751 bamboo_domain::SessionInboxLimits::default(),
12752 ));
12753 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12754 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12755 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12756 let mut restarted = store.load_session(&session.id).await.unwrap().unwrap();
12757 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12758 .await
12759 .unwrap();
12760 assert_eq!(
12761 restarted
12762 .messages
12763 .iter()
12764 .filter(|message| message.id == envelope_id.as_str())
12765 .count(),
12766 1
12767 );
12768 assert!(reopened
12769 .was_admitted(&session.id, &envelope_id)
12770 .await
12771 .unwrap());
12772 let backlog = reopened.inspect(&session.id).await.unwrap();
12773 assert_eq!(backlog.pending + backlog.claimed, 0);
12774 }
12775
12776 #[tokio::test]
12777 async fn actor_checkpoint_success_pre_ack_failure_recovers_without_duplicate() {
12778 let (_temp, store, locked, real_inbox, mut session, claim) =
12779 actor_inbox_fixture("actor-pre-ack-failure").await;
12780 let envelope_id = claim.envelope.id.clone();
12781 let faulted: Arc<dyn SessionInboxPort> = Arc::new(ActorFailBeforeAckInbox {
12782 inner: real_inbox.clone(),
12783 fail_once: std::sync::atomic::AtomicBool::new(true),
12784 });
12785 let persistence: Arc<dyn RuntimeSessionPersistence> = locked.clone();
12786 let binding = actor_binding(store.clone(), faulted, persistence);
12787
12788 assert!(
12789 checkpoint_and_ack_canonical_claim(&binding, &mut session, &claim, None)
12790 .await
12791 .is_err()
12792 );
12793 let durable = store.load_session(&session.id).await.unwrap().unwrap();
12794 assert_eq!(
12795 durable
12796 .messages
12797 .iter()
12798 .filter(|message| message.id == envelope_id.as_str())
12799 .count(),
12800 1
12801 );
12802 assert_eq!(real_inbox.inspect(&session.id).await.unwrap().claimed, 1);
12803 assert!(!real_inbox
12804 .was_admitted(&session.id, &envelope_id)
12805 .await
12806 .unwrap());
12807
12808 let reopened: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12809 store.clone(),
12810 bamboo_domain::SessionInboxLimits::default(),
12811 ));
12812 let recovered = reopened.claim(&session.id, 1).await.unwrap().remove(0);
12813 let persistence: Arc<dyn RuntimeSessionPersistence> = locked;
12814 let binding = actor_binding(store.clone(), reopened.clone(), persistence);
12815 let mut restarted = durable;
12816 checkpoint_and_ack_canonical_claim(&binding, &mut restarted, &recovered, None)
12817 .await
12818 .unwrap();
12819 assert_eq!(
12820 restarted
12821 .messages
12822 .iter()
12823 .filter(|message| message.id == envelope_id.as_str())
12824 .count(),
12825 1
12826 );
12827 assert!(reopened
12828 .was_admitted(&session.id, &envelope_id)
12829 .await
12830 .unwrap());
12831 let backlog = reopened.inspect(&session.id).await.unwrap();
12832 assert_eq!(backlog.pending + backlog.claimed, 0);
12833 }
12834
12835 struct ConfirmationSequenceLink {
12836 frames: VecDeque<ChildFrame>,
12837 sent: Vec<ParentFrame>,
12838 }
12839
12840 #[async_trait]
12841 impl bamboo_subagent::ChildLink for ConfirmationSequenceLink {
12842 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
12843 self.sent.push(frame);
12844 Ok(())
12845 }
12846
12847 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
12848 match self.frames.pop_front() {
12849 Some(frame) => Ok(Some(frame)),
12850 None => std::future::pending().await,
12851 }
12852 }
12853 }
12854
12855 #[tokio::test]
12856 async fn failed_actor_retry_checkpoints_new_input_and_rejects_old_or_stale_state() {
12857 for case in [
12858 "accepted",
12859 "readonly-refusal",
12860 "prefix-refusal",
12861 "old-claim",
12862 "old-checkpoint",
12863 "succeeded",
12864 "changed-birth",
12865 "oversize",
12866 ] {
12867 let temp = tempfile::tempdir().unwrap();
12868 let store = Arc::new(
12869 bamboo_storage::SessionStoreV2::new(temp.path().into())
12870 .await
12871 .unwrap(),
12872 );
12873 let parent = Session::new("retry-parent", "model");
12874 store.save_session(&parent).await.unwrap();
12875 let mut child =
12876 Session::new_child_of("retry-child", &parent, "model", "original assignment");
12877 child.add_message(bamboo_agent_core::Message::user("original assignment"));
12878 store.save_session(&child).await.unwrap();
12879 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
12880 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
12881 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(store.clone()));
12882 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
12883 store.clone(),
12884 bamboo_domain::SessionInboxLimits::default(),
12885 ));
12886 let binding = actor_binding(store.clone(), inbox, locked);
12887 let registration = binding
12888 .router
12889 .register_run(&child.id, "first-run")
12890 .await
12891 .unwrap();
12892 let first = PlainActorActivation::start(
12893 store.clone(),
12894 &child,
12895 &binding,
12896 Some("first-run"),
12897 Some(policy.clone()),
12898 true,
12899 None,
12900 )
12901 .await
12902 .unwrap();
12903 if matches!(case, "old-claim" | "old-checkpoint") {
12904 let old = bamboo_domain::SessionMessageEnvelope::user_input(
12905 &child.id,
12906 "unconfirmed old input",
12907 );
12908 let receipt = first.input_inbox.deliver(&old).await.unwrap();
12909 first
12910 .input_inbox
12911 .mark_activation_eligible(
12912 &child.id,
12913 receipt.generation,
12914 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12915 )
12916 .await
12917 .unwrap();
12918 let claim = first
12919 .claim_input(&binding, &child, "first-run")
12920 .await
12921 .unwrap()
12922 .unwrap();
12923 if case == "old-checkpoint" {
12924 first
12925 .checkpoint_input(&mut child, "first-run", &claim)
12926 .await
12927 .unwrap();
12928 }
12929 }
12930 first
12931 .finish(if case == "succeeded" {
12932 ActorActivationFinish::Succeeded
12933 } else {
12934 ActorActivationFinish::Failed
12935 })
12936 .await
12937 .unwrap();
12938 registration.abandon().await;
12940 assert!(
12941 !binding.router.owns_run(&child.id, "first-run").await,
12942 "{case}: abandoned first run must release its owner before retry"
12943 );
12944 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(
12945 &child.id,
12946 if case == "oversize" {
12947 "x".repeat(8193)
12948 } else {
12949 "NEW_CORRECTION".into()
12950 },
12951 );
12952 let receipt = first.input_inbox.deliver(&envelope).await.unwrap();
12953 first
12954 .input_inbox
12955 .mark_activation_eligible(
12956 &child.id,
12957 receipt.generation,
12958 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
12959 )
12960 .await
12961 .unwrap();
12962 let before = store.load_session(&child.id).await.unwrap().unwrap();
12963 let expected_messages = serde_json::to_value(&before.messages).unwrap();
12964 child = before.clone();
12965 if case == "prefix-refusal" {
12966 child.messages.last_mut().unwrap().content = "stale cached prefix".into();
12967 }
12968 if case == "changed-birth" {
12969 child.created_at += chrono::Duration::milliseconds(1);
12970 }
12971 let _retry_registration = binding
12972 .router
12973 .register_run(&child.id, "retry-run")
12974 .await
12975 .unwrap();
12976 let result = PlainActorActivation::start_and_prepare(
12977 store.clone(),
12978 &mut child,
12979 &binding,
12980 Some("retry-run"),
12981 Some(policy.clone()),
12982 case != "readonly-refusal",
12983 None,
12984 )
12985 .await;
12986 if case != "accepted" {
12987 assert!(result.is_err(), "{case}: no retry Run may be dispatched");
12988 let actual = store.load_session(&before.id).await.unwrap().unwrap();
12989 assert_eq!(
12990 serde_json::to_value(&actual.messages).unwrap(),
12991 expected_messages,
12992 "{case}: failed preparation preserves Main history"
12993 );
12994 assert!(
12995 !first
12996 .input_inbox
12997 .was_admitted(&before.id, &envelope.id)
12998 .await
12999 .unwrap(),
13000 "{case}: no ACK"
13001 );
13002 if case == "readonly-refusal" {
13003 let entry = store.inspect_actor(&before.id).await.unwrap();
13004 assert_eq!(entry.actor.current_attempt, 1);
13005 assert_eq!(
13006 entry.activation.unwrap().activation_id,
13007 first.fence.activation_id
13008 );
13009 let backlog = first.input_inbox.inspect(&before.id).await.unwrap();
13010 assert_eq!((backlog.pending, backlog.claimed), (1, 0));
13011 }
13012 if matches!(case, "prefix-refusal" | "changed-birth" | "oversize") {
13013 assert_eq!(
13014 first.input_inbox.inspect(&before.id).await.unwrap().claimed,
13015 1
13016 );
13017 assert_eq!(
13018 store
13019 .inspect_actor(&before.id)
13020 .await
13021 .unwrap()
13022 .activation
13023 .unwrap()
13024 .status,
13025 bamboo_domain::ActorActivationStatus::Failed
13026 );
13027 }
13028 if case == "oversize" {
13029 let reopened = Arc::new(
13030 bamboo_storage::SessionStoreV2::new(temp.path().into())
13031 .await
13032 .unwrap(),
13033 );
13034 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13035 reopened,
13036 bamboo_domain::SessionInboxLimits::default(),
13037 );
13038 let now = chrono::Utc::now();
13039 let leases = cold_inbox
13040 .inspect_owned_leases(&before.id, 2, now)
13041 .await
13042 .unwrap();
13043 assert_eq!(leases.len(), 1);
13044 assert_eq!(leases[0].failure_count, 1);
13045 assert_eq!(
13046 leases[0].last_error_code.as_deref(),
13047 Some("unsupported_actor_input")
13048 );
13049 let retry_after = leases[0].retry_after.unwrap();
13050 assert!(retry_after > now);
13051 let readiness = cold_inbox
13052 .inspect_wake_readiness(&before.id, now)
13053 .await
13054 .unwrap();
13055 assert!(
13056 readiness.ready.is_none(),
13057 "failed correction must not hot-loop"
13058 );
13059 assert_eq!(readiness.next_due_at, Some(retry_after));
13060 }
13061 continue;
13062 }
13063 let (retry, prepared) = result.unwrap();
13064 let (prefix, mut deliveries) = prepared.unwrap();
13065 assert_eq!(deliveries.len(), 1);
13066 let delivery = deliveries.pop().unwrap();
13067 assert_eq!(
13068 prefix,
13069 serde_json::to_value(&before.messages)
13070 .unwrap()
13071 .as_array()
13072 .unwrap()
13073 .clone()
13074 );
13075 assert_eq!(delivery.envelope, envelope);
13076 assert_eq!(delivery.activation_run_id, "retry-run");
13077 assert_eq!(delivery.canonical_claim_generation, receipt.generation);
13078 assert!(store
13079 .validate_fence(&first.fence, chrono::Utc::now())
13080 .await
13081 .is_err());
13082 let running = store.inspect_actor(&child.id).await.unwrap();
13083 assert_eq!(running.actor.current_attempt, 2);
13084 assert_eq!(
13085 running.activation.as_ref().unwrap().status,
13086 bamboo_domain::ActorActivationStatus::Running
13087 );
13088 assert!(running.activation.unwrap().lease_epoch > first.fence.lease_epoch);
13089 let claim = retry.initial_inputs.first().unwrap();
13090 assert!(child
13091 .session_inbox_admission()
13092 .unwrap()
13093 .contains(&envelope.id));
13094 assert!(
13095 !retry
13096 .input_inbox
13097 .was_admitted(&child.id, &envelope.id)
13098 .await
13099 .unwrap(),
13100 "checkpoint is not startup confirmation"
13101 );
13102 let mut confirmation = admission_confirmation(&child.id, &claim.claim, "retry-run");
13103 confirmation.activation_run_id = "first-run".into();
13104 assert!(retry
13105 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13106 .await
13107 .is_err());
13108 assert!(!retry
13109 .input_inbox
13110 .was_admitted(&child.id, &envelope.id)
13111 .await
13112 .unwrap());
13113 confirmation.activation_run_id = "retry-run".into();
13114 retry
13115 .confirm_input(&binding, &child, "retry-run", claim, &confirmation)
13116 .await
13117 .unwrap();
13118 let (tx, mut rx) = mpsc::channel(1);
13119 retry
13120 .append_reply(&mut child, "RETRY_REPLY".into(), &tx)
13121 .await
13122 .unwrap();
13123 rx.recv().await.unwrap();
13124 retry
13125 .finish(ActorActivationFinish::Succeeded)
13126 .await
13127 .unwrap();
13128 let reopened = Arc::new(
13129 bamboo_storage::SessionStoreV2::new(temp.path().into())
13130 .await
13131 .unwrap(),
13132 );
13133 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
13134 assert_eq!(cold.created_at, before.created_at);
13135 assert_eq!(cold.messages.len(), before.messages.len() + 2);
13136 assert_eq!(
13137 cold.messages[before.messages.len()].id,
13138 envelope.id.as_str()
13139 );
13140 assert_eq!(cold.messages.last().unwrap().content, "RETRY_REPLY");
13141 assert_eq!(
13142 cold.messages
13143 .iter()
13144 .filter(|m| m.id == envelope.id.as_str())
13145 .count(),
13146 1
13147 );
13148 let cold_inbox = bamboo_storage::FileSessionInbox::new(
13149 reopened,
13150 bamboo_domain::SessionInboxLimits::default(),
13151 );
13152 assert!(cold_inbox
13153 .was_admitted(&cold.id, &envelope.id)
13154 .await
13155 .unwrap());
13156 assert_eq!(cold_inbox.inspect(&cold.id).await.unwrap().claimed, 0);
13157 }
13158 }
13159
13160 #[tokio::test]
13161 async fn pre_ack_owned_recovery_requires_expiry_provenance_and_verified_already() {
13162 for case in [
13163 "accepted",
13164 "actor-live",
13165 "claim-live",
13166 "legacy",
13167 "ack",
13168 "readonly",
13169 "prefix",
13170 "duplicate",
13171 "missing-marker",
13172 "mixed",
13173 "project",
13174 "birth",
13175 ] {
13176 let temp = tempfile::tempdir().unwrap();
13177 let store = Arc::new(
13178 bamboo_storage::SessionStoreV2::new(temp.path().into())
13179 .await
13180 .unwrap(),
13181 );
13182 let parent = Session::new("recovery-parent", "model");
13183 store.save_session(&parent).await.unwrap();
13184 let mut child =
13185 Session::new_child_of("recovery-child", &parent, "model", "original task");
13186 child.add_message(bamboo_agent_core::Message::user("original task"));
13187 store.save_session(&child).await.unwrap();
13188 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13189 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13190 let inbox = bamboo_storage::FileSessionInbox::new(
13191 store.clone(),
13192 bamboo_domain::SessionInboxLimits::default(),
13193 );
13194 let envelope =
13196 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RECOVER_SAME_INPUT");
13197 let receipt = inbox.deliver(&envelope).await.unwrap();
13198 inbox
13199 .mark_activation_eligible(
13200 &child.id,
13201 receipt.generation,
13202 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13203 )
13204 .await
13205 .unwrap();
13206 let now = chrono::Utc::now();
13207 let cutoff = now + chrono::Duration::seconds(10);
13208 let consumer = SessionInboxConsumerId::new();
13209 let old = store
13210 .claim_activation(&ActorActivationClaim {
13211 actor_id: child.id.clone(),
13212 run_id: "old-run".into(),
13213 lease_owner: consumer.as_str().into(),
13214 lease_expires_at: if case == "actor-live" {
13215 now + chrono::Duration::minutes(5)
13216 } else {
13217 cutoff
13218 },
13219 inbox_generation: 0,
13220 placement_ref: if case == "legacy" {
13221 None
13222 } else {
13223 Some(bamboo_domain::ActorPlacementRef {
13224 class: bamboo_domain::ActorPlacementClass::Local,
13225 lease_id: "owned-initial-release-v1:actual-old-worker".into(),
13226 slot_epoch: None,
13227 })
13228 },
13229 now,
13230 })
13231 .await
13232 .unwrap();
13233 store
13234 .start_activation(&old.fence(), chrono::Utc::now())
13235 .await
13236 .unwrap();
13237 let claim_now = chrono::Utc::now();
13238 let claim = inbox
13239 .claim_owned(
13240 &child.id,
13241 1,
13242 Some("old-run"),
13243 &SessionInboxLeaseRequest {
13244 consumer,
13245 now: claim_now,
13246 duration: if case == "claim-live" {
13247 chrono::Duration::minutes(5)
13248 } else {
13249 cutoff - claim_now
13250 },
13251 },
13252 )
13253 .await
13254 .unwrap()
13255 .pop()
13256 .unwrap();
13257 child = inbox
13258 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13259 fence: old.fence(),
13260 expected_created_at: child.created_at,
13261 expected_conversation_generation: child.conversation_generation_observation(),
13262 claim: claim.clone(),
13263 expected_messages: child.messages.clone(),
13264 expected_provider_transcript: child.provider_transcript.clone(),
13265 expected_admission: None,
13266 })
13267 .await
13268 .unwrap()
13269 .session;
13270 store
13272 .validate_fence(&old.fence(), chrono::Utc::now())
13273 .await
13274 .unwrap_or_else(|error| panic!("{case}: setup fence: {error:?}"));
13275 let setup_leases = inbox
13276 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13277 .await
13278 .unwrap();
13279 assert_eq!(setup_leases.len(), 1, "{case}: original setup claim");
13280 assert_eq!(setup_leases[0].epoch, claim.lease.epoch, "{case}");
13281 assert_eq!(setup_leases[0].expires_at, claim.lease.expires_at, "{case}");
13282 assert!(!setup_leases[0].expired, "{case}: setup claim must be live");
13283 let setup_at = chrono::Utc::now();
13284 assert!(
13285 setup_at < old.lease_expires_at,
13286 "{case}: setup Actor expired"
13287 );
13288 assert!(
13289 setup_at < claim.lease.expires_at,
13290 "{case}: setup claim expired"
13291 );
13292 eprintln!("pre-ACK {case}: setup live at {setup_at}, cutoff {cutoff}");
13293 if case == "ack" {
13294 inbox
13295 .ack_owned(&child.id, &claim, chrono::Utc::now())
13296 .await
13297 .unwrap();
13298 }
13299 if case == "mixed" {
13300 inbox
13301 .deliver(&bamboo_domain::SessionMessageEnvelope::user_input(
13302 &child.id,
13303 "different input",
13304 ))
13305 .await
13306 .unwrap();
13307 }
13308 let directory = store
13309 .bamboo_home_dir()
13310 .join(store.resolve_rel_path(&child.id).await.unwrap());
13311 let main = std::fs::read(directory.join("session.json")).unwrap();
13312 let canonical = child.clone();
13313 match case {
13314 "prefix" => child.messages[0].content.push_str(" stale"),
13315 "duplicate" => child.messages.push(child.messages.last().unwrap().clone()),
13316 "missing-marker" => {
13317 child
13318 .messages
13319 .last_mut()
13320 .unwrap()
13321 .metadata
13322 .as_mut()
13323 .unwrap()
13324 .as_object_mut()
13325 .unwrap()
13326 .remove("_bamboo_owned_input_checkpoint");
13327 }
13328 "project" => child.set_project_id_meta(bamboo_domain::ProjectId::new().to_string()),
13329 "birth" => child.created_at += chrono::Duration::nanoseconds(1),
13330 _ => {}
13331 }
13332 let delay = (cutoff - chrono::Utc::now()).to_std().unwrap_or_default();
13335 tokio::time::sleep(delay + Duration::from_millis(10)).await;
13336 let recovery_at = chrono::Utc::now();
13337 assert!(recovery_at >= cutoff, "{case}: real expiry is required");
13338 let before_recovery = store.inspect_actor(&child.id).await.unwrap();
13339 let original = before_recovery.activation.unwrap();
13340 assert_eq!(original.fence(), old.fence(), "{case}: same authority");
13341 assert_eq!(original.lease_expires_at, old.lease_expires_at, "{case}");
13342 assert_eq!(
13343 store
13344 .validate_fence(&old.fence(), recovery_at)
13345 .await
13346 .is_ok(),
13347 case == "actor-live",
13348 "{case}: Actor expiry control"
13349 );
13350 let recovery_leases = inbox
13351 .inspect_owned_leases(&child.id, 2, recovery_at)
13352 .await
13353 .unwrap();
13354 if case == "ack" {
13355 assert!(recovery_leases.is_empty(), "{case}: terminal ACK");
13356 } else {
13357 assert_eq!(recovery_leases.len(), 1, "{case}: original recovery claim");
13358 assert_eq!(recovery_leases[0].epoch, claim.lease.epoch, "{case}");
13359 assert_eq!(
13360 recovery_leases[0].expires_at, claim.lease.expires_at,
13361 "{case}"
13362 );
13363 assert_eq!(recovery_leases[0].expired, case != "claim-live", "{case}");
13364 }
13365 eprintln!("pre-ACK {case}: recovery expiry control at {recovery_at}");
13366 let second = Arc::new(
13367 bamboo_storage::SessionStoreV2::new(temp.path().into())
13368 .await
13369 .unwrap(),
13370 );
13371 let binding = actor_binding(
13372 second.clone(),
13373 Arc::new(bamboo_storage::FileSessionInbox::new(
13374 second.clone(),
13375 bamboo_domain::SessionInboxLimits::default(),
13376 )),
13377 Arc::new(bamboo_storage::LockedSessionStore::new(second.clone())),
13378 );
13379 let _owner = binding
13380 .router
13381 .register_run(&child.id, "replacement-run")
13382 .await
13383 .unwrap();
13384 let prepared = PlainActorActivation::start_and_prepare(
13385 second.clone(),
13386 &mut child,
13387 &binding,
13388 Some("replacement-run"),
13389 Some(policy.clone()),
13390 case != "readonly",
13391 Some("probed-new-worker"),
13392 )
13393 .await;
13394 assert_eq!(
13395 std::fs::read(directory.join("session.json")).unwrap(),
13396 main,
13397 "{case}: Already or refusal never rewrites Main"
13398 );
13399 if case != "accepted" {
13400 assert!(prepared.is_err(), "{case}");
13401 assert_eq!(
13402 inbox.was_admitted(&child.id, &envelope.id).await.unwrap(),
13403 case == "ack"
13404 );
13405 continue;
13406 }
13407 let (replacement, delivery) = prepared.unwrap();
13408 assert!(replacement.recovering_pre_ack);
13409 let (messages, mut deliveries) = delivery.unwrap();
13410 assert_eq!(deliveries.len(), 1);
13411 let delivery = deliveries.pop().unwrap();
13412 let new_claim = replacement.initial_inputs.first().unwrap();
13413 assert_eq!(new_claim.claim.envelope, claim.claim.envelope);
13414 assert_eq!(new_claim.claim.generation, claim.claim.generation);
13415 assert_eq!(
13416 new_claim.claim.activation_policy,
13417 claim.claim.activation_policy
13418 );
13419 assert!(new_claim.lease.epoch > claim.lease.epoch);
13420 assert_ne!(new_claim.lease.incarnation, claim.lease.incarnation);
13421 assert_eq!(
13422 replacement.inbox_generation, 1,
13423 "old Actor generation was zero"
13424 );
13425 assert_eq!(
13426 second
13427 .inspect_actor(&child.id)
13428 .await
13429 .unwrap()
13430 .actor
13431 .current_attempt,
13432 2
13433 );
13434 assert!(second
13435 .validate_fence(&old.fence(), chrono::Utc::now())
13436 .await
13437 .is_err());
13438 assert!(inbox
13439 .ack_owned(&child.id, &claim, chrono::Utc::now())
13440 .await
13441 .is_err());
13442 let authority = second.inspect_actor(&child.id).await.unwrap();
13446 let leases = inbox
13447 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13448 .await
13449 .unwrap();
13450 let delayed = inbox
13451 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13452 fence: old.fence(),
13453 expected_created_at: child.created_at,
13454 expected_conversation_generation: child.conversation_generation_observation(),
13455 claim: claim.clone(),
13456 expected_messages: child.messages.clone(),
13457 expected_provider_transcript: child.provider_transcript.clone(),
13458 expected_admission: child.session_inbox_admission().cloned(),
13459 })
13460 .await;
13461 assert!(matches!(
13462 delayed,
13463 Err(bamboo_storage::ActorInputCheckpointError::Actor(
13464 bamboo_domain::ActorDirectoryError::StaleFence
13465 ))
13466 ));
13467 assert_eq!(std::fs::read(directory.join("session.json")).unwrap(), main);
13468 assert_eq!(second.inspect_actor(&child.id).await.unwrap(), authority);
13469 assert_eq!(
13470 inbox
13471 .inspect_owned_leases(&child.id, 2, chrono::Utc::now())
13472 .await
13473 .unwrap(),
13474 leases
13475 );
13476 assert!(!inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13477 assert_eq!(
13478 serde_json::to_value(&child.messages).unwrap(),
13479 serde_json::to_value(&canonical.messages).unwrap()
13480 );
13481 let mut worker: Vec<bamboo_agent_core::Message> = messages
13482 .iter()
13483 .cloned()
13484 .map(|v| serde_json::from_value(v).unwrap())
13485 .collect();
13486 let target = worker.last_mut().unwrap();
13487 assert!(target
13488 .metadata
13489 .as_ref()
13490 .unwrap()
13491 .get("_bamboo_owned_input_checkpoint")
13492 .is_none());
13493 assert!(bamboo_domain::is_matching_session_message(
13494 target, &envelope
13495 ));
13496 let mut wanted = canonical.messages.last().unwrap().clone();
13497 wanted
13498 .metadata
13499 .as_mut()
13500 .unwrap()
13501 .as_object_mut()
13502 .unwrap()
13503 .remove("_bamboo_owned_input_checkpoint");
13504 assert_eq!(
13505 serde_json::to_value(target).unwrap(),
13506 serde_json::to_value(wanted).unwrap()
13507 );
13508 let run = RunSpec {
13509 assignment: "original task".into(),
13510 logical_session: Some(LogicalSessionIdentity {
13511 session_id: child.id.clone(),
13512 parent_session_id: child.parent_session_id.clone(),
13513 root_session_id: child.root_session_id.clone(),
13514 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13515 created_at: child.created_at,
13516 spawn_depth: child.spawn_depth,
13517 }),
13518 }),
13519 project_id: None,
13520 reasoning_effort: None,
13521 permission_policy: Some(PermissionPolicyContext {
13522 revision: policy.policy_revision(),
13523 requested_mode: "default".into(),
13524 effective_mode: "default".into(),
13525 bypass_permissions: false,
13526 auto_approve_permissions: false,
13527 session_id: child.id.clone(),
13528 workspace_path: child.workspace.clone(),
13529 environment_lease: None,
13530 inherit_session_grants: false,
13531 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13532 }),
13533 messages,
13534 activation_run_id: Some("replacement-run".into()),
13535 execution_epoch: 2,
13536 initial_session_messages: vec![delivery.clone()],
13537 secrets: Default::default(),
13538 };
13539 let policy_revision = policy.policy_revision();
13540 let mut expected = Some(expected_default_permission_posture(policy_revision));
13541 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
13542 else {
13543 unreachable!()
13544 };
13545 let (events, mut rx) = mpsc::channel(1);
13546 process_actor_event(
13547 event,
13548 true,
13549 &mut PermissionPostureHandshake::Awaiting,
13550 expected.as_ref(),
13551 Some(&binding),
13552 &mut child,
13553 &events,
13554 &mut ActorEventDisplay::default(),
13555 )
13556 .await
13557 .unwrap();
13558 rx.recv().await.unwrap();
13559 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13560 &run,
13561 &delivery,
13562 uuid::Uuid::new_v4().to_string(),
13563 )
13564 .unwrap();
13565 replacement
13566 .release_initial_input(
13567 &binding,
13568 &child,
13569 &run,
13570 2,
13571 new_claim,
13572 &request,
13573 &mut expected,
13574 None,
13575 )
13576 .await
13577 .unwrap();
13578 assert!(inbox.was_admitted(&child.id, &envelope.id).await.unwrap());
13579 replacement
13580 .append_reply(&mut child, "RECOVERY_REPLY".into(), &events)
13581 .await
13582 .unwrap();
13583 rx.recv().await.unwrap();
13584 replacement
13585 .finish(ActorActivationFinish::Succeeded)
13586 .await
13587 .unwrap();
13588 let cold = second.load_session(&child.id).await.unwrap().unwrap();
13589 assert_eq!(cold.created_at, canonical.created_at);
13590 assert_eq!(
13591 serde_json::to_value(&cold.messages[..canonical.messages.len()]).unwrap(),
13592 serde_json::to_value(&canonical.messages).unwrap()
13593 );
13594 assert_eq!(cold.messages.last().unwrap().content, "RECOVERY_REPLY");
13595 assert_eq!(
13596 cold.messages
13597 .iter()
13598 .filter(|m| m.id == envelope.id.as_str())
13599 .count(),
13600 1
13601 );
13602 }
13603 }
13604
13605 #[cfg(unix)]
13606 #[tokio::test]
13607 async fn owned_initial_release_missing_only_new_capability_rejects_before_spawn_or_run() {
13608 use bamboo_subagent::provision::*;
13609 use std::os::unix::fs::PermissionsExt;
13610 let temp = tempfile::tempdir().unwrap();
13611 let worker = temp.path().join("mixed-worker");
13612 let spawned = temp.path().join("spawned");
13613 let run = temp.path().join("run");
13614 let probes = temp.path().join("probes");
13615 let fabric = temp.path().join("fabric");
13616 let mut report = WorkerCapabilityReport::current();
13617 report
13618 .capabilities
13619 .retain(|name| name != INITIAL_INPUT_RELEASE_WORKER_CAPABILITY);
13620 assert!(!report.supports(INITIAL_INPUT_RELEASE_WORKER_CAPABILITY));
13621 let report = serde_json::to_string(&report).unwrap();
13622 std::fs::write(&worker, format!(
13623 "#!/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"
13624 )).unwrap();
13625 std::fs::set_permissions(&worker, std::fs::Permissions::from_mode(0o700)).unwrap();
13626 let args = vec![
13627 spawned.to_str().unwrap().into(),
13628 run.to_str().unwrap().into(),
13629 probes.to_str().unwrap().into(),
13630 ];
13631 for capability in [
13633 REQUIRED_CHILD_CONTEXT_WORKER_CAPABILITY,
13634 CHILD_CREATION_IDENTITY_WORKER_CAPABILITY,
13635 NATIVE_TOOL_CEILING_WORKER_CAPABILITY,
13636 TYPED_READ_ONLY_WORKER_CAPABILITY,
13637 ] {
13638 bamboo_subagent::fleet::require_worker_capability(&worker, &args, capability)
13639 .await
13640 .unwrap();
13641 }
13642 let runner = ActorChildRunner::new(
13643 "mixed".into(),
13644 worker,
13645 args,
13646 fabric.clone(),
13647 ExecutorSpec::BambooRuntime,
13648 vec![],
13649 "openai".into(),
13650 1,
13651 );
13652 let error = runner
13654 .require_initial_input_release_worker()
13655 .await
13656 .unwrap_err();
13657 assert!(error
13658 .to_string()
13659 .contains("owned initial release worker capability unconfirmed"));
13660 assert_eq!(std::fs::read_to_string(probes).unwrap().lines().count(), 5);
13661 assert!(!spawned.exists() && !run.exists() && !fabric.exists());
13662 assert!(runner.pool.lock().await.is_empty());
13663 }
13664
13665 #[tokio::test]
13666 async fn ordinary_glob_only_observes_own_initialization_signals() {
13667 let temp = tempfile::tempdir().unwrap();
13668 let home = temp.path().canonicalize().unwrap();
13669 let store = bamboo_storage::SessionStoreV2::new(home.clone())
13670 .await
13671 .unwrap();
13672 let parent = Session::new("glob-standard-parent", "model");
13673 store.save_session(&parent).await.unwrap();
13674 let child = Session::new_child_of("glob-standard-child", &parent, "model", "one Glob");
13675 store.save_session(&child).await.unwrap();
13676 let directory = home.join(store.resolve_rel_path(&child.id).await.unwrap());
13677 let unrelated = directory.parent().unwrap().join("unrelated");
13680 std::fs::create_dir(&unrelated).unwrap();
13681 std::fs::write(unrelated.join("session.json"), b"invalid").unwrap();
13682 require_unowned_glob(&store, &child).await.unwrap();
13683 for name in ["actor-authority.json", "actor-authority.initialized.json"] {
13684 let path = directory.join(name);
13685 std::fs::write(&path, b"invalid prior signal").unwrap();
13686 assert!(require_unowned_glob(&store, &child).await.is_err());
13687 std::fs::remove_file(&path).unwrap();
13688 }
13689 #[cfg(unix)]
13690 {
13691 let marker = directory.join("actor-authority.initialized.json");
13692 std::os::unix::fs::symlink(directory.join("missing"), &marker).unwrap();
13693 assert!(require_unowned_glob(&store, &child).await.is_err());
13694 }
13695 }
13696
13697 #[test]
13698 fn owned_glob_collector_requires_actual_ordered_tool_trace_and_typed_tail() {
13699 let call = bamboo_domain::ToolCall {
13700 id: "glob".into(),
13701 tool_type: "function".into(),
13702 function: bamboo_domain::FunctionCall {
13703 name: "Glob".into(),
13704 arguments: r#"{"pattern":"marker.txt"}"#.into(),
13705 },
13706 };
13707 let messages = [
13708 bamboo_agent_core::Message::assistant("", Some(vec![call])),
13709 bamboo_agent_core::Message::tool_result("glob", "marker.txt"),
13710 bamboo_agent_core::Message::assistant("done", None),
13711 ];
13712 let start = serde_json::to_value(AgentEvent::ToolStart {
13713 tool_call_id: "glob".into(),
13714 tool_name: "Glob".into(),
13715 arguments: serde_json::json!({"pattern":"marker.txt"}),
13716 })
13717 .unwrap();
13718 let complete = serde_json::to_value(AgentEvent::ToolComplete {
13719 tool_call_id: "glob".into(),
13720 result: bamboo_domain::ToolResult::text(true, "marker.txt"),
13721 })
13722 .unwrap();
13723 let tail =
13724 serde_json::to_value(bamboo_subagent::proto::ReadOnlyActorTranscript::Complete {
13725 messages: messages
13726 .iter()
13727 .map(serde_json::to_value)
13728 .collect::<Result<_, _>>()
13729 .unwrap(),
13730 })
13731 .unwrap();
13732 let mut ordered = ReadOnlyActorCollector::default();
13733 assert!(ordered.event(&start).unwrap());
13734 assert!(ordered.event(&complete).unwrap());
13735 assert!(!ordered.event(&tail).unwrap());
13736 assert!(ordered.event(&tail).is_err(), "duplicate suffix");
13737 assert_eq!(ordered.finish(Some("done")).unwrap().len(), 3);
13738 assert!(ReadOnlyActorCollector::default()
13739 .finish(Some("done"))
13740 .is_err());
13741 let mut unfinished = ReadOnlyActorCollector::default();
13742 unfinished.event(&start).unwrap();
13743 unfinished.event(&complete).unwrap();
13744 assert!(unfinished.finish(Some("done")).is_err(), "missing suffix");
13745 let mut closed = tail.clone();
13746 closed["grant"] = true.into();
13747 assert!(
13748 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(closed)
13749 .is_err()
13750 );
13751 let mut oversized = tail.clone();
13752 oversized["messages"][2]["content"] = "x".repeat(64 * 1024).into();
13753 assert!(
13754 serde_json::from_value::<bamboo_subagent::proto::ReadOnlyActorTranscript>(oversized)
13755 .unwrap()
13756 .validate()
13757 .is_err()
13758 );
13759 assert!(ReadOnlyActorCollector::default().event(&complete).is_err());
13760 assert!(ReadOnlyActorCollector::default().event(&tail).is_err());
13761 let mut bad = start.clone();
13762 bad["tool_name"] = "Bash".into();
13763 assert!(ReadOnlyActorCollector::default().event(&bad).is_err());
13764 let mut duplicate = ReadOnlyActorCollector::default();
13765 duplicate.event(&start).unwrap();
13766 assert!(duplicate.event(&start).is_err());
13767 let hidden = serde_json::to_value(AgentEvent::ReasoningToken {
13768 content: "hidden".into(),
13769 })
13770 .unwrap();
13771 assert!(ReadOnlyActorCollector::default().event(&hidden).is_err());
13772 let mut mismatched = ReadOnlyActorCollector::default();
13773 mismatched.event(&start).unwrap();
13774 let mut bad = complete.clone();
13775 bad["tool_call_id"] = "foreign".into();
13776 assert!(mismatched.event(&bad).is_err());
13777 mismatched.event(&complete).unwrap();
13778 let mut changed = tail.clone();
13779 changed["messages"][1]["content"] = "fabricated".into();
13780 assert!(mismatched.event(&changed).is_err());
13781 }
13782
13783 #[cfg(unix)]
13784 #[tokio::test]
13785 async fn owned_initial_release_requires_current_prefix_ack_and_same_receipt() {
13786 let temp = tempfile::tempdir().unwrap();
13787 let store = Arc::new(
13788 bamboo_storage::SessionStoreV2::new(temp.path().into())
13789 .await
13790 .unwrap(),
13791 );
13792 let parent = Session::new("release-host-parent", "model");
13793 store.save_session(&parent).await.unwrap();
13794 let mut child = Session::new_child_of("release-host-child", &parent, "model", "assignment");
13795 store.save_session(&child).await.unwrap();
13796 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
13797 store.clone(),
13798 bamboo_domain::SessionInboxLimits::default(),
13799 ));
13800 let binding = actor_binding(
13801 store.clone(),
13802 inbox,
13803 Arc::new(bamboo_storage::LockedSessionStore::new(store.clone())),
13804 );
13805 let _owner = binding
13806 .router
13807 .register_run(&child.id, "release-host-run")
13808 .await
13809 .unwrap();
13810 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
13811 let revision = policy.policy_revision();
13812 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
13813 let activation = PlainActorActivation::start(
13814 store.clone(),
13815 &child,
13816 &binding,
13817 Some("release-host-run"),
13818 Some(policy.clone()),
13819 true,
13820 Some("probed-physical-worker"),
13821 )
13822 .await
13823 .unwrap();
13824 let envelope = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_ONCE");
13825 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
13826 activation
13827 .input_inbox
13828 .mark_activation_eligible(
13829 &child.id,
13830 receipt.generation,
13831 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
13832 )
13833 .await
13834 .unwrap();
13835 let claim = activation
13836 .claim_input(&binding, &child, "release-host-run")
13837 .await
13838 .unwrap()
13839 .unwrap();
13840 let (prefix, delivery) = activation
13841 .checkpoint_input(&mut child, "release-host-run", &claim)
13842 .await
13843 .unwrap();
13844 let mut expected = Some(expected_default_permission_posture(revision));
13845 let ChildFrame::Event { event } = permission_posture_frame(&child.id, revision) else {
13846 unreachable!()
13847 };
13848 let (events, mut events_rx) = mpsc::channel(1);
13849 process_actor_event(
13850 event,
13851 true,
13852 &mut PermissionPostureHandshake::Awaiting,
13853 expected.as_ref(),
13854 Some(&binding),
13855 &mut child,
13856 &events,
13857 &mut ActorEventDisplay::default(),
13858 )
13859 .await
13860 .unwrap();
13861 events_rx.recv().await.unwrap();
13862 let run = RunSpec {
13863 assignment: "assignment".into(),
13864 logical_session: Some(LogicalSessionIdentity {
13865 session_id: child.id.clone(),
13866 parent_session_id: child.parent_session_id.clone(),
13867 root_session_id: child.root_session_id.clone(),
13868 creation: Some(bamboo_subagent::proto::ChildCreationIdentity {
13869 created_at: child.created_at,
13870 spawn_depth: child.spawn_depth,
13871 }),
13872 }),
13873 project_id: None,
13874 reasoning_effort: None,
13875 permission_policy: Some(bamboo_subagent::proto::PermissionPolicyContext {
13876 revision,
13877 requested_mode: "default".into(),
13878 effective_mode: "default".into(),
13879 bypass_permissions: false,
13880 auto_approve_permissions: false,
13881 session_id: child.id.clone(),
13882 workspace_path: child.workspace.clone(),
13883 environment_lease: None,
13884 inherit_session_grants: false,
13885 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
13886 }),
13887 messages: prefix,
13888 activation_run_id: Some("release-host-run".into()),
13889 execution_epoch: 9,
13890 initial_session_messages: vec![delivery.clone()],
13891 secrets: Default::default(),
13892 };
13893 let request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
13894 &run,
13895 &delivery,
13896 uuid::Uuid::new_v4().to_string(),
13897 )
13898 .unwrap();
13899 let mut stale = child.clone();
13900 stale.add_message(bamboo_agent_core::Message::user("not canonical"));
13901 assert!(activation
13902 .release_initial_input(
13903 &binding,
13904 &stale,
13905 &run,
13906 9,
13907 &claim,
13908 &request,
13909 &mut expected,
13910 None
13911 )
13912 .await
13913 .is_err());
13914 assert!(!activation
13915 .input_inbox
13916 .was_admitted(&child.id, &envelope.id)
13917 .await
13918 .unwrap());
13919 use std::os::unix::fs::PermissionsExt;
13922 struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
13923 impl Drop for RestorePermissions {
13924 fn drop(&mut self) {
13925 let _ = std::fs::set_permissions(&self.0, self.1.clone());
13926 }
13927 }
13928 let admitted = store
13929 .bamboo_home_dir()
13930 .join(store.resolve_rel_path(&child.id).await.unwrap())
13931 .join("inbox/admitted");
13932 std::fs::create_dir_all(&admitted).unwrap();
13933 let restore = RestorePermissions(
13934 admitted.clone(),
13935 std::fs::metadata(&admitted).unwrap().permissions(),
13936 );
13937 std::fs::set_permissions(&admitted, std::fs::Permissions::from_mode(0o555)).unwrap();
13938 let verified = activation
13939 .input_inbox
13940 .checkpoint_actor_input(bamboo_storage::ActorInputCheckpoint {
13941 fence: activation.fence.clone(),
13942 expected_created_at: child.created_at,
13943 expected_conversation_generation: child.conversation_generation_observation(),
13944 claim: claim.clone(),
13945 expected_messages: child.messages.clone(),
13946 expected_provider_transcript: child.provider_transcript.clone(),
13947 expected_admission: child.session_inbox_admission().cloned(),
13948 })
13949 .await
13950 .unwrap();
13951 assert_eq!(
13952 verified.status,
13953 bamboo_storage::ActorInputCheckpointStatus::AlreadyCheckpointed
13954 );
13955 let error = activation
13956 .release_initial_input(
13957 &binding,
13958 &child,
13959 &run,
13960 9,
13961 &claim,
13962 &request,
13963 &mut expected,
13964 None,
13965 )
13966 .await
13967 .unwrap_err();
13968 std::fs::set_permissions(&admitted, restore.1.clone()).unwrap();
13969 drop(restore);
13970 assert!(
13971 error.to_string().contains("ACK unresolved"),
13972 "actual release error (<=512B): {}",
13973 error.to_string().chars().take(128).collect::<String>()
13974 );
13975 let release = activation
13976 .release_initial_input(
13977 &binding,
13978 &child,
13979 &run,
13980 9,
13981 &claim,
13982 &request,
13983 &mut expected,
13984 None,
13985 )
13986 .await
13987 .unwrap();
13988 assert!(activation
13989 .input_inbox
13990 .was_admitted(&child.id, &envelope.id)
13991 .await
13992 .unwrap());
13993 assert_eq!(
13994 release.expires_at,
13995 activation
13996 .actor_deadline
13997 .min(claim.lease.expires_at)
13998 .min(activation.input_deadline)
13999 );
14000 assert_eq!(
14001 activation
14002 .release_initial_input(
14003 &binding,
14004 &child,
14005 &run,
14006 9,
14007 &claim,
14008 &request,
14009 &mut expected,
14010 Some(&release)
14011 )
14012 .await
14013 .unwrap(),
14014 release
14015 );
14016 let mut wrong = request.clone();
14017 wrong.nonce = uuid::Uuid::new_v4().to_string();
14018 assert!(activation
14019 .release_initial_input(
14020 &binding,
14021 &child,
14022 &run,
14023 9,
14024 &claim,
14025 &wrong,
14026 &mut expected,
14027 Some(&release)
14028 )
14029 .await
14030 .is_err());
14031 assert!(activation
14032 .release_initial_input(
14033 &binding,
14034 &child,
14035 &run,
14036 10,
14037 &claim,
14038 &request,
14039 &mut expected,
14040 Some(&release)
14041 )
14042 .await
14043 .is_err());
14044 let deadlines = (
14046 activation.fence.clone(),
14047 activation.actor_deadline,
14048 activation.input_deadline,
14049 );
14050 let second = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "RELEASE_TWICE");
14051 let receipt = activation.input_inbox.deliver(&second).await.unwrap();
14052 activation
14053 .input_inbox
14054 .mark_activation_eligible(
14055 &child.id,
14056 receipt.generation,
14057 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14058 )
14059 .await
14060 .unwrap();
14061 let mut link = ConfirmationSequenceLink {
14062 frames: VecDeque::new(),
14063 sent: Vec::new(),
14064 };
14065 let epochs = AtomicU64::new(9);
14066 let (claim2, epoch2) = activation
14067 .continue_input(
14068 &mut link,
14069 &binding,
14070 &mut child,
14071 "release-host-run",
14072 Some((&run, &epochs)),
14073 &mut expected,
14074 "SECOND_REPLY".into(),
14075 &events,
14076 )
14077 .await
14078 .unwrap()
14079 .unwrap();
14080 events_rx.recv().await.unwrap();
14081 assert_eq!(epoch2, 10);
14082 let ParentFrame::Run(next) = &link.sent[0] else {
14083 panic!("second correlated Run")
14084 };
14085 assert_eq!(next.logical_session, run.logical_session);
14086 assert_eq!(next.activation_run_id, run.activation_run_id);
14087 assert_eq!(next.initial_session_messages.len(), 1);
14088 assert!(next
14089 .messages
14090 .iter()
14091 .any(|m| m["id"] == envelope.id.as_str()));
14092 assert!(next.messages.iter().any(|m| m["content"] == "SECOND_REPLY"));
14093 assert!(!next.messages.iter().any(|m| m["id"] == second.id.as_str()));
14094 let request2 = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14095 next,
14096 &next.initial_session_messages[0],
14097 uuid::Uuid::new_v4().to_string(),
14098 )
14099 .unwrap();
14100 assert_ne!(request2.nonce, request.nonce);
14101 macro_rules! release_second {
14102 ($candidate:expr, $request:expr, $epoch:expr, $previous:expr) => {
14103 activation
14104 .release_initial_input(
14105 &binding,
14106 $candidate,
14107 next,
14108 $epoch,
14109 &claim2,
14110 $request,
14111 &mut expected,
14112 $previous,
14113 )
14114 .await
14115 };
14116 }
14117 assert!(release_second!(&child, &request, epoch2, None).is_err());
14118 assert!(release_second!(&child, &request2, 9, None).is_err());
14119 let mut stale2 = child.clone();
14120 stale2.add_message(bamboo_agent_core::Message::user("stale second prefix"));
14121 assert!(release_second!(&stale2, &request2, epoch2, None).is_err());
14122 assert!(!activation
14123 .input_inbox
14124 .was_admitted(&child.id, &second.id)
14125 .await
14126 .unwrap());
14127 let release2 = release_second!(&child, &request2, epoch2, None).unwrap();
14128 assert_eq!(
14129 release_second!(&child, &request2, epoch2, Some(&release2)).unwrap(),
14130 release2
14131 );
14132 let mut wrong2 = request2.clone();
14133 wrong2.nonce = request.nonce.clone();
14134 assert!(release_second!(&child, &wrong2, epoch2, Some(&release2)).is_err());
14135 assert!(activation
14136 .input_inbox
14137 .was_admitted(&child.id, &second.id)
14138 .await
14139 .unwrap());
14140 let expired = bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "EXPIRED_INPUT");
14141 let receipt = activation.input_inbox.deliver(&expired).await.unwrap();
14142 activation
14143 .input_inbox
14144 .mark_activation_eligible(
14145 &child.id,
14146 receipt.generation,
14147 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14148 )
14149 .await
14150 .unwrap();
14151 let now = chrono::Utc::now();
14152 let expired_claim = activation
14153 .input_inbox
14154 .claim_owned(
14155 &child.id,
14156 1,
14157 Some("release-host-run"),
14158 &SessionInboxLeaseRequest {
14159 consumer: activation.input_consumer.clone(),
14160 now,
14161 duration: chrono::Duration::seconds(1),
14162 },
14163 )
14164 .await
14165 .unwrap()
14166 .pop()
14167 .unwrap();
14168 let (prefix, delivery3) = activation
14169 .checkpoint_input(&mut child, "release-host-run", &expired_claim)
14170 .await
14171 .unwrap();
14172 let mut expired_run = next.clone();
14173 expired_run.execution_epoch = 11;
14174 expired_run.messages = prefix;
14175 expired_run.initial_session_messages = vec![delivery3.clone()];
14176 let expired_request = bamboo_subagent::proto::InitialInputReleaseRequest::from_run(
14177 &expired_run,
14178 &delivery3,
14179 uuid::Uuid::new_v4().to_string(),
14180 )
14181 .unwrap();
14182 tokio::time::sleep(
14183 (expired_claim.lease.expires_at - chrono::Utc::now())
14184 .to_std()
14185 .unwrap_or_default(),
14186 )
14187 .await;
14188 assert!(chrono::Utc::now() >= expired_claim.lease.expires_at);
14189 assert!(activation
14190 .release_initial_input(
14191 &binding,
14192 &child,
14193 &expired_run,
14194 11,
14195 &expired_claim,
14196 &expired_request,
14197 &mut expected,
14198 None
14199 )
14200 .await
14201 .is_err());
14202 assert!(!activation
14203 .input_inbox
14204 .was_admitted(&child.id, &expired.id)
14205 .await
14206 .unwrap());
14207 assert_eq!(
14208 (
14209 activation.fence.clone(),
14210 activation.actor_deadline,
14211 activation.input_deadline
14212 ),
14213 deadlines
14214 );
14215 assert_eq!(
14216 link.sent.len(),
14217 1,
14218 "no dispatch from rejected release checks"
14219 );
14220 let cold = store.load_session(&child.id).await.unwrap().unwrap();
14221 for id in [&envelope.id, &second.id] {
14222 assert_eq!(
14223 cold.messages.iter().filter(|m| m.id == id.as_str()).count(),
14224 1
14225 );
14226 assert!(cold.session_inbox_admission().unwrap().contains(id));
14227 }
14228 policy.deny_scoped_session_permission(
14230 &child.id,
14231 bamboo_tools::permission::PermissionType::ExecuteCommand,
14232 "blocked-action",
14233 );
14234 assert!(release_second!(&child, &request2, epoch2, Some(&release2)).is_err());
14235 assert!(activation
14236 .input_inbox
14237 .was_admitted(&child.id, &second.id)
14238 .await
14239 .unwrap());
14240 activation
14241 .finish(ActorActivationFinish::Failed)
14242 .await
14243 .unwrap();
14244 activation
14246 .input_inbox
14247 .ack_owned(&child.id, &claim, chrono::Utc::now())
14248 .await
14249 .unwrap();
14250 assert!(activation
14251 .release_initial_input(
14252 &binding,
14253 &child,
14254 &run,
14255 9,
14256 &claim,
14257 &request,
14258 &mut expected,
14259 Some(&release)
14260 )
14261 .await
14262 .is_err());
14263 let cold = bamboo_storage::SessionStoreV2::new(temp.path().into())
14264 .await
14265 .unwrap()
14266 .load_session(&child.id)
14267 .await
14268 .unwrap()
14269 .unwrap();
14270 assert_eq!(
14271 cold.messages
14272 .iter()
14273 .filter(|m| m.id == envelope.id.as_str())
14274 .count(),
14275 1
14276 );
14277 }
14278
14279 #[tokio::test]
14280 async fn owned_actor_input_checkpoint_precedes_dispatch_and_exact_ack() {
14281 for reject_checkpoint in [false, true] {
14282 let temp = tempfile::tempdir().unwrap();
14283 let store = Arc::new(
14284 bamboo_storage::SessionStoreV2::new(temp.path().into())
14285 .await
14286 .unwrap(),
14287 );
14288 let parent = Session::new("owned-input-parent", "model");
14289 store.save_session(&parent).await.unwrap();
14290 let mut child =
14291 Session::new_child_of("owned-input-child", &parent, "model", "one plain task");
14292 store.save_session(&child).await.unwrap();
14293 let policy = Arc::new(bamboo_tools::permission::PermissionConfig::new());
14294 let raw_storage: Arc<dyn Storage> = store.clone();
14295 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(raw_storage));
14296 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
14297 store.clone(),
14298 bamboo_domain::SessionInboxLimits::default(),
14299 ));
14300 let binding = actor_binding(store.clone(), inbox, locked);
14301 let run_id = "owned-input-run";
14302 let _registration = binding
14303 .router
14304 .register_run(&child.id, run_id)
14305 .await
14306 .unwrap();
14307 assert!(PlainActorActivation::start(
14308 store.clone(),
14309 &child,
14310 &binding,
14311 Some(run_id),
14312 None,
14313 true,
14314 None,
14315 )
14316 .await
14317 .is_err());
14318 assert!(PlainActorActivation::start(
14319 store.clone(),
14320 &child,
14321 &binding,
14322 Some(run_id),
14323 Some(policy.clone()),
14324 true,
14325 None,
14326 )
14327 .await
14328 .is_err());
14329 assert_eq!(
14330 store
14331 .inspect_actor(&child.id)
14332 .await
14333 .unwrap()
14334 .actor
14335 .current_attempt,
14336 0
14337 );
14338 bind_local_control_plane(store.as_ref(), &child.id, policy.as_ref()).await;
14339 let activation = PlainActorActivation::start(
14340 store.clone(),
14341 &child,
14342 &binding,
14343 Some(run_id),
14344 Some(policy.clone()),
14345 true,
14346 None,
14347 )
14348 .await
14349 .unwrap();
14350 let envelope =
14351 bamboo_domain::SessionMessageEnvelope::user_input(&child.id, "CORRECTION_ONCE");
14352 let receipt = activation.input_inbox.deliver(&envelope).await.unwrap();
14353 activation
14354 .input_inbox
14355 .mark_activation_eligible(
14356 &child.id,
14357 receipt.generation,
14358 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
14359 )
14360 .await
14361 .unwrap();
14362 let mut link = ConfirmationSequenceLink {
14363 frames: VecDeque::new(),
14364 sent: Vec::new(),
14365 };
14366 let policy_revision = policy.policy_revision();
14367 let run = RunSpec {
14368 assignment: "one plain task".into(),
14369 logical_session: None,
14370 project_id: None,
14371 reasoning_effort: None,
14372 permission_policy: Some(PermissionPolicyContext {
14373 revision: policy_revision,
14374 requested_mode: "default".into(),
14375 effective_mode: "default".into(),
14376 bypass_permissions: false,
14377 auto_approve_permissions: false,
14378 session_id: child.id.clone(),
14379 workspace_path: child.workspace.clone(),
14380 environment_lease: None,
14381 inherit_session_grants: false,
14382 policy: serde_json::to_value(policy.to_serializable()).unwrap(),
14383 }),
14384 messages: vec![],
14385 activation_run_id: Some(run_id.into()),
14386 execution_epoch: 1,
14387 initial_session_messages: vec![],
14388 secrets: Default::default(),
14389 };
14390 let epochs = AtomicU64::new(1);
14391 let (tx, mut rx) = mpsc::channel(1);
14392 let mut expected = Some(expected_default_permission_posture(policy_revision));
14393 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14394 else {
14395 unreachable!()
14396 };
14397 process_actor_event(
14398 event,
14399 true,
14400 &mut PermissionPostureHandshake::Awaiting,
14401 expected.as_ref(),
14402 Some(&binding),
14403 &mut child,
14404 &tx,
14405 &mut ActorEventDisplay::default(),
14406 )
14407 .await
14408 .unwrap();
14409 rx.recv().await.unwrap();
14410 let first_audit =
14411 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14412 .unwrap()
14413 .audit_revision;
14414 if reject_checkpoint {
14415 tx.send(AgentEvent::Complete {
14418 usage: Default::default(),
14419 })
14420 .await
14421 .unwrap();
14422 }
14423 let mut operation = activation.continue_input(
14424 &mut link,
14425 &binding,
14426 &mut child,
14427 run_id,
14428 Some((&run, &epochs)),
14429 &mut expected,
14430 "FIRST_PLAIN_REPLY".into(),
14431 &tx,
14432 );
14433 if reject_checkpoint {
14434 tokio::select! {
14435 result = &mut operation => panic!("publication barrier released early: {result:?}"),
14436 _ = async {
14437 tokio::time::timeout(Duration::from_secs(5), async {
14438 loop {
14439 let actual = store.load_session("owned-input-child").await.unwrap().unwrap();
14440 if actual.messages.iter().any(|m| m.content == "FIRST_PLAIN_REPLY") { break; }
14441 tokio::task::yield_now().await;
14442 }
14443 }).await.unwrap();
14444 activation.finish(ActorActivationFinish::Failed).await.unwrap();
14445 rx.recv().await.unwrap();
14446 } => {}
14447 }
14448 }
14449 let result = operation.await;
14450 if reject_checkpoint {
14451 assert!(result
14452 .unwrap_err()
14453 .to_string()
14454 .contains("checkpoint unconfirmed"));
14455 assert!(
14456 link.sent.is_empty(),
14457 "zero worker/provider correction dispatch"
14458 );
14459 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14460 assert!(durable
14461 .messages
14462 .iter()
14463 .any(|m| m.content == "FIRST_PLAIN_REPLY"));
14464 assert!(!durable
14465 .messages
14466 .iter()
14467 .any(|m| m.id == envelope.id.as_str()));
14468 assert!(!activation
14469 .input_inbox
14470 .was_admitted(&child.id, &envelope.id)
14471 .await
14472 .unwrap());
14473 assert_eq!(
14474 activation
14475 .input_inbox
14476 .inspect(&child.id)
14477 .await
14478 .unwrap()
14479 .claimed,
14480 1
14481 );
14482 continue;
14483 }
14484 let (claim, epoch) = result.unwrap().unwrap();
14485 rx.recv().await.unwrap(); assert_eq!(epoch, 2);
14487 assert_eq!(link.sent.len(), 1);
14488 let ParentFrame::Run(next) = &link.sent[0] else {
14489 panic!("correlated continuation Run")
14490 };
14491 assert_eq!(next.execution_epoch, 2);
14492 assert_eq!(next.activation_run_id.as_deref(), Some(run_id));
14493 assert!(next
14494 .messages
14495 .iter()
14496 .any(|m| m["content"] == "FIRST_PLAIN_REPLY"));
14497 assert!(!next
14498 .messages
14499 .iter()
14500 .any(|m| m["id"] == envelope.id.as_str()));
14501 let delivery = &next.initial_session_messages[0];
14502 assert_eq!(delivery.envelope, envelope);
14503 let durable = store.load_session(&child.id).await.unwrap().unwrap();
14504 assert!(durable
14505 .session_inbox_admission()
14506 .unwrap()
14507 .contains(&envelope.id));
14508 assert_eq!(
14509 durable
14510 .messages
14511 .iter()
14512 .filter(|m| m.id == envelope.id.as_str())
14513 .count(),
14514 1
14515 );
14516 assert!(
14517 !activation
14518 .input_inbox
14519 .was_admitted(&child.id, &envelope.id)
14520 .await
14521 .unwrap(),
14522 "checkpoint is not a worker admission receipt"
14523 );
14524 let mut confirmation = admission_confirmation(&child.id, &claim.claim, run_id);
14525 confirmation.canonical_claim_generation += 1;
14526 assert!(activation
14527 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14528 .await
14529 .is_err());
14530 assert!(!activation
14531 .input_inbox
14532 .was_admitted(&child.id, &envelope.id)
14533 .await
14534 .unwrap());
14535 confirmation.canonical_claim_generation = claim.claim.generation;
14536 activation
14537 .confirm_input(&binding, &child, run_id, &claim, &confirmation)
14538 .await
14539 .unwrap();
14540 assert!(activation
14541 .input_inbox
14542 .was_admitted(&child.id, &envelope.id)
14543 .await
14544 .unwrap());
14545 assert_eq!(
14546 expected.as_ref().unwrap().expected_audit_revision,
14547 Some(first_audit)
14548 );
14549 let ChildFrame::Event { event } = permission_posture_frame(&child.id, policy_revision)
14550 else {
14551 unreachable!()
14552 };
14553 process_actor_event(
14554 event,
14555 true,
14556 &mut PermissionPostureHandshake::Awaiting,
14557 expected.as_ref(),
14558 Some(&binding),
14559 &mut child,
14560 &tx,
14561 &mut ActorEventDisplay::default(),
14562 )
14563 .await
14564 .unwrap();
14565 rx.recv().await.unwrap();
14566 assert!(
14567 bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
14568 .unwrap()
14569 .audit_revision
14570 > first_audit
14571 );
14572 let reopened = bamboo_storage::SessionStoreV2::new(temp.path().into())
14573 .await
14574 .unwrap();
14575 let cold = reopened.load_session(&child.id).await.unwrap().unwrap();
14576 assert_eq!(
14577 cold.messages
14578 .iter()
14579 .filter(|m| m.id == envelope.id.as_str())
14580 .count(),
14581 1
14582 );
14583 assert!(cold
14584 .session_inbox_admission()
14585 .unwrap()
14586 .contains(&envelope.id));
14587 }
14588 }
14589
14590 fn admission_confirmation(
14591 session_id: &str,
14592 claim: &SessionInboxClaim,
14593 run_id: &str,
14594 ) -> bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14595 bamboo_subagent::proto::SessionMessageAdmissionConfirmation {
14596 target_session_id: session_id.to_string(),
14597 envelope_id: claim.envelope.id.to_string(),
14598 canonical_claim_generation: claim.generation,
14599 activation_run_id: run_id.to_string(),
14600 }
14601 }
14602
14603 fn expected_default_permission_posture(policy_revision: u64) -> ExpectedPermissionPosture {
14604 ExpectedPermissionPosture {
14605 policy_revision,
14606 resolution: bamboo_domain::PermissionModeResolution {
14607 requested: bamboo_domain::SessionPermissionMode::Default,
14608 effective: bamboo_domain::PermissionMode::Default,
14609 },
14610 expected_audit_revision: None,
14611 executor_mapping: "test_actor:permission_mode=default".to_string(),
14612 }
14613 }
14614
14615 fn permission_posture_frame(session_id: &str, policy_revision: u64) -> ChildFrame {
14616 ChildFrame::Event {
14617 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
14618 session_id: session_id.to_string(),
14619 policy_revision,
14620 requested_mode: "default".to_string(),
14621 effective_mode: "default".to_string(),
14622 executor_mapping: "test_actor:permission_mode=default".to_string(),
14623 })
14624 .expect("serialize permission posture event"),
14625 }
14626 }
14627
14628 fn actor_event_frame(event: AgentEvent) -> ChildFrame {
14629 ChildFrame::Event {
14630 event: serde_json::to_value(event).expect("serialize actor event"),
14631 }
14632 }
14633
14634 #[test]
14635 fn actor_event_batch_is_fenced_by_session_activation_and_epoch() {
14636 let session = Session::new_child("logical-child", "logical-parent", "model", "child");
14637 let mut batch = ActorEventBatch {
14638 logical_session: Some(LogicalSessionIdentity {
14639 creation: None,
14640 session_id: session.id.clone(),
14641 parent_session_id: session.parent_session_id.clone(),
14642 root_session_id: session.root_session_id.clone(),
14643 }),
14644 activation_id: Some("activation-7".into()),
14645 execution_epoch: 11,
14646 source_node_id: Some("node-a".into()),
14647 source_actor_id: Some("worker-a".into()),
14648 first_seq: 1,
14649 last_seq: 1,
14650 qos: bamboo_subagent::ActorEventQos::Ephemeral,
14651 events: vec![serde_json::json!({"type":"token","content":"ok"})],
14652 };
14653 validate_actor_event_batch(
14654 &batch,
14655 &session,
14656 "logical-parent",
14657 Some("activation-7"),
14658 11,
14659 "worker-a",
14660 None,
14661 )
14662 .unwrap();
14663
14664 let creation = bamboo_subagent::proto::ChildCreationIdentity {
14665 created_at: session.created_at,
14666 spawn_depth: session.spawn_depth,
14667 };
14668 assert!(validate_actor_event_batch(
14669 &batch,
14670 &session,
14671 "logical-parent",
14672 Some("activation-7"),
14673 11,
14674 "worker-a",
14675 Some(&creation)
14676 )
14677 .is_err());
14678 batch.logical_session.as_mut().unwrap().creation = Some(creation.clone());
14679 validate_actor_event_batch(
14680 &batch,
14681 &session,
14682 "logical-parent",
14683 Some("activation-7"),
14684 11,
14685 "worker-a",
14686 Some(&creation),
14687 )
14688 .unwrap();
14689 batch
14690 .logical_session
14691 .as_mut()
14692 .unwrap()
14693 .creation
14694 .as_mut()
14695 .unwrap()
14696 .created_at += chrono::Duration::nanoseconds(1);
14697 assert!(validate_actor_event_batch(
14698 &batch,
14699 &session,
14700 "logical-parent",
14701 Some("activation-7"),
14702 11,
14703 "worker-a",
14704 Some(&creation)
14705 )
14706 .is_err());
14707 batch.logical_session.as_mut().unwrap().creation = None;
14708
14709 batch.execution_epoch = 10;
14710 assert!(validate_actor_event_batch(
14711 &batch,
14712 &session,
14713 "logical-parent",
14714 Some("activation-7"),
14715 11,
14716 "worker-a",
14717 None,
14718 )
14719 .unwrap_err()
14720 .to_string()
14721 .contains("execution epoch"));
14722 batch.execution_epoch = 11;
14723 batch.activation_id = Some("replaced-activation".into());
14724 assert!(validate_actor_event_batch(
14725 &batch,
14726 &session,
14727 "logical-parent",
14728 Some("activation-7"),
14729 11,
14730 "worker-a",
14731 None,
14732 )
14733 .unwrap_err()
14734 .to_string()
14735 .contains("stale activation"));
14736 batch.activation_id = Some("activation-7".into());
14737 batch
14738 .logical_session
14739 .as_mut()
14740 .expect("logical identity")
14741 .session_id = "foreign-child".into();
14742 assert!(validate_actor_event_batch(
14743 &batch,
14744 &session,
14745 "logical-parent",
14746 Some("activation-7"),
14747 11,
14748 "worker-a",
14749 None,
14750 )
14751 .unwrap_err()
14752 .to_string()
14753 .contains("different logical session"));
14754 batch
14755 .logical_session
14756 .as_mut()
14757 .expect("logical identity")
14758 .session_id = session.id.clone();
14759 batch.source_actor_id = Some("worker-b".into());
14760 assert!(validate_actor_event_batch(
14761 &batch,
14762 &session,
14763 "logical-parent",
14764 Some("activation-7"),
14765 11,
14766 "worker-a",
14767 None,
14768 )
14769 .unwrap_err()
14770 .to_string()
14771 .contains("different physical actor"));
14772 }
14773
14774 #[tokio::test]
14775 async fn directory_event_ingress_rejects_old_attempt_and_forged_identity() {
14776 use bamboo_domain::Storage;
14777
14778 let temp = tempfile::tempdir().unwrap();
14779 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
14780 .await
14781 .unwrap();
14782 let parent = Session::new("event-parent", "model");
14783 let child = Session::new_child_of("event-child", &parent, "model", "task");
14784 store.save_session(&parent).await.unwrap();
14785 store.save_session(&child).await.unwrap();
14786 store.ensure_actor(&child.id).await.unwrap();
14787 let now = chrono::Utc::now();
14788 let claim = |run_id: &str| ActorActivationClaim {
14789 actor_id: child.id.clone(),
14790 run_id: run_id.into(),
14791 lease_owner: "event-owner".into(),
14792 lease_expires_at: now + chrono::Duration::minutes(2),
14793 inbox_generation: 0,
14794 placement_ref: None,
14795 now,
14796 };
14797 let first = store.claim_activation(&claim("run-one")).await.unwrap();
14798 store.start_activation(&first.fence(), now).await.unwrap();
14799 let first_fence = first.fence();
14800 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14801 .await
14802 .unwrap();
14803 validate_directory_actor_event(&store, &first_fence, &child, None)
14804 .await
14805 .unwrap(); assert!(
14807 validate_directory_actor_event(&store, &first_fence, &child, Some("forged-run"))
14808 .await
14809 .is_err()
14810 );
14811 let mut forged = child.clone();
14812 forged.root_session_id = "foreign-root".into();
14813 assert!(
14814 validate_directory_actor_event(&store, &first_fence, &forged, Some("run-one"))
14815 .await
14816 .is_err()
14817 );
14818
14819 store
14820 .finish_activation(&first_fence, now, ActorActivationFinish::Failed)
14821 .await
14822 .unwrap();
14823 let second = store.claim_activation(&claim("run-two")).await.unwrap();
14824 store.start_activation(&second.fence(), now).await.unwrap();
14825 assert!(
14826 validate_directory_actor_event(&store, &first_fence, &child, Some("run-one"))
14827 .await
14828 .is_err()
14829 );
14830 assert!(
14831 validate_directory_actor_event(&store, &first_fence, &child, None)
14832 .await
14833 .is_err()
14834 );
14835 validate_directory_actor_event(&store, &second.fence(), &child, Some("run-two"))
14836 .await
14837 .unwrap();
14838 }
14839
14840 fn completed_actor_frame() -> ChildFrame {
14841 ChildFrame::Terminal {
14842 status: TerminalStatus::Completed,
14843 result: Some("done".to_string()),
14844 error: None,
14845 final_event_watermark: None,
14846 transcript: Vec::new(),
14847 }
14848 }
14849
14850 async fn drive_permission_handshake_frames(
14851 session_id: &str,
14852 frames: impl IntoIterator<Item = ChildFrame>,
14853 expected: ExpectedPermissionPosture,
14854 ) -> (
14855 crate::runtime::runner::Result<Option<String>>,
14856 Session,
14857 Vec<AgentEvent>,
14858 Vec<ParentFrame>,
14859 ) {
14860 let mut link = ConfirmationSequenceLink {
14861 frames: frames.into_iter().collect(),
14862 sent: Vec::new(),
14863 };
14864 let (event_tx, mut event_rx) = mpsc::channel(128);
14865 let cancel = CancellationToken::new();
14866 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14867 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14868 let mut session = Session::new(session_id, "model");
14869 let result = drive(ActorDriveContext {
14870 expected_creation: None,
14871 client: &mut link,
14872 parent_session_id: "permission-parent",
14873 child_session_id: session_id,
14874 child_attempt: 0,
14875 approval_registry: None,
14876 approval_decider: None,
14877 approval_reviewer: None,
14878 escalation_bridge: None,
14879 event_tx: &event_tx,
14880 cancel_token: &cancel,
14881 live_rx: &mut live_rx,
14882 delivery_rx: &mut delivery_rx,
14883 logical_session: &mut session,
14884 expected_permission_posture: Some(expected),
14885 session_inbox_runtime: None,
14886 actor_directory_store: None,
14887 canonical_subagent_tool: None,
14888 ticket_service: None,
14889 activation_run_id: None,
14890 execution_epoch: 0,
14891 expected_source_actor_id: session_id,
14892 initial_inflight_claims: VecDeque::new(),
14893 plain_actor: false,
14894 remote_environment_lease: false,
14895 readonly_output: None,
14896 local_history_tools: None,
14897 local_history_read_only: false,
14898 strict_history_output: None,
14899 plain_input: None,
14900 canonical_activation: None,
14901 canonical_placement_ref: None,
14902 actor_event_observer: None,
14903 plain_run: None,
14904 first_frame_timeout: Some(Duration::from_secs(1)),
14905 })
14906 .await;
14907 let events = std::iter::from_fn(|| event_rx.try_recv().ok()).collect();
14908 (result, session, events, link.sent)
14909 }
14910
14911 #[tokio::test]
14912 async fn actor_permission_handshake_rejects_terminal_without_posture() {
14913 let session_id = "permission-missing";
14914 let (result, session, events, _) = drive_permission_handshake_frames(
14915 session_id,
14916 [completed_actor_frame()],
14917 expected_default_permission_posture(7),
14918 )
14919 .await;
14920
14921 assert!(result
14922 .unwrap_err()
14923 .to_string()
14924 .contains("terminated before permission posture confirmation"));
14925 assert!(events.is_empty());
14926 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
14927 }
14928
14929 #[tokio::test]
14930 async fn remote_environment_rejection_accepts_only_allowlisted_durable_terminal() {
14931 struct TerminalProofProbe {
14932 frame: Option<ChildFrame>,
14933 accepted: bool,
14934 }
14935
14936 #[async_trait]
14937 impl bamboo_subagent::ChildLink for TerminalProofProbe {
14938 async fn send(&mut self, _frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
14939 Ok(())
14940 }
14941
14942 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
14943 Ok(self.frame.take())
14944 }
14945
14946 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
14947 self.accepted = status == TerminalStatus::Error;
14948 }
14949
14950 fn has_pending_durable_terminal(&self) -> bool {
14951 self.accepted
14952 }
14953 }
14954
14955 for (status, error, expected_proof) in [
14956 (
14957 TerminalStatus::Error,
14958 Some("remote_environment_checkout_not_clean"),
14959 true,
14960 ),
14961 (TerminalStatus::Error, Some("untrusted worker error"), false),
14962 (
14963 TerminalStatus::Cancelled,
14964 Some("remote_environment_checkout_not_clean"),
14965 false,
14966 ),
14967 ] {
14968 let mut link = TerminalProofProbe {
14969 frame: Some(ChildFrame::Terminal {
14970 status,
14971 result: None,
14972 error: error.map(str::to_owned),
14973 final_event_watermark: None,
14974 transcript: Vec::new(),
14975 }),
14976 accepted: false,
14977 };
14978 let (event_tx, _event_rx) = mpsc::channel(8);
14979 let cancel = CancellationToken::new();
14980 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
14981 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
14982 let mut session = Session::new("remote-terminal-proof", "model");
14983 let result = drive(ActorDriveContext {
14984 client: &mut link,
14985 parent_session_id: "proof-parent",
14986 child_session_id: "remote-terminal-proof",
14987 child_attempt: 0,
14988 approval_registry: None,
14989 approval_decider: None,
14990 approval_reviewer: None,
14991 escalation_bridge: None,
14992 event_tx: &event_tx,
14993 cancel_token: &cancel,
14994 live_rx: &mut live_rx,
14995 delivery_rx: &mut delivery_rx,
14996 logical_session: &mut session,
14997 expected_permission_posture: Some(expected_default_permission_posture(7)),
14998 expected_creation: None,
14999 session_inbox_runtime: None,
15000 actor_directory_store: None,
15001 canonical_subagent_tool: None,
15002 ticket_service: None,
15003 activation_run_id: None,
15004 execution_epoch: 0,
15005 expected_source_actor_id: "remote-terminal-proof",
15006 initial_inflight_claims: VecDeque::new(),
15007 plain_actor: false,
15008 remote_environment_lease: true,
15009 readonly_output: None,
15010 local_history_tools: None,
15011 local_history_read_only: false,
15012 strict_history_output: None,
15013 plain_input: None,
15014 canonical_activation: None,
15015 canonical_placement_ref: None,
15016 actor_event_observer: None,
15017 plain_run: None,
15018 first_frame_timeout: Some(Duration::from_secs(1)),
15019 })
15020 .await;
15021 assert!(result.is_err());
15022 assert_eq!(
15023 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15024 expected_proof
15025 );
15026 }
15027 }
15028
15029 #[tokio::test(start_paused = true)]
15030 async fn remote_cancel_requires_selected_worker_terminal_for_release_proof() {
15031 struct CancelOutcomeLink {
15032 cancel_sent: bool,
15033 settle: bool,
15034 accepted: bool,
15035 }
15036
15037 #[async_trait]
15038 impl bamboo_subagent::ChildLink for CancelOutcomeLink {
15039 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
15040 self.cancel_sent = matches!(frame, ParentFrame::Cancel);
15041 Ok(())
15042 }
15043
15044 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
15045 if !self.cancel_sent || !self.settle {
15046 std::future::pending::<bamboo_subagent::TransportResult<Option<ChildFrame>>>()
15047 .await
15048 } else {
15049 self.cancel_sent = false;
15050 Ok(Some(ChildFrame::Terminal {
15051 status: TerminalStatus::Cancelled,
15052 result: None,
15053 error: None,
15054 final_event_watermark: None,
15055 transcript: Vec::new(),
15056 }))
15057 }
15058 }
15059
15060 fn accept_durable_terminal(&mut self, status: TerminalStatus) {
15061 self.accepted = status == TerminalStatus::Cancelled;
15062 }
15063
15064 fn has_pending_durable_terminal(&self) -> bool {
15065 self.accepted
15066 }
15067 }
15068
15069 let temp = tempfile::tempdir().unwrap();
15070 let store = bamboo_storage::SessionStoreV2::new(temp.path().into())
15071 .await
15072 .unwrap();
15073 let fence = ActorActivationFence {
15074 schema_version: 2,
15075 actor_id: "cancelled-remote-child".into(),
15076 activation_id: "cancelled-remote-activation".into(),
15077 attempt: 1,
15078 run_id: "cancelled-remote-run".into(),
15079 lease_owner: "cancelled-remote-host".into(),
15080 lease_epoch: 1,
15081 };
15082 for settle in [true, false] {
15083 let mut link = CancelOutcomeLink {
15084 cancel_sent: false,
15085 settle,
15086 accepted: false,
15087 };
15088 let (event_tx, _event_rx) = mpsc::channel(8);
15089 let cancel = CancellationToken::new();
15090 cancel.cancel();
15091 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
15092 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
15093 let mut session = Session::new("cancelled-remote-child", "model");
15094 let result = drive(ActorDriveContext {
15095 expected_creation: None,
15096 client: &mut link,
15097 parent_session_id: "cancelled-remote-parent",
15098 child_session_id: "cancelled-remote-child",
15099 child_attempt: 1,
15100 approval_registry: None,
15101 approval_decider: None,
15102 approval_reviewer: None,
15103 escalation_bridge: None,
15104 event_tx: &event_tx,
15105 cancel_token: &cancel,
15106 live_rx: &mut live_rx,
15107 delivery_rx: &mut delivery_rx,
15108 logical_session: &mut session,
15109 expected_permission_posture: Some(expected_default_permission_posture(7)),
15110 session_inbox_runtime: None,
15111 actor_directory_store: Some(&store),
15112 canonical_subagent_tool: None,
15113 ticket_service: None,
15114 activation_run_id: Some("cancelled-remote-run"),
15115 execution_epoch: 0,
15116 expected_source_actor_id: "cancelled-remote-worker",
15117 initial_inflight_claims: VecDeque::new(),
15118 plain_actor: false,
15119 remote_environment_lease: true,
15120 readonly_output: None,
15121 local_history_tools: None,
15122 local_history_read_only: false,
15123 strict_history_output: None,
15124 plain_input: None,
15125 canonical_activation: Some(&fence),
15126 canonical_placement_ref: None,
15127 actor_event_observer: None,
15128 plain_run: None,
15129 first_frame_timeout: None,
15130 })
15131 .await;
15132 assert!(matches!(result, Err(AgentError::Cancelled)));
15133 assert_eq!(
15134 link.accepted, settle,
15135 "only a consumed exact terminal can release the remote fence"
15136 );
15137 assert_eq!(
15138 bamboo_subagent::ChildLink::has_pending_durable_terminal(&link),
15139 settle
15140 );
15141 }
15142 }
15143
15144 #[tokio::test]
15145 async fn actor_permission_handshake_rejects_malformed_agent_event() {
15146 let session_id = "permission-malformed";
15147 let (result, session, events, _) = drive_permission_handshake_frames(
15148 session_id,
15149 [ChildFrame::Event {
15150 event: serde_json::json!({"type": "token", "content": 42}),
15151 }],
15152 expected_default_permission_posture(7),
15153 )
15154 .await;
15155
15156 assert!(result
15157 .unwrap_err()
15158 .to_string()
15159 .contains("malformed AgentEvent"));
15160 assert!(events.is_empty());
15161 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15162
15163 let (result, _, events, _) = drive_permission_handshake_frames(
15164 session_id,
15165 [
15166 permission_posture_frame(session_id, 7),
15167 ChildFrame::Event {
15168 event: serde_json::json!({"type":"private-download-secret"}),
15169 },
15170 ],
15171 expected_default_permission_posture(7),
15172 )
15173 .await;
15174 assert!(!result
15175 .unwrap_err()
15176 .to_string()
15177 .contains("private-download-secret"));
15178 assert_eq!(
15179 events.len(),
15180 1,
15181 "only the confirmed posture may be forwarded"
15182 );
15183 }
15184
15185 #[tokio::test]
15186 async fn actor_permission_handshake_rejects_execution_event_before_posture() {
15187 let session_id = "permission-early-event";
15188 let early_events = [
15189 (
15190 "progress",
15191 AgentEvent::RunnerProgress {
15192 session_id: session_id.to_string(),
15193 round_count: 1,
15194 },
15195 ),
15196 (
15197 "token",
15198 AgentEvent::Token {
15199 content: "must-not-forward".to_string(),
15200 },
15201 ),
15202 (
15203 "tool",
15204 AgentEvent::ToolStart {
15205 tool_call_id: "early-tool".to_string(),
15206 tool_name: "Read".to_string(),
15207 arguments: serde_json::json!({"file_path": "README.md"}),
15208 },
15209 ),
15210 ];
15211 for (kind, event) in early_events {
15212 let (result, session, events, _) = drive_permission_handshake_frames(
15213 session_id,
15214 [
15215 actor_event_frame(event),
15216 permission_posture_frame(session_id, 7),
15217 completed_actor_frame(),
15218 ],
15219 expected_default_permission_posture(7),
15220 )
15221 .await;
15222
15223 assert!(
15224 result
15225 .unwrap_err()
15226 .to_string()
15227 .contains("execution event before permission posture confirmation"),
15228 "{kind} must fail closed before posture"
15229 );
15230 assert!(events.is_empty(), "{kind} must not be forwarded");
15231 assert!(
15232 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15233 "{kind} must not advance the permission audit"
15234 );
15235 }
15236 }
15237
15238 #[tokio::test]
15239 async fn actor_permission_handshake_rejects_approval_before_posture() {
15240 let session_id = "permission-early-approval";
15241 let (result, session, events, sent) = drive_permission_handshake_frames(
15242 session_id,
15243 [ChildFrame::ApprovalRequest {
15244 id: "approval-before-posture".to_string(),
15245 body: serde_json::json!({
15246 "tool_name": "Bash",
15247 "permission": "execute",
15248 "resource": "echo must-not-run"
15249 }),
15250 }],
15251 expected_default_permission_posture(7),
15252 )
15253 .await;
15254
15255 assert!(result
15256 .unwrap_err()
15257 .to_string()
15258 .contains("requested approval before permission posture confirmation"));
15259 assert!(events.is_empty());
15260 assert!(
15261 sent.is_empty(),
15262 "an unconfirmed actor must receive no approval reply"
15263 );
15264 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15265 }
15266
15267 #[tokio::test]
15268 async fn actor_permission_handshake_rejects_mismatched_posture() {
15269 let session_id = "permission-mismatch";
15270 let (result, session, events, _) = drive_permission_handshake_frames(
15271 session_id,
15272 [permission_posture_frame(session_id, 8)],
15273 expected_default_permission_posture(7),
15274 )
15275 .await;
15276
15277 assert!(result
15278 .unwrap_err()
15279 .to_string()
15280 .contains("does not match the host-dispatched policy"));
15281 assert!(events.is_empty());
15282 assert!(bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none());
15283 }
15284
15285 #[tokio::test]
15286 async fn actor_permission_handshake_rejects_untrusted_executor_mapping() {
15287 let session_id = "permission-hostile-mapping";
15288 for hostile_mapping in [
15289 "wrong_executor:permission_mode=default",
15290 "test_actor:permission_mode=default;credential=must-not-persist",
15291 ] {
15292 let frame = ChildFrame::Event {
15293 event: serde_json::to_value(AgentEvent::PermissionPostureActivated {
15294 session_id: session_id.to_string(),
15295 policy_revision: 7,
15296 requested_mode: "default".to_string(),
15297 effective_mode: "default".to_string(),
15298 executor_mapping: hostile_mapping.to_string(),
15299 })
15300 .expect("serialize hostile posture fixture"),
15301 };
15302 let (result, session, events, _) = drive_permission_handshake_frames(
15303 session_id,
15304 [frame],
15305 expected_default_permission_posture(7),
15306 )
15307 .await;
15308
15309 let error = result.unwrap_err().to_string();
15310 assert!(error.contains("host-dispatched executor mapping"));
15311 assert!(
15312 !error.contains(hostile_mapping),
15313 "untrusted mapping must not be reflected in host errors"
15314 );
15315 assert!(events.is_empty());
15316 assert!(
15317 bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata).is_none(),
15318 "untrusted mapping must not reach durable or in-memory audit state"
15319 );
15320 }
15321 }
15322
15323 #[tokio::test]
15324 async fn actor_permission_handshake_rejects_duplicate_posture() {
15325 let session_id = "permission-duplicate";
15326 let (result, session, events, _) = drive_permission_handshake_frames(
15327 session_id,
15328 [
15329 permission_posture_frame(session_id, 7),
15330 permission_posture_frame(session_id, 7),
15331 completed_actor_frame(),
15332 ],
15333 expected_default_permission_posture(7),
15334 )
15335 .await;
15336
15337 assert!(result
15338 .unwrap_err()
15339 .to_string()
15340 .contains("duplicate permission posture activation"));
15341 assert_eq!(
15342 events.len(),
15343 1,
15344 "only the confirmed posture may be forwarded"
15345 );
15346 assert!(matches!(
15347 events[0],
15348 AgentEvent::PermissionPostureActivated { .. }
15349 ));
15350 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15351 .expect("the first matching posture must be recorded");
15352 assert_eq!(audit.policy_revision, 7);
15353 }
15354
15355 #[tokio::test]
15356 async fn actor_permission_handshake_happy_path_persists_before_forwarding_execution() {
15357 let session_id = "permission-happy";
15358 let (result, session, events, _) = drive_permission_handshake_frames(
15359 session_id,
15360 [
15361 permission_posture_frame(session_id, 7),
15362 actor_event_frame(AgentEvent::RunnerProgress {
15363 session_id: session_id.to_string(),
15364 round_count: 1,
15365 }),
15366 actor_event_frame(AgentEvent::Token {
15367 content: "working".to_string(),
15368 }),
15369 actor_event_frame(AgentEvent::ToolStart {
15370 tool_call_id: "tool-1".to_string(),
15371 tool_name: "Read".to_string(),
15372 arguments: serde_json::json!({"file_path": "README.md"}),
15373 }),
15374 completed_actor_frame(),
15375 ],
15376 expected_default_permission_posture(7),
15377 )
15378 .await;
15379
15380 assert_eq!(result.unwrap().as_deref(), Some("done"));
15381 assert!(matches!(
15382 events.as_slice(),
15383 [
15384 AgentEvent::PermissionPostureActivated { .. },
15385 AgentEvent::RunnerProgress { .. },
15386 AgentEvent::Token { .. },
15387 AgentEvent::ToolStart { .. }
15388 ]
15389 ));
15390 let audit = bamboo_domain::PermissionAuditSnapshot::from_metadata(&session.metadata)
15391 .expect("matching posture must be recorded before execution events are accepted");
15392 assert_eq!(audit.policy_revision, 7);
15393 assert_eq!(audit.executor_mapping, "test_actor:permission_mode=default");
15394 }
15395
15396 #[tokio::test]
15397 async fn actor_download_tool_start_is_projected_before_parent_forwarding() {
15398 for (index, tool_name) in ["browser", "default::browser", "private-namespace::browser"]
15399 .into_iter()
15400 .enumerate()
15401 {
15402 let session_id = format!("actor-download-{index}");
15403 let args = serde_json::json!({
15404 "action":"download",
15405 "selector":"a[data-secret='private-selector']",
15406 "expected_epoch":17,
15407 "data_base64":"private-extra-bytes",
15408 "extra":{"url":"https://private.test/file"},
15409 });
15410 let original = args.clone();
15411 let read_args = serde_json::json!({"file_path":"README.md"});
15412 let (result, _session, events, _) = drive_permission_handshake_frames(
15413 &session_id,
15414 [
15415 permission_posture_frame(&session_id, 7),
15416 actor_event_frame(AgentEvent::ToolStart {
15417 tool_call_id: "download-call".to_string(),
15418 tool_name: tool_name.to_string(),
15419 arguments: args.clone(),
15420 }),
15421 actor_event_frame(AgentEvent::ToolStart {
15422 tool_call_id: "read-call".to_string(),
15423 tool_name: "Read".to_string(),
15424 arguments: read_args.clone(),
15425 }),
15426 completed_actor_frame(),
15427 ],
15428 expected_default_permission_posture(7),
15429 )
15430 .await;
15431 assert_eq!(result.expect("actor completed").as_deref(), Some("done"));
15432 assert_eq!(args, original);
15433 let starts: Vec<_> = events
15434 .iter()
15435 .filter_map(|event| match event {
15436 AgentEvent::ToolStart {
15437 tool_name,
15438 arguments,
15439 ..
15440 } => Some((tool_name.as_str(), arguments)),
15441 _ => None,
15442 })
15443 .collect();
15444 assert_eq!(starts.len(), 2);
15445 assert_eq!(starts[0].0, "browser");
15446 assert_eq!(
15447 starts[0].1,
15448 &serde_json::json!({"action":"download","expected_epoch":17})
15449 );
15450 assert_eq!(starts[1], ("Read", &read_args));
15451 assert!(!format!("{events:?}").contains("private"));
15452 }
15453 }
15454
15455 #[tokio::test]
15456 async fn actor_hook_lifecycle_hides_every_decision_text_across_event_batch_and_nested_stream() {
15457 let session_id = "actor-hook-display";
15458 let private = "private-selector-url-filename-response";
15459 let hook_name = format!("audit-{private}");
15460 let hook = |duration_ms, phase: &str, decision| AgentEvent::HookLifecycle {
15461 hook_name: hook_name.clone(),
15462 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15463 phase: phase.into(),
15464 duration_ms,
15465 decision,
15466 };
15467 let decisions = [
15468 HookResult::Deny {
15469 reason: private.into(),
15470 },
15471 HookResult::InjectContext {
15472 text: private.into(),
15473 },
15474 HookResult::WithContext {
15475 result: Box::new(HookResult::WithContext {
15476 result: Box::new(HookResult::Deny {
15477 reason: private.into(),
15478 }),
15479 text: private.into(),
15480 }),
15481 text: private.into(),
15482 },
15483 HookResult::Suspend {
15484 reason: private.into(),
15485 },
15486 HookResult::Abort {
15487 reason: private.into(),
15488 },
15489 ];
15490 let originals = decisions.to_vec();
15491 let batch_events = [
15492 hook(2, "completed", decisions[1].clone()),
15493 hook(3, "completed", decisions[2].clone()),
15494 hook(4, "completed", decisions[3].clone()),
15495 ]
15496 .into_iter()
15497 .map(|event| serde_json::to_value(event).unwrap())
15498 .collect::<Vec<_>>();
15499 let batch = ActorEventBatch {
15500 logical_session: Some(LogicalSessionIdentity {
15501 creation: None,
15502 session_id: session_id.into(),
15503 parent_session_id: Some("permission-parent".into()),
15504 root_session_id: session_id.into(),
15505 }),
15506 activation_id: None,
15507 execution_epoch: 0,
15508 source_node_id: None,
15509 source_actor_id: Some(session_id.into()),
15510 first_seq: 1,
15511 last_seq: batch_events.len() as u64,
15512 qos: bamboo_subagent::ActorEventQos::classify(&batch_events[0]),
15513 events: batch_events,
15514 };
15515 let nested = AgentEvent::SubAgentEvent {
15516 parent_session_id: "parent".into(),
15517 child_session_id: "child".into(),
15518 event: Box::new(hook(5, "completed", decisions[4].clone())),
15519 };
15520 let raw = serde_json::to_string(&originals).unwrap();
15521 assert!(raw.contains(private));
15522
15523 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15524 session_id,
15525 [
15526 permission_posture_frame(session_id, 7),
15527 actor_event_frame(hook(1, private, decisions[0].clone())),
15528 ChildFrame::EventBatch { batch },
15529 actor_event_frame(nested),
15530 completed_actor_frame(),
15531 ],
15532 expected_default_permission_posture(7),
15533 )
15534 .await;
15535 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15536 let wire = serde_json::to_string(&forwarded).unwrap();
15537 assert!(
15538 !wire.contains(private),
15539 "private hook text reached SSE: {wire}"
15540 );
15541
15542 let hooks = forwarded
15543 .iter()
15544 .filter_map(|event| match event {
15545 AgentEvent::HookLifecycle {
15546 hook_name,
15547 point,
15548 phase,
15549 duration_ms,
15550 decision,
15551 } => Some((hook_name, point, phase, duration_ms, decision)),
15552 _ => None,
15553 })
15554 .collect::<Vec<_>>();
15555 assert_eq!(hooks.len(), 4);
15556 assert!(hooks.iter().all(|(name, point, _, _, _)| {
15557 name == &&"actor-hook-1".to_string()
15558 && **point == bamboo_domain::AgentHookPoint::BeforeToolExecution
15559 }));
15560 assert_eq!(hooks[0].2, "event");
15561 assert_eq!(hooks[1].2, "completed");
15562 assert_eq!(*hooks[0].3, 1);
15563 assert_eq!(*hooks[3].3, 4);
15564 assert!(matches!(hooks[0].4, HookResult::Deny { reason } if reason == "Hook denied"));
15565 assert!(
15566 matches!(hooks[1].4, HookResult::InjectContext { text } if text == "Hook context hidden")
15567 );
15568 assert!(
15569 matches!(hooks[2].4, HookResult::WithContext { result, text }
15570 if text == "Hook context hidden" && matches!(result.as_ref(),
15571 HookResult::WithContext { result, text }
15572 if text == "Hook context hidden" && matches!(result.as_ref(),
15573 HookResult::Deny { reason } if reason == "Hook denied")))
15574 );
15575 assert!(matches!(hooks[3].4, HookResult::Suspend { reason } if reason == "Hook suspended"));
15576 assert!(forwarded.iter().any(|event| matches!(event,
15577 AgentEvent::SubAgentEvent { event, .. }
15578 if matches!(event.as_ref(), AgentEvent::HookLifecycle {
15579 hook_name, duration_ms: 5,
15580 decision: HookResult::Abort { reason }, ..
15581 } if hook_name == "actor-hook-1" && reason == "Hook aborted")
15582 )));
15583 }
15584
15585 #[test]
15586 fn actor_hook_display_depth_and_identity_limits_fail_closed() {
15587 let mut display = ActorEventDisplay::default();
15588 let mut deep = HookResult::Allow;
15589 for _ in 0..=MAX_DISPLAY_HOOK_RESULT_DEPTH {
15590 deep = HookResult::WithContext {
15591 result: Box::new(deep),
15592 text: "private-download-bytes".into(),
15593 };
15594 }
15595 let event = |hook_name: String, decision| AgentEvent::HookLifecycle {
15596 hook_name,
15597 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15598 phase: "completed".into(),
15599 duration_ms: 7,
15600 decision,
15601 };
15602 assert!(display.project(event("deep".into(), deep), 0).is_none());
15603 assert!(display.overflowed);
15604 assert!(display
15605 .project(
15606 event(
15607 "private-download-bytes".into(),
15608 HookResult::Deny {
15609 reason: "private-download-bytes".into(),
15610 },
15611 ),
15612 0,
15613 )
15614 .is_none());
15615
15616 let mut oversized = ActorEventDisplay::default();
15617 assert!(oversized
15618 .project(
15619 event("x".repeat(MAX_DISPLAY_ID_BYTES + 1), HookResult::Allow),
15620 0,
15621 )
15622 .is_none());
15623 assert!(oversized.overflowed);
15624
15625 let mut reconnected = ActorEventDisplay::default();
15629 let private = "private-download-bytes";
15630 for _ in 0..2 {
15631 let projected = reconnected
15632 .project(event(private.into(), HookResult::Allow), 0)
15633 .unwrap();
15634 assert!(
15635 matches!(projected, AgentEvent::HookLifecycle { hook_name, .. }
15636 if hook_name == "actor-hook-1")
15637 );
15638 }
15639 assert_eq!(reconnected.identities.load(Ordering::Relaxed), 1);
15640 for index in 1..MAX_DISPLAY_CALLS {
15641 assert!(reconnected
15642 .project(event(format!("hook-{index}"), HookResult::Allow), 0)
15643 .is_some());
15644 }
15645 assert!(reconnected
15646 .project(event("overflow-private".into(), HookResult::Allow), 0)
15647 .is_none());
15648 assert!(reconnected.overflowed);
15649 assert!(reconnected.hook_names.is_empty());
15650 }
15651
15652 #[test]
15653 fn actor_hook_display_preserves_nontext_decision_kinds() {
15654 for result in [
15655 HookResult::Continue,
15656 HookResult::Mutated,
15657 HookResult::Allow,
15658 HookResult::Ask,
15659 ] {
15660 assert_eq!(hook_result_for_display(result.clone(), 0), Some(result));
15661 }
15662 }
15663
15664 #[tokio::test]
15665 async fn malformed_actor_hook_events_never_fall_back_to_raw_display() {
15666 let session_id = "actor-malformed-hook";
15667 let private = "private-download-selector";
15668 let original = AgentEvent::HookLifecycle {
15669 hook_name: "audit".into(),
15670 point: bamboo_domain::AgentHookPoint::BeforeToolExecution,
15671 phase: "completed".into(),
15672 duration_ms: 9,
15673 decision: HookResult::Deny {
15674 reason: private.into(),
15675 },
15676 };
15677 let mut invalid = serde_json::to_value(&original).unwrap();
15678 invalid["decision"]["type"] = serde_json::json!("unknown_decision");
15679 let nested = AgentEvent::SubAgentEvent {
15680 parent_session_id: "parent".into(),
15681 child_session_id: "child".into(),
15682 event: Box::new(original),
15683 };
15684 let mut invalid_nested = serde_json::to_value(nested).unwrap();
15685 invalid_nested["event"]["decision"]["type"] = serde_json::json!("unknown_decision");
15686 let batch = ActorEventBatch {
15687 logical_session: Some(LogicalSessionIdentity {
15688 creation: None,
15689 session_id: session_id.into(),
15690 parent_session_id: Some("permission-parent".into()),
15691 root_session_id: session_id.into(),
15692 }),
15693 activation_id: None,
15694 execution_epoch: 0,
15695 source_node_id: None,
15696 source_actor_id: Some(session_id.into()),
15697 first_seq: 1,
15698 last_seq: 1,
15699 qos: bamboo_subagent::ActorEventQos::classify(&invalid),
15700 events: vec![invalid.clone()],
15701 };
15702 for frame in [
15703 ChildFrame::Event {
15704 event: invalid.clone(),
15705 },
15706 ChildFrame::EventBatch { batch },
15707 ChildFrame::Event {
15708 event: invalid_nested,
15709 },
15710 ] {
15711 let (outcome, _session, forwarded, _) = drive_permission_handshake_frames(
15712 session_id,
15713 [permission_posture_frame(session_id, 7), frame],
15714 expected_default_permission_posture(7),
15715 )
15716 .await;
15717 let error = outcome.unwrap_err().to_string();
15718 assert!(error.contains("malformed AgentEvent"), "{error}");
15719 assert!(!error.contains(private));
15720 assert!(!serde_json::to_string(&forwarded).unwrap().contains(private));
15721 }
15722
15723 let (event_tx, mut event_rx) = mpsc::channel(8);
15724 let mut session = Session::new("legacy-hook", "model");
15725 let mut handshake = PermissionPostureHandshake::NotRequired;
15726 let mut display = ActorEventDisplay::default();
15727 process_actor_event(
15728 invalid,
15729 false,
15730 &mut handshake,
15731 None,
15732 None,
15733 &mut session,
15734 &event_tx,
15735 &mut display,
15736 )
15737 .await
15738 .unwrap();
15739 assert!(display.overflowed);
15740 assert!(event_rx.try_recv().is_err());
15741 }
15742
15743 #[tokio::test]
15744 async fn actor_download_event_batch_hides_output_and_preserves_read_output() {
15745 let session_id = "actor-download-batch";
15746 let private = "private-download-bytes-and-url";
15747 let events = vec![
15748 AgentEvent::ToolStart {
15749 tool_call_id: "download-call".into(),
15750 tool_name: "default::browser".into(),
15751 arguments: serde_json::json!({
15752 "action":"download", "selector": private, "expected_epoch": 17,
15753 }),
15754 },
15755 AgentEvent::ToolApprovalRequested {
15756 tool_call_id: "download-call".into(),
15757 tool_name: format!("{private}::browser"),
15758 parameters: serde_json::json!({
15759 "action":"download", "selector": private, "expected_epoch": 17,
15760 }),
15761 },
15762 AgentEvent::ToolStart {
15763 tool_call_id: "read-call".into(),
15764 tool_name: "Read".into(),
15765 arguments: serde_json::json!({"file_path":"README.md"}),
15766 },
15767 ];
15768 let batch = ActorEventBatch {
15769 logical_session: Some(LogicalSessionIdentity {
15770 creation: None,
15771 session_id: session_id.into(),
15772 parent_session_id: Some("permission-parent".into()),
15773 root_session_id: session_id.into(),
15774 }),
15775 activation_id: None,
15776 execution_epoch: 0,
15777 source_node_id: None,
15778 source_actor_id: Some(session_id.into()),
15779 first_seq: 1,
15780 last_seq: events.len() as u64,
15781 qos: bamboo_subagent::ActorEventQos::Durable,
15782 events: events
15783 .into_iter()
15784 .map(|event| serde_json::to_value(event).unwrap())
15785 .collect(),
15786 };
15787 let result = bamboo_agent_core::tools::ToolResult {
15788 success: true,
15789 result: format!("{{\"data_base64\":\"{private}\"}}"),
15790 display_preference: Some(private.into()),
15791 images: vec![bamboo_agent_core::tools::ToolResultImage {
15792 mime_type: "image/png".into(),
15793 data: private.into(),
15794 }],
15795 };
15796 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15797 session_id,
15798 [
15799 permission_posture_frame(session_id, 7),
15800 ChildFrame::EventBatch { batch },
15801 actor_event_frame(AgentEvent::ToolToken {
15802 tool_call_id: "download-call".into(),
15803 content: private.into(),
15804 }),
15805 actor_event_frame(AgentEvent::ToolToken {
15806 tool_call_id: "read-call".into(),
15807 content: "public live output".into(),
15808 }),
15809 actor_event_frame(AgentEvent::ToolComplete {
15810 tool_call_id: "download-call".into(),
15811 result,
15812 }),
15813 actor_event_frame(AgentEvent::ToolLifecycle {
15814 tool_call_id: "download-call".into(),
15815 tool_name: "browser".into(),
15816 phase: "finished".into(),
15817 elapsed_ms: Some(1),
15818 is_mutating: false,
15819 auto_approved: false,
15820 summary: Some(private.into()),
15821 error: Some(private.into()),
15822 }),
15823 actor_event_frame(AgentEvent::ToolError {
15824 tool_call_id: "download-call".into(),
15825 error: private.into(),
15826 }),
15827 actor_event_frame(AgentEvent::ToolComplete {
15828 tool_call_id: "read-call".into(),
15829 result: bamboo_agent_core::tools::ToolResult::text(true, "public README text"),
15830 }),
15831 completed_actor_frame(),
15832 ],
15833 expected_default_permission_posture(7),
15834 )
15835 .await;
15836 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15837 let wire = serde_json::to_string(&forwarded).unwrap();
15838 assert!(
15839 !wire.contains(private),
15840 "private content reached actor SSE: {wire}"
15841 );
15842 assert!(wire.contains("public README text"));
15843 assert!(wire.contains("public live output"));
15844 assert!(forwarded.iter().any(|event| matches!(event,
15845 AgentEvent::ToolComplete { tool_call_id, result }
15846 if tool_call_id == "download-call"
15847 && result.result == "Browser tool result hidden"
15848 && result.images.is_empty() && result.display_preference.is_none()
15849 )));
15850 }
15851
15852 #[tokio::test]
15853 async fn actor_missing_reused_and_nested_tool_identity_fails_closed() {
15854 let private = "private-download-content";
15855 let nested = |event| AgentEvent::SubAgentEvent {
15856 parent_session_id: "parent".into(),
15857 child_session_id: "nested-child".into(),
15858 event: Box::new(event),
15859 };
15860 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
15861 "actor-reorder",
15862 [
15863 permission_posture_frame("actor-reorder", 7),
15864 actor_event_frame(AgentEvent::ToolComplete {
15865 tool_call_id: "missing".into(),
15866 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15867 }),
15868 actor_event_frame(AgentEvent::ToolLifecycle {
15869 tool_call_id: "missing".into(),
15870 tool_name: "browser".into(),
15871 phase: private.into(),
15872 elapsed_ms: None,
15873 is_mutating: false,
15874 auto_approved: false,
15875 summary: Some(private.into()),
15876 error: Some(private.into()),
15877 }),
15878 actor_event_frame(AgentEvent::ToolApprovalRequested {
15879 tool_call_id: "approval-only".into(),
15880 tool_name: "browser".into(),
15881 parameters: serde_json::json!({"action":"download","selector":private}),
15882 }),
15883 actor_event_frame(AgentEvent::ToolToken {
15884 tool_call_id: "approval-only".into(),
15885 content: private.into(),
15886 }),
15887 actor_event_frame(AgentEvent::ToolStart {
15888 tool_call_id: "malformed-browser".into(),
15889 tool_name: format!("{private}::browser"),
15890 arguments: serde_json::json!({"selector":private}),
15891 }),
15892 actor_event_frame(AgentEvent::ToolComplete {
15893 tool_call_id: "malformed-browser".into(),
15894 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15895 }),
15896 actor_event_frame(AgentEvent::ToolStart {
15897 tool_call_id: "reused".into(),
15898 tool_name: "Read".into(),
15899 arguments: serde_json::json!({"file_path":"README.md"}),
15900 }),
15901 actor_event_frame(AgentEvent::ToolStart {
15902 tool_call_id: "reused".into(),
15903 tool_name: "browser".into(),
15904 arguments: serde_json::json!({"action":"download","selector":private}),
15905 }),
15906 actor_event_frame(AgentEvent::ToolToken {
15907 tool_call_id: "reused".into(),
15908 content: private.into(),
15909 }),
15910 actor_event_frame(nested(AgentEvent::ToolStart {
15911 tool_call_id: "nested".into(),
15912 tool_name: "browser".into(),
15913 arguments: serde_json::json!({"action":"download","selector":private}),
15914 })),
15915 actor_event_frame(nested(AgentEvent::ToolComplete {
15916 tool_call_id: "nested".into(),
15917 result: bamboo_agent_core::tools::ToolResult::text(true, private),
15918 })),
15919 actor_event_frame(AgentEvent::ChildApprovalRequested {
15920 child_session_id: "nested-child".into(),
15921 request_id: "approval".into(),
15922 tool_name: format!("{private}::browser"),
15923 permission: private.into(),
15924 resource: private.into(),
15925 }),
15926 actor_event_frame(AgentEvent::ChildApprovalChanged {
15927 parent_session_id: "parent".into(),
15928 child_session_id: "nested-child".into(),
15929 child_attempt: 1,
15930 request_id: "approval".into(),
15931 version: 1,
15932 status: "pending".into(),
15933 reason: Some(private.into()),
15934 tool_name: "browser".into(),
15935 permission: private.into(),
15936 resource: private.into(),
15937 created_at: "2026-01-01T00:00:00Z".into(),
15938 resolved_at: None,
15939 }),
15940 completed_actor_frame(),
15941 ],
15942 expected_default_permission_posture(7),
15943 )
15944 .await;
15945 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
15946 let wire = serde_json::to_string(&forwarded).unwrap();
15947 assert!(
15948 !wire.contains(private),
15949 "private content reached actor SSE: {wire}"
15950 );
15951 assert!(wire.contains("Tool result hidden"));
15952 assert!(wire.contains("Browser tool result hidden"));
15953 assert!(wire.contains("Browser interaction approval"));
15954 assert!(!forwarded
15955 .iter()
15956 .any(|event| matches!(event, AgentEvent::ChildApprovalChanged { .. })));
15957 }
15958
15959 #[test]
15960 fn actor_display_tracking_overflow_never_reclassifies_old_private_id() {
15961 let mut display = ActorEventDisplay::default();
15962 display
15963 .project(
15964 AgentEvent::ToolStart {
15965 tool_call_id: "private-call".into(),
15966 tool_name: "browser".into(),
15967 arguments: serde_json::json!({"action":"download","selector":"private"}),
15968 },
15969 0,
15970 )
15971 .unwrap();
15972 for index in 0..MAX_DISPLAY_CALLS {
15973 display
15974 .project(
15975 AgentEvent::ToolStart {
15976 tool_call_id: format!("ordinary-{index}"),
15977 tool_name: "Read".into(),
15978 arguments: serde_json::json!({"file_path":"README.md"}),
15979 },
15980 0,
15981 )
15982 .unwrap();
15983 }
15984 assert!(display.overflowed);
15985 let forwarded = display
15986 .project(
15987 AgentEvent::ToolComplete {
15988 tool_call_id: "private-call".into(),
15989 result: bamboo_agent_core::tools::ToolResult::text(true, "private-bytes"),
15990 },
15991 0,
15992 )
15993 .unwrap();
15994 assert!(!serde_json::to_string(&forwarded)
15995 .unwrap()
15996 .contains("private-bytes"));
15997 }
15998
15999 #[test]
16000 fn nested_actor_display_uses_one_identity_budget_and_bounded_keys() {
16001 let mut display = ActorEventDisplay::default();
16002 let nested = |index: usize| AgentEvent::SubAgentEvent {
16003 parent_session_id: "parent".into(),
16004 child_session_id: format!("child-{index}"),
16005 event: Box::new(AgentEvent::ToolStart {
16006 tool_call_id: format!("read-{index}"),
16007 tool_name: "Read".into(),
16008 arguments: serde_json::json!({"file_path":"README.md"}),
16009 }),
16010 };
16011 for index in 0..(MAX_DISPLAY_CALLS / 2) {
16012 assert!(display.project(nested(index), 0).is_some());
16013 }
16014 assert_eq!(
16015 display.identities.load(Ordering::Relaxed),
16016 MAX_DISPLAY_CALLS
16017 );
16018 assert!(display.project(nested(MAX_DISPLAY_CALLS / 2), 0).is_none());
16019 assert!(display.overflowed);
16020 assert!(display.children.is_empty());
16021
16022 let mut oversized = ActorEventDisplay::default();
16023 assert!(oversized
16024 .project(
16025 AgentEvent::SubAgentEvent {
16026 parent_session_id: "parent".into(),
16027 child_session_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16028 event: Box::new(AgentEvent::ToolComplete {
16029 tool_call_id: "call".into(),
16030 result: bamboo_agent_core::tools::ToolResult::text(
16031 true,
16032 "private-download-bytes",
16033 ),
16034 }),
16035 },
16036 0,
16037 )
16038 .is_none());
16039 assert!(oversized.overflowed);
16040 assert!(oversized
16041 .project(
16042 AgentEvent::ToolStart {
16043 tool_call_id: "x".repeat(MAX_DISPLAY_ID_BYTES + 1),
16044 tool_name: "Read".into(),
16045 arguments: serde_json::json!({"file_path":"README.md"}),
16046 },
16047 0,
16048 )
16049 .is_none());
16050 }
16051
16052 #[tokio::test]
16053 async fn actor_batch_sequence_gap_invalidates_prior_tool_identity() {
16054 let session_id = "actor-gap";
16055 let batch = |seq, event: AgentEvent| {
16056 let event = serde_json::to_value(event).unwrap();
16057 ChildFrame::EventBatch {
16058 batch: ActorEventBatch {
16059 logical_session: Some(LogicalSessionIdentity {
16060 creation: None,
16061 session_id: session_id.into(),
16062 parent_session_id: Some("permission-parent".into()),
16063 root_session_id: session_id.into(),
16064 }),
16065 activation_id: None,
16066 execution_epoch: 0,
16067 source_node_id: None,
16068 source_actor_id: Some(session_id.into()),
16069 first_seq: seq,
16070 last_seq: seq,
16071 qos: bamboo_subagent::ActorEventQos::classify(&event),
16072 events: vec![event],
16073 },
16074 }
16075 };
16076 let (outcome, _, forwarded, _) = drive_permission_handshake_frames(
16077 session_id,
16078 [
16079 permission_posture_frame(session_id, 7),
16080 batch(
16081 1,
16082 AgentEvent::ToolStart {
16083 tool_call_id: "reused-call".into(),
16084 tool_name: "Read".into(),
16085 arguments: serde_json::json!({"file_path":"README.md"}),
16086 },
16087 ),
16088 batch(
16091 3,
16092 AgentEvent::ToolComplete {
16093 tool_call_id: "reused-call".into(),
16094 result: bamboo_agent_core::tools::ToolResult::text(
16095 true,
16096 "private-download-bytes",
16097 ),
16098 },
16099 ),
16100 completed_actor_frame(),
16101 ],
16102 expected_default_permission_posture(7),
16103 )
16104 .await;
16105 assert_eq!(outcome.unwrap().as_deref(), Some("done"));
16106 let wire = serde_json::to_string(&forwarded).unwrap();
16107 assert!(!wire.contains("private-download-bytes"), "{wire}");
16108 assert!(wire.contains("Tool result hidden"));
16109 }
16110
16111 #[tokio::test]
16112 async fn legacy_malformed_actor_event_invalidates_prior_tool_identity() {
16113 let (event_tx, mut event_rx) = mpsc::channel(8);
16114 let mut session = Session::new("legacy-malformed-private", "model");
16115 let mut handshake = PermissionPostureHandshake::NotRequired;
16116 let mut display = ActorEventDisplay::default();
16117 let read_start = serde_json::to_value(AgentEvent::ToolStart {
16118 tool_call_id: "reused-call".into(),
16119 tool_name: "Read".into(),
16120 arguments: serde_json::json!({"file_path":"README.md"}),
16121 })
16122 .unwrap();
16123 process_actor_event(
16124 read_start,
16125 false,
16126 &mut handshake,
16127 None,
16128 None,
16129 &mut session,
16130 &event_tx,
16131 &mut display,
16132 )
16133 .await
16134 .unwrap();
16135
16136 let mut malformed = serde_json::to_value(AgentEvent::ToolStart {
16139 tool_call_id: "reused-call".into(),
16140 tool_name: "browser".into(),
16141 arguments: serde_json::json!({
16142 "action":"download", "selector":"private-download-selector"
16143 }),
16144 })
16145 .unwrap();
16146 malformed["tool_name"] = serde_json::json!(7);
16147 process_actor_event(
16148 malformed,
16149 false,
16150 &mut handshake,
16151 None,
16152 None,
16153 &mut session,
16154 &event_tx,
16155 &mut display,
16156 )
16157 .await
16158 .unwrap();
16159 process_actor_event(
16160 serde_json::to_value(AgentEvent::ToolComplete {
16161 tool_call_id: "reused-call".into(),
16162 result: bamboo_agent_core::tools::ToolResult::text(true, "private-download-bytes"),
16163 })
16164 .unwrap(),
16165 false,
16166 &mut handshake,
16167 None,
16168 None,
16169 &mut session,
16170 &event_tx,
16171 &mut display,
16172 )
16173 .await
16174 .unwrap();
16175 let forwarded = std::iter::from_fn(|| event_rx.try_recv().ok()).collect::<Vec<_>>();
16176 assert_eq!(forwarded.len(), 2);
16177 let wire = serde_json::to_string(&forwarded).unwrap();
16178 assert!(!wire.contains("private-download"), "{wire}");
16179 assert!(wire.contains("Tool result hidden"));
16180 }
16181
16182 #[tokio::test]
16183 async fn actor_initial_batch_acks_in_order_and_rejects_stale_confirmation() {
16184 let temp = tempfile::tempdir().unwrap();
16185 let store = Arc::new(
16186 bamboo_storage::SessionStoreV2::new(temp.path().to_path_buf())
16187 .await
16188 .unwrap(),
16189 );
16190 let storage: Arc<dyn Storage> = store.clone();
16191 let locked = Arc::new(bamboo_storage::LockedSessionStore::new(storage.clone()));
16192 let inbox: Arc<dyn SessionInboxPort> = Arc::new(bamboo_storage::FileSessionInbox::new(
16193 store.clone(),
16194 bamboo_domain::SessionInboxLimits::default(),
16195 ));
16196 let session_id = "actor-confirmation-order";
16197 let run_id = "actor-run-current";
16198 let mut session = Session::new(session_id, "model");
16199 store.save_session(&session).await.unwrap();
16200 for (id, text) in [("actor-first", "first"), ("actor-second", "second")] {
16201 let mut envelope = bamboo_domain::SessionMessageEnvelope::user_input(session_id, text);
16202 envelope.id = bamboo_domain::SessionMessageId::parse(id).unwrap();
16203 inbox.deliver(&envelope).await.unwrap();
16204 }
16205 inbox
16206 .mark_activation_eligible(
16207 session_id,
16208 2,
16209 bamboo_domain::SessionActivationPolicy::InterruptSpecificWait,
16210 )
16211 .await
16212 .unwrap();
16213 let router = SessionActivationRouter::new();
16214 let mut owner_registration = router.register_run(session_id, run_id).await.unwrap();
16215 let binding = SessionInboxRuntimeBinding {
16216 router,
16217 inbox: inbox.clone(),
16218 storage,
16219 persistence: locked,
16220 parent_question_lock: None,
16221 };
16222 let pairs = claim_canonical_deliveries(&binding, &mut session, run_id, usize::MAX, None)
16223 .await
16224 .unwrap();
16225 assert_eq!(
16226 pairs
16227 .iter()
16228 .map(|(claim, _)| claim.envelope.id.as_str())
16229 .collect::<Vec<_>>(),
16230 vec!["actor-first", "actor-second"]
16231 );
16232 let seeded = store.load_session(session_id).await.unwrap().unwrap();
16233 assert_eq!(
16234 seeded
16235 .messages
16236 .iter()
16237 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16238 .map(|message| message.id.as_str())
16239 .collect::<Vec<_>>(),
16240 vec!["actor-first", "actor-second"],
16241 "host context must be durable before actor dispatch"
16242 );
16243 for (claim, _) in &pairs {
16244 assert_eq!(
16245 seeded
16246 .messages
16247 .iter()
16248 .filter(|message| bamboo_domain::is_matching_session_message(
16249 message,
16250 &claim.envelope
16251 ))
16252 .count(),
16253 1,
16254 "pre-dispatch host checkpoint must contain exactly one canonical marker for {}",
16255 claim.envelope.id
16256 );
16257 }
16258 assert!(
16259 seeded.session_inbox_admission().is_none_or(|cursor| {
16260 !cursor.contains(&pairs[0].0.envelope.id)
16261 && !cursor.contains(&pairs[1].0.envelope.id)
16262 }),
16263 "pre-dispatch transcript seeding must not forge worker confirmation"
16264 );
16265 assert_eq!(inbox.inspect(session_id).await.unwrap().claimed, 2);
16266 let claims = pairs
16267 .into_iter()
16268 .map(|(claim, _)| claim)
16269 .collect::<VecDeque<_>>();
16270 let first = claims[0].clone();
16271 let second = claims[1].clone();
16272 let mut stale = admission_confirmation(session_id, &first, "stale-run");
16273 stale.canonical_claim_generation = second.generation;
16274 let mut link = ConfirmationSequenceLink {
16275 frames: VecDeque::from([
16276 ChildFrame::SessionMessageAdmitted {
16277 confirmation: stale,
16278 },
16279 ChildFrame::SessionMessageAdmitted {
16280 confirmation: admission_confirmation(session_id, &first, run_id),
16281 },
16282 ChildFrame::SessionMessageAdmitted {
16283 confirmation: admission_confirmation(session_id, &second, run_id),
16284 },
16285 ChildFrame::Terminal {
16286 status: TerminalStatus::Completed,
16287 result: Some("done".to_string()),
16288 error: None,
16289 final_event_watermark: None,
16290 transcript: Vec::new(),
16291 },
16292 ]),
16293 sent: Vec::new(),
16294 };
16295 let (event_tx, _event_rx) = mpsc::channel(8);
16296 let cancel = CancellationToken::new();
16297 let (_live_tx, mut live_rx) = mpsc::unbounded_channel();
16298 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
16299 let result = drive(ActorDriveContext {
16300 expected_creation: None,
16301 client: &mut link,
16302 parent_session_id: "parent",
16303 child_session_id: session_id,
16304 child_attempt: 0,
16305 approval_registry: None,
16306 approval_decider: None,
16307 approval_reviewer: None,
16308 escalation_bridge: None,
16309 event_tx: &event_tx,
16310 cancel_token: &cancel,
16311 live_rx: &mut live_rx,
16312 delivery_rx: &mut delivery_rx,
16313 logical_session: &mut session,
16314 expected_permission_posture: None,
16315 session_inbox_runtime: Some(&binding),
16316 actor_directory_store: None,
16317 canonical_subagent_tool: None,
16318 ticket_service: None,
16319 activation_run_id: Some(run_id),
16320 execution_epoch: 0,
16321 expected_source_actor_id: session_id,
16322 initial_inflight_claims: claims,
16323 plain_actor: false,
16324 remote_environment_lease: false,
16325 readonly_output: None,
16326 local_history_tools: None,
16327 local_history_read_only: false,
16328 strict_history_output: None,
16329 plain_input: None,
16330 canonical_activation: None,
16331 canonical_placement_ref: None,
16332 actor_event_observer: None,
16333 plain_run: None,
16334 first_frame_timeout: Some(Duration::from_secs(1)),
16335 })
16336 .await
16337 .unwrap();
16338 assert_eq!(result.as_deref(), Some("done"));
16339 assert_eq!(
16340 session
16341 .messages
16342 .iter()
16343 .filter(|message| matches!(message.id.as_str(), "actor-first" | "actor-second"))
16344 .map(|message| message.id.as_str())
16345 .collect::<Vec<_>>(),
16346 vec!["actor-first", "actor-second"]
16347 );
16348 let backlog = inbox.inspect(session_id).await.unwrap();
16349 assert_eq!(backlog.pending + backlog.claimed, 0);
16350 let confirmed = store.load_session(session_id).await.unwrap().unwrap();
16351 let cursor = confirmed
16352 .session_inbox_admission()
16353 .expect("exact worker confirmation must checkpoint the admission cursor");
16354 assert!(cursor.contains(&first.envelope.id));
16355 assert!(cursor.contains(&second.envelope.id));
16356 for claim in [&first, &second] {
16357 assert_eq!(
16358 confirmed
16359 .messages
16360 .iter()
16361 .filter(|message| bamboo_domain::is_matching_session_message(
16362 message,
16363 &claim.envelope
16364 ))
16365 .count(),
16366 1,
16367 "confirmation must retain one exact canonical marker for {}",
16368 claim.envelope.id
16369 );
16370 }
16371 assert!(inbox
16372 .was_admitted(session_id, &first.envelope.id)
16373 .await
16374 .unwrap());
16375 assert!(inbox
16376 .was_admitted(session_id, &second.envelope.id)
16377 .await
16378 .unwrap());
16379 owner_registration.begin_finalization().await;
16380 owner_registration.finish(2).await.unwrap();
16381 }
16382
16383 #[derive(Default)]
16384 struct RecordingCodexTokenAuthority {
16385 issued_for: std::sync::Mutex<Vec<String>>,
16386 revoked: std::sync::Mutex<Vec<String>>,
16387 }
16388
16389 impl CodexRunTokenAuthority for RecordingCodexTokenAuthority {
16390 fn issue(&self, session_id: &str) -> Result<IssuedCodexRunToken, String> {
16391 self.issued_for
16392 .lock()
16393 .expect("issued fixture lock")
16394 .push(session_id.to_string());
16395 Ok(IssuedCodexRunToken {
16396 token_id: format!("id-{session_id}"),
16397 token: format!("bcx1_secret-{session_id}"),
16398 })
16399 }
16400
16401 fn revoke(&self, token_id: &str) {
16402 self.revoked
16403 .lock()
16404 .expect("revoked fixture lock")
16405 .push(token_id.to_string());
16406 }
16407 }
16408
16409 fn codex_executor(auth_mode: Option<&str>, inherit_user_config: Option<bool>) -> ExecutorSpec {
16410 let bamboo_mode = auth_mode == Some("bamboo")
16411 || (auth_mode.is_none() && !inherit_user_config.unwrap_or(false));
16412 ExecutorSpec::Codex {
16413 binary: None,
16414 model: None,
16415 mode: None,
16416 sandbox: None,
16417 inherit_user_config,
16418 auth_mode: auth_mode.map(str::to_string),
16419 base_url: bamboo_mode.then(|| "http://127.0.0.1:9562/openai/v1".to_string()),
16420 wire_api: Some("responses".to_string()),
16421 provider_key_ref: None,
16422 forward_env: None,
16423 approval_policy: None,
16424 network_access: None,
16425 allow_danger_bypass: None,
16426 permission_profile: None,
16427 workspace_owned: None,
16428 }
16429 }
16430
16431 fn permission_resolution(
16432 requested: bamboo_domain::SessionPermissionMode,
16433 effective: bamboo_domain::PermissionMode,
16434 ) -> bamboo_domain::PermissionModeResolution {
16435 bamboo_domain::PermissionModeResolution {
16436 requested,
16437 effective,
16438 }
16439 }
16440
16441 #[test]
16442 fn permission_posture_mapping_contract_is_exact_for_supported_executors() {
16443 use bamboo_domain::{PermissionMode, SessionPermissionMode};
16444
16445 let default =
16446 permission_resolution(SessionPermissionMode::Default, PermissionMode::Default);
16447 assert_eq!(
16448 expected_permission_executor_mapping(&ExecutorSpec::BambooRuntime, default, false)
16449 .unwrap()
16450 .as_deref(),
16451 Some("bamboo_runtime:default")
16452 );
16453 assert_eq!(
16454 expected_permission_executor_mapping(&ExecutorSpec::Echo, default, false).unwrap(),
16455 None,
16456 "transport-only Echo must not claim the typed permission contract"
16457 );
16458 assert_eq!(
16459 expected_permission_executor_mapping(
16460 &ExecutorSpec::CliAdapter {
16461 command: "must-not-appear-in-contract".to_string(),
16462 args: vec!["credential-like-argument".to_string()],
16463 },
16464 default,
16465 false,
16466 )
16467 .unwrap(),
16468 None,
16469 "unimplemented CliAdapter must not leak command data into a contract"
16470 );
16471
16472 let claude = ExecutorSpec::ClaudeCode {
16473 binary: None,
16474 model: None,
16475 permission_mode: Some("default".to_string()),
16476 inherit_user_config: None,
16477 forward_env: None,
16478 };
16479 for (resolution, mapping) in [
16480 (
16481 permission_resolution(SessionPermissionMode::Default, PermissionMode::Plan),
16482 "claude_code:permission_mode=plan",
16483 ),
16484 (
16485 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16486 "claude_code:permission_mode=bypassPermissions",
16487 ),
16488 (
16489 permission_resolution(SessionPermissionMode::Default, PermissionMode::AcceptEdits),
16490 "claude_code:permission_mode=acceptEdits",
16491 ),
16492 (
16493 permission_resolution(SessionPermissionMode::Default, PermissionMode::DontAsk),
16494 "claude_code:permission_mode=dontAsk",
16495 ),
16496 (
16497 permission_resolution(
16498 SessionPermissionMode::Bypass,
16499 PermissionMode::BypassPermissions,
16500 ),
16501 "claude_code:permission_mode=default",
16502 ),
16503 ] {
16504 assert_eq!(
16505 expected_permission_executor_mapping(&claude, resolution, false)
16506 .unwrap()
16507 .as_deref(),
16508 Some(mapping)
16509 );
16510 }
16511 assert_eq!(
16512 expected_permission_executor_mapping(&claude, default, true)
16513 .unwrap()
16514 .as_deref(),
16515 Some("claude_code:blocked_explicit_deny")
16516 );
16517
16518 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
16519 if let ExecutorSpec::Codex {
16520 approval_policy, ..
16521 } = &mut codex_exec
16522 {
16523 *approval_policy = Some("on-failure".to_string());
16524 }
16525 assert_eq!(
16526 expected_permission_executor_mapping(&codex_exec, default, false)
16527 .unwrap()
16528 .as_deref(),
16529 Some("codex_exec:approval_policy=on-failure")
16530 );
16531 assert_eq!(
16532 expected_permission_executor_mapping(
16533 &codex_exec,
16534 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16535 false,
16536 )
16537 .unwrap()
16538 .as_deref(),
16539 Some("codex_exec:approval_policy=never")
16540 );
16541 assert_eq!(
16542 expected_permission_executor_mapping(&codex_exec, default, true)
16543 .unwrap()
16544 .as_deref(),
16545 Some("codex_exec:blocked_explicit_deny")
16546 );
16547
16548 let mut codex_app_server = codex_executor(Some("inherit"), Some(true));
16549 if let ExecutorSpec::Codex {
16550 mode,
16551 approval_policy,
16552 ..
16553 } = &mut codex_app_server
16554 {
16555 *mode = Some("app_server".to_string());
16556 *approval_policy = Some("on-request".to_string());
16557 }
16558 assert_eq!(
16559 expected_permission_executor_mapping(&codex_app_server, default, false)
16560 .unwrap()
16561 .as_deref(),
16562 Some("codex_app_server:approvalPolicy=on-request")
16563 );
16564 assert_eq!(
16565 expected_permission_executor_mapping(
16566 &codex_app_server,
16567 permission_resolution(SessionPermissionMode::Auto, PermissionMode::Auto),
16568 false,
16569 )
16570 .unwrap()
16571 .as_deref(),
16572 Some("codex_app_server:approvalPolicy=never")
16573 );
16574 assert_eq!(
16575 expected_permission_executor_mapping(&codex_app_server, default, true)
16576 .unwrap()
16577 .as_deref(),
16578 Some("codex_app_server:blocked_explicit_deny")
16579 );
16580 }
16581
16582 #[test]
16583 fn only_bamboo_managed_non_git_workspaces_are_marked_owned() {
16584 let project = tempfile::tempdir().unwrap();
16585 let managed = project.path().join(".bamboo/worktree/child-571");
16586 std::fs::create_dir_all(&managed).unwrap();
16587 assert!(!workspace_is_bamboo_owned(managed.to_str().unwrap()));
16588 let marker = project
16589 .path()
16590 .join(".bamboo/worktree/.bamboo-owned/child-571");
16591 std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
16592 std::fs::write(&marker, "bamboo/child-571").unwrap();
16593 assert!(workspace_is_bamboo_owned(managed.to_str().unwrap()));
16594 let nested = managed.join("nested/path");
16595 std::fs::create_dir_all(&nested).unwrap();
16596 assert!(workspace_is_bamboo_owned(nested.to_str().unwrap()));
16597
16598 let arbitrary = tempfile::tempdir().unwrap();
16599 assert!(!workspace_is_bamboo_owned(
16600 arbitrary.path().to_str().unwrap()
16601 ));
16602 }
16603
16604 #[test]
16605 fn bamboo_codex_token_is_per_run_redacted_and_revoked_on_guard_drop() {
16606 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16607 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16608
16609 let (secrets, guard) = build_codex_run_secrets(
16610 &codex_executor(Some("bamboo"), None),
16611 Some(authority_dyn),
16612 "child-570",
16613 )
16614 .unwrap();
16615
16616 let token = secrets
16617 .codex_provider_token
16618 .as_ref()
16619 .expect("bamboo mode mints a token");
16620 assert_eq!(token.expose(), "bcx1_secret-child-570");
16621 assert!(!format!("{token:?}").contains("secret-child-570"));
16622 assert_eq!(
16623 authority.issued_for.lock().unwrap().as_slice(),
16624 ["child-570"]
16625 );
16626 assert!(authority.revoked.lock().unwrap().is_empty());
16627
16628 drop(guard);
16629 assert_eq!(
16630 authority.revoked.lock().unwrap().as_slice(),
16631 ["id-child-570"]
16632 );
16633 }
16634
16635 #[test]
16636 fn non_bamboo_codex_never_mints_and_bamboo_fails_closed_without_authority() {
16637 let authority = Arc::new(RecordingCodexTokenAuthority::default());
16638 let authority_dyn: Arc<dyn CodexRunTokenAuthority> = authority.clone();
16639 let (secrets, guard) = build_codex_run_secrets(
16640 &codex_executor(Some("custom"), None),
16641 Some(authority_dyn),
16642 "child-custom",
16643 )
16644 .unwrap();
16645 assert!(secrets.codex_provider_token.is_none());
16646 assert!(guard.is_none());
16647 assert!(authority.issued_for.lock().unwrap().is_empty());
16648
16649 let error = build_codex_run_secrets(
16650 &codex_executor(Some("bamboo"), None),
16651 None,
16652 "child-no-authority",
16653 )
16654 .err()
16655 .expect("bamboo mode without an authority must fail closed");
16656 assert!(error.to_string().contains("per-run token authority"));
16657 }
16658
16659 #[test]
16660 fn codex_provisioning_never_leaks_the_session_provider_credential() {
16661 let credentials = vec![ScopedCredential {
16662 provider: "openai".to_string(),
16663 api_key: "upstream-secret-must-not-cross".to_string(),
16664 base_url: None,
16665 provider_type: Some("openai".to_string()),
16666 credential_ref: Some("provider.openai.api_key".to_string()),
16667 }];
16668
16669 for (mode, label) in [
16670 (Some("inherit"), "inherit"),
16671 (Some("api_key"), "api_key"),
16672 (Some("bamboo"), "bamboo"),
16673 (None, "default-bamboo"),
16674 ] {
16675 let runner = ActorChildRunner::new(
16676 format!("codex-{label}-test"),
16677 PathBuf::from("/bin/false"),
16678 Vec::new(),
16679 std::env::temp_dir().join(format!("bamboo-codex-{label}-570")),
16680 codex_executor(mode, None),
16681 credentials.clone(),
16682 "openai".to_string(),
16683 1,
16684 );
16685 let mut session = Session::new(format!("child-{label}"), "model");
16686 session.add_message(bamboo_agent_core::Message::user("test"));
16687 let spec = runner.build_spec(
16688 &session,
16689 &crate::runtime::execution::SpawnJob {
16690 parent_session_id: "parent".to_string(),
16691 child_session_id: format!("child-{label}"),
16692 model: "gpt-5.4".to_string(),
16693 disabled_tools: None,
16694 },
16695 );
16696 assert!(
16697 spec.secrets.provider_credentials.is_empty(),
16698 "{label} Codex must not receive the session provider key"
16699 );
16700 }
16701 }
16702
16703 #[test]
16704 fn non_codex_provisioning_still_receives_only_its_selected_provider_credential() {
16705 let credentials = vec![
16706 ScopedCredential {
16707 provider: "openai".to_string(),
16708 api_key: "selected-openai-secret".to_string(),
16709 base_url: None,
16710 provider_type: Some("openai".to_string()),
16711 credential_ref: Some("provider.openai.api_key".to_string()),
16712 },
16713 ScopedCredential {
16714 provider: "other".to_string(),
16715 api_key: "unrelated-secret".to_string(),
16716 base_url: None,
16717 provider_type: Some("openai".to_string()),
16718 credential_ref: Some("provider.other.api_key".to_string()),
16719 },
16720 ];
16721 let runner = ActorChildRunner::new(
16722 "echo-test".to_string(),
16723 PathBuf::from("/bin/false"),
16724 Vec::new(),
16725 std::env::temp_dir().join("bamboo-echo-provider-570"),
16726 ExecutorSpec::Echo,
16727 credentials,
16728 "openai".to_string(),
16729 1,
16730 );
16731 let spec = runner.build_spec(
16732 &Session::new("child-echo", "model"),
16733 &crate::runtime::execution::SpawnJob {
16734 parent_session_id: "parent".to_string(),
16735 child_session_id: "child-echo".to_string(),
16736 model: "gpt-5.4".to_string(),
16737 disabled_tools: None,
16738 },
16739 );
16740
16741 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16742 assert_eq!(
16743 spec.secrets.provider_credentials[0].api_key,
16744 "selected-openai-secret"
16745 );
16746 }
16747
16748 #[tokio::test]
16749 async fn live_provider_config_refreshes_credentials_and_default_per_activation() {
16750 fn provider_instance(
16751 provider_type: &str,
16752 api_key: &str,
16753 ) -> bamboo_config::ProviderInstanceConfig {
16754 bamboo_config::ProviderInstanceConfig {
16755 provider_type: provider_type.to_string(),
16756 label: None,
16757 api_key: api_key.to_string(),
16758 api_key_encrypted: None,
16759 credential_ref: None,
16760 base_url: None,
16761 model: None,
16762 fast_model: None,
16763 vision_model: None,
16764 reasoning_effort: None,
16765 responses_only_models: Vec::new(),
16766 request_overrides: None,
16767 enabled: true,
16768 extra: Default::default(),
16769 }
16770 }
16771
16772 let mut initial = bamboo_llm::Config::default();
16773 initial.provider_instances.insert(
16774 "old-provider".to_string(),
16775 provider_instance("openai", "old-key"),
16776 );
16777 initial.default_provider_instance = Some("old-provider".to_string());
16778 let live = Arc::new(tokio::sync::RwLock::new(initial));
16779
16780 let runner = ActorChildRunner::new(
16781 "echo-live-config-test".to_string(),
16782 PathBuf::from("/bin/false"),
16783 Vec::new(),
16784 std::env::temp_dir().join("bamboo-echo-live-config"),
16785 ExecutorSpec::Echo,
16786 Vec::new(),
16787 "stale-provider".to_string(),
16788 1,
16789 )
16790 .with_live_provider_config(live.clone());
16791 let job = crate::runtime::execution::SpawnJob {
16792 parent_session_id: "parent".to_string(),
16793 child_session_id: "child".to_string(),
16794 model: "model".to_string(),
16795 disabled_tools: None,
16796 };
16797
16798 let first = runner
16799 .build_live_spec(&Session::new("child", "model"), &job)
16800 .await;
16801 assert_eq!(
16802 first.model.as_ref().map(|model| model.provider.as_str()),
16803 Some("old-provider")
16804 );
16805 assert_eq!(first.secrets.provider_credentials.len(), 1);
16806 assert_eq!(first.secrets.provider_credentials[0].api_key, "old-key");
16807
16808 let mut reloaded = bamboo_llm::Config::default();
16809 reloaded.provider_instances.insert(
16810 "new-provider".to_string(),
16811 provider_instance("openai", "new-key"),
16812 );
16813 reloaded.default_provider_instance = Some("new-provider".to_string());
16814 *live.write().await = reloaded;
16815
16816 let second = runner
16817 .build_live_spec(&Session::new("child", "model"), &job)
16818 .await;
16819 assert_eq!(
16820 second.model.as_ref().map(|model| model.provider.as_str()),
16821 Some("new-provider")
16822 );
16823 assert_eq!(second.secrets.provider_credentials.len(), 1);
16824 assert_eq!(second.secrets.provider_credentials[0].api_key, "new-key");
16825 }
16826
16827 #[test]
16828 fn custom_codex_provisioning_scopes_only_the_referenced_credential() {
16829 let mut executor = codex_executor(Some("custom"), None);
16830 if let ExecutorSpec::Codex {
16831 base_url,
16832 provider_key_ref,
16833 ..
16834 } = &mut executor
16835 {
16836 *base_url = Some("https://provider.example/v1".to_string());
16837 *provider_key_ref = Some("provider.custom.api_key".to_string());
16838 }
16839 let credentials = vec![
16840 ScopedCredential {
16841 provider: "openai".to_string(),
16842 api_key: "session-provider-secret".to_string(),
16843 base_url: None,
16844 provider_type: Some("openai".to_string()),
16845 credential_ref: Some("provider.openai.api_key".to_string()),
16846 },
16847 ScopedCredential {
16848 provider: "custom".to_string(),
16849 api_key: "selected-secret".to_string(),
16850 base_url: None,
16851 provider_type: Some("openai".to_string()),
16852 credential_ref: Some("provider.custom.api_key".to_string()),
16853 },
16854 ScopedCredential {
16855 provider: "other".to_string(),
16856 api_key: "unrelated-secret".to_string(),
16857 base_url: None,
16858 provider_type: Some("openai".to_string()),
16859 credential_ref: Some("provider.other.api_key".to_string()),
16860 },
16861 ];
16862 let runner = ActorChildRunner::new(
16863 "codex-test".to_string(),
16864 PathBuf::from("/bin/false"),
16865 Vec::new(),
16866 std::env::temp_dir().join("bamboo-codex-570"),
16867 executor,
16868 credentials,
16869 "openai".to_string(),
16870 1,
16871 );
16872 let mut session = Session::new("child-custom", "model");
16873 session.add_message(bamboo_agent_core::Message::user("test"));
16874 let spec = runner.build_spec(
16875 &session,
16876 &crate::runtime::execution::SpawnJob {
16877 parent_session_id: "parent".to_string(),
16878 child_session_id: "child-custom".to_string(),
16879 model: "gpt-5.4".to_string(),
16880 disabled_tools: None,
16881 },
16882 );
16883
16884 assert_eq!(spec.secrets.provider_credentials.len(), 1);
16885 assert_eq!(
16886 spec.secrets.provider_credentials[0]
16887 .credential_ref
16888 .as_deref(),
16889 Some("provider.custom.api_key")
16890 );
16891 assert_eq!(
16892 spec.secrets.provider_credentials[0].api_key,
16893 "selected-secret"
16894 );
16895 }
16896
16897 fn spec_with(
16898 role: &str,
16899 provider: &str,
16900 model: &str,
16901 workspace: Option<&str>,
16902 disabled: Option<Vec<&str>>,
16903 ) -> ProvisionSpec {
16904 let mut spec = ProvisionSpec::new(
16905 ChildIdentity {
16906 child_id: "c".into(),
16907 parent_id: None,
16908 project_key: None,
16909 role: role.into(),
16910 depth: 0,
16911 },
16912 ExecutorSpec::Echo,
16913 "/tmp/fab".into(),
16914 );
16915 spec.workspace = workspace.map(|w| w.to_string());
16916 spec.model = Some(ModelRefSpec {
16917 provider: provider.into(),
16918 model: model.into(),
16919 });
16920 spec.disabled_tools = disabled.map(|d| d.into_iter().map(String::from).collect());
16921 spec
16922 }
16923
16924 #[test]
16925 fn fingerprint_matches_interchangeable_children() {
16926 let a = spec_with(
16929 "explorer",
16930 "p",
16931 "m",
16932 Some("/ws"),
16933 Some(vec!["Bash", "Edit"]),
16934 );
16935 let mut b = spec_with(
16936 "explorer",
16937 "p",
16938 "m",
16939 Some("/ws"),
16940 Some(vec!["Edit", "Bash"]),
16941 );
16942 b.identity.child_id = "other".into();
16943 assert_eq!(
16944 ActorChildRunner::fingerprint(&a),
16945 ActorChildRunner::fingerprint(&b)
16946 );
16947 }
16948
16949 #[test]
16950 fn logical_identity_is_invariant_across_local_remote_scheduled_and_warm_reuse() {
16951 let mut session =
16952 Session::new_child("logical-child-681", "logical-parent-681", "model", "child");
16953 session.root_session_id = "logical-root-681".to_string();
16954 let job = SpawnJob {
16955 parent_session_id: "logical-parent-681".to_string(),
16956 child_session_id: "logical-child-681".to_string(),
16957 model: "model".to_string(),
16958 disabled_tools: None,
16959 };
16960 let expected = LogicalSessionIdentity {
16961 creation: None,
16962 session_id: "logical-child-681".to_string(),
16963 parent_session_id: Some("logical-parent-681".to_string()),
16964 root_session_id: "logical-root-681".to_string(),
16965 };
16966
16967 let placements_and_transport_ids = [
16968 (Placement::Local, "local-mailbox-first"),
16969 (
16970 Placement::Remote {
16971 endpoint: "wss://remote.example/actor".to_string(),
16972 },
16973 "remote-process-44",
16974 ),
16975 (
16976 Placement::Schedulable {
16977 pool: "gpu-pool".to_string(),
16978 },
16979 "scheduled-mailbox-9",
16980 ),
16981 (Placement::Local, "warm-mailbox-reused-77"),
16983 ];
16984 for (placement, transport_id) in placements_and_transport_ids {
16985 let mut provision = spec_with("worker", "provider", "model", None, None);
16986 provision.placement = placement;
16987 provision.identity.child_id = transport_id.to_string();
16988 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
16989 assert_ne!(
16990 provision.identity.child_id, expected.session_id,
16991 "test fixture must prove transport identity is independent"
16992 );
16993 }
16994 let mut original = spec_with("worker", "provider", "model", Some("/ws"), None);
16995 original.executor = ExecutorSpec::BambooRuntime;
16996 original.identity.depth = session.spawn_depth;
16997 original.capabilities.required_child_context = true;
16998 original.capabilities.child_creation_identity = true;
16999 original.capabilities.enforce_permissions = true;
17000 original.capabilities.native_tool_ceiling_required = true;
17001 original.capabilities.native_tool_ceiling =
17002 Some(bamboo_subagent::proto::NativeToolCeiling {
17003 version: 1,
17004 child_session_id: session.id.clone(),
17005 parent_session_id: job.parent_session_id.clone(),
17006 root_session_id: session.root_session_id.clone(),
17007 created_at: session.created_at,
17008 spawn_depth: session.spawn_depth,
17009 project_id: None,
17010 tools: vec![],
17011 });
17012 original.validate().unwrap();
17013 let first = ActorChildRunner::fresh_required_worker_spec(&original);
17014 let second = ActorChildRunner::fresh_required_worker_spec(&original);
17015 assert_ne!(first.identity.child_id, second.identity.child_id);
17016 assert_ne!(first.identity.child_id, original.identity.child_id);
17017 for physical in [first, second] {
17018 physical.validate().unwrap();
17019 let mut actual = serde_json::to_value(&physical).unwrap();
17020 actual["identity"]["child_id"] = serde_json::json!(original.identity.child_id);
17021 assert_eq!(actual, serde_json::to_value(&original).unwrap());
17022 assert_eq!(logical_identity_for_actor_run(&session, &job), expected);
17023 }
17024 }
17025
17026 #[test]
17027 fn fingerprint_separates_distinct_runtimes() {
17028 let base = spec_with("explorer", "p", "m", Some("/ws"), None);
17029 let base_fp = ActorChildRunner::fingerprint(&base);
17030 assert_ne!(
17032 base_fp,
17033 ActorChildRunner::fingerprint(&spec_with("writer", "p", "m", Some("/ws"), None))
17034 );
17035 assert_ne!(
17036 base_fp,
17037 ActorChildRunner::fingerprint(&spec_with("explorer", "p2", "m", Some("/ws"), None))
17038 );
17039 assert_ne!(
17040 base_fp,
17041 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m2", Some("/ws"), None))
17042 );
17043 assert_ne!(
17044 base_fp,
17045 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws2"), None))
17046 );
17047 assert_ne!(
17048 base_fp,
17049 ActorChildRunner::fingerprint(&spec_with(
17050 "explorer",
17051 "p",
17052 "m",
17053 Some("/ws"),
17054 Some(vec!["Bash"])
17055 ))
17056 );
17057 }
17058
17059 #[test]
17060 fn fingerprint_splits_rotated_provider_credentials_without_exposing_them() {
17061 let mut before = spec_with("explorer", "provider", "model", Some("/ws"), None);
17062 before.secrets.provider_credentials.push(ScopedCredential {
17063 provider: "provider".to_string(),
17064 api_key: "old-secret-key".to_string(),
17065 base_url: Some("https://provider.example/v1".to_string()),
17066 provider_type: Some("openai".to_string()),
17067 credential_ref: None,
17068 });
17069 let mut after = before.clone();
17070 after.secrets.provider_credentials[0].api_key = "new-secret-key".to_string();
17071
17072 let before_fingerprint = ActorChildRunner::fingerprint(&before);
17073 let after_fingerprint = ActorChildRunner::fingerprint(&after);
17074 assert_ne!(before_fingerprint, after_fingerprint);
17075 assert!(!before_fingerprint.contains("old-secret-key"));
17076 assert!(!after_fingerprint.contains("new-secret-key"));
17077 }
17078
17079 #[test]
17080 fn fingerprint_splits_on_baked_capabilities() {
17081 let base_fp =
17086 ActorChildRunner::fingerprint(&spec_with("explorer", "p", "m", Some("/ws"), None));
17087
17088 let mut creation = spec_with("explorer", "p", "m", Some("/ws"), None);
17089 creation.capabilities.child_creation_identity = true;
17090 let typed = ActorChildRunner::fingerprint(&creation);
17091 assert_ne!(
17092 base_fp, typed,
17093 "legacy worker pool cannot satisfy birth-required Run"
17094 );
17095 creation.identity.child_id = "different-logical-child".into();
17096 assert_eq!(
17097 typed,
17098 ActorChildRunner::fingerprint(&creation),
17099 "birth-required mode retains cross-Child pool reuse"
17100 );
17101
17102 let mut depth = spec_with("explorer", "p", "m", Some("/ws"), None);
17103 depth.identity.depth = 2;
17104 assert_ne!(
17105 base_fp,
17106 ActorChildRunner::fingerprint(&depth),
17107 "depth must split"
17108 );
17109
17110 let mut nested = spec_with("explorer", "p", "m", Some("/ws"), None);
17111 nested.capabilities.nested_spawn = true;
17112 assert_ne!(
17113 base_fp,
17114 ActorChildRunner::fingerprint(&nested),
17115 "nested_spawn must split"
17116 );
17117
17118 let mut bypass = spec_with("explorer", "p", "m", Some("/ws"), None);
17119 bypass.capabilities.bypass = true;
17120 assert_ne!(
17121 base_fp,
17122 ActorChildRunner::fingerprint(&bypass),
17123 "bypass must split"
17124 );
17125
17126 let mut auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17127 auto.capabilities.auto_approve_permissions = true;
17128 assert_ne!(
17129 base_fp,
17130 ActorChildRunner::fingerprint(&auto),
17131 "auto_approve_permissions must split"
17132 );
17133
17134 let mut global_auto = spec_with("explorer", "p", "m", Some("/ws"), None);
17135 global_auto.capabilities.permission_requested_mode = "default".to_string();
17136 global_auto.capabilities.permission_effective_mode = "auto".to_string();
17137 global_auto.capabilities.auto_approve_permissions = true;
17138 let mut explicit_auto = global_auto.clone();
17139 explicit_auto.capabilities.permission_requested_mode = "auto".to_string();
17140 assert_ne!(
17141 ActorChildRunner::fingerprint(&global_auto),
17142 ActorChildRunner::fingerprint(&explicit_auto),
17143 "permission_requested_mode must split global and explicit Auto"
17144 );
17145
17146 let mut plan_overlay = explicit_auto.clone();
17147 plan_overlay.capabilities.permission_effective_mode = "plan".to_string();
17148 assert_ne!(
17149 ActorChildRunner::fingerprint(&explicit_auto),
17150 ActorChildRunner::fingerprint(&plan_overlay),
17151 "permission_effective_mode must split Plan overlay from Auto"
17152 );
17153
17154 let mut enforce = spec_with("explorer", "p", "m", Some("/ws"), None);
17155 enforce.capabilities.enforce_permissions = true;
17156 assert_ne!(
17157 base_fp,
17158 ActorChildRunner::fingerprint(&enforce),
17159 "enforce_permissions must split"
17160 );
17161
17162 let mut cap = spec_with("explorer", "p", "m", Some("/ws"), None);
17163 cap.capabilities.max_spawn_depth = Some(8);
17164 assert_ne!(
17165 base_fp,
17166 ActorChildRunner::fingerprint(&cap),
17167 "max_spawn_depth must split"
17168 );
17169
17170 let mut nha = spec_with("explorer", "p", "m", Some("/ws"), None);
17174 nha.capabilities.no_human_approver = true;
17175 assert_ne!(
17176 base_fp,
17177 ActorChildRunner::fingerprint(&nha),
17178 "no_human_approver must split"
17179 );
17180
17181 let mut read_only = spec_with("explorer", "p", "m", Some("/ws"), None);
17184 read_only.capabilities.read_only = true;
17185 assert_ne!(
17186 base_fp,
17187 ActorChildRunner::fingerprint(&read_only),
17188 "read_only must split"
17189 );
17190
17191 let mut gro = spec_with("explorer", "p", "m", Some("/ws"), None);
17193 gro.capabilities.guardian_read_only = true;
17194 assert_ne!(
17195 base_fp,
17196 ActorChildRunner::fingerprint(&gro),
17197 "guardian_read_only must split"
17198 );
17199 }
17200
17201 #[test]
17202 fn fingerprint_splits_codex_exec_and_app_server_workers() {
17203 let mut exec = spec_with("explorer", "p", "m", Some("/ws"), None);
17204 exec.executor = codex_executor(Some("inherit"), None);
17205 let mut app_server = exec.clone();
17206 if let ExecutorSpec::Codex { mode, .. } = &mut app_server.executor {
17207 *mode = Some("app_server".to_string());
17208 }
17209 assert_ne!(
17210 ActorChildRunner::fingerprint(&exec),
17211 ActorChildRunner::fingerprint(&app_server)
17212 );
17213 }
17214
17215 struct StaticDecider(bool);
17216
17217 #[async_trait]
17218 impl ChildApprovalDecider for StaticDecider {
17219 async fn decide(&self, _child: &str, _req: &serde_json::Value) -> bool {
17220 self.0
17221 }
17222 }
17223
17224 struct RecordingReviewer {
17225 reviewed: mpsc::UnboundedSender<(String, String, serde_json::Value)>,
17226 }
17227
17228 #[async_trait]
17229 impl ChildApprovalReviewer for RecordingReviewer {
17230 async fn review(&self, parent: &str, child: &str, request: &serde_json::Value) -> bool {
17231 let _ = self
17232 .reviewed
17233 .send((parent.to_string(), child.to_string(), request.clone()));
17234 true
17235 }
17236 }
17237
17238 struct SilentLink;
17243 #[async_trait]
17244 impl bamboo_subagent::ChildLink for SilentLink {
17245 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17246 Ok(())
17247 }
17248 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17249 std::future::pending().await
17250 }
17251 }
17252
17253 struct InstantTerminalLink {
17255 done: bool,
17256 }
17257
17258 struct ApprovalRoundTripLink {
17259 step: u8,
17260 approval_reply: Option<(String, bool)>,
17261 }
17262
17263 #[async_trait]
17264 impl bamboo_subagent::ChildLink for ApprovalRoundTripLink {
17265 async fn send(&mut self, frame: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17266 if let ParentFrame::ApprovalReply { id, approved } = frame {
17267 self.approval_reply = Some((id, approved));
17268 self.step = 2;
17269 }
17270 Ok(())
17271 }
17272
17273 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17274 match self.step {
17275 0 => {
17276 self.step = 1;
17277 Ok(Some(ChildFrame::ApprovalRequest {
17278 id: "approval-1".into(),
17279 body: serde_json::json!({
17280 "tool_name": "Bash",
17281 "permission": "execute",
17282 "resource": "rm -rf target",
17283 "permission_request": {"reason_code": "hard_dangerous"}
17284 }),
17285 }))
17286 }
17287 1 => std::future::pending().await,
17288 2 => {
17289 self.step = 3;
17290 Ok(Some(ChildFrame::Terminal {
17291 status: TerminalStatus::Completed,
17292 result: Some("done".into()),
17293 error: None,
17294 final_event_watermark: None,
17295 transcript: vec![],
17296 }))
17297 }
17298 _ => std::future::pending().await,
17299 }
17300 }
17301 }
17302
17303 #[tokio::test]
17304 async fn drive_routes_forced_ask_to_parent_reviewer_without_human_event() {
17305 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17306 let (review_tx, mut review_rx) = mpsc::unbounded_channel();
17307 let reviewer: Arc<dyn ChildApprovalReviewer> = Arc::new(RecordingReviewer {
17308 reviewed: review_tx,
17309 });
17310 let cancel = CancellationToken::new();
17311 let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17312 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17313 let mut logical_session = Session::new("child-reviewer", "model");
17314 let live_guard = crate::external_agents::live::register("child-reviewer", live_tx, 0, None);
17315 let mut link = ApprovalRoundTripLink {
17316 step: 0,
17317 approval_reply: None,
17318 };
17319
17320 let result = tokio::time::timeout(
17321 Duration::from_secs(1),
17322 drive(ActorDriveContext {
17323 expected_creation: None,
17324 client: &mut link,
17325 parent_session_id: "parent-reviewer",
17326 child_session_id: "child-reviewer",
17327 child_attempt: 0,
17328 approval_registry: None,
17329 approval_decider: None,
17330 approval_reviewer: Some(&reviewer),
17331 escalation_bridge: None,
17332 event_tx: &event_tx,
17333 cancel_token: &cancel,
17334 live_rx: &mut live_rx,
17335 delivery_rx: &mut delivery_rx,
17336 logical_session: &mut logical_session,
17337 expected_permission_posture: None,
17338 session_inbox_runtime: None,
17339 actor_directory_store: None,
17340 canonical_subagent_tool: None,
17341 ticket_service: None,
17342 activation_run_id: None,
17343 execution_epoch: 0,
17344 expected_source_actor_id: "child-reviewer",
17345 initial_inflight_claims: VecDeque::new(),
17346 plain_actor: false,
17347 remote_environment_lease: false,
17348 readonly_output: None,
17349 local_history_tools: None,
17350 local_history_read_only: false,
17351 strict_history_output: None,
17352 plain_input: None,
17353 canonical_activation: None,
17354 canonical_placement_ref: None,
17355 actor_event_observer: None,
17356 plain_run: None,
17357 first_frame_timeout: None,
17358 }),
17359 )
17360 .await
17361 .expect("worker must receive the reviewer verdict before terminating");
17362
17363 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17364 assert_eq!(
17365 link.approval_reply,
17366 Some(("approval-1".to_string(), true)),
17367 "reviewer verdict must traverse the live route back to the worker"
17368 );
17369 let (parent, child, body) = tokio::time::timeout(Duration::from_secs(1), review_rx.recv())
17370 .await
17371 .expect("reviewer should be invoked off-loop")
17372 .expect("review channel should remain open");
17373 assert_eq!(parent, "parent-reviewer");
17374 assert_eq!(child, "child-reviewer");
17375 assert_eq!(
17376 body.pointer("/permission_request/reason_code")
17377 .and_then(serde_json::Value::as_str),
17378 Some("hard_dangerous")
17379 );
17380 assert!(
17381 event_rx.try_recv().is_err(),
17382 "must not emit a human-review event"
17383 );
17384 drop(live_guard);
17385 }
17386
17387 #[tokio::test]
17388 async fn drive_denies_forced_ask_without_parent_reviewer_or_manual_event() {
17389 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(8);
17390 let cancel = CancellationToken::new();
17391 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17392 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17393 let mut logical_session = Session::new("child-no-reviewer", "model");
17394 let mut link = ApprovalRoundTripLink {
17395 step: 0,
17396 approval_reply: None,
17397 };
17398
17399 let result = tokio::time::timeout(
17400 Duration::from_secs(1),
17401 drive(ActorDriveContext {
17402 expected_creation: None,
17403 client: &mut link,
17404 parent_session_id: "parent-no-reviewer",
17405 child_session_id: "child-no-reviewer",
17406 child_attempt: 0,
17407 approval_registry: None,
17408 approval_decider: None,
17409 approval_reviewer: None,
17410 escalation_bridge: None,
17411 event_tx: &event_tx,
17412 cancel_token: &cancel,
17413 live_rx: &mut live_rx,
17414 delivery_rx: &mut delivery_rx,
17415 logical_session: &mut logical_session,
17416 expected_permission_posture: None,
17417 session_inbox_runtime: None,
17418 actor_directory_store: None,
17419 canonical_subagent_tool: None,
17420 ticket_service: None,
17421 activation_run_id: None,
17422 execution_epoch: 0,
17423 expected_source_actor_id: "child-no-reviewer",
17424 initial_inflight_claims: VecDeque::new(),
17425 plain_actor: false,
17426 remote_environment_lease: false,
17427 readonly_output: None,
17428 local_history_tools: None,
17429 local_history_read_only: false,
17430 strict_history_output: None,
17431 plain_input: None,
17432 canonical_activation: None,
17433 canonical_placement_ref: None,
17434 actor_event_observer: None,
17435 plain_run: None,
17436 first_frame_timeout: None,
17437 }),
17438 )
17439 .await
17440 .expect("fail-closed reply must unblock the child immediately");
17441
17442 assert_eq!(result.ok().flatten().as_deref(), Some("done"));
17443 assert_eq!(link.approval_reply, Some(("approval-1".to_string(), false)));
17444 assert!(
17445 event_rx.try_recv().is_err(),
17446 "missing parent review must not surface a manual approval event"
17447 );
17448 }
17449 #[async_trait]
17450 impl bamboo_subagent::ChildLink for InstantTerminalLink {
17451 async fn send(&mut self, _: ParentFrame) -> bamboo_subagent::TransportResult<()> {
17452 Ok(())
17453 }
17454 async fn next_frame(&mut self) -> bamboo_subagent::TransportResult<Option<ChildFrame>> {
17455 if self.done {
17456 std::future::pending().await
17457 } else {
17458 self.done = true;
17459 Ok(Some(ChildFrame::Terminal {
17460 status: TerminalStatus::Completed,
17461 result: Some("done".into()),
17462 error: None,
17463 final_event_watermark: None,
17464 transcript: vec![],
17465 }))
17466 }
17467 }
17468 }
17469
17470 #[tokio::test]
17471 async fn drive_trips_first_frame_watchdog_on_a_silent_worker() {
17472 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17473 let cancel = CancellationToken::new();
17474 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17475 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17476 let mut logical_session = Session::new("child-x", "model");
17477 let mut link = SilentLink;
17478 let r = drive(ActorDriveContext {
17479 expected_creation: None,
17480 client: &mut link,
17481 parent_session_id: "parent-x",
17482 child_session_id: "child-x",
17483 child_attempt: 0,
17484 approval_registry: None,
17485 approval_decider: None,
17486 approval_reviewer: None,
17487 escalation_bridge: None,
17488 event_tx: &event_tx,
17489 cancel_token: &cancel,
17490 live_rx: &mut live_rx,
17491 delivery_rx: &mut delivery_rx,
17492 logical_session: &mut logical_session,
17493 expected_permission_posture: None,
17494 session_inbox_runtime: None,
17495 actor_directory_store: None,
17496 canonical_subagent_tool: None,
17497 ticket_service: None,
17498 activation_run_id: None,
17499 execution_epoch: 0,
17500 expected_source_actor_id: "child-x",
17501 initial_inflight_claims: VecDeque::new(),
17502 plain_actor: false,
17503 remote_environment_lease: false,
17504 readonly_output: None,
17505 local_history_tools: None,
17506 local_history_read_only: false,
17507 strict_history_output: None,
17508 plain_input: None,
17509 canonical_activation: None,
17510 canonical_placement_ref: None,
17511 actor_event_observer: None,
17512 plain_run: None,
17513 first_frame_timeout: Some(Duration::from_millis(100)),
17514 })
17515 .await;
17516 assert!(
17517 matches!(r, Err(AgentError::WorkerUnresponsive(_))),
17518 "a silent worker must trip the first-frame watchdog, got {r:?}"
17519 );
17520 }
17521
17522 #[tokio::test]
17523 async fn drive_does_not_trip_when_a_frame_arrives() {
17524 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
17525 let cancel = CancellationToken::new();
17526 let (_live_tx, mut live_rx) = mpsc::unbounded_channel::<ParentFrame>();
17527 let (_delivery_tx, mut delivery_rx) = mpsc::unbounded_channel();
17528 let mut logical_session = Session::new("child-y", "model");
17529 let mut link = InstantTerminalLink { done: false };
17530 let r = drive(ActorDriveContext {
17533 expected_creation: None,
17534 client: &mut link,
17535 parent_session_id: "parent-y",
17536 child_session_id: "child-y",
17537 child_attempt: 0,
17538 approval_registry: None,
17539 approval_decider: None,
17540 approval_reviewer: None,
17541 escalation_bridge: None,
17542 event_tx: &event_tx,
17543 cancel_token: &cancel,
17544 live_rx: &mut live_rx,
17545 delivery_rx: &mut delivery_rx,
17546 logical_session: &mut logical_session,
17547 expected_permission_posture: None,
17548 session_inbox_runtime: None,
17549 actor_directory_store: None,
17550 canonical_subagent_tool: None,
17551 ticket_service: None,
17552 activation_run_id: None,
17553 execution_epoch: 0,
17554 expected_source_actor_id: "child-y",
17555 initial_inflight_claims: VecDeque::new(),
17556 plain_actor: false,
17557 remote_environment_lease: false,
17558 readonly_output: None,
17559 local_history_tools: None,
17560 local_history_read_only: false,
17561 strict_history_output: None,
17562 plain_input: None,
17563 canonical_activation: None,
17564 canonical_placement_ref: None,
17565 actor_event_observer: None,
17566 plain_run: None,
17567 first_frame_timeout: Some(Duration::from_millis(50)),
17568 })
17569 .await;
17570 assert_eq!(r.ok().flatten().as_deref(), Some("done"));
17571 }
17572
17573 #[tokio::test]
17574 async fn child_approval_fails_closed_without_decider() {
17575 let body = serde_json::json!({"tool_name":"Bash","permission":"run","resource":"rm -rf /"});
17577 assert!(!decide_child_approval(None, "child-1", &body).await);
17578 }
17579
17580 #[tokio::test]
17581 async fn child_approval_honors_wired_decider() {
17582 let body =
17583 serde_json::json!({"tool_name":"Write","permission":"write","resource":"/tmp/x"});
17584 let approve: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(true));
17585 let deny: Arc<dyn ChildApprovalDecider> = Arc::new(StaticDecider(false));
17586 assert!(decide_child_approval(Some(&approve), "child-1", &body).await);
17587 assert!(!decide_child_approval(Some(&deny), "child-1", &body).await);
17588 }
17589
17590 use crate::runtime::execution::SpawnJob;
17593 use bamboo_agent_core::Session;
17594
17595 fn bogus_runner(placements: HashMap<String, ResolvedRemotePlacement>) -> ActorChildRunner {
17598 ActorChildRunner::new(
17599 "test-actor".into(),
17600 PathBuf::from("/bin/false"),
17601 vec![],
17602 std::env::temp_dir().join("bamboo-test-fab-193"),
17603 ExecutorSpec::Echo,
17604 vec![],
17605 "anthropic".into(),
17606 4,
17607 )
17608 .with_remote_placements(placements)
17609 }
17610
17611 fn session_of_role(role: &str, assignment: &str) -> Session {
17614 let mut s = Session::new("child-1", "test-model");
17615 s.metadata
17616 .insert("subagent_type".to_string(), role.to_string());
17617 s.add_message(bamboo_agent_core::Message::user(assignment));
17618 s
17619 }
17620
17621 fn job_for(child: &str) -> SpawnJob {
17622 SpawnJob {
17623 parent_session_id: "parent-1".into(),
17624 child_session_id: child.into(),
17625 model: String::new(),
17626 disabled_tools: None,
17627 }
17628 }
17629
17630 #[tokio::test]
17631 async fn spawn_depth_provisioning_uses_live_policy_for_local_and_remote_workers() {
17632 let live = Arc::new(tokio::sync::RwLock::new(bamboo_llm::Config::default()));
17633 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17634 let mut remote = HashMap::new();
17635 remote.insert(
17636 "remote-role".into(),
17637 ResolvedRemotePlacement {
17638 endpoint: "wss://worker.example:8443".into(),
17639 token: None,
17640 ca_cert_file: None,
17641 host_label: None,
17642 broker_peer: None,
17643 requirements: None,
17644 },
17645 );
17646 let runner = bogus_runner(remote).with_live_provider_config(live.clone());
17647 let root = Session::new("provision-root", "model");
17648 for role in ["local-role", "remote-role"] {
17649 let mut child =
17650 Session::new_child_of(format!("provision-{role}"), &root, "model", "child");
17651 child.metadata.insert("subagent_type".into(), role.into());
17652 let first = runner.build_live_spec(&child, &job_for(&child.id)).await;
17653 assert_eq!(first.capabilities.max_spawn_depth, Some(1));
17654 assert!(!first.capabilities.nested_spawn);
17655 assert_eq!(
17656 matches!(first.placement, Placement::Remote { .. }),
17657 role == "remote-role"
17658 );
17659 live.write().await.subagents_mut().max_spawn_depth = Some(7);
17660 let second = runner.build_live_spec(&child, &job_for(&child.id)).await;
17661 assert_eq!(second.capabilities.max_spawn_depth, Some(7));
17662 assert!(second.capabilities.nested_spawn);
17663 assert_ne!(
17664 ActorChildRunner::fingerprint(&first),
17665 ActorChildRunner::fingerprint(&second)
17666 );
17667 assert_eq!(second.identity.depth, 1);
17668 live.write().await.subagents_mut().max_spawn_depth = Some(1);
17669 }
17670 let fallback = bogus_runner(HashMap::new()).with_max_spawn_depth(0);
17671 let spec = fallback.build_spec(&root, &job_for(&root.id));
17672 assert_eq!(spec.capabilities.max_spawn_depth, Some(0));
17673 assert!(!spec.capabilities.nested_spawn);
17674 }
17675
17676 struct RecordingChildSessionPort {
17677 parent: Session,
17678 saved: std::sync::Mutex<Option<Session>>,
17679 }
17680
17681 impl RecordingChildSessionPort {
17682 fn saved_child(&self) -> Session {
17683 self.saved
17684 .lock()
17685 .expect("saved-child fixture lock")
17686 .clone()
17687 .expect("create_child_action must save the child")
17688 }
17689 }
17690
17691 #[async_trait]
17692 impl crate::session_app::child_session::ChildSessionPort for RecordingChildSessionPort {
17693 async fn load_root_session(
17694 &self,
17695 root_id: &str,
17696 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17697 assert_eq!(root_id, self.parent.id);
17698 Ok(self.parent.clone())
17699 }
17700
17701 async fn load_child_for_parent(
17702 &self,
17703 _parent_id: &str,
17704 _child_id: &str,
17705 ) -> Result<Session, crate::session_app::child_session::ChildSessionError> {
17706 unreachable!("create_child_action does not reload the child")
17707 }
17708
17709 async fn save_child_session(
17710 &self,
17711 child: &mut Session,
17712 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17713 *self.saved.lock().expect("saved-child fixture lock") = Some(child.clone());
17714 Ok(())
17715 }
17716
17717 async fn save_child_session_authoritative_flags(
17718 &self,
17719 _child: &mut Session,
17720 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17721 unreachable!("new-child creation uses the ordinary save")
17722 }
17723
17724 async fn is_child_running(&self, _child_id: &str) -> bool {
17725 false
17726 }
17727
17728 async fn list_children(
17729 &self,
17730 _parent_id: &str,
17731 ) -> Vec<crate::session_app::child_session::ChildSessionEntry> {
17732 Vec::new()
17733 }
17734
17735 async fn enqueue_child_run(
17736 &self,
17737 _parent: &Session,
17738 _child: &Session,
17739 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17740 unreachable!("fixture creates the child with auto_run=false")
17741 }
17742
17743 async fn cancel_child_run_and_wait(
17744 &self,
17745 _child_id: &str,
17746 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17747 unreachable!("create_child_action does not cancel")
17748 }
17749
17750 async fn delete_child_session(
17751 &self,
17752 _parent_id: &str,
17753 _child_id: &str,
17754 ) -> Result<
17755 crate::session_app::child_session::DeleteChildResult,
17756 crate::session_app::child_session::ChildSessionError,
17757 > {
17758 unreachable!("create_child_action does not delete")
17759 }
17760
17761 async fn get_child_runner_info(
17762 &self,
17763 _child_id: &str,
17764 ) -> Option<crate::session_app::child_session::ChildRunnerInfo> {
17765 None
17766 }
17767
17768 async fn register_parent_wait_for_child(
17769 &self,
17770 _parent_session_id: &str,
17771 _child_session_id: &str,
17772 _tool_call_id: Option<&str>,
17773 ) -> Result<(), crate::session_app::child_session::ChildSessionError> {
17774 unreachable!("create_child_action does not register a wait")
17775 }
17776
17777 async fn register_parent_wait_for_children(
17778 &self,
17779 _parent_session_id: &str,
17780 _child_session_ids: &[String],
17781 _policy: bamboo_domain::session::runtime_state::ChildWaitPolicy,
17782 ) -> Result<usize, crate::session_app::child_session::ChildSessionError> {
17783 unreachable!("create_child_action does not register a wait")
17784 }
17785
17786 async fn active_child_ids(&self, _parent_session_id: &str) -> Vec<String> {
17787 Vec::new()
17788 }
17789
17790 async fn find_resident_child(
17791 &self,
17792 _root_session_id: &str,
17793 _resident_name: &str,
17794 ) -> Option<String> {
17795 None
17796 }
17797
17798 async fn ensure_child_indexed(&self, _child_session_id: &str) {}
17799 }
17800
17801 #[test]
17802 fn build_spec_sets_remote_placement_for_matching_role() {
17803 let mut placements = HashMap::new();
17804 placements.insert(
17805 "explorer".to_string(),
17806 ResolvedRemotePlacement {
17807 endpoint: "wss://gpu-host:8443".into(),
17808 token: Some("T-secret".into()),
17809 ca_cert_file: None,
17810 host_label: None,
17811 broker_peer: None,
17812 requirements: None,
17813 },
17814 );
17815 let runner = bogus_runner(placements);
17816
17817 let s = session_of_role("explorer", "do the thing");
17819 let spec = runner.build_spec(&s, &job_for("child-1"));
17820 match &spec.placement {
17821 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://gpu-host:8443"),
17822 other => panic!("expected Remote, got {other:?}"),
17823 }
17824 assert!(spec.secrets.worker_auth_token.is_none());
17825 }
17826
17827 #[test]
17828 fn build_spec_leaves_local_for_unmatched_role() {
17829 let mut placements = HashMap::new();
17830 placements.insert(
17831 "explorer".to_string(),
17832 ResolvedRemotePlacement {
17833 endpoint: "wss://gpu-host:8443".into(),
17834 token: Some("T".into()),
17835 ca_cert_file: None,
17836 host_label: None,
17837 broker_peer: None,
17838 requirements: None,
17839 },
17840 );
17841 let runner = bogus_runner(placements);
17842
17843 let s = session_of_role("writer", "do the thing");
17845 let spec = runner.build_spec(&s, &job_for("child-1"));
17846 assert_eq!(spec.placement, Placement::Local);
17847 assert!(spec.secrets.worker_auth_token.is_none());
17848 }
17849
17850 #[test]
17851 fn build_spec_local_when_no_placements() {
17852 let runner = bogus_runner(HashMap::new());
17853 let s = session_of_role("explorer", "do the thing");
17854 let spec = runner.build_spec(&s, &job_for("child-1"));
17855 assert_eq!(spec.placement, Placement::Local);
17856 assert!(spec.secrets.worker_auth_token.is_none());
17857 }
17858
17859 #[test]
17860 fn typed_child_state_is_the_read_only_authority_under_auto_and_bypass() {
17861 for mode in [
17862 bamboo_domain::SessionPermissionMode::Auto,
17863 bamboo_domain::SessionPermissionMode::Bypass,
17864 ] {
17865 let runner = bogus_runner(HashMap::new());
17866 let mut child = session_of_role("planner", "inspect and plan");
17867 let runtime = child
17868 .agent_runtime_state
17869 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
17870 runtime.set_permission_mode(mode);
17871 runtime.read_only = true;
17872
17873 let spec = runner.build_spec(&child, &job_for("read-only-child"));
17874
17875 assert!(spec.capabilities.read_only);
17876 assert!(spec.capabilities.read_only_enforced());
17877 assert!(
17878 spec.capabilities.guardian_read_only,
17879 "the compatibility alias must protect planners on older workers"
17880 );
17881 assert_eq!(spec.capabilities.permission_requested_mode, mode.as_str());
17882 assert_eq!(spec.capabilities.permission_effective_mode, "plan");
17883 assert!(
17884 !spec.capabilities.bypass,
17885 "read-only must win over requested {mode:?}"
17886 );
17887 assert_eq!(
17888 spec.capabilities.auto_approve_permissions,
17889 mode == bamboo_domain::SessionPermissionMode::Auto,
17890 "Auto may suppress prompts but must not widen Plan authorization"
17891 );
17892 let disabled = spec
17893 .disabled_tools
17894 .as_ref()
17895 .expect("read-only state must provision a host-owned denylist");
17896 for tool_name in [
17897 "Edit",
17898 "Write",
17899 "Plan",
17900 "SubAgent",
17901 "session_control",
17902 "request_permissions",
17903 ] {
17904 assert!(
17905 disabled.iter().any(|name| name == tool_name),
17906 "read-only provisioning must disable {tool_name}"
17907 );
17908 }
17909 }
17910
17911 let runner = bogus_runner(HashMap::new());
17912 let cosmetic_planner = runner.build_spec(
17913 &session_of_role("planner", "label only"),
17914 &job_for("cosmetic"),
17915 );
17916 assert!(
17917 !cosmetic_planner.capabilities.read_only_enforced(),
17918 "a cosmetic role label must not change authority"
17919 );
17920 assert!(cosmetic_planner.disabled_tools.is_none());
17921
17922 let cosmetic_guardian = runner.build_spec(
17923 &session_of_role("guardian", "label only"),
17924 &job_for("cosmetic-guardian"),
17925 );
17926 assert!(
17927 !cosmetic_guardian.capabilities.read_only_enforced(),
17928 "a Guardian label without typed state or the legacy denylist is cosmetic"
17929 );
17930
17931 let legacy_guardian = session_of_role("guardian", "pre-upgrade reviewer");
17932 let mut legacy_job = job_for("legacy-guardian");
17933 legacy_job.disabled_tools = Some(vec![
17934 "Edit".to_string(),
17935 "Write".to_string(),
17936 "SubAgent".to_string(),
17937 "WebFetch".to_string(),
17938 ]);
17939 let legacy_spec = runner.build_spec(&legacy_guardian, &legacy_job);
17940 assert!(
17941 legacy_spec.capabilities.read_only_enforced(),
17942 "an in-flight pre-upgrade Guardian keeps its read-only authority"
17943 );
17944
17945 let mut differently_named = session_of_role("explorer", "typed authority");
17946 differently_named
17947 .agent_runtime_state
17948 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17949 .read_only = true;
17950 let typed = runner.build_spec(&differently_named, &job_for("typed"));
17951 assert!(
17952 typed.capabilities.read_only_enforced(),
17953 "typed state must enforce read-only independently of role"
17954 );
17955 }
17956
17957 #[test]
17958 fn typed_read_only_forces_native_external_executor_profiles() {
17959 let mut planner = session_of_role("planner", "inspect and plan");
17960 planner
17961 .agent_runtime_state
17962 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
17963 .read_only = true;
17964
17965 let claude_runner = ActorChildRunner::new(
17966 "test-actor".into(),
17967 PathBuf::from("/bin/false"),
17968 vec![],
17969 std::env::temp_dir().join("bamboo-test-plan-claude"),
17970 ExecutorSpec::ClaudeCode {
17971 binary: None,
17972 model: None,
17973 permission_mode: Some("bypassPermissions".to_string()),
17974 inherit_user_config: Some(true),
17975 forward_env: None,
17976 },
17977 vec![],
17978 "anthropic".into(),
17979 4,
17980 );
17981 let claude_spec = claude_runner.build_spec(&planner, &job_for("planner-claude"));
17982 let disabled = claude_spec
17983 .disabled_tools
17984 .as_ref()
17985 .expect("typed read-only child must receive a host-owned denylist");
17986 for tool in ["Bash", "BashInput", "BashOutput", "KillShell"] {
17987 assert!(
17988 disabled.iter().any(|name| name == tool),
17989 "typed read-only child must not advertise {tool}"
17990 );
17991 }
17992 assert!(matches!(
17993 claude_spec.executor,
17994 ExecutorSpec::ClaudeCode {
17995 permission_mode: Some(ref mode),
17996 inherit_user_config: Some(false),
17997 ..
17998 } if mode == "plan"
17999 ));
18000 assert_eq!(claude_spec.capabilities.permission_effective_mode, "plan");
18001
18002 let mut codex_exec = codex_executor(Some("inherit"), Some(true));
18003 if let ExecutorSpec::Codex {
18004 sandbox,
18005 approval_policy,
18006 network_access,
18007 allow_danger_bypass,
18008 permission_profile,
18009 ..
18010 } = &mut codex_exec
18011 {
18012 *sandbox = Some("danger-full-access".to_string());
18013 *approval_policy = Some("on-failure".to_string());
18014 *network_access = Some(true);
18015 *allow_danger_bypass = Some(true);
18016 *permission_profile = Some("full-access".to_string());
18017 }
18018 let codex_runner = ActorChildRunner::new(
18019 "test-actor".into(),
18020 PathBuf::from("/bin/false"),
18021 vec![],
18022 std::env::temp_dir().join("bamboo-test-plan-codex"),
18023 codex_exec,
18024 vec![],
18025 "openai".into(),
18026 4,
18027 );
18028 let codex_spec = codex_runner.build_spec(&planner, &job_for("planner-codex"));
18029 assert_eq!(
18030 expected_permission_executor_mapping(
18031 &codex_spec.executor,
18032 codex_spec.capabilities.permission_resolution().unwrap(),
18033 false,
18034 )
18035 .unwrap()
18036 .as_deref(),
18037 Some("codex_exec:approval_policy=never")
18038 );
18039 assert!(matches!(
18040 codex_spec.executor,
18041 ExecutorSpec::Codex {
18042 sandbox: Some(ref sandbox),
18043 inherit_user_config: Some(false),
18044 approval_policy: Some(ref approval_policy),
18045 network_access: Some(false),
18046 allow_danger_bypass: Some(false),
18047 permission_profile: Some(ref profile),
18048 ..
18049 } if sandbox == "read-only"
18050 && approval_policy == "never"
18051 && profile == "read-only"
18052 ));
18053
18054 let mut app_server = codex_executor(Some("inherit"), Some(true));
18055 if let ExecutorSpec::Codex {
18056 mode,
18057 sandbox,
18058 approval_policy,
18059 network_access,
18060 allow_danger_bypass,
18061 permission_profile,
18062 ..
18063 } = &mut app_server
18064 {
18065 *mode = Some("app_server".to_string());
18066 *sandbox = Some("danger-full-access".to_string());
18067 *approval_policy = Some("on-request".to_string());
18068 *network_access = Some(true);
18069 *allow_danger_bypass = Some(true);
18070 *permission_profile = Some("full-access".to_string());
18071 }
18072 let app_server_runner = ActorChildRunner::new(
18073 "test-actor".into(),
18074 PathBuf::from("/bin/false"),
18075 vec![],
18076 std::env::temp_dir().join("bamboo-test-plan-codex-app-server"),
18077 app_server,
18078 vec![],
18079 "openai".into(),
18080 4,
18081 );
18082 let app_server_spec =
18083 app_server_runner.build_spec(&planner, &job_for("planner-codex-app-server"));
18084 assert_eq!(
18085 expected_permission_executor_mapping(
18086 &app_server_spec.executor,
18087 app_server_spec
18088 .capabilities
18089 .permission_resolution()
18090 .unwrap(),
18091 false,
18092 )
18093 .unwrap()
18094 .as_deref(),
18095 Some("codex_app_server:approvalPolicy=never")
18096 );
18097 assert!(matches!(
18098 app_server_spec.executor,
18099 ExecutorSpec::Codex {
18100 mode: Some(ref mode),
18101 sandbox: Some(ref sandbox),
18102 inherit_user_config: Some(false),
18103 approval_policy: Some(ref approval_policy),
18104 network_access: Some(false),
18105 allow_danger_bypass: Some(false),
18106 permission_profile: Some(ref profile),
18107 ..
18108 } if mode == "app_server"
18109 && sandbox == "read-only"
18110 && approval_policy == "on-request"
18111 && profile == "read-only"
18112 ));
18113 }
18114
18115 #[test]
18116 fn typed_read_only_rejects_both_codex_executor_modes() {
18117 let mut planner = session_of_role("planner", "inspect and plan");
18118 planner
18119 .agent_runtime_state
18120 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18121 .read_only = true;
18122
18123 for mode in ["exec", "app_server"] {
18124 let mut executor = codex_executor(Some("inherit"), Some(true));
18125 if let ExecutorSpec::Codex {
18126 mode: configured_mode,
18127 ..
18128 } = &mut executor
18129 {
18130 *configured_mode = Some(mode.to_string());
18131 }
18132 let runner = ActorChildRunner::new(
18133 "test-actor".into(),
18134 PathBuf::from("/bin/false"),
18135 vec![],
18136 std::env::temp_dir().join(format!("bamboo-test-plan-codex-{mode}")),
18137 executor,
18138 vec![],
18139 "openai".into(),
18140 4,
18141 );
18142 let spec = runner.build_spec(&planner, &job_for(&format!("planner-codex-{mode}")));
18143 let error = ensure_read_only_activation_is_enforceable(&spec)
18144 .expect_err("Codex command execution must fail closed for read-only activations");
18145 let message = error.to_string();
18146 assert!(message.contains("Codex executor"), "{message}");
18147 assert!(message.contains("command execution"), "{message}");
18148 }
18149 }
18150
18151 #[tokio::test]
18152 async fn typed_read_only_codex_fails_before_worker_spawn_or_dispatch() {
18153 let mut planner = session_of_role("planner", "inspect and plan");
18154 planner
18155 .agent_runtime_state
18156 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18157 .read_only = true;
18158
18159 let runner = ActorChildRunner::new(
18160 "test-actor".into(),
18161 PathBuf::from("/bin/false"),
18162 vec![],
18163 std::env::temp_dir().join("bamboo-test-plan-codex-no-spawn"),
18164 codex_executor(Some("inherit"), Some(true)),
18165 vec![],
18166 "openai".into(),
18167 4,
18168 );
18169 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(4);
18170 let error = runner
18171 .execute_external_child(
18172 &mut planner,
18173 &job_for("planner-codex-no-spawn"),
18174 event_tx,
18175 CancellationToken::new(),
18176 )
18177 .await
18178 .expect_err("typed read-only Codex must fail before invoking /bin/false");
18179 let message = error.to_string();
18180 assert!(message.contains("Codex executor"), "{message}");
18181 assert!(message.contains("command execution"), "{message}");
18182 }
18183
18184 #[test]
18185 fn ticket_placement_keeps_local_and_ordinary_remote_execution_available() {
18186 let runner = bogus_runner(HashMap::new());
18187 let ordinary = session_of_role("worker", "ordinary work");
18188 for placement in [
18189 Placement::Local,
18190 Placement::Remote {
18191 endpoint: "wss://fixture.invalid/actor".into(),
18192 },
18193 Placement::Schedulable {
18194 pool: "fixture-pool".into(),
18195 },
18196 ] {
18197 let mut spec = runner.build_spec(&ordinary, &job_for("placement"));
18198 spec.placement = placement;
18199 ensure_ticket_placement_is_enforceable(&ordinary, &spec).unwrap();
18200 for marker in [
18201 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18202 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18203 ] {
18204 let mut ticket = ordinary.clone();
18205 ticket.metadata.insert(marker.into(), "fixture".into());
18206 assert_eq!(
18207 ensure_ticket_placement_is_enforceable(&ticket, &spec).is_ok(),
18208 matches!(spec.placement, Placement::Local)
18209 );
18210 }
18211 }
18212 }
18213
18214 #[tokio::test]
18215 async fn ticket_placement_rejected_before_remote_route_claim_or_worker_spawn() {
18216 for marker in [
18217 crate::ticket_runtime::TICKET_DISPATCH_KEY,
18218 crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY,
18219 ] {
18220 for schedulable in [false, true] {
18221 let mut session = session_of_role("worker", "Ticket work");
18222 session.metadata.insert(marker.into(), "fixture".into());
18223 let before = serde_json::to_value(&session).unwrap();
18224 let remote = if schedulable {
18225 HashMap::new()
18226 } else {
18227 HashMap::from([(
18228 "worker".into(),
18229 ResolvedRemotePlacement {
18230 endpoint: "wss://fixture.invalid/actor".into(),
18231 ..Default::default()
18232 },
18233 )])
18234 };
18235 let sched = if schedulable {
18236 HashMap::from([(
18237 "worker".into(),
18238 sched_placement("fixture-pool", "wss://fixture.invalid"),
18239 )])
18240 } else {
18241 HashMap::new()
18242 };
18243 let runner = bogus_sched_runner(remote, sched);
18244 let (events, mut observations) = mpsc::channel::<AgentEvent>(4);
18245 let error = runner
18246 .execute_external_child(
18247 &mut session,
18248 &job_for("ticket-no-remote"),
18249 events,
18250 CancellationToken::new(),
18251 )
18252 .await
18253 .unwrap_err();
18254 assert!(
18255 error
18256 .to_string()
18257 .contains("Ticket execution requires local placement"),
18258 "{error}"
18259 );
18260 assert_eq!(serde_json::to_value(&session).unwrap(), before);
18261 assert!(observations.try_recv().is_err());
18262 }
18263 }
18264 }
18265
18266 #[test]
18267 fn typed_read_only_rejects_resident_actor_placements() {
18268 let runner = bogus_runner(HashMap::new());
18269 let mut planner = session_of_role("planner", "inspect and plan");
18270 planner
18271 .agent_runtime_state
18272 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18273 .read_only = true;
18274
18275 for placement in [
18276 Placement::Remote {
18277 endpoint: "wss://resident.example/actor".to_string(),
18278 },
18279 Placement::Schedulable {
18280 pool: "resident-pool".to_string(),
18281 },
18282 ] {
18283 let mut spec = runner.build_spec(&planner, &job_for("read-only-resident"));
18284 spec.placement = placement;
18285
18286 let error = ensure_read_only_activation_is_enforceable(&spec)
18287 .expect_err("resident placement must fail closed for read-only activation");
18288 let message = error.to_string();
18289 assert!(message.contains("requires local placement"), "{message}");
18290 assert!(message.contains("read-only tool boundary"), "{message}");
18291 }
18292
18293 let local = runner.build_spec(&planner, &job_for("read-only-local"));
18294 ensure_read_only_activation_is_enforceable(&local)
18295 .expect("a locally provisioned read-only worker is enforceable");
18296 }
18297
18298 #[tokio::test]
18299 async fn build_spec_preserves_exact_inherited_permission_mode_for_child_worker() {
18300 for (label, mode) in [
18304 ("bypass", bamboo_domain::SessionPermissionMode::Bypass),
18305 ("auto", bamboo_domain::SessionPermissionMode::Auto),
18306 ] {
18307 let runner = bogus_runner(HashMap::new());
18308 let mut parent = Session::new(format!("parent-{label}"), "test-model");
18309 parent
18310 .agent_runtime_state
18311 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
18312 .set_permission_mode(mode);
18313 let workspace = tempfile::tempdir().expect("workspace fixture");
18314 let port = RecordingChildSessionPort {
18315 parent: parent.clone(),
18316 saved: Default::default(),
18317 };
18318 let child_id = format!("child-{label}-{}", uuid::Uuid::new_v4());
18319 crate::session_app::child_session::create_child_action(
18320 &port,
18321 crate::session_app::child_session::CreateChildInput {
18322 parent_session: parent,
18323 child_id: child_id.clone(),
18324 title: format!("{label} child"),
18325 responsibility: "Run ordinary commands".to_string(),
18326 assignment_prompt: "run an ordinary command".to_string(),
18327 subagent_type: "explorer".to_string(),
18328 workspace: workspace.path().to_string_lossy().into_owned(),
18329 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18330 model_override: None,
18331 model_ref_override: None,
18332 runtime_metadata: HashMap::new(),
18333 read_only: false,
18334 auto_run: false,
18335 reasoning_effort: None,
18336 lifecycle: None,
18337 resident_name: None,
18338 resident_context: None,
18339 disabled_tools: None,
18340 context_fork: None,
18341 },
18342 )
18343 .await
18344 .expect("create inherited-permission child");
18345 let child = port.saved_child();
18346
18347 assert_eq!(
18348 child
18349 .agent_runtime_state
18350 .as_ref()
18351 .map(bamboo_domain::AgentRuntimeState::effective_permission_mode),
18352 Some(mode),
18353 "create_child_action must inherit {label} from the parent"
18354 );
18355
18356 let spec = runner.build_spec(&child, &job_for(&child_id));
18357
18358 assert_eq!(
18359 spec.capabilities.bypass,
18360 mode == bamboo_domain::SessionPermissionMode::Bypass
18361 );
18362 assert_eq!(
18363 spec.capabilities.auto_approve_permissions,
18364 mode == bamboo_domain::SessionPermissionMode::Auto
18365 );
18366 assert!(
18367 spec.capabilities.enforce_permissions,
18368 "policy evaluation must remain active under {label}"
18369 );
18370 }
18371 }
18372
18373 #[tokio::test]
18374 async fn child_resident_and_guardian_inherit_project_through_actor_run_spec() {
18375 let project_id = bamboo_domain::ProjectId::parse("project-inherited").expect("Project id");
18376 let workspace = tempfile::tempdir().expect("workspace fixture");
18377
18378 for (role, lifecycle, resident_name) in [
18379 ("explorer", None, None),
18380 ("resident", Some("resident"), Some("stable-reviewer")),
18381 ("guardian", None, None),
18382 ] {
18383 let mut parent = Session::new(format!("parent-{role}"), "test-model");
18384 parent.set_project_id_meta(project_id.to_string());
18385 let port = RecordingChildSessionPort {
18386 parent: parent.clone(),
18387 saved: Default::default(),
18388 };
18389 let child_id = format!("child-{role}-{}", uuid::Uuid::new_v4());
18390 crate::session_app::child_session::create_child_action(
18391 &port,
18392 crate::session_app::child_session::CreateChildInput {
18393 parent_session: parent,
18394 child_id: child_id.clone(),
18395 title: format!("{role} child"),
18396 responsibility: "Review the assigned work".to_string(),
18397 assignment_prompt: "inspect the change".to_string(),
18398 subagent_type: role.to_string(),
18399 workspace: workspace.path().to_string_lossy().into_owned(),
18400 workspace_source: crate::project_context::WorkspaceSource::Explicit,
18401 model_override: None,
18402 model_ref_override: None,
18403 runtime_metadata: HashMap::new(),
18404 read_only: false,
18405 auto_run: false,
18406 reasoning_effort: None,
18407 lifecycle: lifecycle.map(str::to_string),
18408 resident_name: resident_name.map(str::to_string),
18409 resident_context: None,
18410 disabled_tools: None,
18411 context_fork: None,
18412 },
18413 )
18414 .await
18415 .expect("create Project-inheriting child");
18416 let child = port.saved_child();
18417 assert_eq!(
18418 crate::project_context::ProjectContextResolver::project_id_from_session(&child),
18419 Some(project_id.clone()),
18420 "{role} child must inherit its parent's Project"
18421 );
18422
18423 assert_eq!(
18424 project_id_for_actor_run(&child).expect("valid actor Project identity"),
18425 Some(project_id.clone()),
18426 "{role} actor RunSpec must preserve inherited Project identity"
18427 );
18428 }
18429 }
18430
18431 #[test]
18432 fn placement_metadata_stamps_remote_and_schedulable_not_local() {
18433 assert_eq!(placement_metadata(&Placement::Local, None), None);
18435
18436 let r = placement_metadata(
18438 &Placement::Remote {
18439 endpoint: "wss://10.0.0.5:8443/stream".into(),
18440 },
18441 None,
18442 )
18443 .unwrap();
18444 assert!(r.contains(r#""kind":"remote""#), "{r}");
18445 assert!(r.contains(r#""host":"10.0.0.5""#), "{r}");
18446
18447 let labeled = placement_metadata(
18449 &Placement::Remote {
18450 endpoint: "ws://169.254.230.101:8899".into(),
18451 },
18452 Some("mini"),
18453 )
18454 .unwrap();
18455 assert!(labeled.contains(r#""host":"mini""#), "{labeled}");
18456
18457 let s = placement_metadata(
18459 &Placement::Schedulable {
18460 pool: "explorers".into(),
18461 },
18462 Some("mini"),
18463 )
18464 .unwrap();
18465 assert!(s.contains(r#""kind":"remote""#), "{s}");
18466 assert!(s.contains(r#""host":"mini""#), "{s}");
18467
18468 let p: bamboo_storage::SessionPlacement = serde_json::from_str(&labeled).unwrap();
18470 assert_eq!(p.kind, "remote");
18471 assert_eq!(p.host, "mini");
18472 }
18473
18474 #[tokio::test]
18477 async fn execute_external_child_rejects_direct_remote_worker() {
18478 let mut placements = HashMap::new();
18479 placements.insert(
18480 "explorer".to_string(),
18481 ResolvedRemotePlacement {
18482 endpoint: "ws://127.0.0.1:1".into(),
18483 token: Some("remote-test-token".into()),
18484 ca_cert_file: None,
18485 host_label: Some("mini-e2e".into()),
18486 broker_peer: None,
18487 requirements: None,
18488 },
18489 );
18490 let runner = bogus_runner(placements);
18491 let mut session = session_of_role("explorer", "hello remote");
18492 let job = job_for("child-1");
18493 let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(64);
18494 let error = runner
18495 .execute_external_child(&mut session, &job, event_tx, CancellationToken::new())
18496 .await
18497 .expect_err("direct remote transport must fail closed");
18498 assert!(error
18499 .to_string()
18500 .contains("remote_broker_activation_unavailable"));
18501 assert!(session
18502 .messages
18503 .iter()
18504 .all(|message| message.role != Role::Assistant));
18505 }
18506
18507 fn bogus_sched_runner(
18513 remote: HashMap<String, ResolvedRemotePlacement>,
18514 sched: HashMap<String, ResolvedSchedulablePlacement>,
18515 ) -> ActorChildRunner {
18516 ActorChildRunner::new(
18517 "test-actor".into(),
18518 PathBuf::from("/bin/false"),
18519 vec![],
18520 std::env::temp_dir().join("bamboo-test-fab-181"),
18521 ExecutorSpec::Echo,
18522 vec![],
18523 "anthropic".into(),
18524 4,
18525 )
18526 .with_remote_placements(remote)
18527 .with_schedulable_placements(sched)
18528 }
18529
18530 fn sched_placement(
18531 pool: &str,
18532 _registry_url: impl Into<String>,
18533 ) -> ResolvedSchedulablePlacement {
18534 ResolvedSchedulablePlacement {
18535 pool: pool.into(),
18536 host_label: None,
18537 requirements: None,
18538 ..Default::default()
18539 }
18540 }
18541
18542 #[test]
18543 fn build_spec_sets_schedulable_placement_for_matching_role() {
18544 let mut sched = HashMap::new();
18545 sched.insert(
18546 "explorer".to_string(),
18547 sched_placement("gpu-pool", "unused"),
18548 );
18549 let runner = bogus_sched_runner(HashMap::new(), sched);
18550
18551 let s = session_of_role("explorer", "do the thing");
18552 let spec = runner.build_spec(&s, &job_for("child-1"));
18553 match &spec.placement {
18554 Placement::Schedulable { pool } => assert_eq!(pool, "gpu-pool"),
18555 other => panic!("expected Schedulable, got {other:?}"),
18556 }
18557 assert!(spec.secrets.worker_auth_token.is_none());
18559 }
18560
18561 #[test]
18562 fn build_spec_remote_wins_when_role_in_both_maps() {
18563 let mut remote = HashMap::new();
18566 remote.insert(
18567 "explorer".to_string(),
18568 ResolvedRemotePlacement {
18569 endpoint: "wss://fixed-host:8443".into(),
18570 token: Some("T-remote".into()),
18571 ca_cert_file: None,
18572 host_label: None,
18573 broker_peer: None,
18574 requirements: None,
18575 },
18576 );
18577 let mut sched = HashMap::new();
18578 sched.insert(
18579 "explorer".to_string(),
18580 sched_placement("gpu-pool", "https://control-plane:9562"),
18581 );
18582 let runner = bogus_sched_runner(remote, sched);
18583
18584 let s = session_of_role("explorer", "do the thing");
18585 let spec = runner.build_spec(&s, &job_for("child-1"));
18586 match &spec.placement {
18587 Placement::Remote { endpoint } => assert_eq!(endpoint, "wss://fixed-host:8443"),
18588 other => panic!("expected Remote (precedence), got {other:?}"),
18589 }
18590 assert!(spec.secrets.worker_auth_token.is_none());
18591 }
18592
18593 #[test]
18594 fn build_spec_local_for_unmatched_schedulable_role() {
18595 let mut sched = HashMap::new();
18596 sched.insert(
18597 "explorer".to_string(),
18598 sched_placement("gpu-pool", "https://control-plane:9562"),
18599 );
18600 let runner = bogus_sched_runner(HashMap::new(), sched);
18601 let s = session_of_role("writer", "do the thing");
18602 let spec = runner.build_spec(&s, &job_for("child-1"));
18603 assert_eq!(spec.placement, Placement::Local);
18604 assert!(spec.secrets.worker_auth_token.is_none());
18605 }
18606
18607 #[test]
18612 fn placement_stamp_uses_node_label_for_remote_and_schedulable() {
18613 let mut remote = HashMap::new();
18615 remote.insert(
18616 "explorer".to_string(),
18617 ResolvedRemotePlacement {
18618 endpoint: "ws://169.254.230.101:8899".into(),
18619 token: None,
18620 ca_cert_file: None,
18621 host_label: Some("mini".into()),
18622 broker_peer: None,
18623 requirements: None,
18624 },
18625 );
18626 let runner = bogus_runner(remote);
18627 let spec = runner.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18628 let stamp = runner
18629 .placement_stamp_for(&spec)
18630 .expect("remote child is stamped");
18631 assert!(stamp.contains(r#""kind":"remote""#), "{stamp}");
18632 assert!(stamp.contains(r#""host":"mini""#), "{stamp}");
18633
18634 let mut remote_nolabel = HashMap::new();
18636 remote_nolabel.insert(
18637 "explorer".to_string(),
18638 ResolvedRemotePlacement {
18639 endpoint: "ws://169.254.230.101:8899".into(),
18640 token: None,
18641 ca_cert_file: None,
18642 host_label: None,
18643 broker_peer: None,
18644 requirements: None,
18645 },
18646 );
18647 let r2 = bogus_runner(remote_nolabel);
18648 let spec2 = r2.build_spec(&session_of_role("explorer", "go"), &job_for("c1"));
18649 assert!(r2
18650 .placement_stamp_for(&spec2)
18651 .unwrap()
18652 .contains(r#""host":"169.254.230.101""#));
18653
18654 let mut sched = HashMap::new();
18656 sched.insert(
18657 "mac-mini-monitor".to_string(),
18658 ResolvedSchedulablePlacement {
18659 pool: "mac-mini-monitor".into(),
18660 host_label: Some("mini".into()),
18661 requirements: None,
18662 ..Default::default()
18663 },
18664 );
18665 let sr = bogus_sched_runner(HashMap::new(), sched);
18666 let spec3 = sr.build_spec(&session_of_role("mac-mini-monitor", "go"), &job_for("c1"));
18667 let stamp3 = sr
18668 .placement_stamp_for(&spec3)
18669 .expect("scheduled child is stamped");
18670 assert!(stamp3.contains(r#""kind":"remote""#), "{stamp3}");
18671 assert!(stamp3.contains(r#""host":"mini""#), "{stamp3}");
18672
18673 let local = bogus_runner(HashMap::new());
18675 let spec4 = local.build_spec(&session_of_role("writer", "go"), &job_for("c1"));
18676 assert_eq!(local.placement_stamp_for(&spec4), None);
18677 }
18678
18679 #[tokio::test]
18680 async fn legacy_schedulable_route_fails_closed_without_local_fallback() {
18681 let mut sched = HashMap::new();
18682 sched.insert(
18683 "explorer".to_string(),
18684 sched_placement("gpu-pool", "unused"),
18685 );
18686 let runner = bogus_sched_runner(HashMap::new(), sched);
18687 let mut session = session_of_role("explorer", "do the thing");
18688 let (event_tx, _rx) = mpsc::channel::<AgentEvent>(8);
18689 let error = runner
18690 .execute_external_child(
18691 &mut session,
18692 &job_for("child-1"),
18693 event_tx,
18694 CancellationToken::new(),
18695 )
18696 .await
18697 .expect_err("legacy bus presence cannot grant placement");
18698 assert!(error
18699 .to_string()
18700 .contains("remote_broker_activation_unavailable"));
18701 }
18702}